From f803ec27a55006782547c4b5e6b1361d249c1320 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 26 Jul 2026 21:31:28 +0200 Subject: [PATCH] WIP Chat overhaul --- public/s/css/f0ckm.css | 360 +++++++++-- public/s/js/f0ckm.js | 9 +- public/s/js/globalchat.js | 1207 +++++++++++++++++++++++++++--------- src/inc/locales/de.json | 1 + src/inc/locales/en.json | 1 + src/inc/locales/nl.json | 1 + src/inc/locales/zange.json | 1 + src/inc/routes/chat.mjs | 19 + views/chat.html | 9 + views/snippets/navbar.html | 3 + 10 files changed, 1266 insertions(+), 345 deletions(-) create mode 100644 views/chat.html diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 60c9d2a..e6b4588 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -10032,6 +10032,7 @@ html[theme="orange"] .image-brand { .pagewrapper:has(.index-layout-wrapper), .pagewrapper:has(.item-layout-container), .pagewrapper:has(.meme-layout-wrapper), + .pagewrapper:has(.chat-page), body.private-gate-active .pagewrapper { padding-right: 0 !important; } @@ -14704,6 +14705,7 @@ body.layout-modern .xd-score-wrapper { z-index: 950; display: flex; flex-direction: column; + justify-content: flex-end; align-items: flex-end; pointer-events: none; font-family: var(--font, monospace); @@ -14788,8 +14790,9 @@ body.scroller-active #gchat-reopen-bubble { display: flex; flex-direction: column; width: 300px; - height: 40dvh; - max-height: calc(100dvh - var(--navbar-h, 50px)); + height: 400px; + max-height: calc(100dvh - var(--navbar-h, 50px) - 15px); + min-height: 180px; background: var(--bg, #1a1a1a); border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.08)); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65); @@ -14974,22 +14977,12 @@ body.scroller-active #gchat-reopen-bubble { white-space: nowrap; } -#gchat-topic { - padding: 5px 10px; - font-size: 0.78em; - font-weight: 600; - color: var(--bg, #111); - background: var(--accent, #f2ef0b); - border-bottom: 1px solid rgba(0, 0, 0, 0.15); - word-break: break-word; - white-space: pre-wrap; - flex-shrink: 0; - opacity: 0.95; } #gchat-messages { - flex: 1; - overflow-y: auto; + flex: 1 1 0%; + min-height: 0; + overflow-y: auto !important; overflow-x: hidden; padding: 8px; display: flex; @@ -15096,7 +15089,9 @@ body.scroller-active #gchat-reopen-bubble { /* Emoji images inside chat bubbles */ .gchat-bubble .emoji { - height: 10.4em; + height: 1.4em; + max-height: 28px; + width: auto; vertical-align: middle; display: inline-block; } @@ -15204,33 +15199,15 @@ body.scroller-active #gchat-reopen-bubble { transform: scale(0.92); } -/* Emoji picker grid (above input area, inside panel) */ #gchat-emoji-picker { display: none; - grid-template-columns: repeat(6, 1fr); - gap: 4px; - padding: 8px; - overflow-y: auto; - max-height: 140px; + flex-direction: column; + max-height: 220px; background: var(--nav-bg, #111); border-top: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.08)); scrollbar-width: thin; } -#gchat-emoji-picker img { - width: 36px; - height: 36px; - object-fit: contain; - cursor: pointer; - border-radius: 4px; - padding: 2px; - transition: background 0.1s; -} - -#gchat-emoji-picker img:hover { - background: rgba(255, 255, 255, 0.12); -} - /* :emoji inline autocomplete — portaled to body */ #gchat-emoji-ac { position: fixed; @@ -15743,14 +15720,319 @@ body.scroller-active #gchat-reopen-bubble { white-space: nowrap; } -.gchat-item-card-meta { - font-size: 0.7rem; - opacity: 0.5; +/* ── Dedicated /chat Page ── */ +html:has(.chat-page), +body:has(.chat-page) { + height: 100% !important; + overflow: hidden !important; +} + +body:has(.chat-page) .pagewrapper { + height: calc(100dvh - var(--navbar-h, 60px)) !important; + min-height: 0 !important; + max-width: 100% !important; + width: 100% !important; + padding: 0 !important; + margin: 0 !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + transition: padding-right 0.3s ease-in-out !important; +} + +@media (min-width: 1000px) { + body:has(.chat-page):not(.sidebar-right-hidden) .pagewrapper { + padding-right: 300px !important; + } +} + +body:has(.chat-page) footer, +body:has(.chat-page) .footer { + display: none !important; +} + +.chat-page { + width: 100%; + height: 100%; + max-width: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + box-sizing: border-box; +} + +.site-chat-container { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + width: 100%; + height: 100%; + position: relative; + border-radius: 0; + overflow: hidden; + border: none; + background: var(--bg, #1a1a1a); + box-shadow: none; +} + +.gchat-page-panel { + position: relative !important; + inset: auto !important; + width: 100% !important; + height: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + display: flex !important; + flex-direction: column !important; + transform: none !important; +} + +.gchat-page-panel #gchat-resize-h, +.gchat-page-panel #gchat-resize-w, +.gchat-page-panel #gchat-resize-e, +.gchat-page-panel .gchat-header-btns { + display: none !important; +} + +/* Flex layout containers for chat body */ +.gchat-body-wrap { + display: flex; + flex: 1; + min-height: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +#gchat-panel:not(.gchat-page-panel) .gchat-body-wrap { + flex-direction: column; +} + +.gchat-page-panel .gchat-body-wrap { + flex-direction: row; +} + +.gchat-main-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + height: 100%; +} + +.gchat-page-panel #gchat-messages { + flex: 1; + min-height: 0; + padding: 14px; + overflow-y: auto; +} + +.gchat-page-panel .gchat-msg { + max-width: 100% !important; +} + +/* Right-side user list in page mode */ +.gchat-page-panel #gchat-online { + width: 250px; + min-width: 200px; + max-width: 300px; + border-left: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + background: rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + flex-shrink: 0; +} + +.gchat-page-panel .gchat-online-header { + padding: 12px 14px 4px 14px; + font-size: 0.92rem; + font-weight: 700; + color: var(--accent, #f2ef0b); + display: flex; + align-items: center; + gap: 8px; +} + +.gchat-page-panel .gchat-online-subhead { + padding: 2px 14px 10px 14px; + font-size: 0.75rem; + opacity: 0.6; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); +} + +.gchat-page-panel .gchat-online-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.gchat-page-panel .gchat-online-user-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 6px; + text-decoration: none; + color: var(--fg, #e0e0e0); + transition: background 0.15s ease; +} + +.gchat-page-panel .gchat-online-user-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.gchat-page-panel .gchat-online-user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.gchat-page-panel .gchat-online-user-avatar.guest-avatar { + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + font-size: 0.8rem; + color: #aaa; +} + +.gchat-page-panel .gchat-online-user-name { + font-size: 0.88rem; + font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 768px) { + .gchat-page-panel .gchat-body-wrap { + flex-direction: column !important; + flex: 1 !important; + min-height: 0 !important; + height: 100% !important; + overflow: hidden !important; + } + + .gchat-page-panel #gchat-online { + width: 100% !important; + max-width: 100% !important; + height: 40px !important; + min-height: 40px !important; + max-height: 40px !important; + border-left: none !important; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.12)) !important; + order: -1 !important; + flex-shrink: 0 !important; + display: flex !important; + flex-direction: row !important; + align-items: center !important; + padding: 0 10px !important; + overflow: hidden !important; + background: rgba(0, 0, 0, 0.3) !important; + } + + .gchat-page-panel .gchat-online-header { + padding: 0 !important; + font-size: 0.8rem !important; + white-space: nowrap !important; + flex-shrink: 0 !important; + gap: 4px !important; + } + + .gchat-page-panel .gchat-online-header span { + font-size: 0.78rem !important; + } + + .gchat-page-panel .gchat-online-subhead { + display: none !important; + } + + .gchat-page-panel .gchat-online-list { + flex: 1 !important; + min-width: 0 !important; + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + overflow-y: hidden !important; + gap: 6px !important; + padding: 0 4px !important; + align-items: center !important; + scrollbar-width: none !important; + -webkit-overflow-scrolling: touch !important; + touch-action: pan-x !important; + } + + .gchat-page-panel .gchat-online-list::-webkit-scrollbar { + display: none !important; + } + + .gchat-page-panel .gchat-online-user-item { + flex-shrink: 0 !important; + padding: 2px 8px !important; + gap: 6px !important; + border-radius: 12px !important; + background: rgba(255, 255, 255, 0.08) !important; + height: 28px !important; + align-items: center !important; + } + + .gchat-page-panel .gchat-online-user-avatar { + width: 20px !important; + height: 20px !important; + } + + .gchat-page-panel .gchat-online-user-name { + font-size: 0.75rem !important; + max-width: 110px !important; + } + + .gchat-page-panel .gchat-main-area { + flex: 1 !important; + min-height: 0 !important; + height: auto !important; + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; + } + + .gchat-page-panel #gchat-messages { + flex: 1 !important; + min-height: 0 !important; + overflow-y: auto !important; + padding: 10px !important; + -webkit-overflow-scrolling: touch !important; + } + + .gchat-page-panel .gchat-msg { + max-width: 92% !important; + } + + .gchat-page-panel #gchat-input-area { + flex-shrink: 0 !important; + background: var(--nav-bg, #0d0d0d) !important; + padding-bottom: env(safe-area-inset-bottom, 0px) !important; + } +} + /* ============================================= SETTINGS PAGE – MOBILE OVERFLOW FIXES Prevents long i18n strings (Deutsch / Zange) diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 2cd2d53..6c15c7b 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -2438,7 +2438,7 @@ window.cancelAnimFrame = (function () { const isAdmin = !!pathname.match(/^\/admin/); const isMod = !!pathname.match(/^\/mod/); const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); - const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/); + const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes|chat)($|\/|\?)/); const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); const parts = pathname.split('/').filter(Boolean); const isItem = !pathname.match(/\/p\//) && ( @@ -2446,8 +2446,9 @@ window.cancelAnimFrame = (function () { (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1])) ); const isMessages = !!pathname.match(/^\/messages(\/|$)/); + const isChat = !!pathname.match(/^\/chat(\/|$)/); const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss'; - const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isAbyss; + const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isChat && !isAbyss; if (isItem) { isNavigating = false; @@ -4079,7 +4080,7 @@ window.cancelAnimFrame = (function () { return false; } - if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) { + if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes|chat)/) || pathname.startsWith('/abyss'))) { e.preventDefault(); e.stopImmediatePropagation(); @@ -4533,7 +4534,7 @@ window.cancelAnimFrame = (function () { (parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) || (parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ); - const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking'); + const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking') || p.startsWith('/chat'); const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/'; window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike }); diff --git a/public/s/js/globalchat.js b/public/s/js/globalchat.js index ac55bd9..f07874e 100644 --- a/public/s/js/globalchat.js +++ b/public/s/js/globalchat.js @@ -15,7 +15,7 @@ const MAX_VISIBLE_MSGS = 100; const RATE_LIMIT_MS = 800; - let isMinimized = localStorage.getItem('f0ck_chat_minimized') !== '0'; + let isMinimized = localStorage.getItem('f0ck_chat_minimized') === '1'; let isClosed = localStorage.getItem('f0ck_chat_closed') === '1'; let lastSent = 0; let customEmojis = null; // name → url @@ -77,10 +77,18 @@ // ── Build widget ───────────────────────────────────────────────────────── function buildWidget() { - const widget = document.createElement('div'); - widget.id = 'gchat-widget'; + 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 = ` -
+
@@ -98,17 +106,14 @@
-
- - - +
- + @@ -116,7 +121,9 @@
`; - document.body.appendChild(widget); + if (!isPageMode && !document.getElementById('gchat-widget')) { + document.body.appendChild(widget); + } } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -651,73 +658,486 @@ } } - // ── Emoji picker ────────────────────────────────────────────────────────── + // ── Tabbed Emoji & Sticker Picker ────────────────────────────────────────── function setupEmojiPicker(textarea) { - const btn = document.getElementById('gchat-emoji-btn'); - if (!btn) return; + 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 = ''; + 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 = '' + (isFav ? '★' : '☆') + '' + (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 = []; - const buildPicker = () => { - if (picker) { picker.remove(); picker = null; } - picker = document.createElement('div'); - picker.id = 'gchat-emoji-picker'; + 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; } + } - if (customEmojis && Object.keys(customEmojis).length > 0) { - Object.keys(customEmojis).forEach(name => { - const img = document.createElement('img'); - img.src = customEmojis[name]; - img.title = `:${name}:`; - img.loading = 'lazy'; - img.onerror = () => { img.style.display = 'none'; }; - img.addEventListener('click', (e) => { - e.stopPropagation(); - const pos = textarea.selectionStart ?? textarea.value.length; - const v = textarea.value; - textarea.value = v.slice(0, pos) + `:${name}:` + v.slice(pos); - const newPos = pos + name.length + 2; - textarea.setSelectionRange(newPos, newPos); - textarea.focus(); - picker.style.display = 'none'; - if (closeHandler) { document.removeEventListener('click', closeHandler); closeHandler = null; } - }); - picker.appendChild(img); - }); + 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 { - picker.innerHTML = '
No custom emojis
'; + 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); } - // Position above input area - const panel = document.getElementById('gchat-panel'); - panel.appendChild(picker); - }; + 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; + }; - btn.addEventListener('click', (e) => { - e.preventDefault(); - if (!picker) buildPicker(); + 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; + } - const isVisible = picker.style.display !== 'none' && picker.style.display !== ''; - if (isVisible) { + 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 = '
No favorites yet. Right-click or hold any sticker to add it here.
'; + 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'; - if (closeHandler) { document.removeEventListener('click', closeHandler); closeHandler = null; } - } else { - picker.style.display = 'grid'; - closeHandler = (ev) => { - if (!picker.contains(ev.target) && ev.target !== btn) { - picker.style.display = 'none'; - document.removeEventListener('click', closeHandler); - closeHandler = null; + 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); } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 0); + 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 = ''; + 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; } + } } }); - // Rebuild picker if emojis load after init window.addEventListener('f0ck:emojis_ready', (e) => { customEmojis = e.detail; - if (picker) { picker.remove(); picker = null; } // force rebuild on next open + if (picker) { picker.remove(); picker = null; } }); } @@ -986,30 +1406,35 @@ buildWidget(); // Gate chat on SSE connection — block input until stream is live - const _panel = document.getElementById('gchat-panel'); - const _inputArea = document.getElementById('gchat-input-area'); - const _messages = document.getElementById('gchat-messages'); let _sseReady = false; function setConnecting(connecting) { - if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '1'; - if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : ''; - if (_messages) _messages.style.opacity = connecting ? '0.35' : '1'; - const sendBtn = document.getElementById('gchat-send-btn'); - if (sendBtn) sendBtn.disabled = 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); } - setConnecting(true); const onSseReady = () => { - if (_sseReady) return; _sseReady = true; clearTimeout(_sseUnlockTimer); - clearInterval(_pollFallback); + if (_pollFallback) clearInterval(_pollFallback); setConnecting(false); }; document.addEventListener('f0ck:sse_ready', onSseReady); - // Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag - if (document.documentElement.dataset.sseReady === '1') onSseReady(); // Absolute fallback: if SSE hasn't connected within 10s, unlock anyway and poll let _pollFallback = null; @@ -1040,6 +1465,9 @@ + 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'); @@ -1051,142 +1479,160 @@ return Math.max(0, bound); } - // Restore saved height - const panel = document.getElementById('gchat-panel'); - const savedH = localStorage.getItem('f0ck_chat_height'); - if (savedH) panel.style.height = savedH + 'px'; + function getMaxPanelHeight() { + const topB = typeof getTopBound === 'function' ? getTopBound() : 50; + return Math.max(180, window.innerHeight - topB - 15); + } - // Restore saved width - const savedW = localStorage.getItem('f0ck_chat_width'); - if (savedW) panel.style.width = savedW + 'px'; + 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'); - function startResizeH(startY, startH, startTop) { - const onMove = (clientY) => { - const delta = startY - clientY; - const maxH = window.innerHeight * 0.95; - 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); + 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 }); } - 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'); - 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); + 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 }); } - 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'); - 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); + 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 }); } - 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'); @@ -1196,31 +1642,40 @@ if (fy) panel.style.top = Math.max(getTopBound(), parseInt(fy)) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; - document.getElementById('gchat-float-btn').innerHTML = ''; + const floatBtn = document.getElementById('gchat-float-btn'); + if (floatBtn) floatBtn.innerHTML = ''; } else { panel.classList.remove('gchat-floating'); panel.style.left = ''; panel.style.top = ''; panel.style.right = ''; panel.style.bottom = ''; - document.getElementById('gchat-float-btn').innerHTML = ''; + 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 = ''; } } applyFloatMode(); - document.getElementById('gchat-float-btn').addEventListener('click', () => { - isFloating = !isFloating; - localStorage.setItem('f0ck_chat_floating', isFloating ? '1' : '0'); - if (isFloating) { - // Snap to current visual position so it doesn't jump - 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(); - }); + 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'); @@ -1259,25 +1714,28 @@ document.addEventListener('touchmove', touchMove, { passive: false }); document.addEventListener('touchend', touchEnd); } - 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 }); + 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(); @@ -1285,7 +1743,6 @@ const bottomBound = window.innerHeight; if (!willExpand) { - // About to minimize: decide anchor based on proximity const distToTop = Math.abs(r.top - topBound); const distToBottom = Math.abs(bottomBound - (r.top + r.height)); const anchorTop = distToTop < distToBottom; @@ -1298,17 +1755,14 @@ requestAnimationFrame(() => { let newTop; if (anchorTop) { - // Anchor to top: keep current top newTop = Math.max(topBound, curTop); } else { - // Anchor to bottom: keep current bottom (existing behavior) newTop = Math.min(bottomBound - 42, Math.max(topBound, curBottom - 42)); } panel.style.top = newTop + 'px'; localStorage.setItem('f0ck_chat_float_y', newTop); }); } else { - // Expanding: use saved anchor or default to bottom if not set const wasAnchorTop = localStorage.getItem('f0ck_chat_anchor_top') === '1'; setMinimized(false); @@ -1317,10 +1771,8 @@ const curTop = parseInt(panel.style.top) || 0; let newTop; if (wasAnchorTop) { - // Expanding from top anchor: top stays same newTop = Math.max(topBound, curTop); } else { - // Expanding from bottom anchor: shift top UP (existing behavior) newTop = Math.max(topBound, curTop - (fullH - 42)); } panel.style.top = newTop + 'px'; @@ -1330,21 +1782,26 @@ } else { setMinimized(!isMinimized); if (!isMinimized) { - // Normal docked expand loadHistory(); } } } - document.getElementById('gchat-minimize-btn').addEventListener('click', e => { - e.stopPropagation(); toggleMinimized(); - }); - document.getElementById('gchat-header').addEventListener('dblclick', e => { - if (!e.target.closest('.gchat-icon-btn')) toggleMinimized(); - }); + 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'); @@ -1352,6 +1809,7 @@ buildReopenBubble(); } function reopenChat() { + if (isPageMode) return; isClosed = false; localStorage.removeItem('f0ck_chat_closed'); const widget = document.getElementById('gchat-widget'); @@ -1361,6 +1819,7 @@ 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'; @@ -1369,40 +1828,47 @@ el.addEventListener('click', reopenChat); document.body.appendChild(el); } - document.getElementById('gchat-close-btn').addEventListener('click', e => { - e.stopPropagation(); closeChat(); - }); + 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; - // Send - document.getElementById('gchat-send-btn').addEventListener('click', sendMessage); - textarea.addEventListener('keydown', e => { - if (e.key === 'Enter' && !e.shiftKey) { - // Don't send while emoji or mention autocomplete is active - 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; + // Delegated Send & Toolbar Click Listeners + document.addEventListener('click', e => { + const sendBtn = e.target.closest('#gchat-send-btn'); + if (sendBtn) { e.preventDefault(); sendMessage(); + return; } }); - // Auto-grow - textarea.addEventListener('input', function () { - this.style.height = 'auto'; - this.style.height = Math.min(this.scrollHeight, 80) + 'px'; + // 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(); + } + } }); - // Toolbar - document.getElementById('gchat-spoiler-btn').addEventListener('click', e => { - e.preventDefault(); insertWrap(textarea, '[spoiler]', '[/spoiler]'); - }); - document.getElementById('gchat-blur-btn').addEventListener('click', e => { - e.preventDefault(); insertWrap(textarea, '[blur]', '[/blur]'); + // 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) @@ -1458,62 +1924,199 @@ applyBackground(e.detail?.background || null); }); - // ── Chat topic helpers ──────────────────────────────────────────────── - function applyTopic(text) { - const el = document.getElementById('gchat-topic'); - if (!el) return; - if (text) { - el.textContent = text; - el.style.display = ''; - } else { - el.style.display = 'none'; - el.textContent = ''; - } - } - - // Restore topic on load - fetch('/api/chat/topic').then(r => r.json()).then(d => { - if (d.success && d.topic) applyTopic(d.topic); - }).catch(() => {}); - - // SSE topic change — apply instantly to all clients - document.addEventListener('f0ck:global_chat_topic', (e) => { - applyTopic(e.detail?.topic || 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) { - el.innerHTML = ''; - el.style.display = 'none'; + if (isPageMode) { + el.style.display = 'flex'; + el.innerHTML = `
Online (0)
`; + } else { + el.innerHTML = ''; + el.style.display = 'none'; + } return; } - 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 `${name}`; - }).join(''); - const extraPill = extra > 0 ? `+${extra}` : ''; - - let countText = `${users.length} online`; - if (guestCount > 0 && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) { - countText += ` (${guestCount} guests)`; + + 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 ` + + ${name} + ${name} + + `; + }).join(''); + + const guestHTML = guestCount > 0 ? ` +
+
+ ${guestCount} Guest${guestCount > 1 ? 's' : ''} +
+ ` : ''; + + el.innerHTML = ` +
+ Online (${totalOnline}) +
+
${countText}
+
+ ${userItemsHTML} + ${guestHTML} +
+ `; + } 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 `${name}`; + }).join(''); + const extraPill = extra > 0 ? `+${extra}` : ''; + + let countText = `${users.length} online`; + if (guestCount > 0 && (window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator)) { + countText += ` (${guestCount} guests)`; + } + const countLabel = `${countText}`; + el.innerHTML = `
${countLabel}
${avatarHTML}${extraPill}
`; } - const countLabel = `${countText}`; - el.innerHTML = `
${countLabel}
${avatarHTML}${extraPill}
`; } + 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) => { - renderOnline(e.detail?.users || [], e.detail?.guestCount || 0); + 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; @@ -1573,11 +2176,11 @@ } } catch (_) {} } - if (!isMinimized) loadHistory(); + if (!isMinimized || (window.location.pathname === '/chat')) loadHistory(); })(); // If user had previously closed the chat, hide on load and show bubble - if (isClosed) { + if (isClosed && (window.location.pathname !== '/chat')) { const widget = document.getElementById('gchat-widget'); if (widget) widget.style.display = 'none'; buildReopenBubble(); diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index ec8ed36..1ef011f 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -4,6 +4,7 @@ "meme": "Meme", "halls": "Hallen", "tags": "Tags", + "chat": "Chat", "search": "Suchen", "random": "Zufall", "profile": "Profil", diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index fddd032..1fd5c3f 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -4,6 +4,7 @@ "meme": "Meme", "halls": "Halls", "tags": "Tags", + "chat": "Chat", "search": "Search", "random": "Random", "profile": "Profile", diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index d0ead57..e7a833a 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -4,6 +4,7 @@ "meme": "meme", "halls": "Hallen", "tags": "Tags", + "chat": "Chat", "search": "Zoeken", "random": "Willekeurig", "profile": "profiel", diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 168ffbc..1fe9f4e 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -4,6 +4,7 @@ "meme": "Memen", "halls": "Hallen", "tags": "Etiketten", + "chat": "Geschwafel", "search": "Suche", "random": "Zufall", "profile": "Profil", diff --git a/src/inc/routes/chat.mjs b/src/inc/routes/chat.mjs index 47bac63..a02c0d5 100644 --- a/src/inc/routes/chat.mjs +++ b/src/inc/routes/chat.mjs @@ -47,6 +47,25 @@ function buildBgHostRegex() { export default (router, tpl) => { + // GET /chat — Dedicated site chat page + router.get('/chat', async (req, res) => { + if (!cfg.websrv.enable_global_chat) { + return res.reply({ code: 404, body: 'Not found' }); + } + if (!req.session) { + return res.redirect('/login'); + } + if (req.session.banned) { + return res.reply({ code: 403, body: 'Access denied: You are banned' }); + } + return res.html(tpl.render('chat', { + session: req.session, + hidePagination: true, + link: { main: '/chat', path: '/chat' }, + domain: cfg.main.url.domain + }, req)); + }); + // GET /api/chat — fetch recent messages router.get('/api/chat', async (req, res) => { if (!cfg.websrv.enable_global_chat) { diff --git a/views/chat.html b/views/chat.html new file mode 100644 index 0000000..d48dc12 --- /dev/null +++ b/views/chat.html @@ -0,0 +1,9 @@ +@include(snippets/header) +
+
+
+ +
+
+
+@include(snippets/footer) diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index d1a00b0..87495fa 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -26,6 +26,9 @@ @endif + @if(enable_global_chat) + + @endif @if(abyss_enabled) @endif