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