Alt+P (Win/ChromeOS) or Ctrl+P (Mac) — persistent chat, friends, emojis, GIFs, EN/SV
// ==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">✕</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);
}
})();