Easychat

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
  }

})();