/** * globalchat.js — Site-wide persistent chat widget * Panel is always visible. Minimize-only. * Features: emoji picker, :emoji autocomplete, @user autocomplete, * greentext, [spoiler], [blur] */ (function () { 'use strict'; if (!window.f0ckSession?.enable_global_chat) return; if (!window.f0ckSession?.logged_in) return; if (window.location.pathname.startsWith('/abyss')) return; const MAX_VISIBLE_MSGS = 100; const RATE_LIMIT_MS = 800; let isMinimized = localStorage.getItem('f0ck_chat_minimized') !== '0'; let isClosed = localStorage.getItem('f0ck_chat_closed') === '1'; let lastSent = 0; let customEmojis = null; // name → url let unreadCount = 0; let chatFocused = document.hasFocus(); const ytOembedCache = new Map(); // videoId → {title, author_name} // Shared IntersectionObserver for lazy-loading embedded images. // Images are rendered with data-lazy-src; this observer sets the real src // when the image is within 200px of the visible scroll area. const lazyImgObserver = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; const img = entry.target; const src = img.dataset.lazySrc; if (src) { img.src = src; delete img.dataset.lazySrc; } lazyImgObserver.unobserve(img); } }, { rootMargin: '200px', // start loading 200px before entering viewport threshold: 0 }); function updateBadge() { const badge = document.getElementById('gchat-badge'); const bubble = document.getElementById('gchat-reopen-bubble'); if (unreadCount > 0) { const label = unreadCount > 99 ? '99+' : String(unreadCount); if (badge) { badge.textContent = label; badge.style.display = 'inline-flex'; } // Bubble badge — create it lazily if it doesn't exist yet if (bubble) { let bb = bubble.querySelector('.gchat-bubble-badge'); if (!bb) { bb = document.createElement('span'); bb.className = 'gchat-bubble-badge'; bubble.appendChild(bb); } bb.textContent = label; bb.style.display = ''; } } else { if (badge) badge.style.display = 'none'; const bb = document.getElementById('gchat-reopen-bubble')?.querySelector('.gchat-bubble-badge'); if (bb) bb.style.display = 'none'; } } function clearUnread() { unreadCount = 0; updateBadge(); } window.addEventListener('focus', () => { chatFocused = true; if (!isMinimized) clearUnread(); }); window.addEventListener('blur', () => { chatFocused = false; }); // ── i18n ───────────────────────────────────────────────────────────────── function t(key, fallback) { return window.f0ckI18n?.[key] || fallback; } // ── Build widget ───────────────────────────────────────────────────────── function buildWidget() { const widget = document.createElement('div'); widget.id = 'gchat-widget'; widget.innerHTML = `
${t('chat_title', 'Site Chat')}
`; document.body.appendChild(widget); } // ── Helpers ─────────────────────────────────────────────────────────────── function esc(str) { const d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } function avatarSrc(msg) { if (msg.avatar_file) return `/a/${msg.avatar_file}`; if (msg.avatar) return `/t/${msg.avatar}.webp`; return '/a/default.png'; } // ── Build allowed-hosts regex (mirrors comments.js logic) ───────────────── function buildAllowedHostsRegex() { const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const hosts = [escapedSiteHost]; if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) { window.f0ckAllowedImages.forEach(h => { hosts.push(`(?:[a-z0-9-]+\\.)*${h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); }); } return hosts.join('|'); } // ── Message rendering with formatting ──────────────────────────────────── function renderMsgContent(raw) { // 1. Process line by line for greentext, then HTML-escape each line const lines = raw.split('\n').map(line => { const trimmed = line.trimStart(); if (trimmed.startsWith('>')) { const content = esc(trimmed.slice(1)); return `>${content}`; } return esc(line); }); // Join lines — skip
between consecutive greentext spans let html = ''; for (let i = 0; i < lines.length; i++) { html += lines[i]; if (i < lines.length - 1) { const curGreen = lines[i].startsWith(''); const nextGreen = lines[i + 1].startsWith(''); if (!(curGreen && nextGreen)) html += '
'; } } // 2. [spoiler]...[/spoiler] let prev, iter = 0; do { prev = html; html = html.replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi, (_, c) => `${c}`); } while (html !== prev && ++iter < 10); // 3. [blur]...[/blur] iter = 0; do { prev = html; html = html.replace(/\[blur\]([\s\S]*?)\[\/blur\]/gi, (_, c) => `${c}`); } while (html !== prev && ++iter < 10); // 4. @mention links html = html.replace(/(? `@${u}`); // 5. Custom emoji :name: if (customEmojis) { html = html.replace(/:([a-z0-9_]+):/g, (m, name) => { if (customEmojis[name]) return `${m}`; return m; }); } // 6. URL linkification + media embeds const hostsRegex = buildAllowedHostsRegex(); // 6a. Raw image URLs from allowed hosts → const imageRegex = new RegExp( `(https?:\\/\\/(?:${hostsRegex})\\/(?:(?!https?:\\/\\/).)+?\\.(?:jpg|jpeg|png|gif|webp)(?:\\?(?:(?!https?:\\/\\/)\\S)+)?)`, 'gi' ); html = html.replace(imageRegex, url => `` ); // 6b. Raw video URLs from allowed hosts →