Files
f0ckm/public/s/js/globalchat.js
2026-07-26 21:31:28 +02:00

2196 lines
102 KiB
JavaScript

/**
* 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') === '1';
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 isPageMode = (window.location.pathname === '/chat');
const containerEl = isPageMode ? document.getElementById('site-chat-container') : null;
if (isPageMode && !containerEl) return;
const widget = isPageMode ? containerEl : (document.getElementById('gchat-widget') || document.createElement('div'));
if (!isPageMode) widget.id = 'gchat-widget';
const panelClass = isPageMode ? 'gchat-page-panel' : (isMinimized ? 'gchat-minimized' : '');
widget.innerHTML = `
<div id="gchat-panel" class="${panelClass}">
<div id="gchat-resize-h"></div>
<div id="gchat-resize-w"></div>
<div id="gchat-resize-e"></div>
<div id="gchat-header">
<span id="gchat-title"><i class="fa-solid fa-comments"></i> ${t('chat_title', 'Site Chat')}<span id="gchat-badge" class="gchat-badge" style="display:none"></span></span>
<div class="gchat-header-btns">
<button id="gchat-float-btn" title="Float / Dock" class="gchat-icon-btn">
<i class="fa-solid fa-up-right-and-down-left-from-center"></i>
</button>
<button id="gchat-minimize-btn" title="${isMinimized ? t('chat_expand','Expand') : t('chat_minimize','Minimize')}" class="gchat-icon-btn">
<i class="fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}"></i>
</button>
<button id="gchat-close-btn" title="${t('chat_close','Close chat')}" class="gchat-icon-btn">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div id="gchat-online"></div>
<div id="gchat-messages"></div>
<div id="gchat-input-area">
<div id="gchat-toolbar">
<button class="gchat-tool-btn" id="gchat-emoji-btn" title="Emoji"><i class="fa-regular fa-face-smile"></i></button>
</div>
<div id="gchat-input-row">
<textarea id="gchat-input" placeholder="${t('chat_placeholder','Chat…')}" maxlength="500" rows="1"></textarea>
<button id="gchat-send-btn" title="${t('common_send','Send')}">
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
</div>
</div>
`;
if (!isPageMode && !document.getElementById('gchat-widget')) {
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 `<span class="gchat-greentext">&gt;${content}</span>`;
}
return esc(line);
});
// Join lines — skip <br> 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('<span class="gchat-greentext">');
const nextGreen = lines[i + 1].startsWith('<span class="gchat-greentext">');
if (!(curGreen && nextGreen)) html += '<br>';
}
}
// 2. [spoiler]...[/spoiler]
let prev, iter = 0;
do {
prev = html;
html = html.replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi,
(_, c) => `<span class="spoiler">${c}</span>`);
} while (html !== prev && ++iter < 10);
// 3. [blur]...[/blur]
iter = 0;
do {
prev = html;
html = html.replace(/\[blur\]([\s\S]*?)\[\/blur\]/gi,
(_, c) => `<span class="blur-text">${c}</span>`);
} while (html !== prev && ++iter < 10);
// 4. @mention links
html = html.replace(/(?<!\[)@([a-zA-Z0-9_\-.]+)/g,
(_, u) => `<a href="/user/${encodeURIComponent(u)}" class="mention">@${u}</a>`);
// 5. Custom emoji :name:
if (customEmojis) {
html = html.replace(/:([a-z0-9_]+):/g, (m, name) => {
if (customEmojis[name]) return `<img src="${customEmojis[name]}" class="emoji" alt="${m}" title="${m}">`;
return m;
});
}
// 6. URL linkification + media embeds
const hostsRegex = buildAllowedHostsRegex();
// 6a. Raw image URLs from allowed hosts → <img>
const imageRegex = new RegExp(
`(https?:\\/\\/(?:${hostsRegex})\\/(?:(?!https?:\\/\\/).)+?\\.(?:jpg|jpeg|png|gif|webp)(?:\\?(?:(?!https?:\\/\\/)\\S)+)?)`,
'gi'
);
html = html.replace(imageRegex, url =>
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
);
// 6b. Raw video URLs from allowed hosts → <video>
const videoRegex = new RegExp(
`(https?:\\/\\/(?:${hostsRegex})\\/(?:(?!https?:\\/\\/)(?![\\[\\]()\\s]))+?\\.(?:mp4|webm|ogv|mov)(?:\\?(?:(?!https?:\\/\\/)\\S)+)?)`,
'gi'
);
html = html.replace(videoRegex, url =>
`<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`
);
// 6c. Raw audio URLs from allowed hosts → <audio>
const audioRegex = new RegExp(
`(https?:\\/\\/(?:${hostsRegex})\\/(?:(?!https?:\\/\\/)(?![\\[\\]()\\s]))+?\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?(?:(?!https?:\\/\\/)\\S)+)?)`,
'gi'
);
html = html.replace(audioRegex, url =>
`<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`
);
// 6d. YouTube URLs → oEmbed preview card (thumbnail + title fetched async)
const ytRegex = /https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"&\s]*&)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^\s]*/gi;
html = html.replace(ytRegex, (match, videoId) => {
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
const thumb = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
return `<a class="gchat-yt-card" data-yt-id="${videoId}" href="${watchUrl}" target="_blank" rel="noopener noreferrer">` +
`<div class="gchat-yt-thumb-wrap">` +
`<img src="${thumb}" class="gchat-yt-thumb" alt="YouTube thumbnail" loading="lazy">` +
`<div class="gchat-yt-play"><i class="fa-brands fa-youtube"></i></div>` +
`</div>` +
`<div class="gchat-yt-info">` +
`<span class="gchat-yt-title">…</span>` +
`<span class="gchat-yt-author"></span>` +
`</div>` +
`</a>`;
});
// 6d.1 Vocaroo URLs → iframe embed
const vocarooRegex = /https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^\s]*/gi;
html = html.replace(vocarooRegex, (match, vocarooId) => {
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
return `<span class="vocaroo-embed-wrap gchat-embed-audio"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
});
// 6d.5 Same-site item page links → post preview card (resolved async)
// Only catches /digits paths — direct media file URLs are handled by 6a-6c & 6e.
const siteHostEsc = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteItemRx = new RegExp(
`https?:\/\/${siteHostEsc}\/(\\d+)(?=[\\s<"']|$)`,
'gi'
);
html = html.replace(siteItemRx, (match, itemId) =>
`<span class="gchat-item-embed gchat-post-card gchat-post-card--loading" data-item-id="${itemId}">` +
`<span class="gchat-post-card__thumb-wrap"><span class="gchat-post-card__thumb-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></span></span>` +
`<span class="gchat-post-card__info"><span class="gchat-post-card__id">#${itemId}</span></span>` +
`</span>`
);
// 6e. Remaining https URLs → embed media if from allowed host, else plain link
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => {
// Skip URLs already embedded by earlier steps
if (match.includes('<img') || match.includes('<video') || match.includes('<audio') ||
match.includes('<iframe') || match.includes('gchat-yt-card') || match.includes('gchat-item-embed'))
return match;
// Use URL API for reliable host + extension detection
try {
const urlObj = new URL(url);
const host = urlObj.host;
const path = urlObj.pathname;
// Derive CDN host from window.f0ckMediaBase (may be on a different subdomain in prod)
let mediaHost = '';
try { mediaHost = new URL(window.f0ckMediaBase || '').host; } catch (_) {}
const isSameSite = host === window.location.host;
const isMediaHost = !!mediaHost && host === mediaHost;
const isAllowedHoster = !isSameSite && !isMediaHost && (window.f0ckAllowedImages || []).some(h =>
host === h || host.endsWith('.' + h)
);
if (isSameSite || isMediaHost || isAllowedHoster) {
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
}
} catch (_) {}
return `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:0.7em;margin-left:3px;opacity:0.6"></i></a>`;
});
return html;
}
function renderMsg(msg) {
const isSelf = (msg.username === window.f0ckSession.user);
const isAdmin = window.f0ckSession.is_admin || window.f0ckSession.is_moderator;
const displayName = msg.display_name || msg.username;
const color = msg.username_color ? `style="color:${esc(msg.username_color)}"` : '';
const ts = msg.created_at
? new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '';
const content = renderMsgContent(msg.message || '');
const msgId = msg.id ? `data-msg-id="${msg.id}"` : '';
const replyUser = esc(msg.username || '');
return `<div class="gchat-msg ${isSelf ? 'gchat-msg-self' : ''}" ${msgId}>
${!isSelf ? `<a href="/user/${esc(msg.username)}" class="gchat-avatar-link">
<img src="${avatarSrc(msg)}" class="gchat-avatar" alt="${esc(displayName)}" loading="lazy">
</a>` : ''}
<div class="gchat-bubble-wrap">
${!isSelf ? `<span class="gchat-username" ${color}>${esc(displayName)}</span>` : ''}
<div class="gchat-bubble">${content}</div>
<div class="gchat-msg-actions">
${ts ? `<span class="gchat-time">${ts}</span>` : ''}
<button class="gchat-reply-btn" data-username="${replyUser}" title="Reply"><i class="fa-solid fa-reply"></i></button>
${isAdmin && msg.id ? `<button class="gchat-del-btn" data-id="${msg.id}" title="Delete"><i class="fa-solid fa-trash"></i></button>` : ''}
</div>
</div>
</div>`;
}
function scrollToBottom(force = false, smooth = false) {
const el = document.getElementById('gchat-messages');
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (!force && !nearBottom) return;
if (smooth) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} else {
// Double rAF ensures layout is committed before reading scrollHeight
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
}
}
/**
* Watch the message container and keep snapping to bottom for durationMs.
* Only stops if the user actively scrolls up via wheel / touch / keyboard.
* Same logic as the DM snapToBottomSticky.
*/
function startStickyScroll(durationMs = 8000) {
const el = document.getElementById('gchat-messages');
if (!el) return;
let userScrolledUp = false;
const onWheel = (e) => { if (e.deltaY < 0) userScrolledUp = true; };
const onKey = (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) userScrolledUp = true; };
let touchStartY = 0;
const onTouchStart = (e) => { touchStartY = e.touches[0]?.clientY ?? 0; };
const onTouchMove = (e) => { if ((e.touches[0]?.clientY ?? 0) > touchStartY + 10) userScrolledUp = true; };
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('keydown', onKey, { passive: true });
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
let ro;
const cleanup = () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('keydown', onKey);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
if (ro) ro.disconnect();
};
if (typeof ResizeObserver !== 'undefined') {
// Debounce: batch rapid layout changes (e.g. progressive image renders)
// into a single smooth scroll instead of many jarring instant jumps.
let debounceTimer = null;
ro = new ResizeObserver(() => {
if (userScrolledUp) { cleanup(); return; }
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, 80);
});
ro.observe(el);
} else {
setTimeout(() => scrollToBottom(true), 300);
setTimeout(() => scrollToBottom(true), 800);
setTimeout(() => scrollToBottom(true), 2000);
}
setTimeout(cleanup, durationMs);
// First snap is instant (no animation — the panel just opened)
scrollToBottom(true);
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
}
async function fetchYtOembed(cardEl) {
const id = cardEl.dataset.ytId;
if (!id) return;
let meta = ytOembedCache.get(id);
if (!meta) {
try {
const r = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(id)}&format=json`
);
meta = await r.json();
ytOembedCache.set(id, meta);
} catch (_) {
meta = { title: 'YouTube', author_name: '' };
}
}
const titleEl = cardEl.querySelector('.gchat-yt-title');
const authorEl = cardEl.querySelector('.gchat-yt-author');
if (titleEl) titleEl.textContent = meta.title || 'YouTube';
if (authorEl) authorEl.textContent = meta.author_name || '';
}
// Resolve same-site item links → post preview card
const itemPreviewCache = new Map(); // id → { item, meta } | null
async function fetchItemPreview(wrapEl) {
const id = wrapEl.dataset.itemId;
if (!id) return;
let cached = itemPreviewCache.get(id);
if (cached === undefined) {
try {
const [itemRes, metaRes] = await Promise.all([
fetch(`/api/v2/item/${id}`),
fetch(`/api/v2/scroller/meta?ids=${id}`)
]);
const itemData = await itemRes.json();
const metaData = await metaRes.json();
const item = (itemData.success && itemData.rows) ? itemData.rows : null;
const meta = metaData[id] || null;
cached = item ? { item, meta } : null;
} catch (_) {
cached = null;
}
itemPreviewCache.set(id, cached);
}
if (!cached) {
// Fallback: plain link
const link = document.createElement('a');
link.href = `/${id}`;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = `#${id}`;
wrapEl.replaceWith(link);
return;
}
const { item, meta } = cached;
const commentCount = meta ? (meta.comment_count || 0) : 0;
const uploader = esc(item.username || 'unknown');
const mime = item.mime || '';
const thumbSrc = `/t/${id}.webp`;
// Media type badge
let typeBadge = '';
if (mime.startsWith('video/')) typeBadge = '<i class="fa-solid fa-film"></i>';
else if (mime.startsWith('audio/')) typeBadge = '<i class="fa-solid fa-music"></i>';
else if (mime.startsWith('image/')) typeBadge = '<i class="fa-solid fa-image"></i>';
const card = document.createElement('a');
card.className = 'gchat-post-card';
card.href = `/${id}`;
card.innerHTML =
`<span class="gchat-post-card__thumb-wrap">` +
`<img class="gchat-post-card__thumb" src="${esc(thumbSrc)}" alt="#${id}" loading="lazy" onerror="this.style.display='none'">` +
(typeBadge ? `<span class="gchat-post-card__type-badge">${typeBadge}</span>` : '') +
`</span>` +
`<span class="gchat-post-card__info">` +
`<span class="gchat-post-card__id">#${id}</span>` +
`<span class="gchat-post-card__uploader"><i class="fa-solid fa-user"></i> ${uploader}</span>` +
`<span class="gchat-post-card__comments"><i class="fa-solid fa-comment"></i> ${commentCount}</span>` +
`</span>`;
wrapEl.replaceWith(card);
}
function appendMsg(msg, scrollForce = false) {
const container = document.getElementById('gchat-messages');
if (!container) return;
const div = document.createElement('div');
div.innerHTML = renderMsg(msg);
const node = div.firstElementChild;
while (container.children.length >= MAX_VISIBLE_MSGS) container.removeChild(container.firstChild);
container.appendChild(node);
setTimeout(() => node.classList.add('gchat-msg-in'), 10);
// Wire up spoiler clicks on newly appended msg
node.querySelectorAll('.spoiler,.blur-text').forEach(s => {
s.addEventListener('click', () => s.classList.toggle('revealed'));
});
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
// Only snap to bottom on image load for NEW incoming messages, not history.
// History already scrolls once at the end of loadHistory; an extra scroll
// here is what causes the double jump.
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in';
lazyImgObserver.observe(img);
});
// Already-src'd images (avatars etc.) — same rule
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in';
});
// Wire up YouTube oEmbed cards
node.querySelectorAll('.gchat-yt-card[data-yt-id]').forEach(fetchYtOembed);
// Wire up same-site item embeds
node.querySelectorAll('.gchat-item-embed[data-item-id]').forEach(fetchItemPreview);
scrollToBottom(scrollForce);
// Unread badge: count if minimized or window not focused
if (isMinimized || !chatFocused) {
unreadCount++;
updateBadge();
}
}
async function loadHistory() {
try {
const res = await fetch('/api/chat', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('gchat-messages');
if (!container) return;
container.innerHTML = '';
(data.messages || []).forEach(m => appendMsg(m, false));
// Double rAF: wait for the browser to commit the layout (panel just became
// visible from display:none) before reading scrollHeight.
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
} catch (e) {
console.error('[Chat] Failed to load history:', e);
}
}
async function sendMessage() {
const input = document.getElementById('gchat-input');
if (!input) return;
const raw = input.value.trim();
if (!raw) return;
// Admin slash commands
if (raw === '/clear') {
const isAdmin = window.f0ckSession.is_admin || window.f0ckSession.is_moderator;
if (!isAdmin) { input.value = ''; return; }
const csrf = window.f0ckSession?.csrf_token;
try {
await fetch('/api/chat', {
method: 'DELETE',
headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) }
});
const container = document.getElementById('gchat-messages');
if (container) container.innerHTML = '';
} catch (e) { console.error('[Chat] Clear error', e); }
input.value = '';
return;
}
// /setbackground <url> [css opts] — e.g. /setbackground https://… center / contain no-repeat
if (raw.startsWith('/setbackground ') || raw === '/clearbg') {
const isAdmin = window.f0ckSession.is_admin || window.f0ckSession.is_moderator;
if (!isAdmin) { input.value = ''; return; }
const csrf = window.f0ckSession?.csrf_token;
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) };
if (raw === '/clearbg') {
// Clear background
await fetch('/api/chat/background', { method: 'POST', headers, body: new URLSearchParams({}) });
} else {
// Parse "/setbackground <url> [opts]"
const rest = raw.slice('/setbackground '.length).trim();
const spaceAfterUrl = rest.search(/\s/);
const url = spaceAfterUrl > -1 ? rest.slice(0, spaceAfterUrl) : rest;
const opts = spaceAfterUrl > -1 ? rest.slice(spaceAfterUrl + 1).trim() : 'center / cover no-repeat';
try {
const res = await fetch('/api/chat/background', { method: 'POST', headers, body: new URLSearchParams({ url, opts }) });
const data = await res.json();
if (!data.success) console.warn('[Chat] setbackground error:', data.msg);
} catch (e) { console.error('[Chat] setbackground error', e); }
}
input.value = '';
return;
}
// /settopic <text> or /cleartopic
if (raw.startsWith('/settopic') || raw === '/cleartopic') {
const isAdmin = window.f0ckSession.is_admin || window.f0ckSession.is_moderator;
if (!isAdmin) { input.value = ''; return; }
const csrf = window.f0ckSession?.csrf_token;
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) };
const topic = raw === '/cleartopic' ? '' : raw.slice('/settopic'.length).trim();
try { await fetch('/api/chat/topic', { method: 'POST', headers, body: new URLSearchParams({ topic }) }); }
catch (e) { console.error('[Chat] settopic error', e); }
input.value = '';
return;
}
const now = Date.now();
if (now - lastSent < RATE_LIMIT_MS) { return; }
lastSent = now;
input.value = '';
input.style.height = 'auto';
const csrf = window.f0ckSession?.csrf_token;
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-Token': csrf } : {})
},
body: new URLSearchParams({ message: raw })
});
const data = await res.json();
if (!data.success) console.warn('[Chat] Send error:', data.msg);
} catch (e) {
console.error('[Chat] Network error', e);
}
}
function setMinimized(val) {
isMinimized = val;
localStorage.setItem('f0ck_chat_minimized', isMinimized ? '1' : '0');
const panel = document.getElementById('gchat-panel');
const btn = document.getElementById('gchat-minimize-btn');
const icon = btn?.querySelector('i');
if (panel) panel.classList.toggle('gchat-minimized', isMinimized);
if (btn) btn.title = isMinimized ? t('chat_expand','Expand') : t('chat_minimize','Minimize');
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
if (!isMinimized) {
clearUnread();
// Wait one rAF so the panel transitions from display:none to its full
// height before loadHistory measures scrollHeight.
requestAnimationFrame(() => loadHistory());
if (!window.matchMedia('(pointer: coarse)').matches)
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
}
}
// ── Tabbed Emoji & Sticker Picker ──────────────────────────────────────────
function setupEmojiPicker(textarea) {
if (!window._emojiPickerFavUtils) {
window._emojiPickerFavUtils = {
KEY: 'f0ck_emoji_favs',
get() { try { return JSON.parse(localStorage.getItem(this.KEY) || '[]'); } catch(e) { return []; } },
has(name) { return this.get().some(f => f.name === name); },
toggle(name, url) {
const favs = this.get();
const idx = favs.findIndex(f => f.name === name);
const action = idx >= 0 ? 'remove' : 'add';
if (idx >= 0) favs.splice(idx, 1); else favs.push({ name, url });
localStorage.setItem(this.KEY, JSON.stringify(favs));
const csrf = window.f0ckSession?.csrf_token;
if (csrf) {
fetch('/api/v2/user/emoji-favorites/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ name, url, action })
}).catch(() => {});
}
},
syncFromDB() {
fetch('/api/v2/user/emoji-favorites', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.success && Array.isArray(data.favorites)) {
localStorage.setItem(this.KEY, JSON.stringify(data.favorites));
window._emojiPickerRefresh?.();
}
}).catch(() => {});
}
};
if (window.f0ckSession?.logged_in) window._emojiPickerFavUtils.syncFromDB();
}
if (!window.stickerPreview) {
const toastEl = document.createElement('div');
toastEl.className = 'emoji-fav-toast';
document.body.appendChild(toastEl);
let _toastTimer = null;
const showToast = (msg) => {
clearTimeout(_toastTimer);
toastEl.textContent = msg;
toastEl.classList.remove('toast-out');
toastEl.classList.add('toast-in');
_toastTimer = setTimeout(() => {
toastEl.classList.replace('toast-in', 'toast-out');
}, 2000);
};
const overlay = document.createElement('div');
overlay.className = 'sticker-preview-overlay';
document.body.appendChild(overlay);
let _hideTimer = null;
window.stickerPreview = {
isShowing: false,
_autoHideTimer: null,
show(src, isVideo, name, url) {
clearTimeout(this._autoHideTimer);
clearTimeout(_hideTimer);
overlay.innerHTML = '';
const el = isVideo ? document.createElement('video') : document.createElement('img');
el.src = src;
if (isVideo) { el.autoplay = true; el.loop = true; el.muted = true; el.playsInline = true; }
overlay.appendChild(el);
if (name && url && window._emojiPickerFavUtils) {
const isFav = window._emojiPickerFavUtils.has(name);
const favBtn = document.createElement('button');
favBtn.className = 'sticker-preview-fav-btn' + (isFav ? ' is-fav' : '');
favBtn.innerHTML = '<i class="' + (isFav ? 'fa-solid' : 'fa-regular') + ' fa-star"></i>';
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
window._emojiPickerFavUtils.toggle(name, url);
const nowFav = window._emojiPickerFavUtils.has(name);
window.stickerPreview.hide();
showToast(nowFav ? 'Emoji added to Favorites' : 'Emoji removed from Favorites');
window._emojiPickerRefresh?.();
});
favBtn.addEventListener('touchend', (e) => { e.preventDefault(); e.stopPropagation(); favBtn.click(); });
overlay.appendChild(favBtn);
}
overlay.classList.add('visible');
this.isShowing = true;
if (isVideo) el.play().catch(() => {});
},
hide() {
clearTimeout(this._autoHideTimer);
this._autoHideTimer = null;
this.isShowing = false;
overlay.classList.remove('visible');
_hideTimer = setTimeout(() => { overlay.innerHTML = ''; }, 200);
}
};
window._showEmojiToast = showToast;
overlay.addEventListener('click', () => window.stickerPreview.hide());
document.addEventListener('mouseup', () => {
if (window.stickerPreview?.isShowing && !window.stickerPreview._autoHideTimer) {
window.stickerPreview.hide();
}
});
}
if (!window._favContextMenu) {
const menu = document.createElement('div');
menu.style.cssText = 'position:fixed;z-index:99999;background:var(--dropdown-bg,#222);border:1px solid var(--nav-border-color,#444);border-radius:7px;padding:4px 0;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:none;min-width:180px;';
document.body.appendChild(menu);
window._favContextMenu = {
el: menu,
show(x, y, name, url, onToggle) {
const isFav = window._emojiPickerFavUtils.has(name);
menu.innerHTML = '';
const item = document.createElement('div');
item.style.cssText = 'padding:9px 16px;cursor:pointer;font-size:0.875em;color:var(--white,#fff);display:flex;align-items:center;gap:8px;border-radius:4px;margin:2px 4px;';
item.innerHTML = '<span style="font-size:1.1em;">' + (isFav ? '&#9733;' : '&#9734;') + '</span>' + (isFav ? 'Remove from Favorites' : 'Add to Favorites');
item.addEventListener('mouseenter', () => { item.style.background = 'rgba(255,255,255,0.09)'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
item.addEventListener('click', (e) => { e.stopPropagation(); window._emojiPickerFavUtils.toggle(name, url); menu.style.display = 'none'; if (onToggle) onToggle(); });
menu.appendChild(item);
menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.style.display = 'block';
requestAnimationFrame(() => {
const r = menu.getBoundingClientRect();
if (r.right > window.innerWidth) menu.style.left = (x - r.width) + 'px';
if (r.bottom > window.innerHeight) menu.style.top = (y - r.height) + 'px';
});
},
hide() { menu.style.display = 'none'; }
};
document.addEventListener('click', () => window._favContextMenu.hide());
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') window._favContextMenu.hide(); });
}
let picker = null;
let closeHandler = null;
let activeTabId = null;
let tabBar = null;
let gridArea = null;
let currentPacks = [];
function insertEmoji(name) {
const input = document.getElementById('gchat-input') || textarea;
if (!input) return;
const pos = input.selectionStart ?? input.value.length;
const val = input.value;
input.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
const newPos = pos + name.length + 2;
input.setSelectionRange(newPos, newPos);
input.focus();
if (picker) picker.style.display = 'none';
if (closeHandler) { document.removeEventListener('click', closeHandler); closeHandler = null; }
}
function makeChatEmojiEl(url, name) {
const isVideo = url && url.endsWith('.webm');
let media;
if (isVideo) {
media = document.createElement('video');
media.src = url;
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.src = url;
media.loading = 'lazy';
}
media.title = `:${name}:`;
media.onerror = () => { media.style.display = 'none'; };
const el = document.createElement('div');
el.className = 'ep-sticker-wrap';
el.appendChild(media);
if (window._emojiPickerFavUtils?.has(name)) {
const badge = document.createElement('i');
badge.className = 'fa-solid fa-star ep-fav-badge';
el.appendChild(badge);
}
let holdTimer = null;
let holdFired = false;
let isTouchHold = false;
const startHold = (fromTouch) => {
holdFired = false;
isTouchHold = !!fromTouch;
holdTimer = setTimeout(() => {
holdFired = true;
window.stickerPreview?.show(url, isVideo, name, url);
}, 800);
};
const cancelHold = () => { clearTimeout(holdTimer); isTouchHold = false; };
const endHold = () => {
clearTimeout(holdTimer);
if (holdFired) {
holdFired = false;
el.addEventListener('click', (e) => {
e.stopImmediatePropagation();
e.preventDefault();
}, { once: true, capture: true });
if (!isTouchHold) window.stickerPreview?.hide();
} else {
window.stickerPreview?.hide();
}
isTouchHold = false;
};
media.addEventListener('mousedown', () => startHold(false));
media.addEventListener('mouseup', endHold);
media.addEventListener('mouseleave', cancelHold);
media.addEventListener('mouseenter', () => {
if (window.stickerPreview?.isShowing) window.stickerPreview.show(url, isVideo, name, url);
});
media.addEventListener('touchstart', () => startHold(true), { passive: true });
media.addEventListener('touchmove', cancelHold, { passive: true });
media.addEventListener('touchend', endHold);
media.addEventListener('touchcancel', cancelHold);
media.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
if (holdFired) return;
window._favContextMenu?.show(e.clientX, e.clientY, name, url, () => {
if (activeTabId === '__favs__') showTab('__favs__', currentPacks);
});
});
return el;
}
function showTab(packId, packs) {
activeTabId = packId;
window._emojiPickerRefresh = () => showTab(activeTabId, currentPacks);
if (tabBar) {
tabBar.querySelectorAll('.ep-tab').forEach(t =>
t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null'))
);
}
if (!gridArea) return;
gridArea.innerHTML = '';
if (packId === '__favs__') {
const favs = window._emojiPickerFavUtils?.get() || [];
if (!favs.length) {
gridArea.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:24px 10px;opacity:0.45;font-size:0.82em;line-height:1.5;">No favorites yet. Right-click or hold any sticker to add it here.</div>';
return;
}
favs.forEach(({ name, url }) => {
const el = makeChatEmojiEl(url, name);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
insertEmoji(name);
});
gridArea.appendChild(el);
});
return;
}
const pack = (packs || []).find(p => String(p.id ?? null) === String(packId ?? null)) || packs?.[0];
if (!pack) return;
pack.emojis.forEach(({ name, url }) => {
const el = makeChatEmojiEl(url, name);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
insertEmoji(name);
});
gridArea.appendChild(el);
});
gridArea.querySelectorAll('video').forEach(v =>
v.play().catch(() => v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true }))
);
}
function buildPicker(emojis, packs) {
const inputArea = document.getElementById('gchat-input-area');
if (!inputArea) return;
if (!picker) {
picker = document.createElement('div');
picker.id = 'gchat-emoji-picker';
picker.className = 'emoji-picker gchat-emoji-picker';
picker.style.display = 'none';
inputArea.insertBefore(picker, inputArea.firstChild);
}
picker.innerHTML = '';
currentPacks = packs || [];
if (!currentPacks || !currentPacks.length) {
picker.classList.remove('has-tabs');
Object.entries(emojis || {}).forEach(([name, url]) => {
const el = makeChatEmojiEl(url, name);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
insertEmoji(name);
});
picker.appendChild(el);
});
return;
}
picker.classList.add('has-tabs');
tabBar = document.createElement('div');
tabBar.className = 'emoji-picker-tabs';
gridArea = document.createElement('div');
gridArea.className = 'emoji-picker-grid';
let lastHoldTime = 0;
const addTabTouch = (tabBtn, cb, onHold) => {
let ts = null, holdTimer = null, holdFired = false;
tabBtn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
tabBtn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) { clearTimeout(holdTimer); ts = null; }
}, { passive: true });
tabBtn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) { e.preventDefault(); e.stopPropagation(); holdFired = false; ts = null; return; }
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
};
const toggleFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// Favs Tab
const favTab = document.createElement('button');
favTab.className = 'ep-tab ep-tab-favs';
favTab.dataset.packId = '__favs__';
favTab.title = 'Favorites';
favTab.type = 'button';
favTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__', currentPacks));
addTabTouch(favTab, () => showTab('__favs__', currentPacks), () => toggleFavDefault(favTab));
favTab.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.stopPropagation();
if (Date.now() - lastHoldTime < 1000) return;
toggleFavDefault(favTab);
});
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
favTab.classList.add('ep-tab-default');
}
tabBar.appendChild(favTab);
// Pack Tabs
currentPacks.forEach((pack) => {
const tab = document.createElement('button');
tab.className = 'ep-tab';
tab.dataset.packId = String(pack.id ?? null);
tab.title = pack.name || 'Emojis';
tab.type = 'button';
if (pack.emojis && pack.emojis.length > 0) {
const iconUrl = pack.thumb_url || (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url;
const icon = document.createElement('img');
icon.src = iconUrl;
icon.alt = pack.name;
icon.loading = 'lazy';
tab.appendChild(icon);
} else {
tab.textContent = '☺';
}
tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showTab(pack.id ?? null, currentPacks));
addTabTouch(tab, () => showTab(pack.id ?? null, currentPacks));
tabBar.appendChild(tab);
});
picker.appendChild(tabBar);
picker.appendChild(gridArea);
const defaultTab = localStorage.getItem('f0ck_emoji_default_tab');
showTab(defaultTab === '__favs__' ? '__favs__' : (currentPacks[0]?.id ?? null), currentPacks);
}
async function populatePicker() {
const cachedPacks = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiPacks) || null;
const cachedEmojis = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
if (cachedEmojis && Object.keys(cachedEmojis).length && cachedPacks && cachedPacks.length > 0) {
buildPicker(cachedEmojis, cachedPacks);
return;
}
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success && data.emojis?.length) {
const map = {};
data.emojis.forEach(e => { map[e.name] = e.url; });
if (typeof CommentSystem !== 'undefined') {
CommentSystem.emojiCache = map;
CommentSystem.emojiPacks = data.packs || [];
}
customEmojis = map;
buildPicker(map, data.packs || []);
}
} catch (e) {
console.warn('[Chat] Failed to load emoji packs:', e);
}
}
// Toggle via button click
document.addEventListener('click', (e) => {
const btn = e.target.closest('#gchat-emoji-btn');
if (btn) {
e.preventDefault();
const inputArea = document.getElementById('gchat-input-area');
if (!inputArea) return;
picker = document.getElementById('gchat-emoji-picker');
if (!picker) {
populatePicker().then(() => {
picker = document.getElementById('gchat-emoji-picker');
if (picker) {
picker.style.display = 'flex';
closeHandler = (ev) => {
if (!picker.contains(ev.target) && !ev.target.closest('#gchat-emoji-btn')) {
picker.style.display = 'none';
document.removeEventListener('click', closeHandler);
closeHandler = null;
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
}
});
return;
}
const isHidden = picker.style.display === 'none' || picker.style.display === '';
if (isHidden) {
populatePicker();
picker.style.display = 'flex';
closeHandler = (ev) => {
if (!picker.contains(ev.target) && !ev.target.closest('#gchat-emoji-btn')) {
picker.style.display = 'none';
document.removeEventListener('click', closeHandler);
closeHandler = null;
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
} else {
picker.style.display = 'none';
if (closeHandler) { document.removeEventListener('click', closeHandler); closeHandler = null; }
}
}
});
window.addEventListener('f0ck:emojis_ready', (e) => {
customEmojis = e.detail;
if (picker) { picker.remove(); picker = null; }
});
}
// ── :emoji inline autocomplete ────────────────────────────────────────────
function setupEmojiAutocomplete(textarea) {
const ac = document.createElement('div');
ac.id = 'gchat-emoji-ac';
ac.style.display = 'none';
document.body.appendChild(ac);
let acMatches = [], acIdx = -1;
const getColon = () => {
const pos = textarea.selectionStart;
const text = textarea.value.slice(0, pos);
const m = text.match(/:([a-z0-9_]{0,})$/i);
if (!m) return null;
return { query: m[1].toLowerCase(), colonPos: pos - m[0].length };
};
const positionAC = () => {
const rect = textarea.getBoundingClientRect();
ac.style.left = rect.left + 'px';
ac.style.width = rect.width + 'px';
ac.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
ac.style.top = 'auto';
};
const hideAC = () => {
ac.style.display = 'none';
ac.innerHTML = '';
acMatches = []; acIdx = -1;
};
const insertEmoji = (name) => {
const hit = getColon();
if (!hit) return;
const before = textarea.value.slice(0, hit.colonPos);
const after = textarea.value.slice(textarea.selectionStart);
const insert = `:${name}:`;
textarea.value = before + insert + after;
const newPos = hit.colonPos + insert.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
hideAC();
};
const setActiveAC = (idx) => {
ac.querySelectorAll('.gchat-emoji-ac-item').forEach(el => el.classList.remove('active'));
const items = ac.querySelectorAll('.gchat-emoji-ac-item');
if (idx >= 0 && idx < items.length) {
items[idx].classList.add('active');
items[idx].scrollIntoView({ block: 'nearest' });
}
acIdx = idx;
};
const renderAC = () => {
const hit = getColon();
if (!hit || !customEmojis) return hideAC();
acMatches = Object.keys(customEmojis).filter(n => n.includes(hit.query)).slice(0, 30);
if (!acMatches.length) return hideAC();
ac.innerHTML = '';
acIdx = -1;
acMatches.forEach((name, i) => {
const item = document.createElement('div');
item.className = 'gchat-emoji-ac-item';
const img = document.createElement('img');
img.src = customEmojis[name]; img.alt = name; img.loading = 'lazy';
const label = document.createElement('span');
label.textContent = `:${name}:`;
item.append(img, label);
item.addEventListener('mousedown', ev => { ev.preventDefault(); insertEmoji(name); });
ac.appendChild(item);
});
positionAC();
ac.style.display = 'flex';
// Auto-select first match so Enter works immediately
setActiveAC(0);
};
textarea.addEventListener('input', () => renderAC());
textarea.addEventListener('click', () => { if (getColon()) renderAC(); });
textarea.addEventListener('blur', () => setTimeout(hideAC, 150));
textarea.addEventListener('keydown', e => {
if (ac.style.display === 'none') return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveAC((acIdx + 1) % acMatches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveAC((acIdx <= 0 ? acMatches.length : acIdx) - 1);
} else if (e.key === 'Enter' || e.key === 'Tab') {
// Insert the active match (always at least 0 after renderAC)
const idx = acIdx >= 0 ? acIdx : 0;
if (acMatches[idx]) {
e.preventDefault(); e.stopImmediatePropagation();
insertEmoji(acMatches[idx]);
}
} else if (e.key === 'Escape') {
hideAC();
}
});
}
// ── @user inline autocomplete (always opens upward) ──────────────────────
function setupMentionAutocomplete(textarea) {
const mac = document.createElement('div');
mac.id = 'gchat-mention-ac';
mac.style.display = 'none';
document.body.appendChild(mac);
let macMatches = [], macIdx = -1;
const DEBOUNCE = 200;
let debounceTimer = null;
const getMention = () => {
const pos = textarea.selectionStart;
const text = textarea.value.slice(0, pos);
const m = text.match(/@([a-zA-Z0-9_\-.]{0,})$/);
if (!m) return null;
return { query: m[1], atPos: pos - m[0].length };
};
const positionMAC = () => {
const rect = textarea.getBoundingClientRect();
mac.style.left = rect.left + 'px';
mac.style.width = rect.width + 'px';
mac.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
mac.style.top = 'auto';
};
const hideMAC = () => {
mac.style.display = 'none';
mac.innerHTML = '';
macMatches = []; macIdx = -1;
};
const insertMention = (username) => {
const hit = getMention();
if (!hit) return;
const before = textarea.value.slice(0, hit.atPos);
const after = textarea.value.slice(textarea.selectionStart);
const insert = username.includes(' ') ? `[@${username}]` : `@${username}`;
textarea.value = before + insert + after;
textarea.setSelectionRange(hit.atPos + insert.length, hit.atPos + insert.length);
textarea.focus();
hideMAC();
};
const setActiveMAC = (idx) => {
mac.querySelectorAll('.gchat-mention-item').forEach(el => el.classList.remove('active'));
const items = mac.querySelectorAll('.gchat-mention-item');
if (idx >= 0 && idx < items.length) {
items[idx].classList.add('active');
items[idx].scrollIntoView({ block: 'nearest' });
}
macIdx = idx;
};
const renderMAC = (users) => {
mac.innerHTML = '';
macMatches = users;
macIdx = -1;
if (!users.length) return hideMAC();
users.forEach(user => {
const item = document.createElement('div');
item.className = 'gchat-mention-item';
const src = user.avatar_file ? `/a/${user.avatar_file}` : (user.avatar ? `/t/${user.avatar}.webp` : '/a/default.png');
const img = document.createElement('img');
img.src = src; img.loading = 'lazy';
img.onerror = () => { img.src = '/a/default.png'; };
const name = document.createElement('span');
name.className = 'gchat-mention-name';
name.textContent = user.user;
item.append(img, name);
if (user.display_name) {
const disp = document.createElement('span');
disp.className = 'gchat-mention-display';
disp.textContent = user.display_name;
item.appendChild(disp);
}
item.addEventListener('mousedown', ev => { ev.preventDefault(); insertMention(user.user); });
mac.appendChild(item);
});
positionMAC();
mac.style.display = 'flex';
setActiveMAC(0);
};
textarea.addEventListener('input', () => {
const hit = getMention();
if (!hit) return hideMAC();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(hit.query)}`);
const data = await res.json();
renderMAC(data.suggestions || []);
} catch { hideMAC(); }
}, DEBOUNCE);
});
textarea.addEventListener('blur', () => setTimeout(hideMAC, 150));
textarea.addEventListener('keydown', e => {
if (mac.style.display === 'none') return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMAC((macIdx + 1) % macMatches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMAC((macIdx <= 0 ? macMatches.length : macIdx) - 1);
} else if (e.key === 'Enter' || e.key === 'Tab') {
const idx = macIdx >= 0 ? macIdx : 0;
if (macMatches[idx]) {
e.preventDefault(); e.stopImmediatePropagation();
insertMention(macMatches[idx].user);
}
} else if (e.key === 'Escape') {
hideMAC();
}
});
}
// ── Toolbar button helpers ────────────────────────────────────────────────
function insertWrap(textarea, open, close) {
const s = textarea.selectionStart, e = textarea.selectionEnd;
const v = textarea.value;
const selected = v.substring(s, e);
if (selected) {
textarea.value = v.slice(0, s) + open + selected + close + v.slice(e);
textarea.setSelectionRange(s + open.length + selected.length + close.length,
s + open.length + selected.length + close.length);
} else {
textarea.value = v.slice(0, s) + open + close + v.slice(s);
textarea.setSelectionRange(s + open.length, s + open.length);
}
textarea.focus();
}
// ── Image modal ───────────────────────────────────────────────────────────
let _imgModal = null;
function openImgModal(src) {
if (!_imgModal) {
_imgModal = document.createElement('div');
_imgModal.id = 'gchat-img-modal';
_imgModal.innerHTML = '<div id="gchat-img-modal-inner"><img id="gchat-img-modal-img" alt=""></div>';
document.body.appendChild(_imgModal);
_imgModal.addEventListener('click', () => closeImgModal());
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeImgModal(); });
}
document.getElementById('gchat-img-modal-img').src = src;
_imgModal.classList.add('gchat-img-modal-open');
document.body.style.overflow = 'hidden';
}
function closeImgModal() {
_imgModal?.classList.remove('gchat-img-modal-open');
document.body.style.overflow = '';
}
// ── Init ─────────────────────────────────────────────────────────────────
function init() {
buildWidget();
// Gate chat on SSE connection — block input until stream is live
let _sseReady = false;
function setConnecting(connecting) {
const inputArea = document.getElementById('gchat-input-area');
const messages = document.getElementById('gchat-messages');
const sendBtn = document.getElementById('gchat-send-btn');
if (inputArea) {
inputArea.style.opacity = connecting ? '0.35' : '1';
inputArea.style.pointerEvents = connecting ? 'none' : 'auto';
}
if (messages) messages.style.opacity = connecting ? '0.35' : '1';
if (sendBtn) sendBtn.disabled = connecting;
}
// Failsafe check if SSE is already active on init
if (document.documentElement.dataset.sseReady === '1') {
_sseReady = true;
setConnecting(false);
} else {
setConnecting(true);
}
const onSseReady = () => {
_sseReady = true;
clearTimeout(_sseUnlockTimer);
if (_pollFallback) clearInterval(_pollFallback);
setConnecting(false);
};
document.addEventListener('f0ck:sse_ready', onSseReady);
// Absolute fallback: if SSE hasn't connected within 10s, unlock anyway and poll
let _pollFallback = null;
const _sseUnlockTimer = setTimeout(() => {
if (_sseReady) return;
console.warn('[Chat] SSE not ready after 10s — unlocking in polling mode');
setConnecting(false);
// Poll /api/chat every 15s as fallback so messages still appear
_pollFallback = setInterval(async () => {
if (_sseReady) { clearInterval(_pollFallback); return; }
try {
const res = await fetch('/api/chat', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('gchat-messages');
if (!container) return;
const lastId = parseInt(container.dataset.lastPollId || '0', 10);
const newMsgs = (data.messages || []).filter(m => m.id > lastId);
if (newMsgs.length) {
newMsgs.forEach(m => appendMsg(m, false));
container.dataset.lastPollId = String(Math.max(...newMsgs.map(m => m.id)));
}
} catch (_) {}
}, 15000);
}, 10000);
const textarea = document.getElementById('gchat-input');
const isPageMode = (window.location.pathname === '/chat');
if (isPageMode) isMinimized = false;
// Returns min top position for float panel (below navbar + MOTD)
function getTopBound() {
const nav = document.querySelector('nav.navbar');
const motd = document.getElementById('motd-container');
let bound = nav ? nav.getBoundingClientRect().bottom : 0;
if (motd && motd.offsetParent !== null && motd.getBoundingClientRect().bottom > bound) {
bound = motd.getBoundingClientRect().bottom;
}
return Math.max(0, bound);
}
function getMaxPanelHeight() {
const topB = typeof getTopBound === 'function' ? getTopBound() : 50;
return Math.max(180, window.innerHeight - topB - 15);
}
const panel = document.getElementById('gchat-panel');
if (!isPageMode && panel) {
// Restore saved height
const savedH = localStorage.getItem('f0ck_chat_height');
if (savedH) {
const parsedH = parseInt(savedH, 10);
const maxH = getMaxPanelHeight();
panel.style.height = (parsedH ? Math.min(parsedH, maxH) : 400) + 'px';
}
// Restore saved width
const savedW = localStorage.getItem('f0ck_chat_width');
if (savedW) panel.style.width = savedW + 'px';
}
// Drag-to-resize height via top invisible strip
const resizeH = document.getElementById('gchat-resize-h');
if (resizeH && !isPageMode) {
function startResizeH(startY, startH, startTop) {
const onMove = (clientY) => {
const delta = startY - clientY;
const maxH = isFloating ? (window.innerHeight * 0.95) : getMaxPanelHeight();
const newH = Math.min(Math.max(startH + delta, 180), maxH);
panel.style.height = newH + 'px';
if (isFloating) {
const newTop = startTop - (newH - startH);
panel.style.top = Math.max(getTopBound(), newTop) + 'px';
}
};
const onEnd = () => {
const h = parseInt(panel.style.height);
if (h) localStorage.setItem('f0ck_chat_height', h);
if (isFloating) localStorage.setItem('f0ck_chat_float_y', parseInt(panel.style.top));
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
document.removeEventListener('touchend', touchEnd);
};
const mouseMove = mv => onMove(mv.clientY);
const mouseEnd = onEnd;
const touchMove = mv => { mv.preventDefault(); onMove(mv.touches[0].clientY); };
const touchEnd = onEnd;
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseEnd);
document.addEventListener('touchmove', touchMove, { passive: false });
document.addEventListener('touchend', touchEnd);
}
resizeH.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (isMinimized) return;
e.preventDefault();
startResizeH(e.clientY, panel.getBoundingClientRect().height, panel.getBoundingClientRect().top);
});
resizeH.addEventListener('touchstart', (e) => {
if (isMinimized) return;
e.preventDefault();
startResizeH(e.touches[0].clientY, panel.getBoundingClientRect().height, panel.getBoundingClientRect().top);
}, { passive: false });
}
// Drag-to-resize width from the left handle
const resizeW = document.getElementById('gchat-resize-w');
if (resizeW && !isPageMode) {
function startResizeW(startX, startW, startLeft) {
const onMove = (clientX) => {
const delta = startX - clientX;
const newW = Math.min(Math.max(startW + delta, 200), window.innerWidth * 0.6);
panel.style.width = newW + 'px';
if (isFloating) {
const newLeft = startLeft - (newW - startW);
panel.style.left = Math.max(0, newLeft) + 'px';
}
};
const onEnd = () => {
const w = parseInt(panel.style.width);
if (w) localStorage.setItem('f0ck_chat_width', w);
if (isFloating) localStorage.setItem('f0ck_chat_float_x', parseInt(panel.style.left));
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
document.removeEventListener('touchend', touchEnd);
};
const mouseMove = mv => onMove(mv.clientX);
const mouseEnd = onEnd;
const touchMove = mv => { mv.preventDefault(); onMove(mv.touches[0].clientX); };
const touchEnd = onEnd;
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseEnd);
document.addEventListener('touchmove', touchMove, { passive: false });
document.addEventListener('touchend', touchEnd);
}
resizeW.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const r = panel.getBoundingClientRect();
startResizeW(e.clientX, r.width, r.left);
});
resizeW.addEventListener('touchstart', (e) => {
e.preventDefault();
const r = panel.getBoundingClientRect();
startResizeW(e.touches[0].clientX, r.width, r.left);
}, { passive: false });
}
// Drag-to-resize width from the RIGHT handle (left edge stays fixed)
const resizeE = document.getElementById('gchat-resize-e');
if (resizeE && !isPageMode) {
function startResizeE(startX, startW) {
const onMove = (clientX) => {
const delta = clientX - startX;
const newW = Math.min(Math.max(startW + delta, 200), window.innerWidth * 0.6);
panel.style.width = newW + 'px';
};
const onEnd = () => {
const w = parseInt(panel.style.width);
if (w) localStorage.setItem('f0ck_chat_width', w);
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
document.removeEventListener('touchend', touchEnd);
};
const mouseMove = mv => onMove(mv.clientX);
const mouseEnd = onEnd;
const touchMove = mv => { mv.preventDefault(); onMove(mv.touches[0].clientX); };
const touchEnd = onEnd;
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseEnd);
document.addEventListener('touchmove', touchMove, { passive: false });
document.addEventListener('touchend', touchEnd);
}
resizeE.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const r = panel.getBoundingClientRect();
startResizeE(e.clientX, r.width);
});
resizeE.addEventListener('touchstart', (e) => {
e.preventDefault();
const r = panel.getBoundingClientRect();
startResizeE(e.touches[0].clientX, r.width);
}, { passive: false });
}
// ── Float / Dock toggle ───────────────────────────────────────────────
let isFloating = localStorage.getItem('f0ck_chat_floating') === '1';
function applyFloatMode() {
if (isPageMode) return;
const widget = document.getElementById('gchat-widget');
if (isFloating) {
panel.classList.add('gchat-floating');
const fx = localStorage.getItem('f0ck_chat_float_x');
const fy = localStorage.getItem('f0ck_chat_float_y');
if (fx) panel.style.left = fx + 'px';
if (fy) panel.style.top = Math.max(getTopBound(), parseInt(fy)) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
const floatBtn = document.getElementById('gchat-float-btn');
if (floatBtn) floatBtn.innerHTML = '<i class="fa-solid fa-down-left-and-up-right-to-center"></i>';
} else {
panel.classList.remove('gchat-floating');
panel.style.left = '';
panel.style.top = '';
panel.style.right = '';
panel.style.bottom = '';
const maxH = window.innerHeight - getTopBound() - 10;
const curH = parseInt(panel.style.height);
if (curH && curH > maxH) {
panel.style.height = maxH + 'px';
}
const floatBtn = document.getElementById('gchat-float-btn');
if (floatBtn) floatBtn.innerHTML = '<i class="fa-solid fa-up-right-and-down-left-from-center"></i>';
}
}
applyFloatMode();
const floatBtn = document.getElementById('gchat-float-btn');
if (floatBtn) {
floatBtn.addEventListener('click', () => {
isFloating = !isFloating;
localStorage.setItem('f0ck_chat_floating', isFloating ? '1' : '0');
if (isFloating) {
const r = panel.getBoundingClientRect();
panel.style.left = r.left + 'px';
panel.style.top = r.top + 'px';
localStorage.setItem('f0ck_chat_float_x', Math.round(r.left));
localStorage.setItem('f0ck_chat_float_y', Math.round(r.top));
}
applyFloatMode();
});
}
// Drag-to-move in float mode — drag the header
const header = document.getElementById('gchat-header');
function startDragMove(startX, startY, offX, offY) {
const r = panel.getBoundingClientRect();
const onMove = (clientX, clientY) => {
const newX = Math.max(0, Math.min(clientX - offX, window.innerWidth - r.width));
const newY = Math.max(getTopBound(), Math.min(clientY - offY, window.innerHeight - r.height));
panel.style.left = newX + 'px';
panel.style.top = newY + 'px';
};
const onEnd = () => {
const curY = parseInt(panel.style.top);
localStorage.setItem('f0ck_chat_float_x', parseInt(panel.style.left));
localStorage.setItem('f0ck_chat_float_y', curY);
if (isMinimized) {
const topBound = getTopBound();
const bottomBound = window.innerHeight;
const distToTop = Math.abs(curY - topBound);
const distToBottom = Math.abs(bottomBound - (curY + 42));
localStorage.setItem('f0ck_chat_anchor_top', distToTop < distToBottom ? '1' : '0');
}
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
document.removeEventListener('touchend', touchEnd);
};
const mouseMove = mv => onMove(mv.clientX, mv.clientY);
const mouseEnd = onEnd;
const touchMove = mv => { mv.preventDefault(); onMove(mv.touches[0].clientX, mv.touches[0].clientY); };
const touchEnd = onEnd;
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseEnd);
document.addEventListener('touchmove', touchMove, { passive: false });
document.addEventListener('touchend', touchEnd);
}
if (header && !isPageMode) {
header.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (!isFloating) return;
if (e.target.closest('button')) return;
e.preventDefault();
const r = panel.getBoundingClientRect();
startDragMove(e.clientX, e.clientY, e.clientX - r.left, e.clientY - r.top);
});
header.addEventListener('touchstart', (e) => {
if (!isFloating) return;
if (e.target.closest('button')) return;
e.preventDefault();
const r = panel.getBoundingClientRect();
const t = e.touches[0];
startDragMove(t.clientX, t.clientY, t.clientX - r.left, t.clientY - r.top);
}, { passive: false });
}
// Minimize toggle — in float mode anchor to bottom edge in both directions
function toggleMinimized() {
if (isPageMode) return;
const willExpand = isMinimized; // about to expand
if (isFloating) {
const r = panel.getBoundingClientRect();
const topBound = getTopBound();
const bottomBound = window.innerHeight;
if (!willExpand) {
const distToTop = Math.abs(r.top - topBound);
const distToBottom = Math.abs(bottomBound - (r.top + r.height));
const anchorTop = distToTop < distToBottom;
localStorage.setItem('f0ck_chat_anchor_top', anchorTop ? '1' : '0');
const curTop = r.top;
const curBottom = r.top + r.height;
setMinimized(true);
requestAnimationFrame(() => {
let newTop;
if (anchorTop) {
newTop = Math.max(topBound, curTop);
} else {
newTop = Math.min(bottomBound - 42, Math.max(topBound, curBottom - 42));
}
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
} else {
const wasAnchorTop = localStorage.getItem('f0ck_chat_anchor_top') === '1';
setMinimized(false);
requestAnimationFrame(() => {
const fullH = panel.getBoundingClientRect().height;
const curTop = parseInt(panel.style.top) || 0;
let newTop;
if (wasAnchorTop) {
newTop = Math.max(topBound, curTop);
} else {
newTop = Math.max(topBound, curTop - (fullH - 42));
}
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
}
} else {
setMinimized(!isMinimized);
if (!isMinimized) {
loadHistory();
}
}
}
const minBtn = document.getElementById('gchat-minimize-btn');
if (minBtn) {
minBtn.addEventListener('click', e => {
e.stopPropagation(); toggleMinimized();
});
}
if (header && !isPageMode) {
header.addEventListener('dblclick', e => {
if (!e.target.closest('.gchat-icon-btn')) toggleMinimized();
});
}
// Close button — hide widget and show reopen bubble
function closeChat() {
if (isPageMode) return;
isClosed = true;
localStorage.setItem('f0ck_chat_closed', '1');
const widget = document.getElementById('gchat-widget');
if (widget) widget.style.display = 'none';
buildReopenBubble();
}
function reopenChat() {
if (isPageMode) return;
isClosed = false;
localStorage.removeItem('f0ck_chat_closed');
const widget = document.getElementById('gchat-widget');
if (widget) widget.style.display = '';
const bubble = document.getElementById('gchat-reopen-bubble');
if (bubble) bubble.remove();
if (isMinimized) { setMinimized(false); } else { loadHistory(); }
}
function buildReopenBubble() {
if (isPageMode) return;
if (document.getElementById('gchat-reopen-bubble')) return;
const el = document.createElement('button');
el.id = 'gchat-reopen-bubble';
el.title = t('chat_open', 'Open chat');
el.innerHTML = '<i class="fa-solid fa-comments"></i>';
el.addEventListener('click', reopenChat);
document.body.appendChild(el);
}
const closeBtn = document.getElementById('gchat-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', e => {
e.stopPropagation(); closeChat();
});
}
// Expose globally for settings page
window.reopenChat = reopenChat;
window.closeChatWidget = closeChat;
// Delegated Send & Toolbar Click Listeners
document.addEventListener('click', e => {
const sendBtn = e.target.closest('#gchat-send-btn');
if (sendBtn) {
e.preventDefault();
sendMessage();
return;
}
});
// Delegated Enter Key press on chat input
document.addEventListener('keydown', e => {
if (e.target && e.target.id === 'gchat-input') {
if (e.key === 'Enter' && !e.shiftKey) {
const emojiAC = document.getElementById('gchat-emoji-ac');
const mentionAC = document.getElementById('gchat-mention-ac');
if ((emojiAC && emojiAC.style.display !== 'none') ||
(mentionAC && mentionAC.style.display !== 'none')) return;
e.preventDefault();
sendMessage();
}
}
});
// Delegated Auto-grow for chat input
document.addEventListener('input', e => {
if (e.target && e.target.id === 'gchat-input') {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 80) + 'px';
}
});
// @mention autocomplete (always opens upward)
setupMentionAutocomplete(textarea);
// Emoji features — must complete before loadHistory() so :codes: render correctly
// Use existing cache if CommentSystem already loaded emojis (synchronous)
if (window.CommentSystem?.emojiCache) {
customEmojis = window.CommentSystem.emojiCache;
}
// Also subscribe to the f0ck:emojis_ready event for late-loading
window.addEventListener('f0ck:emojis_ready', e => { customEmojis = e.detail; });
setupEmojiPicker(textarea);
setupEmojiAutocomplete(textarea);
// SSE live messages
document.addEventListener('f0ck:global_chat', e => appendMsg(e.detail, !isMinimized));
// SSE clear — wipe messages instantly for all clients
document.addEventListener('f0ck:global_chat_clear', () => {
const container = document.getElementById('gchat-messages');
if (container) container.innerHTML = '';
});
// SSE delete — remove a single message for all clients
document.addEventListener('f0ck:global_chat_delete', (e) => {
const id = e.detail?.id;
if (!id) return;
const msgEl = document.querySelector(`#gchat-messages [data-msg-id="${id}"]`);
if (msgEl) {
msgEl.style.opacity = '0';
msgEl.style.transform = 'scaleY(0)';
setTimeout(() => msgEl.remove(), 200);
}
});
// ── Chat background helpers ───────────────────────────────────────────
function applyBackground(css) {
const msgs = document.getElementById('gchat-messages');
if (!msgs) return;
msgs.style.background = css || '';
}
// Restore background on load
fetch('/api/chat/background').then(r => r.json()).then(d => {
if (d.success && d.background) applyBackground(d.background);
}).catch(() => {});
// SSE background change — apply instantly to all clients
document.addEventListener('f0ck:global_chat_background', (e) => {
applyBackground(e.detail?.background || null);
});
// ── Online users bar ─────────────────────────────────────────────────
function renderOnline(users, guestCount = 0) {
const el = document.getElementById('gchat-online');
if (!el) return;
const isPageMode = (window.location.pathname === '/chat');
if ((!users || users.length === 0) && guestCount === 0) {
if (isPageMode) {
el.style.display = 'flex';
el.innerHTML = `<div class="gchat-online-header"><i class="fa-solid fa-users"></i> Online (0)</div><div class="gchat-online-list"></div>`;
} else {
el.innerHTML = '';
el.style.display = 'none';
}
return;
}
if (isPageMode) {
el.style.display = 'flex';
let totalOnline = users.length + guestCount;
let countText = `${users.length} registered`;
if (guestCount > 0) {
countText += `, ${guestCount} guest${guestCount > 1 ? 's' : ''}`;
}
const userItemsHTML = users.map(u => {
const name = esc(u.display_name || u.username || '?');
const uname = esc(u.username || '');
const src = avatarSrc(u);
const colorStyle = u.username_color ? `style="color:${esc(u.username_color)}"` : '';
return `
<a href="/user/${encodeURIComponent(uname)}" class="gchat-online-user-item" title="${name}">
<img src="${src}" class="gchat-online-user-avatar" alt="${name}" loading="lazy">
<span class="gchat-online-user-name" ${colorStyle}>${name}</span>
</a>
`;
}).join('');
const guestHTML = guestCount > 0 ? `
<div class="gchat-online-user-item guest-item">
<div class="gchat-online-user-avatar guest-avatar"><i class="fa-solid fa-user-secret"></i></div>
<span class="gchat-online-user-name">${guestCount} Guest${guestCount > 1 ? 's' : ''}</span>
</div>
` : '';
el.innerHTML = `
<div class="gchat-online-header">
<i class="fa-solid fa-users"></i> <span>Online (${totalOnline})</span>
</div>
<div class="gchat-online-subhead">${countText}</div>
<div class="gchat-online-list">
${userItemsHTML}
${guestHTML}
</div>
`;
} else {
el.style.display = 'block';
// Show up to 8 avatars, then "+N more" pill
const MAX_SHOWN = 8;
const shown = users.slice(0, MAX_SHOWN);
const extra = users.length - shown.length;
const avatarHTML = shown.map(u => {
const name = esc(u.display_name || u.username || '?');
const src = avatarSrc(u);
const colorStyle = u.username_color ? `style="border-color:${esc(u.username_color)}"` : '';
return `<img src="${src}" class="gchat-online-avatar" title="${name}" alt="${name}" loading="lazy" ${colorStyle}>`;
}).join('');
const extraPill = extra > 0 ? `<span class="gchat-online-extra">+${extra}</span>` : '';
let countText = `${users.length} online`;
if (guestCount > 0 && (window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator)) {
countText += ` (${guestCount} guests)`;
}
const countLabel = `<span class="gchat-online-count">${countText}</span>`;
el.innerHTML = `<div class="gchat-online-inner">${countLabel}<div class="gchat-online-avatars">${avatarHTML}${extraPill}</div></div>`;
}
}
let lastPresenceUsers = [];
let lastPresenceGuests = 0;
function syncChatLocation() {
const isPageMode = (window.location.pathname === '/chat');
const container = isPageMode ? document.getElementById('site-chat-container') : null;
let widget = document.getElementById('gchat-widget');
let panel = document.getElementById('gchat-panel');
if (!panel) {
buildWidget();
panel = document.getElementById('gchat-panel');
}
const messagesEl = document.getElementById('gchat-messages');
const inputAreaEl = document.getElementById('gchat-input-area');
const onlineEl = document.getElementById('gchat-online');
if (isPageMode) {
if (!container) return;
isMinimized = false;
isClosed = false;
const reopenBubble = document.getElementById('gchat-reopen-bubble');
if (reopenBubble) reopenBubble.remove();
// Build page-mode wrapper if not present
let bodyWrap = panel.querySelector('.gchat-body-wrap');
if (!bodyWrap) {
bodyWrap = document.createElement('div');
bodyWrap.className = 'gchat-body-wrap';
const mainArea = document.createElement('div');
mainArea.className = 'gchat-main-area';
bodyWrap.appendChild(mainArea);
panel.appendChild(bodyWrap);
}
const mainArea = bodyWrap.querySelector('.gchat-main-area');
if (messagesEl && mainArea) mainArea.appendChild(messagesEl);
if (inputAreaEl && mainArea) mainArea.appendChild(inputAreaEl);
if (onlineEl && bodyWrap) bodyWrap.appendChild(onlineEl);
if (!container.contains(panel)) {
container.appendChild(panel);
}
if (widget && widget.parentElement) {
widget.remove();
}
if (panel) {
panel.className = 'gchat-page-panel';
panel.style.height = '';
panel.style.width = '';
panel.style.left = '';
panel.style.top = '';
panel.style.display = '';
}
renderOnline(lastPresenceUsers, lastPresenceGuests);
if (_sseReady || document.documentElement.dataset.sseReady === '1') {
setConnecting(false);
}
loadHistory();
} else {
if (!widget) {
widget = document.createElement('div');
widget.id = 'gchat-widget';
document.body.appendChild(widget);
}
widget.style.display = isClosed ? 'none' : '';
// Remove page-mode wrapper if present, moving elements back as direct children of panel
const bodyWrap = panel.querySelector('.gchat-body-wrap');
if (bodyWrap) {
if (onlineEl) panel.appendChild(onlineEl);
if (messagesEl) panel.appendChild(messagesEl);
if (inputAreaEl) panel.appendChild(inputAreaEl);
bodyWrap.remove();
}
if (panel.parentElement !== widget) {
widget.appendChild(panel);
}
if (panel) {
panel.className = isMinimized ? 'gchat-minimized' : '';
const savedH = localStorage.getItem('f0ck_chat_height');
if (savedH) {
const parsedH = parseInt(savedH, 10);
const maxH = getMaxPanelHeight();
panel.style.height = (parsedH ? Math.min(parsedH, maxH) : 400) + 'px';
}
const savedW = localStorage.getItem('f0ck_chat_width');
if (savedW) panel.style.width = savedW + 'px';
applyFloatMode();
}
if (isClosed) {
buildReopenBubble();
} else {
const reopenBubble = document.getElementById('gchat-reopen-bubble');
if (reopenBubble) reopenBubble.remove();
}
renderOnline(lastPresenceUsers, lastPresenceGuests);
}
}
document.addEventListener('f0ck:global_chat_presence', (e) => {
lastPresenceUsers = e.detail?.users || [];
lastPresenceGuests = e.detail?.guestCount || 0;
renderOnline(lastPresenceUsers, lastPresenceGuests);
});
document.addEventListener('f0ck:contentLoaded', syncChatLocation);
window.addEventListener('popstate', syncChatLocation);
// Event delegation: reply + admin delete buttons inside #gchat-messages
const msgArea = document.getElementById('gchat-messages');
const csrf = () => window.f0ckSession?.csrf_token;
// Clear unread when user interacts with the chat
msgArea.addEventListener('mouseenter', clearUnread);
textarea.addEventListener('focus', clearUnread);
msgArea.addEventListener('click', async (e) => {
// Reply button
const replyBtn = e.target.closest('.gchat-reply-btn');
if (replyBtn) {
const username = replyBtn.dataset.username;
if (username) {
const mention = `@${username} `;
textarea.value = mention + textarea.value;
textarea.setSelectionRange(mention.length, mention.length);
textarea.focus();
}
return;
}
// Admin delete button
const delBtn = e.target.closest('.gchat-del-btn');
if (delBtn) {
const msgId = delBtn.dataset.id;
if (!msgId) return;
try {
const res = await fetch(`/api/chat/${msgId}`, {
method: 'DELETE',
headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf() ? { 'X-CSRF-Token': csrf() } : {}) }
});
const data = await res.json();
if (data.success) {
const msgEl = msgArea.querySelector(`[data-msg-id="${msgId}"]`);
if (msgEl) {
msgEl.style.opacity = '0';
msgEl.style.transform = 'scaleY(0)';
setTimeout(() => msgEl.remove(), 200);
}
}
} catch (err) { console.error('[Chat] Delete error', err); }
return;
}
});
// Initial history — load emojis first so :codes: render as images from the start
(async () => {
if (!customEmojis) {
try {
const r = await fetch('/api/v2/emojis');
const data = await r.json();
if (data.success) {
customEmojis = {};
data.emojis.forEach(e => { customEmojis[e.name] = e.url; });
if (window.CommentSystem) window.CommentSystem.emojiCache = customEmojis;
}
} catch (_) {}
}
if (!isMinimized || (window.location.pathname === '/chat')) loadHistory();
})();
// If user had previously closed the chat, hide on load and show bubble
if (isClosed && (window.location.pathname !== '/chat')) {
const widget = document.getElementById('gchat-widget');
if (widget) widget.style.display = 'none';
buildReopenBubble();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();