您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Slide out menu with Ctrl+Shift+;, username support using GM storage, colored usernames, basic emotes, bottom-aligned chat
// ==UserScript== // @name Conscience Stream (Global in-browser chat) // @namespace https://greasyfork.org/en/scripts/549770-conscience-stream-global-in-browser-chat // @version 1.4a // @description Slide out menu with Ctrl+Shift+;, username support using GM storage, colored usernames, basic emotes, bottom-aligned chat // @author You // @match *://*/* // @grant GM.addStyle // @grant GM.setValue // @grant GM.getValue // @grant GM.xmlHttpRequest // @connect * // ==/UserScript== (async function() { 'use strict'; // Add styles for slide-out menu + chat GM.addStyle(` #tm-slideout-menu { position: fixed; top: 0; right: -380px; width: 380px; height: 100%; backdrop-filter: blur(18px) saturate(160%); -webkit-backdrop-filter: blur(18px) saturate(160%); background: linear-gradient(135deg, rgba(25,25,30,0.85) 0%, rgba(15,15,20,0.78) 60%, rgba(10,10,15,0.72) 100%); border-left: 1px solid rgba(255,255,255,0.08); color: #f5f7fa; box-shadow: -4px 0 14px rgba(0,0,0,0.55); transition: right 0.45s cubic-bezier(.4,.0,.2,1), opacity 0.45s ease, transform 0.5s cubic-bezier(.4,.0,.2,1); z-index: 999999; display: flex; flex-direction: column; opacity: 0; pointer-events: none; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif; box-sizing: border-box; overflow-x: hidden; transform: scale(.985); } /* Ensure all children use border-box to prevent width overflow in Chrome */ #tm-slideout-menu *, #tm-slideout-menu *::before, #tm-slideout-menu *::after { box-sizing: border-box; } #tm-slideout-menu.active { right: 0; opacity: 1; pointer-events: auto; transform: scale(1); } /* Reduced motion: strip transitions */ .tm-reduced-motion #tm-slideout-menu { transition: none !important; transform: none !important; } .tm-reduced-motion #tm-slideout-menu.active { transition: none !important; } .tm-reduced-motion #tm-jump-latest-btn, .tm-reduced-motion #tm-new-msg-badge, .tm-reduced-motion .tm-chat-message { transition: none !important; animation: none !important; } #tm-slideout-menu h2 { margin: 0; padding: 18px 22px 10px; font-size: 18px; font-weight: 600; letter-spacing: .5px; background: linear-gradient(90deg,#8f5fff,#6a5af9,#4d65f9); -webkit-background-clip: text; color: transparent; user-select: none; } #tm-username-container { padding: 4px 18px 10px; display: flex; gap: 8px; } #tm-username-input { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.06); color: #fff; font-size: 14px; outline: none; transition: border .2s, background .2s; } #tm-username-input:focus { border-color: #7f6bff; background: rgba(255,255,255,0.12); box-shadow: 0 0 0 3px rgba(127,107,255,0.25); } #tm-save-username-btn { padding: 10px 14px; border-radius: 10px; border: 0; background: linear-gradient(135deg,#7c5bff,#5b8dff); color: #fff; cursor: pointer; font-size: 13px; font-weight: 600; letter-spacing:.3px; display:inline-flex; align-items:center; gap:6px; box-shadow: 0 4px 12px -2px rgba(91,141,255,0.45); transition: transform .15s ease, box-shadow .3s ease; } #tm-save-username-btn:hover { transform: translateY(-2px); box-shadow:0 6px 18px -2px rgba(91,141,255,0.55); } #tm-info { padding: 0 20px 10px; font-size: 12px; color: #c7ced7; line-height: 1.5; } #tm-info p { margin: 4px 0; } #tm-chat-container { flex: 1; min-height:0; display: flex; flex-direction: column; justify-content: flex-end; overflow-y: auto; padding: 10px 18px 70px; gap: 10px; position: relative; } #tm-jump-latest-btn { position: absolute; left: 50%; bottom: 90px; /* dynamic bottom */ transform: translate(-50%,70px); opacity:0; pointer-events:none; transition: opacity .25s, transform .35s cubic-bezier(.4,.0,.2,1); background: rgba(45,55,85,0.7); backdrop-filter: blur(10px) saturate(160%); color:#fff; font-size:12px; font-weight:600; letter-spacing:.5px; padding:8px 16px; border-radius:24px; border:1px solid rgba(255,255,255,0.18); cursor:pointer; box-shadow:0 4px 12px -2px rgba(0,0,0,0.45); z-index: 2; outline: none; display:flex; align-items:center; justify-content:center; line-height:1; } #tm-jump-latest-btn:focus { outline: none; } #tm-jump-latest-btn:focus-visible { box-shadow:0 0 0 3px rgba(127,107,255,0.55), 0 4px 12px -2px rgba(0,0,0,0.45); } #tm-jump-latest-btn:hover { background: rgba(90,110,190,0.82); } #tm-jump-latest-btn.active { opacity:1; pointer-events:auto; transform: translate(-50%,0); } /* New message badge (subtle) */ #tm-new-msg-badge { position:absolute; left: 50%; bottom: 90px; transform: translate(-50%,70px); opacity:0; pointer-events:none; transition: opacity .25s, transform .35s cubic-bezier(.4,.0,.2,1); background: rgba(70,90,150,0.78); backdrop-filter: blur(10px) saturate(160%); color:#fff; font-size:11px; font-weight:600; letter-spacing:.5px; padding:6px 14px; border-radius:20px; border:1px solid rgba(255,255,255,0.18); box-shadow:0 4px 12px -2px rgba(0,0,0,0.45); z-index: 2; cursor:pointer; } #tm-new-msg-badge.active { opacity:1; pointer-events:auto; transform: translate(-50%,0); } #tm-chat-container::-webkit-scrollbar { width: 10px; } #tm-chat-container::-webkit-scrollbar-track { background: transparent; } #tm-chat-container::-webkit-scrollbar-thumb { background: linear-gradient(180deg,#4d4f5a,#2f3138); border-radius: 20px; border:2px solid transparent; background-clip: padding-box; } #tm-chat-container::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg,#636672,#3b3d45); border-radius: 20px; border:2px solid transparent; background-clip: padding-box; } .tm-chat-message { display: flex; flex-direction: column; gap: 4px; animation: tmFadeIn .4s ease; position:relative; overflow:visible; z-index:1; } .tm-bubble { max-width: 92%; padding: 10px 14px; border-radius: 16px; line-height: 1.4; font-size: 14px; position: relative; word-break: break-word; backdrop-filter: blur(4px); } /* Swap sides: user (tm-me) now left, others right */ .tm-me { align-items: flex-start; } .tm-other { align-items: flex-end; } .tm-me .tm-bubble { background: linear-gradient(135deg,#5a7dff,#866bff); color:#fff; border-bottom-left-radius: 4px; box-shadow: 0 4px 10px -2px rgba(90,125,255,0.4); } .tm-other .tm-bubble { background: rgba(255,255,255,0.08); color:#f2f5fa; border:1px solid rgba(255,255,255,0.08); border-bottom-right-radius:4px; } .tm-username { font-size: 11px; font-weight:600; letter-spacing:.5px; text-transform: uppercase; opacity:.85; padding:0 2px 2px; user-select:none; width:100%; } .tm-me .tm-username { text-align: left; background: linear-gradient(90deg,#a9b8ff,#d2c2ff); -webkit-background-clip:text; color:transparent; } .tm-other .tm-username { text-align: right; color:#8fa0b3; } #tm-chat-box { padding: 14px 16px 18px; border-top: 1px solid rgba(255,255,255,0.08); background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0)); box-sizing: border-box; } #tm-chat-input { resize: none; width: 100%; max-width:100%; height:40px; min-height:40px; max-height:40px; overflow-y:auto; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.18); border-radius: 14px; color: #fff; padding: 10px 12px; font-size: 14px; font-family: inherit; outline: none; transition: border .2s, background .25s, box-shadow .25s; display:block; line-height:18px; } #tm-chat-input:focus { border-color: #7f6bff; background: rgba(255,255,255,0.12); box-shadow: 0 0 0 3px rgba(127,107,255,0.25); } @keyframes tmFadeIn { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } } /* Checkbox styling (remove Firefox dotted outline, keep accessible focus-visible) */ #tm-settings-popup input[type=checkbox] { outline: none !important; box-shadow:none; accent-color:#7f6bff; } #tm-settings-popup input[type=checkbox]:focus { outline: none; box-shadow:none; } #tm-settings-popup input[type=checkbox]:focus-visible { outline: 2px solid rgba(127,107,255,0.85); outline-offset: 2px; border-radius:4px; } /* High contrast fallback */ @media (forced-colors: active) { #tm-settings-popup input[type=checkbox]:focus-visible { outline: 2px solid Highlight; } } /* Rainbow animation for /rainbow command */ @keyframes tmRainbowShift { 0% { filter:hue-rotate(0deg);} 100% { filter:hue-rotate(360deg);} } .tm-rainbow-bubble { position:relative; } .tm-rainbow-bubble::before { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(135deg,#ff6ab7,#ffcd56,#64ff8f,#5bbdff,#b07bff,#ff6ab7); background-size:400% 400%; animation: tmRainbowGrad 8s linear infinite; opacity:0.9; z-index:0; } .tm-rainbow-bubble > * { position:relative; z-index:1; } @keyframes tmRainbowGrad { 0%{ background-position:0% 50%; } 50%{ background-position:100% 50%; } 100%{ background-position:0% 50%; } } .tm-mention { background:rgba(255,255,255,0.18); padding:0 4px; border-radius:6px; font-weight:600; } .tm-mention-self { background:linear-gradient(135deg,#ff8a6b,#ffb36b); color:#1a1c22; padding:0 6px; border-radius:8px; font-weight:700; box-shadow:0 2px 6px -2px rgba(0,0,0,0.4); } /* Reactions */ .tm-reaction-bar { display:none; } .tm-react-chip, .tm-react-btn { font-size:12px; line-height:1; padding:4px 8px; border-radius:14px; background:rgba(255,255,255,0.12); color:#fff; cursor:pointer; user-select:none; display:inline-flex; align-items:center; gap:4px; border:1px solid rgba(255,255,255,0.18); transition:background .25s,border .25s,opacity .25s,transform .25s; } .tm-react-chip:hover { background:rgba(255,255,255,0.22); } /* Side floating + button (appears on message hover) */ .tm-react-btn { position:absolute; top:50%; transform:translateY(-50%); width:26px; height:26px; padding:0; justify-content:center; font-weight:700; letter-spacing:.5px; opacity:0; pointer-events:none; background:rgba(40,45,60,0.85); backdrop-filter:blur(10px) saturate(180%); -webkit-backdrop-filter:blur(10px) saturate(180%); box-shadow:0 4px 14px -4px rgba(0,0,0,0.55); transition:opacity .25s,background .25s; } /* Show button when hovering message wrapper or bubble or button itself */ .tm-chat-message:hover .tm-react-btn, .tm-react-btn:hover { opacity:1; pointer-events:auto; } /* Orientation: self (left) gets + on right side of bubble; others (right) get + on left side */ .tm-chat-message.tm-me .tm-bubble { position:relative; } .tm-chat-message.tm-other .tm-bubble { position:relative; } .tm-chat-message.tm-me .tm-react-btn { left:100%; margin-left:8px; border-top-left-radius:8px; border-bottom-left-radius:8px; } .tm-chat-message.tm-other .tm-react-btn { right:100%; margin-right:8px; border-top-right-radius:8px; border-bottom-right-radius:8px; } .tm-react-btn:hover { background:rgba(70,80,110,0.95); } /* Corner reaction chips */ .tm-reaction-corner { position:absolute; bottom:-12px; display:flex; gap:4px; align-items:flex-end; } .tm-chat-message.tm-me .tm-reaction-corner { right:6px; } .tm-chat-message.tm-other .tm-reaction-corner { left:6px; } .tm-reaction-corner .tm-react-chip { background:rgba(40,45,60,0.9); border:1px solid rgba(255,255,255,0.25); padding:4px 6px; font-size:11px; box-shadow:0 4px 10px -3px rgba(0,0,0,0.55); } .tm-reaction-corner .tm-react-chip:hover { transform:translateY(-2px); } .tm-react-palette { display:flex; gap:6px; padding:6px 6px 4px; background:rgba(20,24,34,0.97); backdrop-filter:blur(18px) saturate(200%); -webkit-backdrop-filter:blur(18px) saturate(200%); border:1px solid rgba(255,255,255,0.25); border-radius:12px; position:absolute; z-index:1000002; box-shadow:0 14px 36px -10px rgba(0,0,0,0.65); } .tm-react-emoji-option { font-size:18px; cursor:pointer; line-height:1; padding:4px 4px 2px; border-radius:8px; transition:background .2s; } .tm-react-emoji-option:hover { background:rgba(255,255,255,0.15); } .tm-react-chip-count { font-size:11px; font-weight:600; opacity:.75; } `); // Basic emotes map const emotes = { ":)": "😊", ":-)": "😊", ":(": "☹️", ":-(": "☹️", ":D": "😄", ":-D": "😄", ":P": "😛", ":-P": "😛", ";)": "😉", ";-)": "😉", "<3": "❤️", ":o": "😮", ":O": "😮", "B)": "😎", "B-)": "😎" }; function parseEmotes(msg) { const pattern = new RegExp(Object.keys(emotes).map(k => k.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')).join("|"), "g"); return msg.replace(pattern, (match) => emotes[match]); } // Username color hashing (deterministic pastel-ish but distinct hues) const usernameColorCache = new Map(); function hashUsername(name){ let h = 0; for(let i=0;i<name.length;i++){ h = (h*131 + name.charCodeAt(i)) >>> 0; } return h; } function usernameToColor(name){ if(usernameColorCache.has(name)) return usernameColorCache.get(name); const h = hashUsername(name); // Spread across hue wheel; avoid clustering: use golden ratio conjugate offset const hue = ( (h % 360) + ((h/360)%1)*222 ) % 360; // pseudo scramble const sat = 62 + (h % 24); // 62-85% const light = 52 + (h % 14); // 52-65% const color = `hsl(${hue.toFixed(1)}, ${sat}%, ${light}%)`; usernameColorCache.set(name, color); return color; } function usernameGradient(name){ const base = usernameToColor(name); // hsl(h,s%,l%) // Slightly rotate hue and adjust lightness for second stop const m = /hsl\(([^,]+),\s*([^,]+),\s*([^\)]+)\)/.exec(base); if(!m) return base; let h = parseFloat(m[1]); let s = m[2]; let l = parseFloat(m[3]); const h2 = (h + 18) % 360; const l2 = Math.min(78, l + 14); return `linear-gradient(135deg, ${base}, hsl(${h2.toFixed(1)}, ${s}, ${l2.toFixed(1)}))`; } function ensureContrast(fgHsl){ // Convert HSL to RGB then compute relative luminance for deciding dark/light text // We only need to know whether to use light overlay gradient or muted grey fallback. return fgHsl; // For now we rely on chosen lightness range (52-65%) which contrasts on dark bg. } // Client ID generation for dedup (double Enter) & offline queue function generateClientId(){ if(window.crypto && crypto.randomUUID) return crypto.randomUUID(); // Fallback simple UUID v4-ish return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{ const r = Math.random()*16|0; const v = c==='x'?r:(r&0x3|0x8); return v.toString(16); }); } let inFlightMessage = { text:'', clientId:null, time:0 }; const offlineQueue = []; // {clientId, username, text, createdAt, attempts} let flushingQueue = false; const recentSent = []; // track recent sent texts for self-detection heuristic // Create the menu element const menu = document.createElement("div"); menu.id = "tm-slideout-menu"; // Manual DOM build to satisfy strict Trusted Types CSP (e.g., YouTube) const titleEl = document.createElement('h2'); titleEl.textContent = 'Conscience Stream'; menu.appendChild(titleEl); const userContainer = document.createElement('div'); userContainer.id='tm-username-container'; const usernameInputEl = document.createElement('input'); usernameInputEl.type='text'; usernameInputEl.id='tm-username-input'; usernameInputEl.placeholder='Username'; const saveButtonEl = document.createElement('button'); saveButtonEl.id='tm-save-username-btn'; saveButtonEl.type='button'; saveButtonEl.textContent='Save'; userContainer.appendChild(usernameInputEl); userContainer.appendChild(saveButtonEl); menu.appendChild(userContainer); const settingsAnchor = document.createElement('div'); settingsAnchor.id='tm-settings-anchor'; settingsAnchor.style.height='2px'; menu.appendChild(settingsAnchor); const chatContainerDiv = document.createElement('div'); chatContainerDiv.id='tm-chat-container'; menu.appendChild(chatContainerDiv); // Presence indicator (always visible). Will be updated by heuristic + optional backend /presence endpoint. const presenceBar = document.createElement('div'); presenceBar.id='tm-presence-bar'; presenceBar.style.cssText='position:absolute;top:0;left:0;right:0;height:20px;padding:2px 10px;font-size:11px;display:flex;align-items:center;gap:8px;color:#cfd6e0;opacity:.85;pointer-events:none;'; presenceBar.textContent = 'Active: 0'; chatContainerDiv.appendChild(presenceBar); const chatBoxDiv = document.createElement('div'); chatBoxDiv.id='tm-chat-box'; chatBoxDiv.style.position='relative'; // Input row (textarea + emoji button) for better alignment vs overlaid button const inputRow = document.createElement('div'); inputRow.style.cssText='display:flex;align-items:stretch;gap:6px;width:100%;'; const chatTextarea = document.createElement('textarea'); chatTextarea.id='tm-chat-input'; chatTextarea.placeholder='Send a message...'; chatTextarea.style.flex='1 1 auto'; // Remove earlier right padding hack; natural padding already set via CSS block at top const composeEmojiBtn = document.createElement('button'); composeEmojiBtn.type='button'; composeEmojiBtn.id='tm-compose-emoji-btn'; composeEmojiBtn.textContent='😀'; composeEmojiBtn.setAttribute('aria-label','Insert emoji'); composeEmojiBtn.title='Insert emoji (click)'; composeEmojiBtn.style.cssText='flex:0 0 40px;width:40px;height:40px;border:1px solid rgba(255,255,255,0.18);border-radius:12px;background:linear-gradient(140deg,rgba(110,99,255,0.32),rgba(150,133,255,0.18));color:#fff;font-size:19px;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(10px) saturate(160%);-webkit-backdrop-filter:blur(10px) saturate(160%);transition:background .25s,border-color .25s,transform .15s;box-shadow:0 2px 8px -2px rgba(0,0,0,0.55);padding:0;line-height:1;'; composeEmojiBtn.style.alignSelf='stretch'; composeEmojiBtn.addEventListener('mouseenter',()=>{ composeEmojiBtn.style.background='linear-gradient(140deg,rgba(130,119,255,0.55),rgba(170,153,255,0.38))'; }); composeEmojiBtn.addEventListener('mouseleave',()=>{ composeEmojiBtn.style.background='linear-gradient(140deg,rgba(110,99,255,0.35),rgba(150,133,255,0.22))'; composeEmojiBtn.style.transform='translateY(0)'; }); composeEmojiBtn.addEventListener('mousedown',()=>{ composeEmojiBtn.style.transform='translateY(1px)'; }); composeEmojiBtn.addEventListener('mouseup',()=>{ composeEmojiBtn.style.transform='translateY(0)'; }); composeEmojiBtn.addEventListener('focus',()=>{ composeEmojiBtn.style.boxShadow='0 0 0 3px rgba(127,107,255,0.45)'; }); composeEmojiBtn.addEventListener('blur',()=>{ composeEmojiBtn.style.boxShadow='0 3px 10px -3px rgba(0,0,0,0.55)'; }); // Send icon button const sendBtn = document.createElement('button'); sendBtn.type='button'; sendBtn.id='tm-send-btn'; sendBtn.setAttribute('aria-label','Send message'); sendBtn.title='Send (Enter)'; sendBtn.innerHTML='\u27A4'; // arrow icon sendBtn.style.cssText='flex:0 0 40px;width:40px;height:40px;border:1px solid rgba(255,255,255,0.18);border-radius:12px;background:linear-gradient(140deg,rgba(90,150,255,0.35),rgba(130,170,255,0.20));color:#fff;font-size:18px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(10px) saturate(160%);-webkit-backdrop-filter:blur(10px) saturate(160%);transition:background .25s,transform .15s,opacity .25s;box-shadow:0 2px 8px -2px rgba(0,0,0,0.55);padding:0;line-height:1;'; const sendBtnBaseBg='linear-gradient(140deg,rgba(90,150,255,0.35),rgba(130,170,255,0.20))'; const sendBtnHoverBg='linear-gradient(140deg,rgba(110,170,255,0.55),rgba(150,190,255,0.32))'; sendBtn.addEventListener('mouseenter',()=>{ sendBtn.style.background=sendBtnHoverBg; }); sendBtn.addEventListener('mouseleave',()=>{ sendBtn.style.background=sendBtnBaseBg; sendBtn.style.transform='translateY(0)'; }); sendBtn.addEventListener('mousedown',()=>{ sendBtn.style.transform='translateY(1px)'; }); sendBtn.addEventListener('mouseup',()=>{ sendBtn.style.transform='translateY(0)'; }); function updateSendBtnState(){ if(chatTextarea.value.trim()){ sendBtn.style.opacity='1'; sendBtn.disabled=false; } else { sendBtn.style.opacity='.45'; sendBtn.disabled=true; } } sendBtn.addEventListener('click',()=>{ const val = chatTextarea.value.trim(); if(!val) return; sendMessage(val); chatTextarea.value=''; updateSendBtnState(); }); chatTextarea.addEventListener('input', updateSendBtnState); inputRow.appendChild(chatTextarea); inputRow.appendChild(composeEmojiBtn); inputRow.appendChild(sendBtn); chatBoxDiv.appendChild(inputRow); menu.appendChild(chatBoxDiv); document.body.appendChild(menu); let isOpen = false; // Stable user identity (userId) independent of username; persisted via GM storage let userId = await GM.getValue('tmUserId', null); if(!userId){ userId = (window.crypto && crypto.randomUUID)? crypto.randomUUID(): 'uid-'+Date.now()+'-'+Math.random().toString(16).slice(2); await GM.setValue('tmUserId', userId); } // Load username from GM storage const usernameInput = menu.querySelector("#tm-username-input"); let username = await GM.getValue("tmChatUsername", "Anonymous"); usernameInput.value = username; function normalizedName(v){ return (v||'').trim().toLowerCase(); } let usernameLower = normalizedName(username); const saveBtn = menu.querySelector("#tm-save-username-btn"); saveBtn.addEventListener("click", async () => { const prev = username; username = usernameInput.value.trim() || "Anonymous"; usernameLower = normalizedName(username); await GM.setValue("tmChatUsername", username); if(prev !== username){ try { await httpRequest('POST', `${BACKEND_URL}/users/rename`, { userId, username }); } catch {} const notice = `* ${prev} is now known as ${username}`; sendMessage(notice); document.querySelectorAll(`.tm-chat-message[data-user-id="${userId}"] .tm-username`).forEach(el=>{ const txt = el.textContent||''; const selfTag = txt.includes('• you'); el.textContent = username + (selfTag? ' • you':''); }); } else { showStatus('Username unchanged','info',1800); } }); // Preferences & settings popup let notificationsEnabled = await GM.getValue('tmNotificationsEnabled', true); let notificationSoundEnabled = await GM.getValue('tmNotificationSoundEnabled', true); let showStartupHint = await GM.getValue('tmShowStartupHint', true); let reducedMotion = await GM.getValue('tmReducedMotion', false); (function buildSettings(){ const settingsBtn = document.createElement('button'); settingsBtn.id = 'tm-settings-btn'; settingsBtn.type = 'button'; settingsBtn.setAttribute('aria-label','Chat settings'); settingsBtn.textContent = '⚙️'; settingsBtn.style.cssText='position:absolute;top:10px;right:12px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#f5f7fa;width:34px;height:34px;border-radius:10px;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);transition:background .25s,border .25s;z-index:3;'; settingsBtn.addEventListener('mouseenter', ()=>{ settingsBtn.style.background='rgba(255,255,255,0.15)'; }); settingsBtn.addEventListener('mouseleave', ()=>{ settingsBtn.style.background='rgba(255,255,255,0.08)'; }); menu.appendChild(settingsBtn); const popup = document.createElement('div'); popup.id = 'tm-settings-popup'; popup.style.cssText='position:absolute;top:54px;right:16px;width:270px;padding:14px 16px;display:none;flex-direction:column;gap:10px;background:rgba(25,28,40,0.92);backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(255,255,255,0.12);border-radius:14px;box-shadow:0 10px 28px -8px rgba(0,0,0,0.55);font-size:12px;z-index:1000000;'; // Build popup content manually const header = document.createElement('div'); header.style.cssText='font-size:13px;font-weight:600;letter-spacing:.5px;opacity:.85;display:flex;align-items:center;justify-content:space-between;'; const headerSpan = document.createElement('span'); headerSpan.style.userSelect='none'; headerSpan.textContent='Options'; const closeBtn = document.createElement('button'); closeBtn.id='tm-close-settings'; closeBtn.style.cssText='background:transparent;border:0;color:#c7ced7;font-size:18px;cursor:pointer;line-height:1;padding:2px 6px;border-radius:8px;'; closeBtn.textContent='×'; header.appendChild(headerSpan); header.appendChild(closeBtn); popup.appendChild(header); function addCheckbox(id,label){ const lab=document.createElement('label'); lab.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;'; const cb=document.createElement('input'); cb.type='checkbox'; cb.id=id; cb.style.cssText='width:14px;height:14px;cursor:pointer;'; const span=document.createElement('span'); span.style.flex='1'; span.textContent=label; lab.appendChild(cb); lab.appendChild(span); popup.appendChild(lab); return cb; } const notifCb = addCheckbox('tm-enable-notifications','Show pop-up notifications'); const soundCb = addCheckbox('tm-enable-sound','Play notification sound'); const hintCb = addCheckbox('tm-show-startup-hint','Show startup hint'); const reducedMotionCb = addCheckbox('tm-reduced-motion','Reduced motion'); const hintInfo = document.createElement('div'); hintInfo.style.cssText='font-size:11px;line-height:1.4;opacity:.6;'; hintInfo.textContent='Startup hint shows a toast explaining the hotkey (Ctrl+Shift+;).'; popup.appendChild(hintInfo); menu.appendChild(popup); // Elements already created above notifCb.checked = notificationsEnabled; soundCb.checked = notificationSoundEnabled; hintCb.checked = showStartupHint; reducedMotionCb.checked = reducedMotion; notifCb.addEventListener('change', async ()=>{ notificationsEnabled = notifCb.checked; await GM.setValue('tmNotificationsEnabled', notificationsEnabled); showStatus('Notifications ' + (notificationsEnabled? 'enabled':'disabled'),'info',2000); }); soundCb.addEventListener('change', async ()=>{ notificationSoundEnabled = soundCb.checked; await GM.setValue('tmNotificationSoundEnabled', notificationSoundEnabled); showStatus('Sound ' + (notificationSoundEnabled? 'enabled':'disabled'),'info',2000); }); hintCb.addEventListener('change', async ()=>{ showStartupHint = hintCb.checked; await GM.setValue('tmShowStartupHint', showStartupHint); showStatus('Startup hint ' + (showStartupHint? 'enabled':'disabled'),'info',2000); }); reducedMotionCb.addEventListener('change', async ()=>{ reducedMotion = reducedMotionCb.checked; await GM.setValue('tmReducedMotion', reducedMotion); applyReducedMotion(); showStatus('Reduced motion ' + (reducedMotion? 'on':'off'),'info',2000); }); function togglePopup(){ popup.style.display = (popup.style.display==='flex')?'none':'flex'; } settingsBtn.addEventListener('click', (e)=>{ e.stopPropagation(); togglePopup(); }); closeBtn.addEventListener('click', (e)=>{ e.stopPropagation(); popup.style.display='none'; }); document.addEventListener('mousedown', (e)=>{ if(!popup.contains(e.target) && e.target !== settingsBtn){ popup.style.display='none'; } }); })(); // Function to toggle menu function toggleMenu() { isOpen = !isOpen; if (isOpen) { menu.classList.add("active"); // Focus chat input shortly after opening to ensure element is rendered setTimeout(()=>{ try { chatInput.focus(); chatInput.selectionStart = chatInput.value.length; } catch {} }, 30); } else { menu.classList.remove("active"); } } function applyReducedMotion(){ if(reducedMotion){ document.documentElement.classList.add('tm-reduced-motion'); } else { document.documentElement.classList.remove('tm-reduced-motion'); } } applyReducedMotion(); // Close panel on outside click (ignore internal floating palettes/toasts) document.addEventListener('mousedown', (e) => { if(!isOpen) return; if(menu.contains(e.target)) return; if(e.target.closest && ( e.target.closest('.tm-toast') || e.target.closest('.tm-react-palette') || e.target.closest('.tm-compose-emoji-palette') )) return; isOpen = false; menu.classList.remove('active'); }); // Keyboard shortcut: Ctrl+Shift+; (semicolon). Shift+; produces ':' on many layouts, so check code & both keys. document.addEventListener("keydown", (e) => { if (e.ctrlKey && e.shiftKey && (e.code === "Semicolon" || e.key === ";" || e.key === ":")) { e.preventDefault(); toggleMenu(); } if(e.key === 'Escape' && isOpen){ e.preventDefault(); isOpen = false; menu.classList.remove('active'); } }); // Outside click close disabled: panel persists until hotkey toggle // Chat handling + backend integration (HTTP polling) const chatContainer = menu.querySelector("#tm-chat-container"); const chatInput = menu.querySelector("#tm-chat-input"); const chatBox = menu.querySelector('#tm-chat-box'); // Jump to latest button (outside scroll area, positioned relative to panel) const jumpBtn = document.createElement('button'); jumpBtn.id = 'tm-jump-latest-btn'; jumpBtn.textContent = 'Jump to latest'; menu.appendChild(jumpBtn); // New messages badge (appears when new messages arrive while user idle & slightly above bottom) const newMsgBadge = document.createElement('div'); newMsgBadge.id = 'tm-new-msg-badge'; newMsgBadge.textContent = 'New messages below'; menu.appendChild(newMsgBadge); function updateJumpPosition(){ // Place button just above chat box with 12px gap if (!chatBox) return; const boxHeight = chatBox.getBoundingClientRect().height; jumpBtn.style.bottom = (boxHeight + 12) + 'px'; newMsgBadge.style.bottom = (boxHeight + 12) + 'px'; } const BACKEND_URL = window.TM_CHAT_BACKEND || "http://157.245.39.77:9092"; // allow override // lastTimestamp tracks latest seen update time (created or modified) from server let lastTimestamp = 0; let polling = false; let backoff = 3000; // start 3s const maxBackoff = 30000; let stopped = false; let initialHistoryLoaded = false; // suppress notifications until first poll finishes // Track messages we've already rendered to avoid duplicate DOM entries & notifications const seenMessages = new Set(); let pendingNewMessages = false; // unseen messages below viewport const clientIdToServerId = new Map(); function messageKey(m){ if(!m) return ''; if(m.id) return 'id:'+m.id; if(m.clientId && clientIdToServerId.has(m.clientId)) return 'id:'+clientIdToServerId.get(m.clientId); if(m.clientId) return 'cid:'+m.clientId; return `${m.createdAt}|${m.username}|${m.text}`; } function atBottom() { return (chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 8; } function nearBottom(){ // Within 300px of bottom considered near return (chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 300; } function distanceToBottom(){ return chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight; } let lastUserScrollTime = Date.now(); const autoStickIdleMs = 4000; // user idle threshold for auto-scrolling when near bottom const closeBottomPx = 180; // if within this distance show subtle badge instead of jump button let suppressScrollMark = false; function markUserScroll(){ if(suppressScrollMark) return; // ignore programmatic scrolls lastUserScrollTime = Date.now(); } function scrollToBottom(behavior='smooth'){ if(reducedMotion) behavior = 'auto'; suppressScrollMark = true; chatContainer.scrollTo({ top: chatContainer.scrollHeight, behavior }); // allow next tick to re-enable user scroll mark setTimeout(()=>{ suppressScrollMark = false; }, 60); } function showNewMsgBadge(){ if(!pendingNewMessages) return; jumpBtn.classList.remove('active'); newMsgBadge.classList.add('active'); } function hideNewMsgBadge(){ newMsgBadge.classList.remove('active'); } function updateJumpBtn() { if(atBottom()){ jumpBtn.classList.remove('active'); hideNewMsgBadge(); pendingNewMessages = false; return; } const dist = distanceToBottom(); if(dist < closeBottomPx){ if(pendingNewMessages){ showNewMsgBadge(); } else { hideNewMsgBadge(); jumpBtn.classList.add('active'); } } else { hideNewMsgBadge(); jumpBtn.classList.add('active'); } } function renderMessage(m, highlightSelf=false, fragmentTarget=null, suppressNotify=false, allowUpdate=false) { const key = messageKey(m); const already = seenMessages.has(key); if(already && !allowUpdate) return; // skip duplicate if not updating if(!already) seenMessages.add(key); let existingWrapper = chatContainer.querySelector(`[data-msg-key="${CSS.escape(key)}"]`); if(already) existingWrapper = chatContainer.querySelector(`[data-msg-key="${CSS.escape(key)}"]`); const wrapper = document.createElement("div"); wrapper.className = `tm-chat-message ${highlightSelf? 'tm-me':'tm-other'}`; wrapper.dataset.msgKey = key; if(m.userId) wrapper.dataset.userId = m.userId; const safeUser = m.username.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); const safeText = m.text.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); const parsed = parseEmotes(safeText); const userColor = !highlightSelf ? ensureContrast(usernameToColor(m.username)) : null; const isAction = parsed.startsWith('* '); const userDiv = document.createElement('div'); userDiv.className='tm-username'; if(!highlightSelf && userColor) userDiv.style.color = userColor; userDiv.textContent = safeUser + (highlightSelf ? ' • you' : ''); const bubble = document.createElement('div'); bubble.className='tm-bubble'; if(!highlightSelf){ // Apply gradient background + subtle border tint for others dynamically const grad = usernameGradient(m.username); bubble.style.background = grad; bubble.style.border = '1px solid rgba(255,255,255,0.12)'; bubble.style.color = '#fff'; } if(highlightSelf && rainbowMode){ bubble.classList.add('tm-rainbow-bubble'); bubble.style.background='transparent'; bubble.style.border='1px solid rgba(255,255,255,0.18)'; } if(isAction){ bubble.style.fontStyle='italic'; bubble.style.opacity='.9'; } if(m.offlineQueued){ bubble.style.opacity='.7'; bubble.style.borderStyle='dashed'; } // Timestamp const created = m.createdAt ? new Date(m.createdAt) : new Date(); const hh = created.getHours().toString().padStart(2,'0'); const mm = created.getMinutes().toString().padStart(2,'0'); const timeLabel = `${hh}:${mm}`; const timeSpan = document.createElement('span'); timeSpan.textContent = timeLabel; timeSpan.title = created.toISOString(); timeSpan.style.cssText='font-size:10px;opacity:.55;margin-left:8px;white-space:nowrap;align-self:flex-end;font-weight:500;'; // Message body wrapper to place timestamp inline (flex) const msgLine = document.createElement('div'); msgLine.style.cssText='display:flex;align-items:flex-end;gap:4px;flex-wrap:wrap;'; const msgTextSpan = document.createElement('span'); // Mention highlighting: split on @word boundaries const mentionRegex = /@([A-Za-z0-9_]{2,24})/g; let lastIndex = 0; let match; while((match = mentionRegex.exec(parsed))){ const start = match.index; const end = start + match[0].length; if(start > lastIndex){ const chunk = document.createElement('span'); chunk.textContent = parsed.slice(lastIndex,start); msgTextSpan.appendChild(chunk); } const mentionSpan = document.createElement('span'); const mentioned = match[1]; const isSelf = mentioned.toLowerCase() === username.toLowerCase(); mentionSpan.className = isSelf ? 'tm-mention-self' : 'tm-mention'; mentionSpan.textContent = match[0]; msgTextSpan.appendChild(mentionSpan); lastIndex = end; } if(lastIndex < parsed.length){ const tail = document.createElement('span'); tail.textContent = parsed.slice(lastIndex); msgTextSpan.appendChild(tail); } msgLine.appendChild(msgTextSpan); msgLine.appendChild(timeSpan); if(m.offlineQueued){ const queuedSpan = document.createElement('span'); queuedSpan.textContent='(queued)'; queuedSpan.style.cssText='font-size:10px;opacity:.55;margin-left:4px;'; msgLine.appendChild(queuedSpan); } bubble.appendChild(msgLine); // Floating add reaction button (inside bubble so hover over bubble keeps it visible) const addBtn = document.createElement('div'); addBtn.className='tm-react-btn'; addBtn.textContent='+'; bubble.appendChild(addBtn); // Corner reaction chips container const cornerHolder = document.createElement('div'); cornerHolder.className='tm-reaction-corner'; bubble.appendChild(cornerHolder); wrapper.appendChild(userDiv); wrapper.appendChild(bubble); if(already && existingWrapper){ // Replace bubble while preserving scroll position roughly existingWrapper.replaceWith(wrapper); } else { (fragmentTarget || chatContainer).appendChild(wrapper); } if(!highlightSelf && !suppressNotify) maybeNotifyNewMessage(m); // Auto-scroll / indicator logic const now = Date.now(); const idle = (now - lastUserScrollTime) > autoStickIdleMs; const dist = distanceToBottom(); if(highlightSelf || atBottom() || (nearBottom() && idle)){ scrollToBottom('smooth'); hideNewMsgBadge(); pendingNewMessages = false; } else { if(dist < closeBottomPx){ pendingNewMessages = true; showNewMsgBadge(); } else { updateJumpBtn(); } } // updatePresence() removed in backend-authoritative presence refactor; call suppressed } // --- Presence Tracking (backend authoritative) ------------------------- async function presenceHeartbeat(){ try { await httpRequest('POST', `${BACKEND_URL}/presence/heartbeat`, { userId }); } catch {} } async function presencePoll(){ try { const res = await httpRequest('GET', `${BACKEND_URL}/presence/active`); if(res.ok){ const data = await res.json(); presenceBar.textContent = `Active: ${typeof data.count==='number'?data.count:0}`; } } catch {} } setInterval(presenceHeartbeat, 15000); // heartbeat every 15s (server TTL 30s) setInterval(presencePoll, 10000); // poll count every 10s presenceHeartbeat(); presencePoll(); // Local reaction state: key -> { emoji -> count, selfSet: Set(emoji) } const reactionState = new Map(); const defaultEmojis = ['👍','❤️','😂','🔥','😮','🎉']; function ensureReactionBucket(msgKey){ if(!reactionState.has(msgKey)){ reactionState.set(msgKey, { counts: new Map(), self: new Set() }); } return reactionState.get(msgKey); } function renderReactionsForMessage(msgKey){ const bucket = reactionState.get(msgKey); const wrapper = chatContainer.querySelector(`[data-msg-key="${CSS.escape(msgKey)}"]`); if(!wrapper) return; const bubble = wrapper.querySelector('.tm-bubble'); if(!bubble) return; const corner = bubble.querySelector('.tm-reaction-corner'); if(!corner) return; corner.innerHTML=''; if(!bucket || bucket.counts.size===0) return; for(const [emoji,count] of bucket.counts.entries()){ const chip = document.createElement('div'); chip.className='tm-react-chip'; chip.dataset.emoji=emoji; chip.textContent=emoji; const span = document.createElement('span'); span.className='tm-react-chip-count'; span.textContent=String(count); chip.appendChild(span); if(bucket.self.has(emoji)) chip.style.background='rgba(90,110,190,0.6)'; corner.appendChild(chip); } } async function toggleReaction(msgKey, emoji, sendNetwork){ const bucket = ensureReactionBucket(msgKey); const alreadyHad = bucket.self.has(emoji); if(alreadyHad){ // Remove current reaction (toggle off) bucket.self.delete(emoji); bucket.counts.set(emoji, Math.max(0,(bucket.counts.get(emoji)||1)-1)); if(bucket.counts.get(emoji)===0) bucket.counts.delete(emoji); } else { // Enforce single reaction: remove any existing self reactions first for(const e of Array.from(bucket.self)){ bucket.self.delete(e); const cur = bucket.counts.get(e) || 0; if(cur <= 1) bucket.counts.delete(e); else bucket.counts.set(e, cur-1); } bucket.self.add(emoji); bucket.counts.set(emoji, (bucket.counts.get(emoji)||0)+1); } renderReactionsForMessage(msgKey); if(sendNetwork){ let messageId=''; if(msgKey.startsWith('id:')) messageId = msgKey.slice(3); else if(msgKey.startsWith('cid:')) { const provisional = msgKey.slice(4); if(clientIdToServerId.has(provisional)) messageId = clientIdToServerId.get(provisional); } if(!messageId) return; // wait until server id known try { await httpRequest('POST', `${BACKEND_URL}/reactions`, { messageId, emoji, username }); } catch {} } } // Event delegation for reactions chatContainer.addEventListener('click', (e)=>{ const chip = e.target.closest('.tm-react-chip'); if(chip){ const wrapper = chip.closest('.tm-chat-message'); if(!wrapper) return; const msgKey = wrapper.dataset.msgKey; const emoji = chip.dataset.emoji; toggleReaction(msgKey, emoji, true); return; } const addBtn = e.target.closest('.tm-react-btn'); if(addBtn){ const wrapper = addBtn.closest('.tm-chat-message'); if(!wrapper) return; const msgKey = wrapper.dataset.msgKey; showReactionPalette(addBtn, msgKey); } }); function showReactionPalette(anchorEl, msgKey){ hideReactionPalette(); const palette = document.createElement('div'); palette.className='tm-react-palette'; defaultEmojis.forEach(em=>{ const opt = document.createElement('div'); opt.className='tm-react-emoji-option'; opt.textContent=em; opt.dataset.emoji=em; palette.appendChild(opt); }); document.body.appendChild(palette); const rect = anchorEl.getBoundingClientRect(); // Position slightly above and aligned right to the button requestAnimationFrame(()=>{ const palRect = palette.getBoundingClientRect(); const top = window.scrollY + rect.top - palRect.height - 8; const left = Math.min(window.scrollX + rect.right - palRect.width, window.scrollX + rect.left); palette.style.top = Math.max(4, top) + 'px'; palette.style.left = Math.max(4, left) + 'px'; }); const handler = (ev)=>{ if(ev.target.classList && ev.target.classList.contains('tm-react-emoji-option')){ const emoji = ev.target.dataset.emoji; toggleReaction(msgKey, emoji, true); hideReactionPalette(); } else if(!palette.contains(ev.target)){ hideReactionPalette(); } }; setTimeout(()=>{ document.addEventListener('mousedown', handler, { once:true }); },0); palette.dataset.handler='1'; } function hideReactionPalette(){ const existing = document.querySelector('.tm-react-palette'); if(existing) existing.remove(); } function httpRequest(method, url, jsonBody) { return new Promise((resolve, reject) => { const doFetchFallback = () => { fetch(url, { method, headers: jsonBody ? { 'Content-Type': 'application/json' } : {}, body: jsonBody ? JSON.stringify(jsonBody) : undefined, }).then(resp => resolve(resp)).catch(err => reject(err)); }; if (typeof GM !== 'undefined' && GM.xmlHttpRequest) { try { GM.xmlHttpRequest({ method, url, headers: jsonBody ? { 'Content-Type': 'application/json' } : {}, data: jsonBody ? JSON.stringify(jsonBody) : undefined, onload: (resp) => { resolve({ ok: resp.status >=200 && resp.status <300, status: resp.status, json: () => { try { return JSON.parse(resp.responseText); } catch { return {}; } }, text: () => resp.responseText }); }, onerror: () => { console.warn('[tm-chat] GM.xmlHttpRequest network error, falling back to fetch', method, url); doFetchFallback(); }, ontimeout: () => { console.warn('[tm-chat] GM.xmlHttpRequest timeout, falling back to fetch', method, url); doFetchFallback(); } }); } catch(err){ console.warn('[tm-chat] GM.xmlHttpRequest threw, using fetch fallback', err); doFetchFallback(); } } else { doFetchFallback(); } }); } // Lightweight ephemeral status banner let statusTimer = null; function showStatus(msg, kind='info', timeout=4000){ let el = document.getElementById('tm-status-banner'); if(!el){ el = document.createElement('div'); el.id = 'tm-status-banner'; el.style.cssText = 'position:absolute;left:12px;right:12px;bottom:140px;z-index:3;padding:10px 14px;border-radius:12px;font-size:12px;font-weight:600;letter-spacing:.4px;display:flex;align-items:center;gap:8px;backdrop-filter:blur(10px) saturate(160%);-webkit-backdrop-filter:blur(10px) saturate(160%);box-shadow:0 6px 18px -4px rgba(0,0,0,.45);transition:opacity .3s,transform .3s;opacity:0;transform:translateY(6px);'; menu.appendChild(el); } const colors = { info: 'linear-gradient(135deg,rgba(90,110,190,.85),rgba(110,140,230,.85))', warn: 'linear-gradient(135deg,rgba(190,140,60,.9),rgba(230,170,90,.9))', error:'linear-gradient(135deg,rgba(190,70,70,.92),rgba(230,100,100,.9))' }; el.style.background = colors[kind] || colors.info; el.textContent = msg; requestAnimationFrame(()=>{ el.style.opacity='1'; el.style.transform='translateY(0)'; }); if(statusTimer) clearTimeout(statusTimer); statusTimer = setTimeout(()=>{ el.style.opacity='0'; el.style.transform='translateY(6px)'; }, timeout); } // Toast notifications (bottom-right when panel closed) let toastQueue = []; let toastActive = false; // Notification chime: use Web Audio API for higher reliability across browsers (unlock after user gesture) let audioCtx = null; let audioUnlocked = false; let pendingChime = false; // if a notification arrives before unlock let lastChimeTime = 0; const chimeMinIntervalMs = 4000; // throttle interval function unlockAudio(){ if(audioUnlocked) return; try { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); const buf = audioCtx.createBuffer(1, 1, 22050); // silent buffer const src = audioCtx.createBufferSource(); src.buffer = buf; src.connect(audioCtx.destination); src.start(0); audioUnlocked = true; document.removeEventListener('pointerdown', unlockAudio, true); document.removeEventListener('keydown', unlockAudio, true); if(pendingChime){ pendingChime = false; // play queued chime now that we're unlocked setTimeout(()=>playChime(), 0); } } catch {} } // Use pointerdown which fires earlier than click and covers touch + mouse document.addEventListener('pointerdown', unlockAudio, true); document.addEventListener('keydown', unlockAudio, true); function playChime(){ try { const nowMs = Date.now(); if(nowMs - lastChimeTime < chimeMinIntervalMs){ // Too soon; skip this sound but still allow visual toast return; } if(!audioUnlocked){ pendingChime = true; return; } audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); if(audioCtx.state === 'suspended') audioCtx.resume(); const now = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(880, now); osc.frequency.linearRampToValueAtTime(1320, now + 0.18); // quick up-chirp gain.gain.setValueAtTime(0.001, now); gain.gain.linearRampToValueAtTime(0.35, now + 0.02); gain.gain.exponentialRampToValueAtTime(0.0005, now + 0.4); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(now); osc.stop(now + 0.42); lastChimeTime = nowMs; } catch {} } function maybeNotifyNewMessage(m){ if(!notificationsEnabled) return; if(isOpen) return; // panel visible toastQueue.push({user:m.username, text:m.text, system:false}); if(notificationSoundEnabled) playChime(); if(!toastActive) showNextToast(); } function showNextToast(){ if(!toastQueue.length){ toastActive=false; return; } toastActive=true; const item = toastQueue.shift(); let t = document.createElement('div'); t.className='tm-toast'; t.style.cssText='position:fixed;bottom:18px;right:18px;width:300px;max-width:300px;min-height:86px;box-sizing:border-box;background:'+(item.system? 'linear-gradient(135deg,#42506a,#2c3444)':'rgba(25,28,40,0.88)')+';backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);color:#fff;padding:12px 16px;border-radius:14px;font-size:13px;line-height:1.35;box-shadow:0 8px 24px -6px rgba(0,0,0,.55);display:flex;flex-direction:column;gap:6px;z-index:999998;opacity:0;transform:translateY(8px);transition:opacity .35s,transform .35s;cursor:pointer;border:1px solid rgba(255,255,255,0.12);overflow:hidden;'; const safeUser = (item.user||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); const safeText = (item.text||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); const uDiv = document.createElement('div'); uDiv.style.cssText='font-weight:600;font-size:11px;letter-spacing:.5px;opacity:.85;text-transform:uppercase;'; uDiv.textContent = safeUser; const msgDiv = document.createElement('div'); msgDiv.style.cssText='font-size:13px;'; msgDiv.textContent = safeText; t.appendChild(uDiv); t.appendChild(msgDiv); document.body.appendChild(t); requestAnimationFrame(()=>{ t.style.opacity='1'; t.style.transform='translateY(0)'; }); let hideTimer = setTimeout(()=>{ hideToast(t); }, 5000); t.addEventListener('click', ()=>{ hideToast(t); toggleMenu(); }); function hideToast(el){ el.style.opacity='0'; el.style.transform='translateY(8px)'; setTimeout(()=>{ el.remove(); showNextToast(); }, 380); } } // System toast (startup hint) function showStartupHintToast(){ if(!notificationsEnabled) return; if(!showStartupHint) return; toastQueue.push({user:'Tip', text:'Press Ctrl+Shift+; to open the chat panel.', system:true}); if(notificationSoundEnabled) playChime(); if(!toastActive) showNextToast(); } async function sendMessage(text) { try { // Slash command preprocessing if(text.startsWith('/')) { const trimmed = text.trim(); const firstSpace = trimmed.indexOf(' '); const cmd = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase(); const rest = firstSpace === -1 ? '' : trimmed.slice(firstSpace+1); switch(cmd){ case '/help': showStatus('Commands: /me <action>, /shrug <text>, /rainbow, /help','info',6000); return; // do not send to server case '/me': if(!rest){ showStatus('Usage: /me <action>','warn',2500); return; } text = `* ${username} ${rest}`; break; case '/shrug': text = (rest? rest + ' ' : '') + '¯\\_(ツ)_/¯'; break; case '/rainbow': enableRainbowMode(); showStatus('Rainbow mode on for 60s','info',2500); return; default: showStatus('Unknown command. Try /help','warn',2500); return; } } // In-flight dedup: if same text & still sending within 1500ms, ignore duplicate trigger const now = Date.now(); if(inFlightMessage.text === text && (now - inFlightMessage.time) < 1500){ return; // duplicate rapid send } const clientId = generateClientId(); recentSent.push({text, t: Date.now()}); if(recentSent.length>25) recentSent.splice(0, recentSent.length-25); inFlightMessage = { text, clientId, time: now }; const res = await httpRequest('POST', `${BACKEND_URL}/messages`, { userId, username, text }); if (!res.ok) { let bodyText = await res.text(); let err = {}; try { err = JSON.parse(bodyText); } catch {} const code = res.status; const reason = (err && err.error) ? err.error : 'send failed'; let friendly = ''; const now = Date.now(); switch(reason){ case 'rate limit': // per-IP: 5 / 5s friendly = 'Too fast (IP limit). Wait ~5s and try again.'; break; case 'user rate limit': friendly = 'You sent too many messages. Cooldown about 30s.'; break; case 'duplicate message': friendly = 'Duplicate blocked. Change it or wait ~12s.'; break; case 'too many urls': friendly = 'Too many links (max 3). Remove some and resend.'; break; case 'empty message': friendly = 'Cannot send an empty message.'; break; default: friendly = 'Send failed ('+reason+').'; } showStatus(friendly, (code===400||code===409)?'warn':'error'); console.warn('Send failed', code, reason); // Non-network error: clear in-flight (avoid blocking future sends) inFlightMessage = { text:'', clientId:null, time:0 }; } else { const data = await res.json(); const m = data.message; if (m && m.createdAt) { if (m.createdAt > lastTimestamp) lastTimestamp = m.createdAt; if(m.id) clientIdToServerId.set(clientId, m.id); m.clientId = clientId; // retain provisional reference renderMessage(m, true); } inFlightMessage = { text:'', clientId:null, time:0 }; } } catch (e) { // Network-level error (no HTTP response). Probe /health to differentiate backend down vs endpoint-specific failure. console.warn('[tm-chat] send network error', e); let backendReachable = false; try { const probe = await httpRequest('GET', `${BACKEND_URL}/health`); backendReachable = probe && probe.ok; } catch {} if(!backendReachable){ const queued = { clientId: inFlightMessage.clientId || generateClientId(), username, text, createdAt: Date.now(), offlineQueued:true, attempts:0 }; offlineQueue.push(queued); renderMessage(queued, true); showStatus('Offline: message queued ('+offlineQueue.length+')','warn',4000); } else { showStatus('Send failed (network/CORS). Check console.','error',4000); } inFlightMessage = { text:'', clientId:null, time:0 }; } } async function flushOfflineQueue(){ if(flushingQueue || !offlineQueue.length) return; flushingQueue = true; try { for(let i=0;i<offlineQueue.length;i++){ const item = offlineQueue[i]; const res = await httpRequest('POST', `${BACKEND_URL}/messages`, { userId, username:item.username, text:item.text }); if(res.ok){ // Replace queued placeholder styling by re-rendering message with same key suppressed const data = await res.json(); const m = data.message || { username:item.username, text:item.text, createdAt: Date.now(), clientId:item.clientId }; if(m.id) clientIdToServerId.set(item.clientId, m.id); m.clientId = item.clientId; renderMessage(m, m.username===username, null, true); // suppress notify (already saw) renderReactionsForMessage(messageKey(m)); offlineQueue.splice(i,1); i--; } else { item.attempts++; if(item.attempts > 5){ showStatus('Failed to send queued message after retries','error',4000); offlineQueue.splice(i,1); i--; } } await new Promise(r=>setTimeout(r, 350)); // small spacing to avoid rate limits } if(!offlineQueue.length){ showStatus('All queued messages sent','info',2000); } } catch(err){ // stay queued } finally { flushingQueue = false; } } // Rainbow mode state let rainbowMode = false; let rainbowTimer = null; function enableRainbowMode(){ rainbowMode = true; if(rainbowTimer) clearTimeout(rainbowTimer); rainbowTimer = setTimeout(()=>{ rainbowMode = false; }, 60000); // 60s } async function pollMessages(){ if(polling || stopped) return; polling = true; try { const res = await httpRequest('GET', `${BACKEND_URL}/messages?since=${lastTimestamp}`); if(res.ok){ const data = await res.json(); const messages = data.messages || []; if(messages.length){ // Build fragment for performance const wasAtBottom = atBottom(); const idle = (Date.now() - lastUserScrollTime) > autoStickIdleMs; const autoStickCandidate = wasAtBottom || (nearBottom() && idle); const frag = document.createDocumentFragment(); for(const m of messages){ // Determine server update time (modifiedAt preferred) const updatedAt = m.modifiedAt && m.modifiedAt > m.createdAt ? m.modifiedAt : m.createdAt; if(updatedAt && updatedAt > lastTimestamp) lastTimestamp = updatedAt; // markUserActive() removed (server authoritative presence); no-op let isSelf = (m.userId && m.userId === userId) || false; if(!isSelf && !m.userId){ isSelf = normalizedName(m.username) === usernameLower; if(!isSelf){ const nowT = Date.now(); for(let i=recentSent.length-1;i>=0;i--){ const rs = recentSent[i]; if(nowT - rs.t > 5000) break; if(rs.text === m.text){ isSelf = true; break; } } } } const key = messageKey(m); const already = seenMessages.has(key); // Render (allow update path to replace existing DOM and not notify) renderMessage(m, isSelf, frag, (!initialHistoryLoaded) || already, already); // Merge reaction summaries try { const rx = m.reactions || m.Reactions; if(Array.isArray(rx)){ const bucket = ensureReactionBucket(key); // Overwrite counts from server bucket.counts.clear(); for(const r of rx){ if(!r) continue; const e = r.emoji || r.Emoji || r.E || r.e; const c = r.count || r.Count || r.c; if(e && typeof c === 'number' && c>0) bucket.counts.set(e, c); } // If we previously thought we reacted but server count says otherwise (e.g., lost due to server reject), adjust self set for(const e of Array.from(bucket.self)){ if(!bucket.counts.has(e)) bucket.self.delete(e); } renderReactionsForMessage(key); } } catch {} } chatContainer.appendChild(frag); if(autoStickCandidate){ scrollToBottom('auto'); hideNewMsgBadge(); pendingNewMessages = false; } else if(!wasAtBottom){ const dist = distanceToBottom(); if(dist < closeBottomPx){ pendingNewMessages = true; showNewMsgBadge(); } else { updateJumpBtn(); } } // updatePresence() removed } // After first successful history load, enable notifications if(!initialHistoryLoaded){ initialHistoryLoaded = true; } // After a successful poll, try flushing any offline queued messages if(initialHistoryLoaded && offlineQueue.length){ flushOfflineQueue(); } // updatePresence() removed backoff = 3000; // reset backoff on success } else { console.warn('Poll failed status', res.status); backoff = Math.min(backoff * 1.6, maxBackoff); } } catch(err){ console.warn('Polling error', err); backoff = Math.min(backoff * 1.6, maxBackoff); } finally { polling = false; if(!stopped){ setTimeout(pollMessages, backoff); } } } // Kick off polling shortly after load setTimeout(()=>{ pollMessages(); }, 250); // Show startup hint toast after slight delay so queue system ready setTimeout(()=>{ showStartupHintToast(); }, 1200); // Input key handling (Enter to send, Shift+Enter newline) chatInput.addEventListener('keydown', (e)=>{ if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); const val = chatInput.value.trim(); if(!val) return; sendMessage(val); chatInput.value=''; chatInput.style.height=''; } }); // Compose emoji picker const composeBtn = document.getElementById('tm-compose-emoji-btn'); const composeEmojis = ['😀','😄','😁','😊','😉','😍','🤔','😎','🤯','😅','😭','👍','👎','🔥','🎉','❤️','😮','😂','🤖','💡']; let composePalette = null; function closeComposePalette(){ if(composePalette){ composePalette.remove(); composePalette=null; } } function openComposePalette(anchor){ closeComposePalette(); const pal = document.createElement('div'); pal.className='tm-compose-emoji-palette'; pal.style.cssText='position:absolute;bottom:60px;right:12px;display:grid;grid-template-columns:repeat(8,1fr);gap:6px;padding:10px 10px 8px;background:rgba(25,28,40,0.94);backdrop-filter:blur(18px) saturate(180%);-webkit-backdrop-filter:blur(18px) saturate(180%);border:1px solid rgba(255,255,255,0.18);border-radius:14px;box-shadow:0 14px 36px -10px rgba(0,0,0,0.65);z-index:1000003;font-size:20px;'; // Prevent outside-click handler from seeing palette interactions pal.addEventListener('mousedown', e=> e.stopPropagation()); composeEmojis.forEach(em=>{ const cell = document.createElement('button'); cell.type='button'; cell.textContent=em; cell.style.cssText='background:transparent;border:0;cursor:pointer;width:32px;height:32px;font-size:20px;line-height:1;border-radius:8px;display:flex;align-items:center;justify-content:center;transition:background .2s;'; cell.addEventListener('mouseenter',()=>{ cell.style.background='rgba(255,255,255,0.15)'; }); cell.addEventListener('mouseleave',()=>{ cell.style.background='transparent'; }); cell.addEventListener('click',()=>{ insertEmojiAtCursor(em); if(reducedMotion){ closeComposePalette(); } else { pal.style.opacity='0'; pal.style.transform='translateY(6px)'; setTimeout(closeComposePalette,180); } }); pal.appendChild(cell); }); chatBoxDiv.appendChild(pal); requestAnimationFrame(()=>{ pal.style.opacity='1'; pal.style.transform='translateY(0)'; }); composePalette = pal; const outsideHandler = (ev)=>{ if(!pal.contains(ev.target) && ev.target !== anchor){ closeComposePalette(); document.removeEventListener('mousedown', outsideHandler, true); } }; setTimeout(()=> document.addEventListener('mousedown', outsideHandler, true),0); } function insertEmojiAtCursor(em){ const start = chatInput.selectionStart; const end = chatInput.selectionEnd; const v = chatInput.value; chatInput.value = v.slice(0,start) + em + v.slice(end); const newPos = start + em.length; chatInput.selectionStart = chatInput.selectionEnd = newPos; chatInput.dispatchEvent(new Event('input')); chatInput.focus(); } if(composeBtn){ composeBtn.addEventListener('mousedown', e=> e.stopPropagation()); composeBtn.addEventListener('click',(e)=>{ e.stopPropagation(); if(composePalette){ closeComposePalette(); return; } openComposePalette(composeBtn); }); } // Fixed single-line input: only adjust jump position on input chatInput.addEventListener('input', ()=>{ if(composeBtn){ composeBtn.style.height = chatInput.offsetHeight + 'px'; } updateJumpPosition(); }); // Scroll handling chatContainer.addEventListener('scroll', ()=>{ updateJumpBtn(); markUserScroll(); if(atBottom()) hideNewMsgBadge(); }); jumpBtn.addEventListener('click', ()=>{ scrollToBottom('smooth'); pendingNewMessages = false; hideNewMsgBadge(); }); newMsgBadge.addEventListener('click', ()=>{ scrollToBottom('smooth'); pendingNewMessages = false; hideNewMsgBadge(); }); // Observe chat box height changes to reposition jump button if(window.ResizeObserver){ const ro = new ResizeObserver(()=>{ updateJumpPosition(); }); ro.observe(chatBox); } else { window.addEventListener('resize', updateJumpPosition); } // Initial layout adjustments updateJumpPosition(); updateJumpBtn(); })();