Easychat

Alt+P (Win/ChromeOS) or Ctrl+P (Mac) — persistent chat, friends, emojis, GIFs, EN/SV

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Easychat
// @namespace    https://greasyfork.org/
// @version      2.0.0
// @description  Alt+P (Win/ChromeOS) or Ctrl+P (Mac) — persistent chat, friends, emojis, GIFs, EN/SV
// @author       You
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @license MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── CONFIG ────────────────────────────────────────────────────────────────
  const SUPABASE_URL  = 'https://eou6kjoy1ykobyjpjpkq.supabase.co';
  const SUPABASE_ANON = 'sb_publishable_eou6_k-oJY1YKoByYPjpkQ_J6JtXrq5';
  // Tenor v2 key — free, no credit card. Replace with your own from https://tenor.com/developer/keyregistration
  const TENOR_KEY = 'AIzaSyAyimkuEcdX5zVXBNkFEaJ6WUDe1v8tl5Y';
  // ─────────────────────────────────────────────────────────────────────────

  // ── i18n ─────────────────────────────────────────────────────────────────
  const STRINGS = {
    en: {
      appName:'Pals',chat:'Chat',requests:'Requests',add:'Add',settings:'Settings',
      loginTitle:'Welcome back',signupTitle:'Create account',
      emailPh:'Email',passPh:'Password',usernamePh:'Username',
      signIn:'Sign In',signUp:'Sign Up',
      noAccount:"Don't have an account?",hasAccount:'Already have one?',
      friendRequests:'Friend Requests',addFriend:'Add a Friend',
      searchUser:'Search username\u2026',sendReq:'Send Request',
      searching:'Searching\u2026',notFound:'\u274c User not found',thatsYou:"\u274c That's you!",
      reqSent:'\u2705 Request sent to',alreadyFriends:'\u274c Already sent or friends',
      noPending:'No pending requests',noFriends:'No friends yet \u2013 add someone!',
      messagePh:'Message\u2026',send:'Send',pickFriend:'Pick a friend to chat \u2190',
      darkMode:'Dark Mode',language:'Language',signedInAs:'Signed in as',
      logout:'Sign Out',version:'Pals v2.0 \u00b7 Alt+P / Ctrl+P to toggle',
      chooseLanguage:'Choose your language',langSub:'You can change this in Settings.',
      searchGifs:'Search GIFs\u2026',loading:'Loading\u2026',accept:'\u2713 Accept',decline:'\u2715 Decline'
    },
    sv: {
      appName:'Kompisar',chat:'Chatt',requests:'Förfrågn.',add:'Lägg till',settings:'Inställningar',
      loginTitle:'Välkommen tillbaka',signupTitle:'Skapa konto',
      emailPh:'E-post',passPh:'Lösenord',usernamePh:'Användarnamn',
      signIn:'Logga in',signUp:'Registrera',
      noAccount:'Inget konto?',hasAccount:'Har du redan ett?',
      friendRequests:'Vänförfrågningar',addFriend:'Lägg till en vän',
      searchUser:'Sök användarnamn\u2026',sendReq:'Skicka förfrågan',
      searching:'Söker\u2026',notFound:'\u274c Användaren hittades inte',thatsYou:'\u274c Det är du!',
      reqSent:'\u2705 Förfrågan skickad till',alreadyFriends:'\u274c Redan skickad eller vänner',
      noPending:'Inga väntande förfrågningar',noFriends:'Inga vänner än \u2013 lägg till någon!',
      messagePh:'Meddelande\u2026',send:'Skicka',pickFriend:'Välj en vän att chatta med \u2190',
      darkMode:'Mörkt läge',language:'Språk',signedInAs:'Inloggad som',
      logout:'Logga ut',version:'Kompisar v2.0 \u00b7 Alt+P / Ctrl+P för att växla',
      chooseLanguage:'Välj ditt språk',langSub:'Du kan ändra detta i Inställningar.',
      searchGifs:'Sök GIF:ar\u2026',loading:'Laddar\u2026',accept:'\u2713 Acceptera',decline:'\u2715 Neka'
    }
  };
  let lang = GM_getValue('pals_lang', null);
  const s = () => STRINGS[lang || 'en'];

  // ── Persistent prefs ──────────────────────────────────────────────────────
  let darkMode = GM_getValue('pals_dark', false);
  let _token   = GM_getValue('pals_token', null);
  let _user    = null;
  try { const r = GM_getValue('pals_user', null); if (r) _user = JSON.parse(r); } catch {}
  let _lastFriendId = GM_getValue('pals_last_fid', null);
  let _lastFriendUn = GM_getValue('pals_last_fun', null);

  // ── Supabase ──────────────────────────────────────────────────────────────
  function ah() {
    const h = { 'Content-Type': 'application/json', 'apikey': SUPABASE_ANON };
    if (_token) h['Authorization'] = 'Bearer ' + _token;
    return h;
  }
  async function sb(path, opts) {
    opts = opts || {};
    try {
      const res = await fetch(SUPABASE_URL + path, { method: opts.method || 'GET', headers: Object.assign(ah(), opts.headers || {}), body: opts.body });
      const txt = await res.text();
      let data; try { data = JSON.parse(txt); } catch (e) { data = txt; }
      return { ok: res.ok, status: res.status, data: data };
    } catch (e) { return { ok: false, status: 0, data: null }; }
  }

  async function doSignUp(email, password, username) {
    const r = await sb('/auth/v1/signup', { method: 'POST', body: JSON.stringify({ email: email, password: password }) });
    if (!r.ok) return { error: r.data && (r.data.msg || r.data.message || r.data.error_description) || 'Signup failed' };
    _token = r.data.access_token;
    GM_setValue('pals_token', _token);
    await sb('/rest/v1/profiles', { method: 'POST', headers: { 'Prefer': 'return=minimal' }, body: JSON.stringify({ id: r.data.user.id, username: username, email: email }) });
    _user = { id: r.data.user.id, email: email, username: username };
    GM_setValue('pals_user', JSON.stringify(_user));
    return { user: _user };
  }

  async function doSignIn(email, password) {
    const r = await sb('/auth/v1/token?grant_type=password', { method: 'POST', body: JSON.stringify({ email: email, password: password }) });
    if (!r.ok) return { error: r.data && r.data.error_description || 'Login failed' };
    _token = r.data.access_token;
    GM_setValue('pals_token', _token);
    const p = await sb('/rest/v1/profiles?id=eq.' + r.data.user.id + '&select=*');
    const prof = Array.isArray(p.data) ? p.data[0] : null;
    _user = { id: r.data.user.id, email: r.data.user.email, username: prof && prof.username || r.data.user.email };
    GM_setValue('pals_user', JSON.stringify(_user));
    return { user: _user };
  }

  function doSignOut() {
    _token = null; _user = null;
    GM_setValue('pals_token', null); GM_setValue('pals_user', null);
    GM_setValue('pals_last_fid', null); GM_setValue('pals_last_fun', null);
    _lastFriendId = null; _lastFriendUn = null;
  }

  async function findUser(username) {
    const r = await sb('/rest/v1/profiles?username=eq.' + encodeURIComponent(username) + '&select=id,username');
    return Array.isArray(r.data) ? r.data[0] || null : null;
  }

  async function sendFriendReq(toId) {
    return sb('/rest/v1/friend_requests', { method: 'POST', headers: { 'Prefer': 'return=minimal' }, body: JSON.stringify({ from_id: _user.id, to_id: toId, status: 'pending' }) });
  }

  async function respondReq(reqId, status) {
    return sb('/rest/v1/friend_requests?id=eq.' + reqId, { method: 'PATCH', headers: { 'Prefer': 'return=minimal' }, body: JSON.stringify({ status: status }) });
  }

  async function getIncomingReqs() {
    const r = await sb('/rest/v1/friend_requests?to_id=eq.' + _user.id + '&status=eq.pending&select=id,from_id,profiles!friend_requests_from_id_fkey(username)');
    return Array.isArray(r.data) ? r.data : [];
  }

  async function getFriends() {
    const r1 = await sb('/rest/v1/friend_requests?from_id=eq.' + _user.id + '&status=eq.accepted&select=to_id,profiles!friend_requests_to_id_fkey(id,username)');
    const r2 = await sb('/rest/v1/friend_requests?to_id=eq.' + _user.id + '&status=eq.accepted&select=from_id,profiles!friend_requests_from_id_fkey(id,username)');
    var list = [];
    if (Array.isArray(r1.data)) r1.data.forEach(function (x) { var p = x['profiles!friend_requests_to_id_fkey']; if (p) list.push(p); });
    if (Array.isArray(r2.data)) r2.data.forEach(function (x) { var p = x['profiles!friend_requests_from_id_fkey']; if (p) list.push(p); });
    return list;
  }

  function roomId(a, b) { return [a, b].sort().join('__'); }

  async function getMessages(friendId) {
    const room = roomId(_user.id, friendId);
    const r = await sb('/rest/v1/messages?room_id=eq.' + encodeURIComponent(room) + '&order=created_at.asc&limit=120&select=*');
    return Array.isArray(r.data) ? r.data : [];
  }

  async function sendMessage(friendId, body) {
    const room = roomId(_user.id, friendId);
    return sb('/rest/v1/messages', { method: 'POST', headers: { 'Prefer': 'return=minimal' }, body: JSON.stringify({ room_id: room, sender_id: _user.id, body: body }) });
  }

  // ── Tenor GIFs ────────────────────────────────────────────────────────────
  async function searchGifs(q) {
    try {
      const url = 'https://tenor.googleapis.com/v2/search?q=' + encodeURIComponent(q || 'funny') + '&key=' + TENOR_KEY + '&limit=12&media_filter=gif';
      const r = await fetch(url);
      const d = await r.json();
      return (d.results || []).map(function (g) {
        return { url: g.media_formats && (g.media_formats.gif || g.media_formats.tinygif) && (g.media_formats.gif || g.media_formats.tinygif).url, preview: g.media_formats && g.media_formats.tinygif && g.media_formats.tinygif.url };
      });
    } catch (e) { return []; }
  }

  // ── Avatars ───────────────────────────────────────────────────────────────
  const AVC = ['#e17055','#fdcb6e','#00b894','#0984e3','#6c5ce7','#fd79a8','#00cec9','#a29bfe'];
  function avc(name) { var h = 0; for (var i = 0; i < (name || '?').length; i++) h = (h * 31 + (name || '?').charCodeAt(i)) % AVC.length; return AVC[h]; }
  function avl(name) { return (name || '?')[0].toUpperCase(); }

  // ── Emoji list ────────────────────────────────────────────────────────────
  const EMOJIS = '😀😂🥹😍😎🥳🤔😴😭😱🤣❤️🔥✅💀👍👎🙏💯🎉😅😏🤯🥰😤😬🤝💪👀🫶🌈✨💬🍕🎮📱🎵⚡🚀💡🦋🌸🎯💎🏆🌙⭐🎨🧠🫀💝'.split('');

  // ── CSS ───────────────────────────────────────────────────────────────────
  GM_addStyle([
    '#pals-ov{position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);font-family:Segoe UI,system-ui,sans-serif;animation:pfIn .16s ease}',
    '@keyframes pfIn{from{opacity:0}to{opacity:1}}',
    '#pals-sh{width:800px;max-width:97vw;height:550px;max-height:96vh;border-radius:16px;overflow:hidden;display:flex;box-shadow:0 28px 72px rgba(0,0,0,.6);animation:pfUp .2s cubic-bezier(.22,1,.36,1)}',
    '@keyframes pfUp{from{transform:translateY(20px) scale(.96)}to{transform:none}}',

    /* light */
    '#pals-sh.light{background:#f4f2ee;color:#111}',
    '.light .p-sb{background:#ebe8e2;border-right:1px solid #d6d2ca}.light .p-main{background:#f4f2ee}',
    '.light .p-irow{background:#ebe8e2;border-top:1px solid #d6d2ca}.light .p-mi{background:#fff;color:#111;border:1px solid #ccc}',
    '.light .bme{background:#111;color:#fff}.light .bth{background:#dedad2;color:#111}',
    '.light .p-fi:hover,.light .p-fi.act{background:#d6d2ca}.light .p-tab.on{background:#d6d2ca}',
    '.light .p-abox{background:#fff}.light .p-inp{background:#fff;border:1px solid #ccc;color:#111}',
    '.light .p-sp{background:#f4f2ee}.light .p-ri{background:#dedad2}',
    '.light .p-ep,.light .p-gp{background:#fff;border:1px solid #ddd}',
    '.light .p-gsi{background:#fff;border:1px solid #ccc;color:#111}',
    '.light .p-lpbox{background:#fff}.light .p-lbtn{background:#f4f2ee;border:1px solid #d6d2ca;color:#111}',
    '.light .p-lbtn:hover{background:#ebe8e2}',
    /* dark */
    '#pals-sh.dark{background:#111;color:#e5e2dc}',
    '.dark .p-sb{background:#1a1a1a;border-right:1px solid #272727}.dark .p-main{background:#111}',
    '.dark .p-irow{background:#1a1a1a;border-top:1px solid #272727}.dark .p-mi{background:#252525;color:#e5e2dc;border:1px solid #333}',
    '.dark .bme{background:#e5e2dc;color:#111}.dark .bth{background:#252525;color:#e5e2dc}',
    '.dark .p-fi:hover,.dark .p-fi.act{background:#252525}.dark .p-tab.on{background:#252525}',
    '.dark .p-abox{background:#1a1a1a}.dark .p-inp{background:#252525;border:1px solid #333;color:#e5e2dc}',
    '.dark .p-sp{background:#111}.dark .p-ri{background:#252525}',
    '.dark .p-ep,.dark .p-gp{background:#1e1e1e;border:1px solid #333}',
    '.dark .p-gsi{background:#252525;border:1px solid #333;color:#e5e2dc}',
    '.dark .p-lpbox{background:#1a1a1a}.dark .p-lbtn{background:#252525;border:1px solid #333;color:#e5e2dc}',
    '.dark .p-lbtn:hover{background:#2f2f2f}',
    /* sidebar */
    '.p-sb{width:215px;flex-shrink:0;display:flex;flex-direction:column}',
    '.p-sbh{padding:14px 14px 8px;font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.45;display:flex;align-items:center;justify-content:space-between}',
    '.p-ic{background:none;border:none;cursor:pointer;padding:4px 6px;border-radius:6px;opacity:.55;font-size:15px;color:inherit;transition:opacity .12s}',
    '.p-ic:hover{opacity:1}',
    '.p-tabs{display:flex;padding:0 8px 8px;gap:3px}',
    '.p-tab{flex:1;padding:5px 0;border-radius:7px;font-size:11px;font-weight:600;border:none;cursor:pointer;background:none;color:inherit;opacity:.5;transition:all .12s;position:relative}',
    '.p-tab.on{opacity:1}.p-tab:hover{opacity:.8}',
    '.p-fl{flex:1;overflow-y:auto;padding:0 6px}',
    '.p-fi{display:flex;align-items:center;gap:9px;padding:8px 7px;border-radius:9px;cursor:pointer;transition:background .1s;user-select:none}',
    '.p-av{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;flex-shrink:0}',
    '.p-fn{font-size:13px;font-weight:500}',
    '.p-bdg{background:#e74c3c;color:#fff;border-radius:50%;width:17px;height:17px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;position:absolute;top:-3px;right:-3px}',
    '.p-sbft{padding:9px 11px;display:flex;align-items:center;gap:7px}',
    '.p-uc{font-size:12px;font-weight:600;flex:1;opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
    /* main */
    '.p-main{flex:1;display:flex;flex-direction:column;min-width:0}',
    '.p-mh{padding:13px 16px;font-size:14px;font-weight:700;border-bottom:1px solid rgba(128,128,128,.13);display:flex;align-items:center;gap:9px}',
    '.p-msgs{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:6px}',
    '.p-bub{max-width:66%;padding:8px 12px;border-radius:14px;font-size:13.5px;line-height:1.45;word-break:break-word}',
    '.bme{align-self:flex-end;border-bottom-right-radius:4px}.bth{align-self:flex-start;border-bottom-left-radius:4px}',
    '.p-bub img{max-width:180px;border-radius:8px;display:block}',
    '.p-irow{display:flex;gap:6px;padding:10px 12px;align-items:flex-end;position:relative}',
    '.p-mi{flex:1;border-radius:10px;padding:9px 12px;font-size:13.5px;outline:none;resize:none;min-height:38px;max-height:100px;font-family:inherit}',
    '.p-sb2{padding:9px 14px;border-radius:10px;border:none;background:#111;color:#fff;font-weight:700;font-size:13px;cursor:pointer;transition:opacity .12s;flex-shrink:0}',
    '.dark .p-sb2{background:#e5e2dc;color:#111}.p-sb2:hover{opacity:.75}',
    '.p-ico{background:none;border:none;cursor:pointer;font-size:18px;padding:4px 6px;border-radius:7px;opacity:.6;transition:opacity .12s;flex-shrink:0;color:inherit}',
    '.p-ico:hover{opacity:1}',
    '.p-gb{font-size:11px;font-weight:800;padding:6px 8px;background:rgba(128,128,128,.15);border-radius:7px;border:none;cursor:pointer;opacity:.7;color:inherit;flex-shrink:0}',
    '.p-gb:hover{opacity:1}',
    /* emoji */
    '.p-ep{position:absolute;bottom:60px;left:12px;width:268px;padding:10px;border-radius:12px;display:flex;flex-wrap:wrap;gap:4px;z-index:99;box-shadow:0 8px 28px rgba(0,0,0,.28)}',
    '.p-eb{background:none;border:none;font-size:20px;cursor:pointer;padding:3px;border-radius:5px;transition:transform .1s}',
    '.p-eb:hover{transform:scale(1.28)}',
    /* gif */
    '.p-gp{position:absolute;bottom:60px;left:12px;width:290px;border-radius:12px;padding:10px;z-index:99;box-shadow:0 8px 28px rgba(0,0,0,.28)}',
    '.p-gsi{width:100%;box-sizing:border-box;padding:7px 10px;border-radius:8px;font-size:12.5px;margin-bottom:8px;outline:none;font-family:inherit}',
    '.p-gg{display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;max-height:180px;overflow-y:auto}',
    '.p-gif{width:100%;border-radius:6px;cursor:pointer;display:block;transition:opacity .12s}.p-gif:hover{opacity:.8}',
    /* empty */
    '.p-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:.3;gap:6px}',
    '.p-eico{font-size:36px}.p-etxt{font-size:13px;font-weight:500}',
    /* auth */
    '.p-asc{flex:1;display:flex;align-items:center;justify-content:center}',
    '.p-abox{width:310px;border-radius:14px;padding:30px 26px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
    '.p-at{font-size:22px;font-weight:800;margin-bottom:3px}.p-as{font-size:13px;opacity:.45;margin-bottom:22px}',
    '.p-inp{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:9px;font-size:13.5px;margin-bottom:10px;font-family:inherit;outline:none}',
    '.p-pb{width:100%;padding:11px;border-radius:9px;border:none;background:#111;color:#fff;font-size:14px;font-weight:700;cursor:pointer;margin-top:3px;transition:opacity .12s}',
    '.dark .p-pb{background:#e5e2dc;color:#111}.p-pb:hover{opacity:.8}.p-pb:disabled{opacity:.5;cursor:default}',
    '.p-sw{text-align:center;margin-top:13px;font-size:12.5px;opacity:.5;cursor:pointer}.p-sw span{text-decoration:underline}',
    '.p-er{color:#e74c3c;font-size:12px;margin-bottom:7px;min-height:15px}',
    /* requests */
    '.p-rp{flex:1;overflow-y:auto;padding:12px 16px}',
    '.p-ri{display:flex;align-items:center;gap:9px;padding:9px 11px;border-radius:10px;margin-bottom:7px}',
    '.p-rn{flex:1;font-size:13px;font-weight:600}',
    '.p-rb{padding:5px 11px;border-radius:7px;border:none;font-size:12px;font-weight:700;cursor:pointer;transition:opacity .12s}',
    '.p-rb:hover{opacity:.8}.p-ac{background:#27ae60;color:#fff;margin-right:4px}.p-dc{background:#e74c3c;color:#fff}',
    /* add */
    '.p-af{padding:16px}.p-ar{display:flex;gap:7px}',
    /* settings */
    '.p-sp{flex:1;padding:18px 20px;overflow-y:auto}',
    '.p-st{font-size:16px;font-weight:800;margin-bottom:18px}',
    '.p-sr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}',
    '.p-sl{font-size:13.5px;font-weight:500}',
    '.p-tog{width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;position:relative;transition:background .18s;flex-shrink:0}',
    '.p-tog::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:transform .18s}',
    '.p-tog.on{background:#111}.dark .p-tog.on{background:#e5e2dc}.p-tog.on::after{transform:translateX(20px)}.p-tog.off{background:#aaa}',
    '.p-lo{padding:9px 18px;border-radius:9px;border:none;background:#e74c3c;color:#fff;font-size:13px;font-weight:700;cursor:pointer;margin-top:10px}',
    '.p-ls{padding:7px 10px;border-radius:8px;border:1px solid #aaa;font-size:13px;background:inherit;color:inherit;cursor:pointer;outline:none}',
    /* lang pick */
    '.p-lw{flex:1;display:flex;align-items:center;justify-content:center}',
    '.p-lpbox{text-align:center;padding:32px 28px;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.2);width:280px}',
    '.p-lpico{font-size:42px;margin-bottom:10px}.p-lptit{font-size:20px;font-weight:800;margin-bottom:5px}',
    '.p-lpsub{font-size:13px;opacity:.45;margin-bottom:22px}',
    '.p-lbtn{width:100%;padding:13px;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;margin-bottom:10px;transition:background .12s}',
    /* scrollbars */
    '.p-fl::-webkit-scrollbar,.p-msgs::-webkit-scrollbar,.p-rp::-webkit-scrollbar,.p-gg::-webkit-scrollbar{width:3px}',
    '.p-fl::-webkit-scrollbar-thumb,.p-msgs::-webkit-scrollbar-thumb,.p-rp::-webkit-scrollbar-thumb,.p-gg::-webkit-scrollbar-thumb{background:rgba(128,128,128,.25);border-radius:2px}'
  ].join(''));

  // ── Runtime state ─────────────────────────────────────────────────────────
  var isOpen = false;
  var activeView = 'chat';
  var activeFriend = null;
  var authMode = 'login';
  var pollTimer = null;
  var chatPollTimer = null;
  var lastMsgLen = 0;
  var overlay, shell, flEl, mainEl, badgeEl;

  // ── Toggle open/close ─────────────────────────────────────────────────────
  document.addEventListener('keydown', function (e) {
    var mac = /mac/i.test(navigator.platform);
    if ((mac ? e.ctrlKey : e.altKey) && e.key.toLowerCase() === 'p') {
      e.preventDefault();
      isOpen ? closeChat() : openChat();
    }
  });

  function openChat() {
    if (isOpen) return;
    isOpen = true;
    buildUI();
  }

  function closeChat() {
    if (!isOpen) return;
    isOpen = false;
    clearInterval(pollTimer);
    clearInterval(chatPollTimer);
    if (overlay) { overlay.remove(); overlay = null; }
  }

  // ── Build UI ──────────────────────────────────────────────────────────────
  function buildUI() {
    overlay = document.createElement('div');
    overlay.id = 'pals-ov';
    overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) closeChat(); });
    shell = document.createElement('div');
    shell.id = 'pals-sh';
    shell.className = darkMode ? 'dark' : 'light';
    overlay.appendChild(shell);
    document.body.appendChild(overlay);

    if (!lang) renderLangPick();
    else if (!_user) renderAuth();
    else renderApp();
  }

  // ── Language picker ───────────────────────────────────────────────────────
  function renderLangPick() {
    shell.innerHTML = '';
    var wrap = document.createElement('div');
    wrap.className = 'p-lw';
    var box = document.createElement('div');
    box.className = 'p-lpbox ' + (darkMode ? 'dark' : 'light');
    box.innerHTML = '<div class="p-lpico">\uD83D\uDCAC</div><div class="p-lptit">Pals</div><div class="p-lpsub">Choose your language / V\u00e4lj ditt spr\u00e5k</div><button class="p-lbtn" id="p-len">\uD83C\uDDEC\uD83C\uDDE7 English</button><button class="p-lbtn" id="p-lsv">\uD83C\uDDF8\uD83C\uDDEA Svenska</button>';
    wrap.appendChild(box);
    shell.appendChild(wrap);
    box.querySelector('#p-len').addEventListener('click', function () { lang = 'en'; GM_setValue('pals_lang', 'en'); renderAuth(); });
    box.querySelector('#p-lsv').addEventListener('click', function () { lang = 'sv'; GM_setValue('pals_lang', 'sv'); renderAuth(); });
  }

  // ── Auth ──────────────────────────────────────────────────────────────────
  function renderAuth() {
    shell.innerHTML = '';
    var str = s();
    var isL = authMode === 'login';
    var screen = document.createElement('div');
    screen.className = 'p-asc';
    var box = document.createElement('div');
    box.className = 'p-abox';
    box.innerHTML = '<div class="p-at">\uD83D\uDCAC ' + str.appName + '</div><div class="p-as">' + (isL ? str.loginTitle : str.signupTitle) + '</div>' +
      (!isL ? '<input class="p-inp" id="p-un" placeholder="' + str.usernamePh + '" autocomplete="off">' : '') +
      '<input class="p-inp" id="p-em" type="email" placeholder="' + str.emailPh + '" autocomplete="off">' +
      '<input class="p-inp" id="p-pw" type="password" placeholder="' + str.passPh + '">' +
      '<div class="p-er" id="p-er"></div>' +
      '<button class="p-pb" id="p-sub">' + (isL ? str.signIn : str.signUp) + '</button>' +
      '<div class="p-sw">' + (isL ? str.noAccount : str.hasAccount) + ' <span id="p-sw">' + (isL ? str.signUp : str.signIn) + '</span></div>';
    screen.appendChild(box);
    shell.appendChild(screen);

    box.querySelector('#p-sw').addEventListener('click', function () {
      authMode = isL ? 'signup' : 'login';
      renderAuth();
    });

    box.querySelector('#p-sub').addEventListener('click', async function () {
      var btn = box.querySelector('#p-sub');
      var er = box.querySelector('#p-er');
      var email = box.querySelector('#p-em').value.trim();
      var pw = box.querySelector('#p-pw').value;
      er.textContent = ''; btn.disabled = true; btn.textContent = '\u2026';
      var res;
      if (isL) {
        res = await doSignIn(email, pw);
      } else {
        var un = box.querySelector('#p-un').value.trim();
        if (!un) { er.textContent = str.usernamePh + ' required'; btn.disabled = false; btn.textContent = str.signUp; return; }
        res = await doSignUp(email, pw, un);
      }
      if (res.error) { er.textContent = res.error; btn.disabled = false; btn.textContent = isL ? str.signIn : str.signUp; }
      else renderApp();
    });

    box.addEventListener('keydown', function (e) { if (e.key === 'Enter') box.querySelector('#p-sub').click(); });
  }

  // ── App ───────────────────────────────────────────────────────────────────
  function renderApp() {
    shell.innerHTML = '';
    var str = s();

    // Restore last active friend across sessions
    if (_lastFriendId && _lastFriendUn && !activeFriend) {
      activeFriend = { id: _lastFriendId, username: _lastFriendUn };
    }

    // Sidebar
    var sb = document.createElement('div');
    sb.className = 'p-sb';
    sb.innerHTML = '<div class="p-sbh"><span>' + str.appName + '</span><button class="p-ic" id="p-x">&#x2715;</button></div>' +
      '<div class="p-tabs"><button class="p-tab on" data-v="chat">' + str.chat + '</button><button class="p-tab" data-v="requests" style="position:relative">' + str.requests + '<span class="p-bdg" id="p-bdg" style="display:none">0</span></button><button class="p-tab" data-v="add">' + str.add + '</button></div>' +
      '<div class="p-fl" id="p-fl"></div>' +
      '<div class="p-sbft"><div class="p-uc">@' + _user.username + '</div><button class="p-ic" id="p-stbtn" title="' + str.settings + '">\u2699\uFE0F</button></div>';
    shell.appendChild(sb);
    flEl = sb.querySelector('#p-fl');
    badgeEl = sb.querySelector('#p-bdg');
    sb.querySelector('#p-x').addEventListener('click', closeChat);
    sb.querySelector('#p-stbtn').addEventListener('click', function () { switchView('settings'); });
    sb.querySelectorAll('.p-tab').forEach(function (tab) {
      tab.addEventListener('click', function () { switchView(tab.dataset.v); });
    });

    mainEl = document.createElement('div');
    mainEl.className = 'p-main';
    shell.appendChild(mainEl);

    loadFriends();
    switchView('chat');
    startPolling();
  }

  function switchView(v) {
    activeView = v;
    clearInterval(chatPollTimer);
    shell.querySelectorAll('.p-tab').forEach(function (t) { t.classList.toggle('on', t.dataset.v === v); });
    if (v === 'chat') renderChat();
    else if (v === 'requests') renderRequests();
    else if (v === 'add') renderAdd();
    else if (v === 'settings') renderSettings();
  }

  // ── Friends list ──────────────────────────────────────────────────────────
  async function loadFriends() {
    var friends = await getFriends();
    flEl.innerHTML = '';
    if (!friends.length) {
      flEl.innerHTML = '<div style="padding:12px 6px;font-size:12px;opacity:.4;text-align:center">' + s().noFriends + '</div>';
      return;
    }
    friends.forEach(function (f) {
      var el = document.createElement('div');
      el.className = 'p-fi' + (activeFriend && activeFriend.id === f.id ? ' act' : '');
      el.innerHTML = '<div class="p-av" style="background:' + avc(f.username) + '">' + avl(f.username) + '</div><div class="p-fn">@' + f.username + '</div>';
      el.addEventListener('click', function () {
        activeFriend = { id: f.id, username: f.username };
        _lastFriendId = f.id; _lastFriendUn = f.username;
        GM_setValue('pals_last_fid', f.id);
        GM_setValue('pals_last_fun', f.username);
        flEl.querySelectorAll('.p-fi').forEach(function (i) { i.classList.remove('act'); });
        el.classList.add('act');
        switchView('chat');
      });
      flEl.appendChild(el);
    });
  }

  // ── Chat ──────────────────────────────────────────────────────────────────
  function renderChat() {
    mainEl.innerHTML = '';
    var str = s();
    if (!activeFriend) {
      mainEl.innerHTML = '<div class="p-empty"><div class="p-eico">\uD83D\uDCAC</div><div class="p-etxt">' + str.pickFriend + '</div></div>';
      return;
    }

    mainEl.innerHTML = '<div class="p-mh"><div class="p-av" style="background:' + avc(activeFriend.username) + ';width:27px;height:27px;font-size:11px">' + avl(activeFriend.username) + '</div>@' + activeFriend.username + '</div>' +
      '<div class="p-msgs" id="p-msgs"></div>' +
      '<div class="p-irow" id="p-irow"><button class="p-ico" id="p-emob">\uD83D\uDE0A</button><button class="p-gb" id="p-gifb">GIF</button><textarea class="p-mi" id="p-mi" placeholder="' + str.messagePh + '" rows="1"></textarea><button class="p-sb2" id="p-snd">' + str.send + '</button></div>';

    var msgsEl = mainEl.querySelector('#p-msgs');
    var irowEl = mainEl.querySelector('#p-irow');
    var inp = mainEl.querySelector('#p-mi');
    var sndBtn = mainEl.querySelector('#p-snd');
    var emoOpen = false, gifOpen = false;

    // Load messages – always pulls latest from DB, persists forever
    async function loadMsgs(scrollToBottom) {
      var msgs = await getMessages(activeFriend.id);
      if (msgs.length === lastMsgLen && !scrollToBottom) return;
      lastMsgLen = msgs.length;
      msgsEl.innerHTML = '';
      msgs.forEach(function (m) {
        var bub = document.createElement('div');
        bub.className = 'p-bub ' + (m.sender_id === _user.id ? 'bme' : 'bth');
        if (m.body && m.body.indexOf('__GIF__') === 0) {
          var img = document.createElement('img');
          img.src = m.body.slice(7);
          img.className = 'p-gif'; img.loading = 'lazy';
          bub.appendChild(img);
        } else {
          bub.textContent = m.body;
        }
        msgsEl.appendChild(bub);
      });
      if (scrollToBottom) {
        msgsEl.scrollTop = msgsEl.scrollHeight;
      } else {
        var atBottom = msgsEl.scrollHeight - msgsEl.scrollTop <= msgsEl.clientHeight + 80;
        if (atBottom) msgsEl.scrollTop = msgsEl.scrollHeight;
      }
    }

    async function doSend(body) {
      if (!body || !body.trim()) return;
      inp.value = '';
      await sendMessage(activeFriend.id, body);
      await loadMsgs(true);
    }

    sndBtn.addEventListener('click', function () { doSend(inp.value); });
    inp.addEventListener('keydown', function (e) {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(inp.value); }
    });

    // Emoji panel
    mainEl.querySelector('#p-emob').addEventListener('click', function (e) {
      e.stopPropagation();
      var existing = mainEl.querySelector('#p-ep');
      mainEl.querySelector('#p-gp') && mainEl.querySelector('#p-gp').remove();
      gifOpen = false;
      if (existing) { existing.remove(); emoOpen = false; return; }
      emoOpen = true;
      var ep = document.createElement('div');
      ep.className = 'p-ep'; ep.id = 'p-ep';
      EMOJIS.forEach(function (em) {
        var btn = document.createElement('button');
        btn.className = 'p-eb'; btn.textContent = em;
        btn.addEventListener('click', function (ev) { ev.stopPropagation(); inp.value += em; inp.focus(); });
        ep.appendChild(btn);
      });
      irowEl.appendChild(ep);
    });

    // GIF panel
    mainEl.querySelector('#p-gifb').addEventListener('click', async function (e) {
      e.stopPropagation();
      var existing = mainEl.querySelector('#p-gp');
      mainEl.querySelector('#p-ep') && mainEl.querySelector('#p-ep').remove();
      emoOpen = false;
      if (existing) { existing.remove(); gifOpen = false; return; }
      gifOpen = true;
      var gp = document.createElement('div');
      gp.className = 'p-gp'; gp.id = 'p-gp';
      gp.innerHTML = '<input class="p-gsi" id="p-gq" placeholder="' + str.searchGifs + '"><div class="p-gg" id="p-gg"><div style="opacity:.4;font-size:12px;padding:4px">' + str.loading + '</div></div>';
      irowEl.appendChild(gp);
      var qEl = gp.querySelector('#p-gq');
      var ggEl = gp.querySelector('#p-gg');

      async function doGifSearch(q) {
        ggEl.innerHTML = '<div style="opacity:.4;font-size:12px;padding:4px">' + str.loading + '</div>';
        var gifs = await searchGifs(q);
        ggEl.innerHTML = '';
        if (!gifs.length) { ggEl.innerHTML = '<div style="opacity:.4;font-size:12px;padding:4px">No results</div>'; return; }
        gifs.forEach(function (g) {
          var img = document.createElement('img');
          img.src = g.preview || g.url; img.className = 'p-gif'; img.loading = 'lazy';
          img.addEventListener('click', async function () {
            gp.remove(); gifOpen = false;
            await doSend('__GIF__' + g.url);
          });
          ggEl.appendChild(img);
        });
      }

      doGifSearch('trending');
      var deb;
      qEl.addEventListener('input', function () { clearTimeout(deb); deb = setTimeout(function () { doGifSearch(qEl.value); }, 500); });
      qEl.addEventListener('click', function (ev) { ev.stopPropagation(); });
    });

    // Close panels on outside click
    document.addEventListener('click', function () {
      if (mainEl) {
        mainEl.querySelector('#p-ep') && mainEl.querySelector('#p-ep').remove();
        mainEl.querySelector('#p-gp') && mainEl.querySelector('#p-gp').remove();
      }
      emoOpen = false; gifOpen = false;
    });

    lastMsgLen = 0;
    loadMsgs(true);
    clearInterval(chatPollTimer);
    chatPollTimer = setInterval(function () { if (activeView === 'chat') loadMsgs(false); }, 4000);
  }

  // ── Requests ──────────────────────────────────────────────────────────────
  async function renderRequests() {
    var str = s();
    mainEl.innerHTML = '<div class="p-mh">' + str.friendRequests + '</div><div class="p-rp" id="p-rl"><div style="opacity:.4;font-size:13px">' + str.loading + '</div></div>';
    var rl = mainEl.querySelector('#p-rl');
    var reqs = await getIncomingReqs();
    rl.innerHTML = '';
    if (!reqs.length) { rl.innerHTML = '<div style="opacity:.4;font-size:13px">' + str.noPending + '</div>'; return; }
    reqs.forEach(function (req) {
      var prof = req['profiles!friend_requests_from_id_fkey'];
      var un = prof && prof.username || req.from_id;
      var el = document.createElement('div');
      el.className = 'p-ri';
      el.innerHTML = '<div class="p-av" style="background:' + avc(un) + ';width:30px;height:30px;font-size:11px">' + avl(un) + '</div><div class="p-rn">@' + un + '</div><button class="p-rb p-ac">' + str.accept + '</button><button class="p-rb p-dc">' + str.decline + '</button>';
      el.querySelector('.p-ac').addEventListener('click', async function () { await respondReq(req.id, 'accepted'); await loadFriends(); renderRequests(); });
      el.querySelector('.p-dc').addEventListener('click', async function () { await respondReq(req.id, 'declined'); renderRequests(); });
      rl.appendChild(el);
    });
  }

  // ── Add friend ────────────────────────────────────────────────────────────
  function renderAdd() {
    var str = s();
    mainEl.innerHTML = '<div class="p-mh">' + str.addFriend + '</div><div class="p-af"><div class="p-ar"><input class="p-inp" id="p-ain" placeholder="' + str.searchUser + '" autocomplete="off" style="flex:1;margin-bottom:0"><button class="p-pb" id="p-asb" style="width:auto;margin-top:0;padding:10px 14px">' + str.sendReq + '</button></div><div id="p-ast" style="font-size:12.5px;margin-top:9px;min-height:16px"></div></div>';
    var inp = mainEl.querySelector('#p-ain');
    var btn = mainEl.querySelector('#p-asb');
    var st = mainEl.querySelector('#p-ast');
    async function doAdd() {
      var un = inp.value.trim(); if (!un) return;
      st.textContent = str.searching; btn.disabled = true;
      var found = await findUser(un);
      if (!found) { st.textContent = str.notFound; btn.disabled = false; return; }
      if (found.id === _user.id) { st.textContent = str.thatsYou; btn.disabled = false; return; }
      var r = await sendFriendReq(found.id);
      st.textContent = r.ok ? str.reqSent + ' @' + un : str.alreadyFriends;
      if (r.ok) inp.value = '';
      btn.disabled = false;
    }
    btn.addEventListener('click', doAdd);
    inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') doAdd(); });
  }

  // ── Settings ──────────────────────────────────────────────────────────────
  function renderSettings() {
    var str = s();
    mainEl.innerHTML = '<div class="p-sp"><div class="p-st">\u2699\uFE0F ' + str.settings + '</div>' +
      '<div class="p-sr"><div class="p-sl">' + str.darkMode + '</div><button class="p-tog ' + (darkMode ? 'on' : 'off') + '" id="p-dktog"></button></div>' +
      '<div class="p-sr"><div class="p-sl">' + str.language + '</div><select class="p-ls" id="p-ls"><option value="en"' + (lang === 'en' ? ' selected' : '') + '>\uD83C\uDDEC\uD83C\uDDE7 English</option><option value="sv"' + (lang === 'sv' ? ' selected' : '') + '>\uD83C\uDDF8\uD83C\uDDEA Svenska</option></select></div>' +
      '<div class="p-sr"><div class="p-sl">' + str.signedInAs + '</div><div style="font-size:13px;opacity:.55">@' + _user.username + '</div></div>' +
      '<button class="p-lo" id="p-lo">' + str.logout + '</button>' +
      '<div style="font-size:11px;opacity:.25;margin-top:20px">' + str.version + '</div></div>';

    mainEl.querySelector('#p-dktog').addEventListener('click', function () {
      darkMode = !darkMode;
      GM_setValue('pals_dark', darkMode);
      this.classList.toggle('on', darkMode);
      this.classList.toggle('off', !darkMode);
      shell.className = darkMode ? 'dark' : 'light';
    });
    mainEl.querySelector('#p-ls').addEventListener('change', function () {
      lang = this.value;
      GM_setValue('pals_lang', lang);
      renderSettings();
      // also update sidebar tab labels
      var tabs = shell.querySelectorAll('.p-tab');
      var str2 = s();
      if (tabs[0]) tabs[0].textContent = str2.chat;
      if (tabs[1]) { tabs[1].innerHTML = str2.requests + (badgeEl ? '<span class="p-bdg" id="p-bdg" style="display:' + (badgeEl.style.display) + '">' + badgeEl.textContent + '</span>' : ''); badgeEl = tabs[1].querySelector('#p-bdg'); }
      if (tabs[2]) tabs[2].textContent = str2.add;
      var uc = shell.querySelector('.p-uc'); if (uc) uc.textContent = '@' + _user.username;
    });
    mainEl.querySelector('#p-lo').addEventListener('click', function () { doSignOut(); closeChat(); });
  }

  // ── Polling ───────────────────────────────────────────────────────────────
  function startPolling() {
    async function poll() {
      if (!_user || !isOpen) return;
      var reqs = await getIncomingReqs();
      if (badgeEl) { badgeEl.textContent = reqs.length; badgeEl.style.display = reqs.length ? 'flex' : 'none'; }
    }
    poll();
    clearInterval(pollTimer);
    pollTimer = setInterval(poll, 8000);
  }

})();