2196 lines
102 KiB
JavaScript
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">>${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 ? '★' : '☆') + '</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();
|
|
}
|
|
})();
|