/** * scroller.js — Doomscroll controller * * Fixes in this version: * 1. Infinite scroll never stops — random order always retries, fallback * scroll listener if IntersectionObserver misses the sentinel. * 2. Volume slider with localStorage persistence. * 3. Comment input: emoji bar + @mention autocomplete. * 4. Double-tap / double-click to toggle favourite (POST /api/v2/togglefav). * 5. URL hash sync + sessionStorage back-navigation restore. */ (function () { 'use strict'; // ── PJAX guard ───────────────────────────────────────────────────────────── // When navigated to via PJAX, scroller.js re-executes. Prevent the stale // closure from the previous instance from acting on the NEW feed element // by tagging each feed element on first bind. const initFeed = document.getElementById('scroller-feed'); if (!initFeed) return; // safety: no feed = wrong page if (initFeed.__scrollerBound) return; // already bound to THIS feed element initFeed.__scrollerBound = true; // ── DOM ───────────────────────────────────────────────────────────────── const feed = initFeed; const sentinel = document.getElementById('scroller-sentinel'); const emptyEl = document.getElementById('scroller-empty'); const muteBtn = document.getElementById('scroller-mute-btn'); const volumePopup = document.getElementById('volume-popup'); const volumeSlider = document.getElementById('volume-slider'); const volumePct = document.getElementById('volume-pct'); const loader = document.getElementById('scroller-loader'); const filterOpenBtn = document.getElementById('filter-open-btn'); const filterPanel = document.getElementById('filter-panel'); const filterBackdrop = document.getElementById('filter-backdrop'); const filterResetBtn = document.getElementById('filter-reset-btn'); const filterApplyBtn = document.getElementById('filter-apply-btn'); const filterSummary = document.getElementById('filter-active-summary'); const tagInput = document.getElementById('filter-tag-input'); const tagClear = document.getElementById('filter-tag-clear'); const tagSuggestEl = document.getElementById('tag-suggestions'); const activeTagsEl = document.getElementById('filter-active-tags'); const commentsPanel = document.getElementById('comments-panel'); const commentsBackdrop = document.getElementById('comments-backdrop'); const commentsList = document.getElementById('comments-list'); const commentsCount = document.getElementById('comments-count'); const commentsOpenLink = document.getElementById('comments-open-link'); const commentsLoading = document.getElementById('comments-loading'); const commentsEmpty = document.getElementById('comments-empty'); const commentInput = document.getElementById('comment-input'); const commentSendBtn = document.getElementById('comment-send-btn'); const mentionDropdown = document.getElementById('mention-dropdown'); const settingsOpenBtn = document.getElementById('settings-open-btn'); const settingsPanel = document.getElementById('settings-panel'); const settingsBackdrop = document.getElementById('settings-backdrop'); const stHideUi = document.getElementById('st-hide-ui'); const stStartUnmuted = document.getElementById('st-start-unmuted'); const stCanvasBg = document.getElementById('st-canvas-bg'); const stLeftHand = document.getElementById('st-left-hand'); const stAutoNext = document.getElementById('st-auto-next'); const autoNextLoopsInp = document.getElementById('st-auto-next-loops'); const settingsSaveBtn = document.getElementById('settings-save-preset-btn'); const settingsPresetsList = document.getElementById('settings-presets-list'); const externalUrlInput = document.getElementById('external-url-input'); const externalUrlBtn = document.getElementById('external-url-btn'); const chanOpenBtn = document.getElementById('chan-open-btn'); const chanPanel = document.getElementById('chan-panel'); const chanBackdrop = document.getElementById('chan-backdrop'); const chanUrlInput = document.getElementById('chan-url-input'); const chanUrlLoadBtn = document.getElementById('chan-url-load-btn'); const chanGrid = document.getElementById('chan-catalog-grid'); const chanLoading = document.getElementById('chan-catalog-loading'); const chanGalleryBtn = document.getElementById('chan-gallery-btn'); const chanGallerySidebar = document.getElementById('chan-gallery-sidebar'); // ── State ──────────────────────────────────────────────────────────────── const defaultMode = window.scrollerMode ?? 0; const CACHE_KEY = 'scroller_state'; const CACHE_MAX = 200; const CACHE_TTL = 30 * 60 * 1000; const VOL_KEY = 'scroller_volume'; const PREFS_KEY = 'scroller_prefs'; const PRESETS_KEY = 'scroller_presets'; // ── User Preferences ────────────────────────────────────────────────────── function loadPrefs() { try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { return {}; } } function savePrefs(p) { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } const prefs = loadPrefs(); // canvasBg: true by default (animated background enabled) let canvasBgEnabled = prefs.canvasBg !== false; // hideUI: false by default let hideUIEnabled = prefs.hideUI === true; // autoNext: false by default let autoNextEnabled = prefs.autoNext === true; let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10)); let leftHandEnabled = prefs.leftHand === true; let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null }; let pending = { ...applied, tags: [] }; // Volume / mute let isMuted = prefs.startUnmuted !== true; // default muted unless user opted for sound let volume = parseFloat(localStorage.getItem(VOL_KEY) ?? '1'); if (isNaN(volume) || volume < 0 || volume > 1) volume = 1; let isFetching = false; let hasMore = true; let consecutiveEmpty = 0; // how many empty fetches in a row (caps random infinite scroll) let lastCursor = null; let seenIds = new Set(); // tracks all seen item IDs to prevent random-mode repeats let currentSlide = null; let currentMedia = null; // Comments let commentsItemId = null; let tagBarItemId = null; let commentsPosting = false; // Mention autocomplete let mentionQuery = ''; let mentionTimer = null; let mentionResults = []; let mentionIndex = -1; // Tag autocomplete let tagTimer = null; let tagCache = {}; let lastSugg = []; let tagSuggIdx = -1; // keyboard nav index for filter-panel tag suggestions // Volume popup let volPopupTimer = null; // Emoji autocomplete let emojiMode = false; // true when showing emoji suggestions, false for mentions let emojiResults = []; // current emoji suggestion list [[code, char], ...] let acIndex = -1; // shared keyboard nav index // Session cache helpers ────────────────────────────────────────────── function readCache() { try { const c = JSON.parse(sessionStorage.getItem(CACHE_KEY) || 'null'); if (!c || !c.ts || Date.now() - c.ts > CACHE_TTL) { clearCache(); return null; } return c; } catch { return null; } } function clearCache() { try { sessionStorage.removeItem(CACHE_KEY); } catch {} } function appendToCache(items) { try { const existing = readCache(); const existingItems = existing?.items || []; const ids = new Set(existingItems.map(i => i.id)); const merged = [...existingItems, ...items.filter(i => !ids.has(i.id))].slice(-CACHE_MAX); sessionStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), filters: { ...applied }, items: merged, activeId: currentSlide ? currentSlide.dataset.id : (merged[0]?.id ?? null) })); } catch { clearCache(); } } function updateCacheActiveId(id) { try { const raw = sessionStorage.getItem(CACHE_KEY); if (!raw) return; const c = JSON.parse(raw); c.activeId = String(id); c.ts = Date.now(); sessionStorage.setItem(CACHE_KEY, JSON.stringify(c)); } catch {} } function tryRestoreFromCache() { const cache = readCache(); if (!cache?.items?.length) return false; // If there's a hash ID (e.g. /abyss#1234 shared via DM), check whether // that item is actually in the cache. If it isn't, skip the cache restore // entirely so that fetchItems() runs with ?anchor= and the correct // item is guaranteed to be included in the fresh batch. const hid = hashId(); // If hash is board/postid (e.g. wsg/6132740), auto-load that 4chan thread const chanHash = hid.match(/^([a-z0-9]+)\/\d+$/); if (chanHash) { const board = chanHash[1]; // We need to find the thread ID from the post. Use the board catalog or // just load a search — but simplest: set externalUrl and skip cache restore. // The thread ID is unknown from just the post ID, so we skip cache and // let the init block below handle it. return false; } if (hid && !cache.items.some(item => String(item.id) === hid)) return false; if (cache.filters) { applied = { mode: defaultMode, mime: '', order: 'random', tags: [], ...cache.filters }; applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : []; pending = { ...applied, tags: [...applied.tags] }; } cache.items.forEach(item => feed.insertBefore(createSlide(item), sentinel)); const targetId = hid || cache.activeId; const target = targetId ? feed.querySelector(`.scroll-slide[data-id="${targetId}"]`) : null; const toActivate = target || feed.querySelector('.scroll-slide'); if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); updateFilterSummary(); syncPanelUI(); }, 50); return true; } // ── Helpers ─────────────────────────────────────────────────────────────── function esc(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function decodeHtmlEntities(str) { if (!str) return ''; const txt = document.createElement('textarea'); txt.innerHTML = String(str); return txt.value; } function timeAgo(iso) { const s = Math.floor((Date.now() - new Date(iso)) / 1000); const i = window.f0ckI18n || {}; const fmt = (tpl, n, unit) => (tpl || `{n} ${unit}${n !== 1 ? 's' : ''}`).replace('{n}', n); const ago = (t) => (i.ta_ago || '{t} ago').replace('{t}', t); if (s < 60) return i.ta_just_now || 'just now'; const m = Math.floor(s / 60); if (m < 60) return ago(fmt(m === 1 ? i.ta_minute : i.ta_minutes, m, 'minute')); const h = Math.floor(s / 3600); if (h < 24) return ago(fmt(h === 1 ? i.ta_hour : i.ta_hours, h, 'hour')); const d = Math.floor(s / 86400); if (d < 7) return ago(fmt(d === 1 ? i.ta_day : i.ta_days, d, 'day')); const w = Math.floor(d / 7); if (d < 30) return ago(fmt(w === 1 ? i.ta_week : i.ta_weeks, w, 'week')); const mo = Math.floor(d / 30); if (d < 365) return ago(fmt(mo === 1 ? i.ta_month : i.ta_months, mo, 'month')); const y = Math.floor(d / 365); return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year')); } function hashId() { // Check path first /abyss/1234 or /abyss/gif/1234 const pathClean = location.pathname.replace(/\/$/, ''); const pathMatch = pathClean.match(/\/abyss\/([a-zA-Z0-9_\/-]+)$/); if (pathMatch) return pathMatch[1]; // Fallback to hash const raw = location.hash.replace(/^#/, '').trim(); if (/^\d+$/.test(raw)) return raw; if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw; return ''; } let lastPushedUrl = location.pathname + location.hash; function pushHash(id) { if (!id) return; const newUrl = '/abyss/' + id; if (newUrl === lastPushedUrl) return; lastPushedUrl = newUrl; history.pushState({ scrollerId: id }, '', newUrl); updateCacheActiveId(id); } // Handle back/forward within abyss — scroll to the target slide window.addEventListener('popstate', (e) => { if (!document.body.classList.contains('scroller-active')) return; const id = e.state?.scrollerId || hashId(); if (!id) return; lastPushedUrl = '/abyss/' + id; const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`); if (slide) { slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); // ── Volume / Mute ──────────────────────────────────────────────────────── function syncVolumeUI() { const icon = muteBtn.querySelector('i'); if (isMuted || volume === 0) { icon.className = 'fa-solid fa-volume-xmark'; } else if (volume < 0.4) { icon.className = 'fa-solid fa-volume-low'; } else { icon.className = 'fa-solid fa-volume-high'; } volumeSlider.value = isMuted ? 0 : volume; volumePct.textContent = isMuted ? '0%' : Math.round(volume * 100) + '%'; } function applyVolumeToAll() { feed.querySelectorAll('video, audio').forEach(el => { el.muted = isMuted; el.volume = volume; }); feed.querySelectorAll('.ruffle-container').forEach(el => { if (el._rufflePlayer) { el._rufflePlayer.volume = isMuted ? 0 : volume; } }); } function saveVolume() { localStorage.setItem(VOL_KEY, String(volume)); } // Toggle mute on click; show popup; long-hold => just popup muteBtn.addEventListener('click', () => { if (isMuted) { isMuted = false; if (volume === 0) volume = 0.5; } else { isMuted = true; } syncVolumeUI(); applyVolumeToAll(); saveVolume(); // Persist mute state so it's restored on next visit prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }); function showVolumePopup() { volumePopup.classList.add('open'); clearTimeout(volPopupTimer); volPopupTimer = setTimeout(() => volumePopup.classList.remove('open'), 3000); } volumeSlider.addEventListener('input', () => { volume = parseFloat(volumeSlider.value); isMuted = volume === 0; syncVolumeUI(); applyVolumeToAll(); saveVolume(); // Persist mute state prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); clearTimeout(volPopupTimer); volPopupTimer = setTimeout(() => volumePopup.classList.remove('open'), 2500); }); // Open popup on hover (mouseenter); close on mouseleave with a short grace period // so the cursor can migrate from the button to the popup without it snapping shut. let volHoverTimer = null; const openVolPopupHover = () => { clearTimeout(volHoverTimer); clearTimeout(volPopupTimer); volumePopup.classList.add('open'); }; const closeVolPopupHover = () => { clearTimeout(volHoverTimer); volHoverTimer = setTimeout(() => volumePopup.classList.remove('open'), 200); }; muteBtn.addEventListener('mouseenter', openVolPopupHover); muteBtn.addEventListener('mouseleave', closeVolPopupHover); volumePopup.addEventListener('mouseenter', () => clearTimeout(volHoverTimer)); volumePopup.addEventListener('mouseleave', closeVolPopupHover); // Close popup if click outside document.addEventListener('click', e => { if (!document.body.classList.contains('scroller-active')) return; if (!volumePopup.contains(e.target) && e.target !== muteBtn && !muteBtn.contains(e.target)) { volumePopup.classList.remove('open'); } }, true); // ── Panels ─────────────────────────────────────────────────────────────── let panelOpen = false; // true while any sheet panel is open function openPanel(panel, backdrop) { closeAllPanels(); panel.classList.add('open'); backdrop.classList.add('open'); document.body.style.overflow = 'hidden'; panelOpen = true; } function closePanel(panel, backdrop) { panel.classList.remove('open'); backdrop.classList.remove('open'); document.body.style.overflow = ''; panelOpen = !!(filterPanel.classList.contains('open') || commentsPanel.classList.contains('open') || settingsPanel.classList.contains('open')); // Blur any focused input so keyboard shortcuts (arrows etc.) are immediately restored if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { document.activeElement.blur(); } // Capture currentSlide NOW — reloadFeed() (called from filterApply) will null it const snapTarget = currentSlide; if (snapTarget) { setTimeout(() => { snapTarget.scrollIntoView({ behavior: 'instant', block: 'start' }); }, 250); } } function closeAllPanels() { closePanel(filterPanel, filterBackdrop); closePanel(commentsPanel, commentsBackdrop); closePanel(settingsPanel, settingsBackdrop); if (typeof closeTagBar === 'function') closeTagBar(); if (typeof closeSharePanel === 'function') closeSharePanel(); if (typeof closeChanPanel === 'function') closeChanPanel(); const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) active.blur(); } function addSwipeClose(panel, backdrop) { let startY = 0; panel.addEventListener('touchstart', e => { startY = e.touches[0].clientY; }, { passive: true }); panel.addEventListener('touchend', e => { if (e.changedTouches[0].clientY - startY > 60) closePanel(panel, backdrop); }, { passive: true }); } addSwipeClose(filterPanel, filterBackdrop); addSwipeClose(commentsPanel, commentsBackdrop); addSwipeClose(settingsPanel, settingsBackdrop); filterBackdrop.addEventListener('click', () => closePanel(filterPanel, filterBackdrop)); commentsBackdrop.addEventListener('click', () => closePanel(commentsPanel, commentsBackdrop)); settingsBackdrop.addEventListener('click', () => closePanel(settingsPanel, settingsBackdrop)); // ── Settings panel logic ────────────────────────────────────────────────── function applyHideUI(val) { hideUIEnabled = val; document.body.classList.toggle('ui-hidden', val); if (stHideUi) stHideUi.classList.toggle('on', val); } function applyStartUnmuted(val) { if (stStartUnmuted) stStartUnmuted.classList.toggle('on', val); } function applyCanvasBg(val) { canvasBgEnabled = val; if (stCanvasBg) stCanvasBg.classList.toggle('on', val); // If turning off on a currently active slide, cancel the rAF loop and show thumbnail if (!val && currentSlide) { if (currentSlide._bgRaf) { cancelAnimationFrame(currentSlide._bgRaf); currentSlide._bgRaf = null; } const canvas = currentSlide.querySelector('canvas.scroll-bg-blur'); const video = currentSlide.querySelector('video'); if (canvas && !video && currentSlide._thumbnail) { const img = new Image(); img.onload = () => { const ctx = canvas.getContext('2d'); if (ctx) ctx.drawImage(img, 0, 0, canvas.width, canvas.height); }; img.src = currentSlide._thumbnail; } } } // Apply prefs on load applyHideUI(hideUIEnabled); applyStartUnmuted(prefs.startUnmuted === true); applyCanvasBg(canvasBgEnabled); // Left-hand mode function applyLeftHand(val) { leftHandEnabled = val; document.body.classList.toggle('left-hand-mode', val); if (stLeftHand) stLeftHand.classList.toggle('on', val); // Inject / remove 4chan action buttons in every slide's scroll-actions document.querySelectorAll('.scroll-actions .chan-action-btn').forEach(el => el.remove()); if (val) { document.querySelectorAll('.scroll-actions').forEach(actions => { // Only add if 4chan buttons are visible (a 4chan feed is active) if (chanOpenBtn && chanOpenBtn.style.display !== 'none') { const openBtn = document.createElement('button'); openBtn.className = 'chan-action-btn'; openBtn.title = chanOpenBtn.title; openBtn.innerHTML = `
`; openBtn.addEventListener('click', () => chanOpenBtn.click()); actions.appendChild(openBtn); } if (chanGalleryBtn && chanGalleryBtn.style.display !== 'none') { const galBtn = document.createElement('button'); galBtn.className = 'chan-action-btn'; galBtn.title = chanGalleryBtn.title; galBtn.innerHTML = `
`; galBtn.addEventListener('click', () => chanGalleryBtn.click()); actions.appendChild(galBtn); } }); } } applyLeftHand(leftHandEnabled); // Toggle clicks if (stHideUi) stHideUi.addEventListener('click', () => { const next = !hideUIEnabled; prefs.hideUI = next; savePrefs(prefs); applyHideUI(next); }); if (stStartUnmuted) stStartUnmuted.addEventListener('click', () => { const next = !(prefs.startUnmuted === true); prefs.startUnmuted = next; savePrefs(prefs); // Also apply to current session immediately isMuted = !next; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); applyStartUnmuted(next); }); if (stCanvasBg) stCanvasBg.addEventListener('click', () => { const next = !canvasBgEnabled; prefs.canvasBg = next; savePrefs(prefs); applyCanvasBg(next); }); if (stLeftHand) stLeftHand.addEventListener('click', () => { const next = !leftHandEnabled; prefs.leftHand = next; savePrefs(prefs); applyLeftHand(next); }); // Auto-next toggle function applyAutoNext(val) { autoNextEnabled = val; if (stAutoNext) stAutoNext.classList.toggle('on', val); if (autoNextLoopsInp) autoNextLoopsInp.disabled = !val; } applyAutoNext(autoNextEnabled); if (autoNextLoopsInp) { autoNextLoopsInp.value = autoNextLoops; autoNextLoopsInp.addEventListener('change', () => { const v = Math.max(0, Math.min(99, parseInt(autoNextLoopsInp.value, 10))); autoNextLoops = isNaN(v) ? 1 : v; autoNextLoopsInp.value = autoNextLoops; prefs.autoNextLoops = v; savePrefs(prefs); }); } if (stAutoNext) stAutoNext.addEventListener('click', () => { const next = !autoNextEnabled; prefs.autoNext = next; savePrefs(prefs); applyAutoNext(next); }); // Helper: advance to next slide let autoNextCooldown = false; function hideLoader() { loader.classList.add('hidden'); // After the 0.3s CSS opacity transition, pull it fully out of the touch layer // (iOS Safari can still swallow scroll events through a pointer-events:none fixed overlay) setTimeout(() => { loader.style.display = 'none'; }, 350); } function goNextSlide() { if (!currentSlide || autoNextCooldown) return; autoNextCooldown = true; // 1.5s grace: prevents chaining on very short videos; user can swipe back during this window. setTimeout(() => { autoNextCooldown = false; }, 1500); const slides = [...feed.querySelectorAll('.scroll-slide')]; const idx = slides.indexOf(currentSlide); if (idx < 0 || idx + 1 >= slides.length) return; // Use index-based scroll position: each slide is exactly feed.clientHeight tall. // Avoids the offsetTop-vs-scroll-container mismatch that can land between snap points. const targetScrollTop = (idx + 1) * feed.clientHeight; setTimeout(() => { feed.scrollTop = targetScrollTop; }, 50); } // ── Filter Presets CRUD ─────────────────────────────────────────────────── const PRESETS_LABELS = { mode: { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' } }; function getPresets() { try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '[]'); } catch { return []; } } function savePresets(arr) { localStorage.setItem(PRESETS_KEY, JSON.stringify(arr)); } function renderPresets() { if (!settingsPresetsList) return; const list = getPresets(); settingsPresetsList.innerHTML = ''; if (!list.length) { settingsPresetsList.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.no_presets) || 'No saved presets yet.'}
`; return; } const buildMeta = (p) => [ PRESETS_LABELS.mode[p.mode] ?? `Mode ${p.mode}`, p.mime || 'All types', p.order, ...(p.tags.length ? [`Tags: ${p.tags.slice(0, 3).join(', ')}${p.tags.length > 3 ? '\u2026' : ''}`] : []) ].join(' \u00b7 '); list.forEach((preset, i) => { const row = document.createElement('div'); row.className = 'preset-row'; const topRow = document.createElement('div'); topRow.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;min-width:0;'; topRow.innerHTML = '
' + '
' + esc(preset.name) + '
' + '
' + esc(buildMeta(preset)) + '
' + '
' + '' + ''; row.appendChild(topRow); topRow.querySelector('.preset-row-info').addEventListener('click', () => { pending.mode = preset.mode; pending.order = preset.order; pending.mime = preset.mime; pending.tags = [...preset.tags]; closePanel(filterPanel, filterBackdrop); applied = { ...pending, tags: [...pending.tags] }; reloadFeed(); updateFilterUI(); updateFilterSummary(); }); topRow.querySelector('.preset-row-del').addEventListener('click', e => { e.stopPropagation(); savePresets(getPresets().filter((_, j) => j !== i)); renderPresets(); }); const editor = document.createElement('div'); editor.className = 'preset-editor'; let editTags = [...preset.tags]; const tagList = document.createElement('div'); tagList.className = 'preset-editor-tags'; // Helper: persist current editTags to storage and refresh the row meta immediately. // Uses preset.name as stable key (index i can drift if other presets are deleted). const persistTags = () => { const presets = getPresets(); const idx = presets.findIndex(p => p.name === preset.name); if (idx === -1) { console.error('[presets] persistTags: preset not found:', preset.name); return; } presets[idx].tags = [...editTags]; savePresets(presets); topRow.querySelector('.preset-row-meta').textContent = buildMeta(presets[idx]); }; const renderEditorTags = () => { tagList.innerHTML = editTags.length ? editTags.map((t, ti) => '' + esc(t) + '' ).join('') : 'No tags'; tagList.querySelectorAll('.preset-editor-tag-del').forEach(btn => { btn.addEventListener('click', ev => { ev.stopPropagation(); editTags.splice(+btn.dataset.ti, 1); renderEditorTags(); persistTags(); // auto-save on remove }); }); }; const addWrap = document.createElement('div'); addWrap.className = 'preset-editor-add'; const tagInp = document.createElement('input'); tagInp.type = 'text'; tagInp.placeholder = 'Add tag\u2026'; tagInp.autocomplete = 'off'; tagInp.spellcheck = false; const addBtnEl = document.createElement('button'); addBtnEl.textContent = (window.f0ckI18n && window.f0ckI18n.add) || 'Add'; addWrap.appendChild(tagInp); addWrap.appendChild(addBtnEl); const doAdd = () => { const v = tagInp.value.trim().toLowerCase().replace(/\s+/g, '_'); if (v && !editTags.includes(v)) { editTags.push(v); renderEditorTags(); persistTags(); // auto-save on add } tagInp.value = ''; tagInp.focus(); }; addBtnEl.addEventListener('click', ev => { ev.stopPropagation(); doAdd(); }); tagInp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); ev.stopPropagation(); doAdd(); } }); const actions = document.createElement('div'); actions.className = 'preset-editor-actions'; const overwriteBtn = document.createElement('button'); overwriteBtn.className = 'preset-editor-overwrite'; overwriteBtn.innerHTML = `${(window.f0ckI18n && window.f0ckI18n.update_preset) || 'Update & apply preset'} ${(window.f0ckI18n && window.f0ckI18n.update_preset_sub) || 'Save changes and reload feed now'}`; overwriteBtn.addEventListener('click', ev => { ev.stopPropagation(); const presets = getPresets(); const idx = presets.findIndex(p => p.name === preset.name); if (idx === -1) return; // Save: keep editTags, sync mode/order/mime from active filters presets[idx] = { ...presets[idx], mode: applied.mode, order: applied.order, mime: applied.mime, tags: [...editTags] }; savePresets(presets); const saved = presets[idx]; // Apply immediately — same as clicking the preset name applied = { mode: saved.mode, order: saved.order, mime: saved.mime, tags: [...saved.tags] }; pending = { ...applied, tags: [...applied.tags] }; closePanel(filterPanel, filterBackdrop); reloadFeed(); updateFilterUI(); updateFilterSummary(); showShareToast('Preset updated & applied'); }); actions.appendChild(overwriteBtn); editor.appendChild(tagList); editor.appendChild(addWrap); editor.appendChild(actions); row.appendChild(editor); topRow.querySelector('.preset-row-edit').addEventListener('click', ev => { ev.stopPropagation(); const opening = !editor.classList.contains('open'); settingsPresetsList.querySelectorAll('.preset-editor.open').forEach(el => { el.classList.remove('open'); el.closest('.preset-row')?.classList.remove('editing'); }); if (opening) { const fresh = getPresets().find(p => p.name === preset.name); editTags = [...(fresh?.tags ?? preset.tags)]; renderEditorTags(); editor.classList.add('open'); row.classList.add('editing'); setTimeout(() => tagInp.focus(), 50); } }); settingsPresetsList.appendChild(row); }); } if (settingsSaveBtn) { settingsSaveBtn.addEventListener('click', () => { const name = prompt('Preset name:', `${PRESETS_LABELS.mode[applied.mode] ?? 'Mode'} / ${applied.order}`); if (!name || !name.trim()) return; const list = getPresets(); list.push({ name: name.trim(), mode: applied.mode, order: applied.order, mime: applied.mime, tags: [...applied.tags] }); savePresets(list); renderPresets(); showShareToast('Preset saved'); }); } if (filterOpenBtn) { filterOpenBtn.addEventListener('click', () => { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }); } if (settingsOpenBtn) { settingsOpenBtn.addEventListener('click', () => { openPanel(settingsPanel, settingsBackdrop); }); } // Spoiler/blur reveal & context links — delegated click on the comments list commentsList.addEventListener('click', e => { const sp = e.target.closest('.scroller-spoiler, .scroller-blur'); if (sp) sp.classList.toggle('revealed'); const contextLink = e.target.closest('.comment-context-link'); if (contextLink) { e.preventDefault(); const id = contextLink.dataset.id; const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`); if (target) { // Clear any previous persistent highlight commentsList.querySelectorAll('.comment-linked').forEach(el => el.classList.remove('comment-linked')); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Flash the animation for attention, then keep the persistent highlight target.classList.add('highlight-comment', 'comment-linked'); setTimeout(() => target.classList.remove('highlight-comment'), 2500); } } }); // ── Slide activation ────────────────────────────────────────────────────── function activateSlide(slide) { if (!slide || slide === currentSlide) return; if (currentSlide) deactivateSlide(currentSlide); currentSlide = slide; slide._deactivated = false; slide.classList.add('active'); pushHash(slide.dataset.id); updateGalleryActive(slide.dataset.id); const video = slide.querySelector('video'); const audio = slide.querySelector('audio'); const cover = slide.querySelector('.audio-cover'); const fill = slide.querySelector('.scroll-progress-fill'); const thumb = slide.querySelector('.scroll-progress-thumb'); if (video) { currentMedia = video; // Restore src if we aborted loading when deactivating if (video._savedSrc && !video.src) { video.src = video._savedSrc; video.currentTime = video._savedTime || 0; } else { video.currentTime = 0; } video.muted = isMuted; video.volume = volume; video.classList.add('ready'); // Force load if video hasn't started loading (preload=none for external) if (video.readyState === 0) { video.preload = 'auto'; video.load(); } else { video.currentTime = 0; } video.play().catch(err => { if (err && err.name === 'NotAllowedError') { video.muted = true; isMuted = true; syncVolumeUI(); video.play().catch(() => {}); } }); wireProgress(slide, video, fill, thumb); // Start live canvas background loop (only if pref enabled) const bgC = slide.querySelector('canvas.scroll-bg-blur'); if (bgC && canvasBgEnabled) { const bgX = bgC.getContext('2d'); const rafLoop = () => { if (!video || video.paused || video.ended) { slide._bgRaf = null; return; } try { bgX.drawImage(video, 0, 0, bgC.width, bgC.height); } catch {} slide._bgRaf = requestAnimationFrame(rafLoop); }; video.addEventListener('playing', () => { if (slide._bgRaf) cancelAnimationFrame(slide._bgRaf); rafLoop(); }); } // Auto-next: count loops and advance after N plays if (autoNextEnabled) { video.loop = false; slide._playCount = 0; // reset each time this slide is activated const onVideoEnded = () => { if (!autoNextEnabled || currentSlide !== slide) return; slide._playCount++; if (slide._playCount <= autoNextLoops) { video.currentTime = 0; video.play().catch(() => {}); } else { goNextSlide(); } }; slide._autoNextMedia = video; slide._autoNextHandler = onVideoEnded; video.addEventListener('ended', onVideoEnded); } else { video.loop = true; } } else if (audio) { currentMedia = audio; if (audio._savedSrc && !audio.src) { audio.src = audio._savedSrc; audio.currentTime = audio._savedTime || 0; } else { audio.currentTime = 0; } audio.muted = isMuted; audio.volume = volume; audio.play().catch(err => { if (err && err.name === 'NotAllowedError') { audio.muted = true; isMuted = true; syncVolumeUI(); audio.play().catch(() => {}); } }); if (cover) cover.classList.add('playing'); wireProgress(slide, audio, fill, thumb); // Always ensure the canvas bg shows the thumbnail (onload may have raced ahead of activation) const audioBgC = slide.querySelector('canvas.scroll-bg-blur'); if (audioBgC && slide._thumbnail) { const audioBgX = audioBgC.getContext('2d'); if (audioBgX) { const t = new Image(); t.onload = () => { try { audioBgX.drawImage(t, 0, 0, audioBgC.width, audioBgC.height); } catch {} }; t.src = slide._thumbnail; } } // Auto-next for audio if (autoNextEnabled) { audio.loop = false; slide._playCount = 0; // reset each time this slide is activated const onAudioEnded = () => { if (!autoNextEnabled || currentSlide !== slide) return; slide._playCount++; if (slide._playCount <= autoNextLoops) { audio.currentTime = 0; audio.play().catch(() => {}); } else { goNextSlide(); } }; slide._autoNextMedia = audio; slide._autoNextHandler = onAudioEnded; audio.addEventListener('ended', onAudioEnded); } else { audio.loop = true; } } else { // YouTube: inject src now so only the active slide autoplays const ytContainer = slide.querySelector('.yt-container'); if (ytContainer) { const ytId = ytContainer.dataset.ytId; const iframe = ytContainer.querySelector('iframe'); if (iframe && ytId && !iframe.src) { iframe.src = `https://www.youtube.com/embed/${ytId}?autoplay=1&loop=1&playlist=${ytId}`; } } // Check for Ruffle SWF container — poll until RufflePlayer WASM is ready const ruffleEl = slide.querySelector('.ruffle-container'); if (ruffleEl && window.scrollerEnableSwf) { if (ruffleEl._rufflePlayer) { // Already initialised — just resume try { ruffleEl._rufflePlayer.play(); } catch {} currentMedia = ruffleEl._rufflePlayer; } else { // Try to init Ruffle — returns true when player is created, false to keep retrying const tryInitRuffle = () => { if (!ruffleEl.isConnected) return true; // slide removed — stop polling if (!window.RufflePlayer || typeof window.RufflePlayer.newest !== 'function') return false; try { const ruffle = window.RufflePlayer.newest(); if (!ruffle) return false; // WASM not ready yet const player = ruffle.createPlayer(); player.style.cssText = 'width:100%;height:100%;display:block;'; const ph = ruffleEl.querySelector('.ruffle-placeholder'); if (ph) ph.style.display = 'none'; ruffleEl.appendChild(player); player.load({ url: ruffleEl.dataset.swf, config: { volume: isMuted ? 0 : volume } }); player.volume = isMuted ? 0 : volume; // Failsafe: some Ruffle versions reset volume after WASM init setTimeout(() => { if (player.isConnected) player.volume = isMuted ? 0 : volume; }, 100); ruffleEl._rufflePlayer = player; // If slide was deactivated while we were loading, destroy immediately if (slide._deactivated) { try { player.pause(); player.remove(); } catch {} ruffleEl._rufflePlayer = null; const ph2 = ruffleEl.querySelector('.ruffle-placeholder'); if (ph2) ph2.style.display = 'block'; return true; } if (currentSlide === slide) currentMedia = player; return true; } catch (e) { console.warn('[Ruffle] init error', e); return false; } }; // Ensure ruffle.js is in the DOM (handles PJAX context with ?v= query string) const ensureRuffle = (cb) => { if (window.RufflePlayer) { cb(); return; } if (document.querySelector('script[src*="/s/ruffle/ruffle.js"]')) { cb(); return; } const s = document.createElement('script'); s.src = '/s/ruffle/ruffle.js'; s.onload = cb; document.head.appendChild(s); }; ensureRuffle(() => { if (currentSlide !== slide) return; // Don't init if user scrolled away if (tryInitRuffle()) return; let attempts = 0; const poll = setInterval(() => { attempts++; if (currentSlide !== slide || tryInitRuffle() || attempts >= 100) { clearInterval(poll); if (attempts >= 100 && currentSlide === slide) console.warn('[Ruffle] timed out waiting for WASM'); } }, 100); }); } } // Only set currentMedia to null if we didn't find a ruffle player if (!ruffleEl || !ruffleEl._rufflePlayer) { currentMedia = null; } else { currentMedia = ruffleEl._rufflePlayer; } } preloadNeighbors(slide); // Schedule a live meta refresh for this slide (debounced batch) scheduleMetaRefresh(slide); } // ── Live meta refresh ──────────────────────────────────────────────────── // Batches slide IDs that need refreshing and fires a single API call const metaRefreshPending = new Set(); let metaRefreshTimer = null; function scheduleMetaRefresh(slide) { if (!slide || slide._metaRefreshed) return; // only refresh once per slide metaRefreshPending.add(slide.dataset.id); clearTimeout(metaRefreshTimer); metaRefreshTimer = setTimeout(flushMetaRefresh, 150); } async function flushMetaRefresh() { if (!metaRefreshPending.size) return; const ids = [...metaRefreshPending]; metaRefreshPending.clear(); try { const resp = await fetch(`/api/v2/scroller/meta?ids=${ids.join(',')}`); if (!resp.ok) return; const meta = await resp.json(); for (const [id, data] of Object.entries(meta)) { const slide = feed.querySelector(`.scroll-slide[data-id="${id}"]`); if (!slide) continue; slide._metaRefreshed = true; applySlideMetaUpdate(slide, data); } } catch {} } function applySlideMetaUpdate(slide, data) { // Update fav count + faved state const favBtn = slide.querySelector('.js-fav-btn'); if (favBtn) { const countEl = favBtn.querySelector('.scroll-btn-count'); if (countEl) countEl.textContent = data.fav_count ?? countEl.textContent; if (data.is_faved != null) { favBtn.classList.toggle('faved', data.is_faved); const icon = favBtn.querySelector('i'); if (icon) icon.className = (data.is_faved ? 'fa-solid' : 'fa-regular') + ' fa-heart'; } } // Update comment count const commBtn = slide.querySelector('.js-comments-btn'); if (commBtn) { const countEl = commBtn.querySelector('.scroll-btn-count'); if (countEl && data.comment_count != null) countEl.textContent = data.comment_count; } // Update tags if (data.tags != null) applyFreshTags(slide, data.tags); } function applyFreshTags(slide, tagString) { const metaInner = slide.querySelector('.scroll-meta-inner'); if (!metaInner) return; let tagsDiv = slide.querySelector('.scroll-tags'); if (!tagString) { if (tagsDiv) tagsDiv.remove(); return; } if (!tagsDiv) { tagsDiv = document.createElement('div'); tagsDiv.className = 'scroll-tags'; const idLink = metaInner.querySelector('.scroll-id-link'); if (idLink) metaInner.insertBefore(tagsDiv, idLink); else metaInner.appendChild(tagsDiv); } const newTags = tagString.split(', ').map(t => t.trim()).filter(Boolean); const existingTags = new Set([...tagsDiv.querySelectorAll('.scroll-tag-pill')].map(p => p.dataset.tag)); for (const tag of newTags) { if (!existingTags.has(tag)) { const pill = document.createElement('span'); pill.className = 'scroll-tag-pill'; pill.dataset.tag = tag; pill.innerHTML = `${esc(tag)}`; if (window.scrollerIsMod) { const del = document.createElement('button'); del.className = 'scroll-tag-pill-del'; del.title = `Remove tag '${tag}'`; del.innerHTML = '×'; del.addEventListener('click', async (e) => { e.stopPropagation(); const itemId = slide.dataset.id; try { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, { method: 'DELETE', headers: { 'x-csrf-token': window.scrollerCsrf || '' } }); const data = await resp.json(); if (data.success) pill.remove(); else console.warn('[scroller] tag delete failed', data.msg); } catch (err) { console.error('[scroller] tag delete error', err); } }); pill.appendChild(del); } // Filter on tag text click (not delete button) pill.addEventListener('click', e => { if (e.target.closest('.scroll-tag-pill-del')) return; e.stopPropagation(); filterByTag(tag); }); tagsDiv.appendChild(pill); } } } function deactivateSlide(slide) { if (!slide) return; slide.classList.remove('active'); const v = slide.querySelector('video'); const a = slide.querySelector('audio'); const c = slide.querySelector('.audio-cover'); if (v) { v.pause(); // Stop canvas bg loop if (slide._bgRaf) { cancelAnimationFrame(slide._bgRaf); slide._bgRaf = null; } // Strip .ready so the blurry bg shows again before the video re-buffers v.classList.remove('ready'); // Clean up auto-next handler if (slide._autoNextHandler) { v.removeEventListener('ended', slide._autoNextHandler); slide._autoNextHandler = null; } // Save position, then abort all network loading immediately. // If the video already ended, save 0 so re-activation restarts from the beginning. v._savedSrc = v._savedSrc || v.src || v.currentSrc; v._savedTime = v.ended ? 0 : v.currentTime; v.removeAttribute('src'); // Remove any children too v.querySelectorAll('source').forEach(s => s.removeAttribute('src')); v.load(); // aborts buffering } if (a) { a.pause(); a._savedSrc = a._savedSrc || a.src || a.currentSrc; a._savedTime = a.ended ? 0 : a.currentTime; a.removeAttribute('src'); a.load(); if (c) c.classList.remove('playing'); } // Stop YouTube iframe by clearing src const ytContainer = slide.querySelector('.yt-container'); if (ytContainer) { const iframe = ytContainer.querySelector('iframe'); if (iframe) iframe.removeAttribute('src'); } // Stop Ruffle player by removing it (destroys WASM instance) slide._deactivated = true; const ruffleEl = slide.querySelector('.ruffle-container'); if (ruffleEl && ruffleEl._rufflePlayer) { try { ruffleEl._rufflePlayer.pause(); ruffleEl._rufflePlayer.remove(); ruffleEl._rufflePlayer = null; const ph = ruffleEl.querySelector('.ruffle-placeholder'); if (ph) ph.style.display = 'block'; } catch {} } } // No‑op: preloading neighbours eats bandwidth on real connections. // The browser will buffer naturally once the video src is set on activate. function preloadNeighbors() {} // ── Seekable progress bar ───────────────────────────────────────────────── function wireProgress(slide, media, fill, thumb) { if (!fill) return; const bar = fill.parentElement; const onTime = () => { if (!slide.classList.contains('active')) { media.removeEventListener('timeupdate', onTime); return; } const pct = media.duration ? (media.currentTime / media.duration) * 100 : 0; fill.style.width = pct + '%'; if (thumb) thumb.style.right = (100 - pct) + '%'; }; media.addEventListener('timeupdate', onTime); const seek = (cx) => { if (!media.duration) return; const rect = bar.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (cx - rect.left) / rect.width)); media.currentTime = pct * media.duration; fill.style.width = (pct * 100) + '%'; if (thumb) thumb.style.right = ((1 - pct) * 100) + '%'; }; let dragging = false; const onMove = e => { if (!dragging) return; bar.classList.add('dragging'); seek(e.touches ? e.touches[0].clientX : e.clientX); }; const onEnd = () => { dragging = false; bar.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; bar.addEventListener('click', e => seek(e.clientX)); bar.addEventListener('mousedown', e => { dragging = true; seek(e.clientX); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); }); bar.addEventListener('touchstart', e => { dragging = true; seek(e.touches[0].clientX); document.addEventListener('touchmove', onMove, { passive: true }); document.addEventListener('touchend', onEnd); }, { passive: true }); } // ── Double-tap / double-click → favourite ──────────────────────────────── let lastTapTime = 0; let lastTapSlide = null; const DOUBLE_DELAY = 320; // ── Floating heart animation ────────────────────────────────────────────── const HEART_EMOJIS = ['❤️', '💕', '💖', '💗', '💓', '💞', '💝']; let heartEmojIdx = 0; function spawnHeart(slide, clientX, clientY) { const rect = slide.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; const emoji = HEART_EMOJIS[heartEmojIdx % HEART_EMOJIS.length]; heartEmojIdx++; const el = document.createElement('div'); el.className = 'tap-heart'; el.textContent = emoji; // Random horizontal drift ±30px, random size 52–76px const drift = (Math.random() - 0.5) * 60; const size = 52 + Math.random() * 24; el.style.cssText = `left:${x}px;top:${y}px;font-size:${size}px;--drift:${drift}px`; slide.appendChild(el); // Remove after animation completes el.addEventListener('animationend', () => el.remove(), { once: true }); } function flashFav(slide) { const el = slide.querySelector('.fav-flash'); if (!el) return; el.classList.remove('show', 'hide'); void el.offsetWidth; el.classList.add('show'); setTimeout(() => { el.classList.remove('show'); el.classList.add('hide'); }, 800); } async function toggleFav(slide) { if (!window.scrollerLoggedIn) return; const id = slide.dataset.localId || slide.dataset.id; // External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; } const favBtn = slide.querySelector('.js-fav-btn'); // Optimistic: immediately flip the button state before the server responds const wasFaved = favBtn ? favBtn.classList.contains('faved') : false; if (favBtn) { const nowFaved = !wasFaved; favBtn.classList.toggle('faved', nowFaved); const icon = favBtn.querySelector('i'); if (icon) icon.className = (nowFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart'; const countEl = favBtn.querySelector('.scroll-btn-count'); if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1)); if (nowFaved) flashFav(slide); } try { const resp = await fetch('/api/v2/togglefav', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `postid=${id}` }); const data = await resp.json(); // Sync count to server truth (handles race conditions) if (data.success && favBtn && data.favs) { const countEl = favBtn.querySelector('.scroll-btn-count'); if (countEl) countEl.textContent = data.favs.length; } } catch { // Rollback optimistic update on error if (favBtn) { favBtn.classList.toggle('faved', wasFaved); const icon = favBtn.querySelector('i'); if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart'; } } } // ── Tap overlay + double-tap ────────────────────────────────────────────── function setupTapOverlay(slide, getSpeedEndedAt) { const tapEl = slide.querySelector('.tap-overlay'); const icon = slide.querySelector('.tap-pause-icon'); if (!tapEl) return; let singleTimer = null; const FAV_BURST_WINDOW = 2500; // ms — while in burst, extra taps just show hearts let lastDoubleTapAt = 0; const handleTap = (e) => { if (getSpeedEndedAt && Date.now() - getSpeedEndedAt() < 250) return; const now = Date.now(); if (lastTapSlide === slide && now - lastTapTime < DOUBLE_DELAY) { // Double tap clearTimeout(singleTimer); lastTapTime = 0; lastTapSlide = null; spawnHeart(slide, e.clientX, e.clientY); const favBtn = slide.querySelector('.js-fav-btn'); const isFaved = favBtn && favBtn.classList.contains('faved'); if (!isFaved) { // Not faved → fav it toggleFav(slide); lastDoubleTapAt = now; } else if (now - lastDoubleTapAt > FAV_BURST_WINDOW) { // Already faved, but been a while → unfav toggleFav(slide); lastDoubleTapAt = 0; } else { // In burst window → just hearts, keep faved lastDoubleTapAt = now; } return; } lastTapTime = now; lastTapSlide = slide; // Delay single-tap action so we can detect double-tap clearTimeout(singleTimer); singleTimer = setTimeout(() => { const media = slide.querySelector('video') || slide.querySelector('audio'); if (!media) return; if (media.paused) { media.play().catch(() => {}); icon.innerHTML = ''; } else { media.pause(); icon.innerHTML = ''; } icon.classList.remove('show', 'hide'); void icon.offsetWidth; icon.classList.add('show'); setTimeout(() => { icon.classList.remove('show'); icon.classList.add('hide'); }, 850); }, DOUBLE_DELAY); }; tapEl.addEventListener('click', handleTap); tapEl.addEventListener('contextmenu', e => e.preventDefault()); // Long-press: hold to peek (hide UI + pause media), release to restore let holdTimer = null; let peekPaused = false; // tracks if WE paused the media const startHold = () => { holdTimer = setTimeout(() => { slide.classList.add('ui-peek'); const media = slide.querySelector('video') || slide.querySelector('audio'); if (media && !media.paused) { media.pause(); peekPaused = true; } }, 300); }; const endHold = () => { clearTimeout(holdTimer); holdTimer = null; slide.classList.remove('ui-peek'); if (peekPaused) { const media = slide.querySelector('video') || slide.querySelector('audio'); if (media) media.play().catch(() => {}); peekPaused = false; } }; tapEl.addEventListener('touchstart', startHold, { passive: true }); tapEl.addEventListener('touchend', endHold, { passive: true }); tapEl.addEventListener('touchcancel',endHold, { passive: true }); // Only cancel the timer on move (prevent swipe mis-activation); // if peek is already showing, moving the finger keeps it active. tapEl.addEventListener('touchmove', () => { clearTimeout(holdTimer); holdTimer = null; }, { passive: true }); } // ── Create slide ────────────────────────────────────────────────────────── function createSlide(item) { const slide = document.createElement('div'); slide.className = 'scroll-slide'; slide.dataset.id = item.id; if (item.local_id) slide.dataset.localId = item.local_id; slide._thumbnail = item.thumbnail; // stored for canvas redraw + applyCanvasBg fallback const bgCanvas = document.createElement('canvas'); bgCanvas.className = 'scroll-bg-blur'; // Low resolution — blurred so heavily that full res is wasted bgCanvas.width = 64; bgCanvas.height = 36; // Draw thumbnail immediately for a static bg before video starts const bgCtx = bgCanvas.getContext('2d'); const bgThumb = new Image(); bgThumb.onload = () => { try { bgCtx.drawImage(bgThumb, 0, 0, 64, 36); } catch {} }; bgThumb.src = item.thumbnail; slide.appendChild(bgCanvas); const mediaEl = document.createElement('div'); mediaEl.className = 'scroll-media'; if (item.is_video) { const v = document.createElement('video'); v.src = item.dest; v.loop = true; v.playsInline = true; v.muted = isMuted; v.volume = volume; v.preload = 'none'; // .ready class is registered fresh in activateSlide each time mediaEl.appendChild(v); } else if (item.is_youtube) { const ytId = (item.dest || '').replace('yt:', ''); const d = document.createElement('div'); d.className = 'yt-container'; // NOTE: iframe src is injected in activateSlide to prevent all slides autoplaying at once d.dataset.ytId = ytId; d.innerHTML = ``; mediaEl.appendChild(d); } else if (item.is_audio) { // Audio: blurry bg acts as album art — vinyl disc shows cover art const cover = document.createElement('div'); cover.className = 'audio-cover'; if (item.thumbnail) { // Quote and escape the URL so a ')' or '"' in the path can't break the // CSS url() value or inject arbitrary styles. cover.style.backgroundImage = `url("${item.thumbnail.replace(/["\\]/g, '')}")` ; cover.style.backgroundSize = 'cover'; cover.style.backgroundPosition = 'center'; } mediaEl.appendChild(cover); const au = document.createElement('audio'); au.src = item.dest; au.loop = true; au.muted = isMuted; au.volume = volume; au.preload = 'none'; mediaEl.appendChild(au); } else if (item.is_swf && window.scrollerEnableSwf) { // SWF via Ruffle — container is populated lazily in activateSlide const ruffleContainer = document.createElement('div'); ruffleContainer.className = 'ruffle-container'; ruffleContainer.dataset.swf = item.dest; ruffleContainer.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000;'; // Thumbnail placeholder until activated const placeholder = document.createElement('img'); placeholder.src = item.thumbnail; placeholder.alt = ''; placeholder.className = 'ruffle-placeholder'; placeholder.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none;'; ruffleContainer.style.position = 'relative'; ruffleContainer.appendChild(placeholder); mediaEl.appendChild(ruffleContainer); } else { const img = document.createElement('img'); img.src = item.dest; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; mediaEl.appendChild(img); } slide.appendChild(mediaEl); slide.appendChild(Object.assign(document.createElement('div'), { className: 'scroll-overlay' })); const tap = document.createElement('div'); tap.className = 'tap-overlay'; const tapIcon = document.createElement('div'); tapIcon.className = 'tap-pause-icon'; // YouTube and Flash need direct interaction if (item.is_youtube || item.is_swf) tap.style.pointerEvents = 'none'; tap.appendChild(tapIcon); slide.appendChild(tap); // Fav flash element const favFlash = document.createElement('div'); favFlash.className = 'fav-flash'; favFlash.innerHTML = '❤️'; slide.appendChild(favFlash); // Meta (z:10) const meta = document.createElement('div'); meta.className = 'scroll-meta'; // esc() the color value: a raw '"' in username_color would break out of the // style attribute and allow arbitrary HTML injection (XSS). const colorStyle = item.username_color ? `color:${esc(item.username_color)}` : ''; const ratingHtml = `${esc(item.rating_label)}`; const ocHtml = item.is_oc ? ` OC` : ''; const badgesHtml = `
${ratingHtml}${ocHtml}
`; // Build clickable tag pills const tagsHtml = item.tags ? `
${ item.tags.split(', ').map(t => `${esc(t.trim())}`).join('') }
` : ''; meta.innerHTML = `
${badgesHtml}
${esc(item.display_name || item.username)}
${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}
${tagsHtml} ${item.is_external && item.external_board && item.external_tid ? `/${item.external_board}/thread/${item.external_tid}` : (item.local_id ? `#${item.local_id}` : `#${item.id}`)}
`; slide.appendChild(meta); // Actions (z:10) — no Filter button here, it lives in the topbar const actions = document.createElement('div'); actions.className = 'scroll-actions'; const _i = window.f0ckI18n || {}; actions.innerHTML = ` ${window.scrollerLoggedIn ? ` ` : ''} ${window.scrollerLoggedIn ? ` ` : ''} ${item.is_external ? ( item.local_id ? `
${_i.view_label || 'View'}
` : `` ) : `
${_i.open_label || 'Open'}
`} `; slide.appendChild(actions); // ── Speed-hold zones (Direct children of slide for maximum reliability) ── // Right zone (above actions) const rightSpeedZone = document.createElement('button'); rightSpeedZone.className = 'js-speed-hold-btn'; rightSpeedZone.setAttribute('aria-hidden', 'true'); rightSpeedZone.tabIndex = -1; Object.assign(rightSpeedZone.style, { position: 'absolute', right: '0', bottom: '0', width: '40px', height: '100vh', background: 'none', border: 'none', padding: '0', cursor: 'pointer', opacity: '0', pointerEvents: 'all', touchAction: 'none', zIndex: '5' }); slide.appendChild(rightSpeedZone); // Left zone (above meta) const leftSpeedZone = document.createElement('button'); leftSpeedZone.className = 'js-speed-hold-btn'; leftSpeedZone.setAttribute('aria-hidden', 'true'); leftSpeedZone.tabIndex = -1; Object.assign(leftSpeedZone.style, { position: 'absolute', left: '0', bottom: '0', width: '40px', height: '100vh', background: 'none', border: 'none', padding: '0', cursor: 'pointer', opacity: '0', pointerEvents: 'all', touchAction: 'none', zIndex: '5' }); slide.appendChild(leftSpeedZone); // In left-hand mode, inject 4chan action buttons into this slide's actions if (leftHandEnabled) { if (chanOpenBtn && chanOpenBtn.style.display !== 'none') { const ob = document.createElement('button'); ob.className = 'chan-action-btn'; ob.title = chanOpenBtn.title; ob.innerHTML = `
`; ob.addEventListener('click', () => chanOpenBtn.click()); actions.appendChild(ob); } if (chanGalleryBtn && chanGalleryBtn.style.display !== 'none') { const gb = document.createElement('button'); gb.className = 'chan-action-btn'; gb.title = chanGalleryBtn.title; gb.innerHTML = `
`; gb.addEventListener('click', () => chanGalleryBtn.click()); actions.appendChild(gb); } } // Progress bar (z:10) const pBar = document.createElement('div'); pBar.className = 'scroll-progress-bar'; pBar.innerHTML = '
'; slide.appendChild(pBar); setupTapOverlay(slide, () => speedEndedAt); // ── Hold-to-2× speed logic (shared by multiple triggers) ── let speedTimer = null; let speedActive = false; let speedEndedAt = 0; let wasPausedByHold = false; const endSpeed = () => { clearTimeout(speedTimer); speedTimer = null; document.removeEventListener('pointerup', endSpeed); document.removeEventListener('pointercancel',endSpeed); if (speedActive) { speedActive = false; speedEndedAt = Date.now(); const media = slide.querySelector('video') || slide.querySelector('audio'); if (media) { media.playbackRate = 1; if (wasPausedByHold) { media.pause(); wasPausedByHold = false; } } const ind = document.getElementById('speed-indicator'); if (ind) ind.classList.remove('show'); } }; const wireSpeedHold = (el, onlyMouse = false) => { if (!el) return; el.addEventListener('pointerdown', (e) => { if (onlyMouse && e.pointerType !== 'mouse') return; document.addEventListener('pointerup', endSpeed, { once: true }); document.addEventListener('pointercancel',endSpeed, { once: true }); speedTimer = setTimeout(() => { speedActive = true; const media = slide.querySelector('video') || slide.querySelector('audio'); if (media) { if (media.paused) { wasPausedByHold = true; media.play().catch(() => {}); } media.playbackRate = 2; const ind = document.getElementById('speed-indicator'); if (ind) ind.classList.add('show'); } }, 150); }, { passive: true }); }; wireSpeedHold(rightSpeedZone); wireSpeedHold(leftSpeedZone); // On desktop, holding anywhere (mouse) triggers speed hold wireSpeedHold(slide.querySelector('.tap-overlay'), true); const favBtn = actions.querySelector('.js-fav-btn'); if (favBtn) { let favLastClick = 0; favBtn.addEventListener('click', () => { const now = Date.now(); if (now - favLastClick < DOUBLE_DELAY) return; favLastClick = now; toggleFav(slide); }); } actions.querySelector('.js-comments-btn').addEventListener('click', () => { const slide = actions.closest('.scroll-slide'); const id = slide?.dataset.localId || item.id; openComments(id); }); const tagBtn = actions.querySelector('.js-tag-btn'); if (tagBtn) tagBtn.addEventListener('click', () => { const slide = tagBtn.closest('.scroll-slide'); const id = slide?.dataset.localId || item.id; if (!/^\d+$/.test(id)) { showShareToast((window.f0ckI18n && window.f0ckI18n.add_to_site_first) || 'Add to site first to tag'); return; } openTagBar(id); }); const shareBtn = actions.querySelector('.js-share-btn'); if (shareBtn) shareBtn.addEventListener('click', () => { const slide = shareBtn.closest('.scroll-slide'); const localId = slide?.dataset.localId; const id = localId || shareBtn.dataset.id; const abyssUrl = localId ? `${location.origin}/abyss#${localId}` : `${location.origin}/abyss#${id}`; openSharePanel(abyssUrl); }); const rehostBtn = actions.querySelector('.rehost-btn'); if (rehostBtn) rehostBtn.addEventListener('click', () => rehostItem(item, rehostBtn)); // Rating cycle — always wire the click; server enforces mod auth via 403 const rBadge = slide.querySelector('.scroll-rating[data-item-id]'); if (rBadge) { if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle'); rBadge.addEventListener('click', e => { if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold e.stopPropagation(); const slideEl = rBadge.closest('.scroll-slide'); const id = slideEl?.dataset.localId || rBadge.dataset.itemId; if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; } cycleRatingOptimistic(rBadge, id, false); }); } // Wire clickable tag pills (pointer-events:all override comes from CSS) meta.querySelectorAll('.scroll-tag-pill').forEach(pill => { const t = pill.dataset.tag; pill.addEventListener('click', e => { if (e.target.closest('.scroll-tag-pill-del')) return; e.stopPropagation(); filterByTag(t); }); if (window.scrollerIsMod) { const del = document.createElement('button'); del.className = 'scroll-tag-pill-del'; del.title = `Remove tag '${t}'`; del.innerHTML = '×'; del.addEventListener('click', async (e) => { e.stopPropagation(); const itemId = slide.dataset.id; try { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, { method: 'DELETE', headers: { 'x-csrf-token': window.scrollerCsrf || '' } }); const data = await resp.json(); if (data.success) pill.remove(); else console.warn('[scroller] tag delete failed', data.msg); } catch (err) { console.error('[scroller] tag delete error', err); } }); pill.appendChild(del); } }); return slide; } // ── Feed fetch ──────────────────────────────────────────────────────────── async function fetchItems() { if (isFetching) return; // For random mode: allow up to 5 consecutive empty results before giving up if (!hasMore && applied.order !== 'random') return; if (applied.order === 'random' && consecutiveEmpty >= 5) { if (feed.querySelectorAll('.scroll-slide').length === 0) emptyEl.classList.add('show'); return; } isFetching = true; const params = new URLSearchParams({ limit: 12, mode: applied.mode, orderby: applied.order }); if (applied.mime) params.set('mime', applied.mime); if (applied.tags.length > 0) params.set('tag', applied.tags.join(',')); if (applied.order !== 'random') { // Ordered modes: use cursor-based pagination if (lastCursor) params.set('after', lastCursor); } else if (seenIds.size > 0) { // Random mode: send the most-recently-seen IDs (tail of the set) so the server avoids them; // recent items are most likely to collide with the next RANDOM() batch. const allSeen = [...seenIds]; const recentSeen = allSeen.slice(-200); // last 200 seen params.set('exclude', recentSeen.join(',')); } // On the very first fetch, anchor to the hash ID so it's always in the batch const hid = hashId(); if (hid && seenIds.size === 0 && !lastCursor) params.set('anchor', hid); try { let endpoint = `/api/v2/scroller/feed?${params}`; if (applied.externalUrl) { const match = applied.externalUrl.match(/(?:boards\.)?4chan\.org\/([a-z0-9]+)\/thread\/(\d+)/); if (match) { endpoint = `/api/v2/scroller/external/4chan/${match[1]}/${match[2]}`; } } const resp = await fetch(endpoint); const data = await resp.json(); let items = data.items || []; if (applied.externalUrl && data.posts) { if (chanGalleryBtn) chanGalleryBtn.style.display = ''; // Load persisted rehosts from localStorage for this thread const threadKey = `rehosted_4chan_${data.board}_${data.tid}`; let cachedRehosts = {}; try { cachedRehosts = JSON.parse(localStorage.getItem(threadKey) || '{}'); } catch(_) {} // Merge server-side rehosts with local cache (server is authoritative, cache fills gaps) const allRehosts = Object.assign({}, cachedRehosts, data.rehosts || {}); // Persist merged map back try { localStorage.setItem(threadKey, JSON.stringify(allRehosts)); } catch(_) {} items = data.posts .filter(p => p.tim && p.ext) .map(p => { const ext = (p.ext || "").toLowerCase(); const isVideo = ['.webm', '.mp4'].includes(ext); const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext); const isWsg = data.board === 'wsg'; const isGif = data.board === 'gif'; // Respect instance-level allowed mime categories const mimeCats = window.scrollerMimeCats || ['video', 'image', 'audio']; if (isVideo && !mimeCats.includes('video')) return null; if (isImage && !mimeCats.includes('image')) return null; if (!isVideo && !isImage) return null; // Respect user-selected mime filter if (applied.mime === 'video' && !isVideo) return null; if (applied.mime === 'image' && !isImage) return null; const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`; const local_id = allRehosts[external_media_url] || null; return { id: `${data.board}/${p.no}`, external_id: p.no, external_tid: data.tid, external_board: data.board, external_source: '4chan', is_external: true, local_id, external_media_url, mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'), dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`, thumbnail: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}s.jpg`, username: 'Anonymous', display_name: 'Anonymous', avatar: '/a/default.png', stamp: p.time, timeago: timeAgo(p.time * 1000), tags: `4chan, /${data.board}/`, is_video: isVideo, is_image: isImage, is_audio: false, comment_count: p.replies || 0, rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'), rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged'), original_filename: p.filename ? `${decodeHtmlEntities(p.filename)}${ext}` : null }; }) .filter(Boolean); // Fetch metadata for rehosted items (username, avatar, timestamp) const rehostItemIds = items.filter(i => i.local_id).map(i => i.local_id); if (rehostItemIds.length > 0) { try { const metaResp = await fetch('/api/v2/scroller/external/rehost-meta', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `ids=${rehostItemIds.join(',')}` }); const metaMap = await metaResp.json(); items.forEach(item => { if (item.local_id && metaMap[item.local_id]) { const m = metaMap[item.local_id]; item.username = m.username; item.display_name = m.display_name; item.avatar = m.avatar; if (m.comment_count != null) item.comment_count = m.comment_count; if (m.rating_class) { item.rating_class = m.rating_class; item.rating_label = m.rating_label; } } }); } catch (e) { console.warn('[CHAN] rehost-meta fetch failed:', e); } } // Apply order filter client-side (API returns thread order) if (applied.order === 'random') { for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } } else if (applied.order === 'newest') { items.sort((a, b) => b.stamp - a.stamp); } else if (applied.order === 'oldest') { items.sort((a, b) => a.stamp - b.stamp); } // If loading with a specific hash target, ensure it's first const hid = hashId(); if (hid && !currentSlide) { const idx = items.findIndex(it => it.id === hid); if (idx > 0) { const [target] = items.splice(idx, 1); items.unshift(target); } } // Store post→thread mapping so #board/postno can resolve back to the thread URL try { const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}'); items.forEach(it => { postMap[it.id] = data.tid; }); localStorage.setItem('4chan_post_threads', JSON.stringify(postMap)); } catch(_) {} } if (!data.success || items.length === 0) { consecutiveEmpty++; const noSlides = feed.querySelectorAll('.scroll-slide').length === 0; if (applied.order !== 'random') { hasMore = false; } if (noSlides) { if (applied.externalUrl) { alert((window.f0ckI18n && window.f0ckI18n.chan_load_failed) || 'Could not load 4chan thread. It might be archived or have no compatible media.'); applied.externalUrl = null; pending.externalUrl = null; if (galleryOpen) toggleGallery(); if (externalUrlInput) externalUrlInput.value = ''; reloadFeed(); return; } // Anchor was requested but blocked (NSFW/NSFL for guest). const lockSlide = document.createElement('div'); lockSlide.className = 'scroll-slide'; lockSlide.dataset.lock = '1'; lockSlide.style.cssText = 'background:#000;'; lockSlide.innerHTML = [ '
', '', '

This post is currently unavailable

', '
', ].join(''); feed.insertBefore(lockSlide, sentinel); history.replaceState(null, '', '/abyss'); consecutiveEmpty = 0; hasMore = true; hideLoader(); setTimeout(() => fetchItems(), 0); } else { emptyEl.classList.add('show'); hideLoader(); } if (!data.success) alert(((window.f0ckI18n && window.f0ckI18n.fetch_failed) || 'Fetch failed: {msg}').replace('{msg}', data.msg || 'Unknown error')); return; // exit — no items to process } consecutiveEmpty = 0; let newCount = 0; items.forEach(item => { if (seenIds.has(item.id)) return; seenIds.add(item.id); feed.insertBefore(createSlide(item), sentinel); newCount++; }); // If every item in the batch was a duplicate, treat it like an empty result if (newCount === 0) { consecutiveEmpty++; } // External threads load all items at once — no pagination if (applied.externalUrl) { hasMore = false; } if (data.nextCursor) lastCursor = data.nextCursor; appendToCache(items); if (!currentSlide) { const hid = hashId(); const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null; const first = feed.querySelector('.scroll-slide:not([data-lock])'); const toActivate = target || first; if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); }, 200); } } catch (err) { console.error('[SCROLLER] Fetch error:', err); } finally { isFetching = false; hideLoader(); } } async function rehostItem(item, btn) { if (btn.classList.contains('loading') || btn.classList.contains('success')) return; if (!window.scrollerLoggedIn) { alert((window.f0ckI18n && window.f0ckI18n.login_required) || 'You must be logged in to add items.'); return; } btn.classList.add('loading'); const icon = btn.querySelector('i'); const origClass = icon.className; icon.className = 'fa-solid fa-spinner'; let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw')); if (item.external_board === 'wsg') rating = 'sfw'; else if (item.external_board === 'gif') rating = 'nsfw'; try { const resp = await fetch('/api/v2/scroller/rehost', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'x-csrf-token': window.scrollerCsrf || '' }, body: new URLSearchParams({ url: item.external_media_url || item.dest, rating: rating, tags: '4chan', comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`, ...(item.original_filename ? { original_filename: item.original_filename } : {}) }) }); const data = await resp.json(); if (data.success) { // Persist to localStorage so it survives thread reloads if (item.external_board && item.external_media_url) { const threadKey = `rehosted_4chan_${item.external_board}_${applied.externalUrl?.match(/(\d+)\/?$/)?.[1] || ''}` ; try { const cached = JSON.parse(localStorage.getItem(threadKey) || '{}'); cached[item.external_media_url] = data.item_id; localStorage.setItem(threadKey, JSON.stringify(cached)); } catch(_) {} } btn.classList.remove('loading'); btn.classList.add('success'); icon.className = data.repost ? 'fa-solid fa-link' : 'fa-solid fa-check'; if (data.repost) showShareToast('Already on site — linked to existing item'); // Update slide's local ID so fav/comments work immediately const slide = btn.closest('.scroll-slide'); if (slide) slide.dataset.localId = data.item_id; // Update button to link to the new site-internal post setTimeout(() => { btn.outerHTML = `
View
`; }, 800); // Update scroll-id-link to show local ID if (slide) { const idLink = slide.querySelector('.scroll-id-link'); if (idLink) { idLink.href = `/${data.item_id}`; idLink.textContent = `#${data.item_id}`; } // Reflect rehoster's username and local timestamp if (window.scrollerUsername) { slide.querySelectorAll('.scroll-user-link').forEach(link => { link.href = `/user/${window.scrollerUsername}`; }); const nameEl = slide.querySelector('.scroll-username'); if (nameEl) nameEl.textContent = window.scrollerDisplayName || window.scrollerUsername; const avatarEl = slide.querySelector('.scroll-avatar'); if (avatarEl) avatarEl.src = window.scrollerUserAvatar || '/a/default.png'; } const timeEl = slide.querySelector('.scroll-timeago'); if (timeEl) timeEl.textContent = (window.f0ckI18n && window.f0ckI18n.just_now) || 'just now'; // Enable rating cycle now that item has a local_id const rBadge = slide.querySelector('.scroll-rating[data-item-id]'); if (rBadge) rBadge.classList.add('can-cycle'); } } else { throw new Error(data.msg); } } catch (e) { btn.classList.remove('loading'); icon.className = origClass; alert(((window.f0ckI18n && window.f0ckI18n.rehost_failed) || 'Rehost failed: {msg}').replace('{msg}', e.message)); } } function cycleRatingOptimistic(badge, id, showToast = false) { if (!badge || !id || isNaN(id)) return; let currentRating = badge.dataset.rating || ''; if (!currentRating) { if (badge.classList.contains('sfw')) currentRating = 'sfw'; else if (badge.classList.contains('nsfw')) currentRating = 'nsfw'; else if (badge.classList.contains('nsfl')) currentRating = 'nsfl'; } let nextRating = 'sfw'; if (currentRating === 'sfw') nextRating = 'nsfw'; else if (currentRating === 'nsfw') nextRating = 'nsfl'; const mapping = { sfw: { label: 'SFW', cls: 'sfw', toast: '🛡 SFW' }, nsfw: { label: 'NSFW', cls: 'nsfw', toast: '🔥 NSFW' }, nsfl: { label: 'NSFL', cls: 'nsfl', toast: '💀 NSFL' } }; const info = mapping[nextRating]; const oldClassName = badge.className; const oldTextContent = badge.textContent; const oldDatasetRating = badge.dataset.rating; // Track active request ID to ignore out-of-order race conditions on rapid keypresses const reqId = (badge._lastCycleReqId || 0) + 1; badge._lastCycleReqId = reqId; // Optimistically apply new state badge.className = `scroll-rating ${info.cls}${window.scrollerIsMod ? ' can-cycle' : ''}`; badge.textContent = info.label; badge.dataset.rating = info.cls; if (showToast) { showShareToast(info.toast); } fetch(`/api/v2/tags/${id}/cycle-rating`, { method: 'PUT', headers: { 'x-csrf-token': window.scrollerCsrf || '' } }) .then(r => r.json()) .then(data => { if (badge._lastCycleReqId !== reqId) return; // ignore stale responses if (!data.success) { revert(); return; } // Verify we match actual server result badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`; badge.textContent = data.rating_label; badge.dataset.rating = data.rating_class; }) .catch(() => { if (badge._lastCycleReqId !== reqId) return; // ignore stale responses revert(); }); function revert() { badge.className = oldClassName; badge.textContent = oldTextContent; badge.dataset.rating = oldDatasetRating; showShareToast('⚠️ Failed to update rating'); } } function reloadFeed() { clearCache(); // Pause previous if (currentMedia) { try { currentMedia.pause(); // If it's a Ruffle player, destroy it immediately for clean transition if (currentMedia.tagName === 'RUFFLE-PLAYER') { const container = currentMedia.parentElement; currentMedia.remove(); if (container && container._rufflePlayer === currentMedia) { container._rufflePlayer = null; const ph = container.querySelector('.ruffle-placeholder'); if (ph) ph.style.display = 'block'; } } } catch {} } currentSlide = null; currentMedia = null; // Close gallery sidebar if open and hide button if (galleryOpen) toggleGallery(); if (chanGalleryBtn) chanGalleryBtn.style.display = 'none'; feed.querySelectorAll('.scroll-slide').forEach(s => { slideObserver.unobserve(s); s.remove(); }); emptyEl.classList.remove('show'); emptyEl.innerHTML = `

${(window.f0ckI18n && window.f0ckI18n.nothing_found) || 'Nothing found with current filters'}

`; hasMore = true; isFetching = false; lastCursor = null; consecutiveEmpty = 0; seenIds = new Set(); feed.scrollTop = 0; // Re-show loader for the next fetch cycle loader.style.display = ''; loader.classList.remove('hidden'); history.replaceState(null, '', '/abyss'); fetchItems(); updateFilterSummary(); } /** Instantly filter feed by a single tag (called from tag pill click). */ function filterByTag(tagDisplay) { const normalized = tagDisplay.toLowerCase().replace(/[^a-z0-9]/g, ''); applied = { ...applied, tags: [normalized] }; pending = { ...applied, tags: [normalized] }; reloadFeed(); syncPanelUI(); } // ── Observers ───────────────────────────────────────────────────────────── let scrollingToRandom = null; // slide element we're scrolling to, suppresses intermediate activations const slideObserver = new IntersectionObserver((entries) => { entries.forEach(e => { // Ignore intersection changes caused by viewport resize when the // keyboard hides after panel close — would activate the wrong slide if (panelOpen) return; // During random-scroll, only activate the target slide if (scrollingToRandom) { if (e.isIntersecting && e.intersectionRatio >= 0.6 && e.target === scrollingToRandom) { scrollingToRandom = null; activateSlide(e.target); } return; } if (e.isIntersecting && e.intersectionRatio >= 0.6) activateSlide(e.target); }); }, { root: feed, threshold: 0.6 }); // Primary: sentinel intersection const sentinelObserver = new IntersectionObserver(entries => { if (entries[0].isIntersecting) fetchItems(); }, { root: feed, threshold: 0.1 }); sentinelObserver.observe(sentinel); // Fallback: scroll event to catch cases where sentinel observer fires but // isFetching was true at that moment and the observer won't re-fire. let scrollFallbackTimer = null; feed.addEventListener('scroll', () => { clearTimeout(scrollFallbackTimer); scrollFallbackTimer = setTimeout(() => { if (panelOpen) return; // don't trigger fetch while a panel is open const { scrollTop, scrollHeight, clientHeight } = feed; if (scrollHeight - scrollTop - clientHeight < clientHeight * 1.5) { fetchItems(); } }, 120); }, { passive: true }); const mutObs = new MutationObserver(mutations => { mutations.forEach(m => m.addedNodes.forEach(node => { if (node.classList && node.classList.contains('scroll-slide')) slideObserver.observe(node); })); }); mutObs.observe(feed, { childList: true }); // ── Comments ────────────────────────────────────────────────────────────── // ── Reply state ────────────────────────────────────────────────────────── let replyToCommentId = null; let replyToUsername = null; function setReplyTo(commentId, username) { replyToCommentId = commentId; replyToUsername = username; const _i = window.f0ckI18n || {}; const indicator = document.getElementById('reply-indicator'); if (indicator) { indicator.querySelector('.reply-indicator-text').textContent = (_i.replying_to || 'Replying to {user}').replace('{user}', '@' + username); indicator.style.display = 'flex'; } if (commentInput) { const quote = `>>${commentId} `; const start = commentInput.selectionStart; const end = commentInput.selectionEnd; const val = commentInput.value; commentInput.value = val.substring(0, start) + quote + val.substring(end); commentInput.focus(); commentInput.selectionStart = commentInput.selectionEnd = start + quote.length; commentInput.dispatchEvent(new Event('input', { bubbles: true })); } } function quoteComment(id, username) { if (!commentInput) return; const commentEl = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`); if (!commentEl) return; const contentEl = commentEl.querySelector('.comment-content'); if (!contentEl) return; const raw = (contentEl.dataset.raw || '').replace(//gi, '\n').trim(); const lines = raw.split('\n'); const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`; const start = commentInput.selectionStart; const end = commentInput.selectionEnd; const val = commentInput.value; commentInput.value = val.substring(0, start) + quote + val.substring(end); commentInput.focus(); commentInput.selectionStart = commentInput.selectionEnd = start + quote.length; commentInput.dispatchEvent(new Event('input', { bubbles: true })); } function clearReply() { replyToCommentId = null; replyToUsername = null; const indicator = document.getElementById('reply-indicator'); if (indicator) indicator.style.display = 'none'; } const replyCancelBtn = document.getElementById('reply-cancel-btn'); if (replyCancelBtn) replyCancelBtn.addEventListener('click', clearReply); function renderCommentEl(c, canReply) { const el = document.createElement('div'); el.className = 'comment-item'; el.dataset.commentId = c.id || ''; const av = c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'); const nc = c.username_color ? `color:${esc(c.username_color)}` : ''; const _i = window.f0ckI18n || {}; const uname = c.display_name || c.username || 'anon'; el.innerHTML = `
${esc(uname)}
${renderCommentContent(c.content || '')}
${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')} ${canReply ? ` ` : ''}
`; // Wire buttons const replyBtn = el.querySelector('.comment-reply-btn'); if (replyBtn) { replyBtn.addEventListener('click', () => { setReplyTo(replyBtn.dataset.id, replyBtn.dataset.user); }); } const quoteBtn = el.querySelector('.comment-quote-btn'); if (quoteBtn) { quoteBtn.addEventListener('click', () => { quoteComment(quoteBtn.dataset.id, quoteBtn.dataset.user); }); } return el; } async function openComments(itemId) { commentsItemId = itemId; clearReply(); commentsOpenLink.href = `/${itemId}`; commentsLoading.style.display = 'block'; commentsEmpty.style.display = 'none'; commentsCount.textContent = ''; commentsList.querySelectorAll('.comment-item').forEach(el => el.remove()); openPanel(commentsPanel, commentsBackdrop); // Desktop only: focus input immediately (skip on touch to avoid surprise keyboard) if (commentInput && !window.matchMedia('(pointer: coarse)').matches) { setTimeout(() => commentInput.focus(), 120); } try { const resp = await fetch(`/api/comments/${itemId}?sort=new`); const data = await resp.json(); commentsLoading.style.display = 'none'; const visible = data.comments ? data.comments.filter(c => !c.is_deleted) : []; commentsCount.textContent = `(${visible.length})`; // Sync the slide action button with the real count from the server const slide = feed.querySelector(`.scroll-slide[data-id="${itemId}"]`); if (slide) { const btn = slide.querySelector('.js-comments-btn .scroll-btn-count'); if (btn) btn.textContent = visible.length; } if (!visible.length) { commentsEmpty.style.display = 'block'; return; } const canReply = !!window.scrollerLoggedIn; visible.forEach(c => commentsList.appendChild(renderCommentEl(c, canReply))); } catch { commentsLoading.style.display = 'none'; commentsEmpty.style.display = 'block'; commentsEmpty.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.failed_load_comments) || 'Failed to load'}`; } } // ── Tag bar ─────────────────────────────────────────────────────────────── const tagBar = document.getElementById('tag-bar'); const tagBarClose = document.getElementById('tag-bar-close-btn'); function openTagBar(itemId) { closeAllPanels(); tagBarItemId = itemId; if (!tagBar) return; tagBar.classList.add('open'); // Focus the input after transition const inp = document.getElementById('scroll-tag-input'); if (inp) setTimeout(() => inp.focus(), 240); } function closeTagBar() { if (!tagBar) return; tagBar.classList.remove('open'); closeSugg(); tagBarItemId = null; const inp = document.getElementById('scroll-tag-input'); if (inp) { inp.value = ''; inp.blur(); } } if (tagBarClose) tagBarClose.addEventListener('click', closeTagBar); // ── Share panel ──────────────────────────────────────────────────────────── const sharePanel = document.getElementById('share-panel'); const shareBackdrop = document.getElementById('share-backdrop'); const shareCopyRow = document.getElementById('share-copy-row'); const shareCopySub = document.getElementById('share-copy-sub'); const shareDmRow = document.getElementById('share-dm-row'); const shareDmSearch = document.getElementById('share-dm-search'); const shareUserInput = document.getElementById('share-user-input'); const shareUserResults = document.getElementById('share-user-results'); let sharePanelUrl = ''; let shareUserTimer = null; const SHARE_RECENTS_KEY = 'scroller_share_recents'; function getShareRecents() { try { return JSON.parse(localStorage.getItem(SHARE_RECENTS_KEY) || '[]'); } catch { return []; } } function saveShareRecent(u) { const list = getShareRecents().filter(r => r.user !== u.user); list.unshift({ user: u.user, display_name: u.display_name || '', avatar: u.avatar || '', avatar_file: u.avatar_file || '' }); localStorage.setItem(SHARE_RECENTS_KEY, JSON.stringify(list.slice(0, 5))); } function renderShareRow(u, container, label) { const row = document.createElement('div'); row.className = 'share-user-row'; const av = u.avatar_file ? `/a/${u.avatar_file}` : (u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png'); row.innerHTML = ``; if (label) { const lbl = document.createElement('span'); lbl.className = 'share-user-row-label'; lbl.textContent = label; row.appendChild(lbl); } row.addEventListener('click', async () => { row.style.opacity = '0.5'; row.style.pointerEvents = 'none'; try { await sendDmSilent(u.user, sharePanelUrl); saveShareRecent(u); closeSharePanel(); showShareToast(`✓ Sent to ${u.display_name || u.user}`); } catch (err) { row.style.opacity = ''; row.style.pointerEvents = ''; showShareToast(err.message, true); } }); container.appendChild(row); return row; } function showShareRecents() { if (!shareUserResults) return; const recents = getShareRecents(); shareUserResults.innerHTML = ''; if (!recents.length) { shareUserResults.classList.remove('show'); return; } const hdr = document.createElement('div'); hdr.className = 'share-recents-label'; hdr.textContent = (window.f0ckI18n && window.f0ckI18n.recent) || 'Recent'; shareUserResults.appendChild(hdr); recents.forEach(u => renderShareRow(u, shareUserResults)); shareUserResults.classList.add('show'); } function openSharePanel(url) { sharePanelUrl = url || ''; // Reset state if (shareCopySub) shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copy_clipboard) || 'Copy to clipboard'; if (shareDmSearch) shareDmSearch.classList.remove('show'); if (shareUserInput) shareUserInput.value = ''; if (shareUserResults) { shareUserResults.innerHTML = ''; shareUserResults.classList.remove('show'); } if (sharePanel) sharePanel.classList.add('open'); if (shareBackdrop) shareBackdrop.classList.add('show'); } function closeSharePanel() { if (sharePanel) sharePanel.classList.remove('open'); if (shareBackdrop) shareBackdrop.classList.remove('show'); if (shareDmSearch) shareDmSearch.classList.remove('show'); if (shareUserResults) { shareUserResults.innerHTML = ''; shareUserResults.classList.remove('show'); } if (shareUserInput) shareUserInput.value = ''; } if (shareBackdrop) shareBackdrop.addEventListener('click', closeSharePanel); // Copy link row if (shareCopyRow) { shareCopyRow.addEventListener('click', async () => { try { await navigator.clipboard.writeText(sharePanelUrl); if (shareCopySub) { shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copied) || 'Copied ✓'; setTimeout(() => { if (shareCopySub) shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copy_clipboard) || 'Copy to clipboard'; }, 1800); } } catch { window.prompt('Copy link:', sharePanelUrl); } }); } // Send via DM row — toggle user search if (shareDmRow) { shareDmRow.addEventListener('click', () => { if (!shareDmSearch) return; const showing = shareDmSearch.classList.toggle('show'); if (showing) { showShareRecents(); // show recent recipients immediately if (shareUserInput) shareUserInput.focus(); } }); } // User search for DM // ── Silent DM send with Web Crypto (E2E, same scheme as messages.js) ──────── const DM_KEY_NAME = 'f0ck_dm_privkey'; function b64u(buf) { const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); let s = ''; for (const b of u8) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } async function sendDmSilent(recipientUsername, text) { const subtle = crypto.subtle; // 1. Resolve user → user_id const resolveResp = await fetch(`/api/dm/resolve/${encodeURIComponent(recipientUsername)}`); const resolveData = await resolveResp.json(); if (!resolveData.success) throw new Error(resolveData.msg || 'User not found'); const recipientId = resolveData.user_id; // 2. Load own private key const privJwk = localStorage.getItem(DM_KEY_NAME); if (!privJwk) throw new Error('No encryption key — open Messages to set one up first'); // 3. Fetch recipient public key const pubResp = await fetch(`/api/dm/pubkey/${recipientId}`); const pubData = await pubResp.json(); if (!pubData.success) throw new Error("Recipient hasn't set up their encryption key yet"); // 4. ECDH derive shared key const privKey = await subtle.importKey('jwk', JSON.parse(privJwk), { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']); const pubKey = await subtle.importKey('jwk', JSON.parse(pubData.pubkey), { name: 'ECDH', namedCurve: 'P-256' }, true, []); const sharedKey = await subtle.deriveKey({ name: 'ECDH', public: pubKey }, privKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); // 5. Encrypt const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text)); // 6. Send const csrf = window.f0ckSession?.csrf_token || ''; const sendResp = await fetch(`/api/dm/send/${recipientId}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf }, body: new URLSearchParams({ ciphertext: b64u(enc), iv: b64u(iv) }).toString() }); const sendData = await sendResp.json(); if (!sendData.success) throw new Error(sendData.msg || 'Failed to send'); } function showShareToast(msg, isError = false) { let toast = document.getElementById('share-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'share-toast'; toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%) translateY(20px);' + 'background:rgba(20,20,28,.95);color:#fff;padding:10px 18px;border-radius:22px;' + 'font-size:.82rem;font-weight:600;z-index:2000;pointer-events:none;' + 'box-shadow:0 4px 20px rgba(0,0,0,.5);opacity:0;transition:opacity .18s,transform .18s;'; document.body.appendChild(toast); } toast.textContent = msg; toast.style.background = isError ? 'rgba(200,40,40,.95)' : 'rgba(20,20,28,.95)'; toast.style.opacity = '1'; toast.style.transform = 'translateX(-50%) translateY(0)'; clearTimeout(toast._t); toast._t = setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(-50%) translateY(20px)'; }, 2400); } async function fetchShareUsers(q) { try { const r = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(q)}`); const data = await r.json(); if (!shareUserResults) return; const users = data.suggestions || []; shareUserResults.innerHTML = ''; if (!users.length) { shareUserResults.classList.remove('show'); return; } users.forEach(u => renderShareRow(u, shareUserResults)); shareUserResults.classList.add('show'); } catch {} } if (shareUserInput) { shareUserInput.addEventListener('input', () => { const q = shareUserInput.value.trim(); clearTimeout(shareUserTimer); if (!q) { showShareRecents(); return; } shareUserTimer = setTimeout(() => fetchShareUsers(q), 230); }); } // ── Sitewide emoji picker ───────────────────────────────────────────────── // Loads custom emojis from /api/v2/emojis (same source as comments.js). // Wires the ☺ trigger button to show an image picker grid, // AND updates the :shortcode: inline autocomplete to prefer custom emojis. let customEmojis = {}; // { name: url } let emojiPickerEl = null; let emojiPickerCloseHandler = null; async function loadCustomEmojis() { // Reuse cache from comments.js if already loaded if (window.CommentSystem?.emojiCache) { customEmojis = window.CommentSystem.emojiCache; return; } try { const res = await fetch('/api/v2/emojis'); const data = await res.json(); if (data.emojis) data.emojis.forEach(e => { customEmojis[e.name] = e.url; }); } catch {} } /** * Render comment text: escapes HTML then replaces :emoji_name: with tags * for any custom (site) emoji that's loaded. Unknown shortcodes stay as text. */ function renderCommentContent(text) { if (!text) return ''; // Build allowed-image host regex (same origin + f0ckAllowedImages list) const escH = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteHost = escH(window.location.host); const allowedHosts = [siteHost]; if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) { window.f0ckAllowedImages.forEach(h => allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escH(h)}`)); } const hostsPart = allowedHosts.join('|'); const safeS = '(?:(?!https?:\\/\\/)\\S)'; const domainOrRel = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsPart})|(?=\\/[a-zA-Z0-9_\\-]))`; const imageRe = new RegExp( `(?/gi, '\n'); const escaped = esc(normalized); const lines = escaped.split('\n').map(line => { const trimmed = line.trimStart(); // Greentext / quote: lines starting with > // Exclude only the numeric context links (>>ID) so they can be handled as interactive links. if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) { const after = line.substring(line.indexOf('>') + 4); const withEmoji = after.replace(/:([a-z0-9_]+):/g, (m, name) => { const url = customEmojis[name]; return url ? `:${esc(name)}:` : m; }); return `>${withEmoji}`; } // Blank lines → small spacer if (!trimmed) return ''; // 1. Replace image URLs from allowed hosts FIRST (before emoji, so we never // accidentally re-match the .png inside an already-inserted src) let out = line.replace(imageRe, (_, url) => { const fullUrl = url.startsWith('http') || url.startsWith('//') || url.startsWith('/') ? url : '//' + url; return ``; }); // 2. Replace custom emoji shortcodes with images (runs on raw text so it // won't see the tags inserted above) out = out.replace(/:([a-z0-9_]+):/g, (m, name) => { const url = customEmojis[name]; return url ? `:${esc(name)}:` : m; }); // 3. Replace >>ID patterns with context links out = out.replace(/(? { return `>>${id}`; }); return out; }); let html = lines.map((line, i) => { if (i === lines.length - 1) return line; if (line.includes('scroller-greentext') || line.includes('display:block')) return line; return line + '
'; }).join(''); // [spoiler]…[/spoiler] — iterative to handle nesting const spoilerRe = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi; let prev, n = 0; do { prev = html; html = html.replace(spoilerRe, (_, c) => `${c}`); } while (html !== prev && ++n < 10); // [blur]…[/blur] — iterative const blurRe = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi; n = 0; do { prev = html; html = html.replace(blurRe, (_, c) => `${c}`); } while (html !== prev && ++n < 10); return html; } // Track insert position across emoji clicks (without needing input focused) let emojiInsertPos = null; function buildEmojiPickerEl() { const picker = document.createElement('div'); picker.className = 'emoji-picker'; // reuse comments.css class if present picker.style.cssText = [ 'position:fixed','z-index:9999','bottom:80px','left:12px','right:12px', 'max-height:220px','overflow-y:auto', 'background:rgba(10,10,14,.97)','border:1px solid rgba(255,255,255,.12)', 'border-radius:14px','padding:10px','display:flex','flex-wrap:wrap','gap:6px', 'box-shadow:0 8px 40px rgba(0,0,0,.7)' ].join(';'); Object.keys(customEmojis).forEach(name => { const img = document.createElement('img'); img.src = customEmojis[name]; img.title = ':' + name + ':'; img.alt = name; img.loading = 'lazy'; img.style.cssText = 'width:40px;height:40px;object-fit:contain;cursor:pointer;border-radius:6px;transition:transform .1s'; img.addEventListener('mouseenter', () => { img.style.transform = 'scale(1.2)'; }); img.addEventListener('mouseleave', () => { img.style.transform = 'scale(1)'; }); img.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (!commentInput) return; const val = commentInput.value; const code = ':' + name + ':'; // Use tracked position; fall back to end const pos = (emojiInsertPos !== null && emojiInsertPos <= val.length) ? emojiInsertPos : val.length; commentInput.value = val.slice(0, pos) + code + val.slice(pos); emojiInsertPos = pos + code.length; // advance for next insert // Update send button state — no focus(), picker stays open if (commentSendBtn) commentSendBtn.disabled = !commentInput.value.trim(); }); picker.appendChild(img); }); if (!Object.keys(customEmojis).length) { picker.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.no_custom_emojis) || 'No custom emojis'}
`; } return picker; } function hideEmojiPicker() { if (emojiPickerEl?.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl); emojiPickerEl = null; if (emojiPickerCloseHandler) { document.removeEventListener('click', emojiPickerCloseHandler); emojiPickerCloseHandler = null; } } const emojiTriggerBtn = document.getElementById('comment-emoji-trigger'); if (emojiTriggerBtn) { // Load emojis once (don't wait) loadCustomEmojis(); emojiTriggerBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (emojiPickerEl) { hideEmojiPicker(); return; } emojiPickerEl = buildEmojiPickerEl(); document.body.appendChild(emojiPickerEl); emojiPickerEl.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); emojiPickerCloseHandler = ev => { if (!emojiPickerEl?.contains(ev.target) && ev.target !== emojiTriggerBtn) hideEmojiPicker(); }; setTimeout(() => document.addEventListener('click', emojiPickerCloseHandler), 0); }); } // ── Comment input: @mention autocomplete ───────────────────────────────── function insertMention(username) { if (!commentInput) return; const val = commentInput.value; const atIdx = val.lastIndexOf('@', commentInput.selectionStart - 1); if (atIdx === -1) return; const before = val.slice(0, atIdx); const after = val.slice(commentInput.selectionStart); commentInput.value = before + '@' + username + ' ' + after; const pos = before.length + username.length + 2; commentInput.setSelectionRange(pos, pos); commentInput.focus(); closeMentionDropdown(); commentInput.dispatchEvent(new Event('input')); } function closeMentionDropdown() { if (mentionDropdown) { mentionDropdown.classList.remove('show'); mentionDropdown.innerHTML = ''; } mentionResults = []; mentionIndex = -1; } function renderMentionDropdown(users) { mentionResults = users; mentionIndex = -1; if (!mentionDropdown) return; if (!users.length) { closeMentionDropdown(); return; } mentionDropdown.innerHTML = ''; users.forEach((u, i) => { const item = document.createElement('div'); item.className = 'mention-item'; item.dataset.idx = i; const av = u.avatar_file ? `/a/${u.avatar_file}` : (u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png'); item.innerHTML = `${esc(u.display_name || u.user)}`; item.addEventListener('mousedown', e => { e.preventDefault(); insertMention(u.user); }); mentionDropdown.appendChild(item); }); mentionDropdown.classList.add('show'); } async function fetchMentions(q) { try { const r = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(q)}`); const data = await r.json(); if (data.success) renderMentionDropdown(data.suggestions || []); } catch {} } // ── Emoji autocomplete (:shortcode:) ───────────────────────────────────── function closeAcDropdown() { if (mentionDropdown) { mentionDropdown.classList.remove('show'); mentionDropdown.innerHTML = ''; } emojiMode = false; emojiResults = []; mentionResults = []; acIndex = -1; mentionIndex = -1; // keep compat } function renderEmojiDropdown(matches) { emojiMode = true; emojiResults = matches; acIndex = -1; if (!mentionDropdown || !matches.length) { closeAcDropdown(); return; } mentionDropdown.innerHTML = ''; matches.forEach(([code, char], i) => { const item = document.createElement('div'); item.className = 'mention-item'; item.dataset.idx = i; item.innerHTML = `${char}:${code}:`; item.addEventListener('mousedown', e => { e.preventDefault(); insertEmoji(char, code); }); mentionDropdown.appendChild(item); }); mentionDropdown.classList.add('show'); } // Render custom (site) emoji suggestions: show img + name function renderCustomEmojiAC(matches) { emojiMode = true; emojiResults = matches; acIndex = -1; if (!mentionDropdown || !matches.length) { closeAcDropdown(); return; } mentionDropdown.innerHTML = ''; matches.forEach(([, name, url], i) => { const item = document.createElement('div'); item.className = 'mention-item'; item.dataset.idx = i; item.innerHTML = `${esc(name)}:${esc(name)}:`; item.addEventListener('mousedown', e => { e.preventDefault(); insertCustomEmoji(name); }); mentionDropdown.appendChild(item); }); mentionDropdown.classList.add('show'); } function insertCustomEmoji(name) { if (!commentInput) return; const val = commentInput.value; const cursor = commentInput.selectionStart; const textBefore = val.slice(0, cursor); const colonIdx = textBefore.lastIndexOf(':'); if (colonIdx === -1) return; const code = ':' + name + ':'; const before = val.slice(0, colonIdx); const after = val.slice(cursor); commentInput.value = before + code + after; const pos = colonIdx + code.length; commentInput.setSelectionRange(pos, pos); commentInput.focus(); closeAcDropdown(); commentInput.dispatchEvent(new Event('input')); } function insertEmoji(char, code) { if (!commentInput) return; const val = commentInput.value; const cursor = commentInput.selectionStart; const textBefore = val.slice(0, cursor); // Find the opening colon const colonIdx = textBefore.lastIndexOf(':'); if (colonIdx === -1) return; const before = val.slice(0, colonIdx); const after = val.slice(cursor); commentInput.value = before + char + after; const pos = colonIdx + char.length; commentInput.setSelectionRange(pos, pos); commentInput.focus(); closeAcDropdown(); commentInput.dispatchEvent(new Event('input')); } if (commentInput) { // Keep emojiInsertPos in sync with the real cursor whenever the user // types or moves the caret themselves (tap/click into the input, arrow keys, etc.) const syncInsertPos = () => { emojiInsertPos = commentInput.selectionStart; }; commentInput.addEventListener('click', syncInsertPos); commentInput.addEventListener('keyup', syncInsertPos); commentInput.addEventListener('input', () => { // Sync position after typing emojiInsertPos = commentInput.selectionStart; // Resize commentInput.style.height = 'auto'; commentInput.style.height = Math.min(commentInput.scrollHeight, 100) + 'px'; if (commentSendBtn) commentSendBtn.disabled = !commentInput.value.trim(); const cursor = commentInput.selectionStart; const textBefore = commentInput.value.slice(0, cursor); // 1. :emoji: shortcode detection — custom site emojis only const emojiMatch = textBefore.match(/:([a-z0-9_]{1,})$/); if (emojiMatch) { const q = emojiMatch[1]; const customHits = Object.keys(customEmojis) .filter(n => n.startsWith(q)).slice(0, 12) .map(n => ['__custom__', n, customEmojis[n]]); if (customHits.length) { renderCustomEmojiAC(customHits); return; } closeAcDropdown(); return; } // 2. @mention detection const mentionMatch = textBefore.match(/@([a-zA-Z0-9_.\-]*)$/); if (mentionMatch) { mentionQuery = mentionMatch[1]; emojiMode = false; clearTimeout(mentionTimer); if (mentionQuery.length >= 1) { mentionTimer = setTimeout(() => fetchMentions(mentionQuery), 250); } else { closeAcDropdown(); } } else { closeAcDropdown(); } }); commentInput.addEventListener('keydown', e => { // Ctrl+Enter → submit comment (ignore if autocomplete is open with selection) if (e.key === 'Enter' && e.ctrlKey && !(mentionDropdown?.classList.contains('show') && acIndex >= 0)) { e.preventDefault(); if (commentSendBtn && !commentSendBtn.disabled) commentSendBtn.click(); return; } if (!mentionDropdown?.classList.contains('show')) return; const listLen = emojiMode ? emojiResults.length : mentionResults.length; if (e.key === 'ArrowDown') { e.preventDefault(); acIndex = Math.min(acIndex + 1, listLen - 1); mentionIndex = acIndex; mentionDropdown.querySelectorAll('.mention-item').forEach((el, i) => el.classList.toggle('selected', i === acIndex)); } else if (e.key === 'ArrowUp') { e.preventDefault(); acIndex = Math.max(acIndex - 1, 0); mentionIndex = acIndex; mentionDropdown.querySelectorAll('.mention-item').forEach((el, i) => el.classList.toggle('selected', i === acIndex)); } else if ((e.key === 'Enter' || e.key === 'Tab') && acIndex >= 0) { e.preventDefault(); if (emojiMode) { if (emojiResults[acIndex][0] === '__custom__') { const [,name,] = emojiResults[acIndex]; insertCustomEmoji(name); } else { const [code, char] = emojiResults[acIndex]; insertEmoji(char, code); } } else insertMention(mentionResults[acIndex].user); } else if (e.key === 'Escape') { closeAcDropdown(); } }); commentInput.addEventListener('blur', () => { setTimeout(closeAcDropdown, 200); }); } // ── Post comment ────────────────────────────────────────────────────────── if (commentSendBtn) { commentSendBtn.addEventListener('click', async () => { const content = commentInput ? commentInput.value.trim() : ''; if (!content || !commentsItemId || commentsPosting) return; commentsPosting = true; commentSendBtn.disabled = true; try { let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`; if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`; const resp = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: postBody }); const data = await resp.json(); if (data.success) { if (commentInput) { commentInput.value = ''; commentInput.style.height = 'auto'; } commentSendBtn.disabled = true; commentsEmpty.style.display = 'none'; const displayName = window.scrollerDisplayName || window.scrollerUsername || 'you'; const el = renderCommentEl({ id: data.comment?.id, avatar_file: null, avatar: null, username: window.scrollerUsername, display_name: displayName, content: content, created_at: null }, !!window.scrollerLoggedIn); // Set avatar from global const avImg = el.querySelector('.comment-avatar'); if (avImg) avImg.src = window.scrollerUserAvatar || '/a/default.png'; commentsList.appendChild(el); commentsList.scrollTop = commentsList.scrollHeight; clearReply(); // Update panel header count const cur = parseInt(commentsCount.textContent.replace(/\D/g, '') || '0'); commentsCount.textContent = `(${cur + 1})`; // Update the slide action button count const slide = feed.querySelector(`.scroll-slide[data-id="${commentsItemId}"]`); if (slide) { const btn = slide.querySelector('.js-comments-btn .scroll-btn-count'); if (btn) btn.textContent = cur + 1; } } } catch {} finally { commentsPosting = false; } }); } // ── Tag input ────────────────────────────────────────────────────────────── const addTagInput = document.getElementById('scroll-tag-input'); const addTagSendBtn = document.getElementById('scroll-tag-send-btn'); const addTagSuggBox = document.getElementById('scroll-tag-suggestions'); let tagAddSuggIdx = -1; let tagAddSuggItems = []; let tagAddSuggTimer = null; let tagPosting = false; function closeSugg() { if (addTagSuggBox) { addTagSuggBox.classList.remove('show'); addTagSuggBox.innerHTML = ''; } tagAddSuggItems = []; tagAddSuggIdx = -1; } function renderTagSugg(rows) { if (!addTagSuggBox || !rows.length) { closeSugg(); return; } addTagSuggBox.innerHTML = ''; tagAddSuggItems = rows; tagAddSuggIdx = -1; rows.forEach((r, i) => { const el = document.createElement('div'); el.className = 'scroll-tag-sugg-item'; el.dataset.idx = i; el.innerHTML = `${esc(r.tag)}${r.uses ?? ''}`; el.addEventListener('mouseup', (ev) => { const sel = window.getSelection?.()?.toString().trim(); if (!sel || sel === r.tag) return; el.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); ev.stopPropagation(); window._showSelTagPopover?.(sel, el, (confirmed) => { window.getSelection?.()?.removeAllRanges(); addTagInput.value = confirmed; closeSugg(); submitTag(); }); }); el.addEventListener('click', e => { const sel = window.getSelection?.()?.toString().trim(); if (sel && sel !== r.tag) { e.preventDefault(); e.stopPropagation(); return; } addTagInput.value = r.tag; closeSugg(); submitTag(); }); addTagSuggBox.appendChild(el); }); addTagSuggBox.classList.add('show'); } async function fetchTagSugg(q) { try { const r = await fetch(`/api/v2/scroller/tags?q=${encodeURIComponent(q)}`); renderTagSugg(await r.json()); } catch {} } async function submitTag() { if (!addTagInput || tagPosting) return; // Always tag the currently active slide, regardless of which slide opened the bar const targetId = currentSlide?.dataset?.localId || currentSlide?.dataset?.id || tagBarItemId; if (!targetId || !/^\d+$/.test(targetId)) { showShareToast('Add to site first to tag'); return; } const tag = addTagInput.value.trim(); if (!tag) return; tagPosting = true; if (addTagSendBtn) addTagSendBtn.disabled = true; closeSugg(); try { const resp = await fetch(`/api/v2/tags/${targetId}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `tagname=${encodeURIComponent(tag)}` }); const data = await resp.json(); if (data.success) { addTagInput.value = ''; addTagInput.focus(); // stay focused for next tag // Apply full fresh tag list from response const targetSlide = currentSlide || feed.querySelector(`.scroll-slide[data-id="${targetId}"]`); if (targetSlide) applyFreshTags(targetSlide, (data.tags || []).filter(t => t.id > 2).map(t => t.tag).join(', ')); // ✓ flash on send button if (addTagSendBtn) { addTagSendBtn.innerHTML = ''; setTimeout(() => { addTagSendBtn.innerHTML = ''; }, 900); } } else { addTagInput.placeholder = data.msg || 'Error adding tag'; addTagInput.value = ''; addTagInput.focus(); setTimeout(() => { addTagInput.placeholder = 'Add a tag to this item…'; }, 2500); } } catch (err) { addTagInput.placeholder = 'Network error — try again'; setTimeout(() => { addTagInput.placeholder = 'Add a tag to this item…'; }, 2500); } finally { tagPosting = false; if (addTagSendBtn) addTagSendBtn.disabled = false; } } if (addTagInput) { addTagInput.addEventListener('input', () => { const q = addTagInput.value.trim(); clearTimeout(tagAddSuggTimer); if (q.length >= 2) tagAddSuggTimer = setTimeout(() => fetchTagSugg(q), 220); else closeSugg(); }); addTagInput.addEventListener('keydown', e => { if (addTagSuggBox?.classList.contains('show') && tagAddSuggItems.length) { if (e.key === 'ArrowDown') { e.preventDefault(); tagAddSuggIdx = Math.min(tagAddSuggIdx + 1, tagAddSuggItems.length - 1); addTagSuggBox.querySelectorAll('.scroll-tag-sugg-item').forEach((el, i) => el.classList.toggle('selected', i === tagAddSuggIdx)); } else if (e.key === 'ArrowUp') { e.preventDefault(); tagAddSuggIdx = Math.max(tagAddSuggIdx - 1, 0); addTagSuggBox.querySelectorAll('.scroll-tag-sugg-item').forEach((el, i) => el.classList.toggle('selected', i === tagAddSuggIdx)); } else if ((e.key === 'Enter' || e.key === 'Tab') && tagAddSuggIdx >= 0) { e.preventDefault(); addTagInput.value = tagAddSuggItems[tagAddSuggIdx].tag; closeSugg(); return; } else if (e.key === 'Escape') { closeSugg(); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitTag(); } }); addTagInput.addEventListener('blur', () => setTimeout(closeSugg, 200)); } if (addTagSendBtn) addTagSendBtn.addEventListener('click', submitTag); // ── Filter panel ────────────────────────────────────────────────────────── const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' }; filterOpenBtn.addEventListener('click', () => { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; renderActiveTags(); openPanel(filterPanel, filterBackdrop); }); function syncPanelUI() { document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode)); document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime)); document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order)); if (externalUrlInput) externalUrlInput.value = pending.externalUrl || ''; renderActiveTags(); } function makePillListener(groupId, key, transform) { document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => { pill.addEventListener('click', () => { document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active')); pill.classList.add('active'); pending[key] = transform ? transform(pill.dataset[key]) : pill.dataset[key]; }); }); } makePillListener('mode-pills', 'mode', v => +v); makePillListener('mime-pills', 'mime', v => v); makePillListener('order-pills', 'order', v => v); filterApplyBtn.addEventListener('click', () => { applied = { ...pending, tags: [...pending.tags] }; closePanel(filterPanel, filterBackdrop); reloadFeed(); }); if (externalUrlBtn) { externalUrlBtn.addEventListener('click', () => { const val = externalUrlInput.value.trim(); if (!val) { applied.externalUrl = null; } else { if (!val.includes('boards.4chan.org')) { alert((window.f0ckI18n && window.f0ckI18n.invalid_chan_url) || 'Please enter a valid 4chan thread URL'); return; } applied.externalUrl = val; applied.order = 'oldest'; } closePanel(filterPanel, filterBackdrop); reloadFeed(); }); } // ── Auto-load 4chan thread from hash (e.g. #wsg/6132740) ──────────────── let chanHashPending = null; // async promise if we need server lookup { const hid = hashId(); const chanMatch = hid.match(/^([a-z0-9]+)\/(\d+)$/); if (chanMatch && !applied.externalUrl) { const board = chanMatch[1]; const postno = chanMatch[2]; // Look up thread ID from the post→thread mapping let tid = null; try { const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}'); tid = postMap[hid]; // hid is "board/postno" } catch(_) {} if (tid) { applied.externalUrl = `https://boards.4chan.org/${board}/thread/${tid}`; applied.order = 'oldest'; unlock4chan(); } else { // No local mapping — ask the server to find the thread chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`) .then(r => r.json()) .then(data => { window.f0ckDebug('[CHAN] Find result:', data); if (data.success && data.tid) { applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`; applied.order = 'oldest'; unlock4chan(); window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl); } }) .catch(err => { console.error('[CHAN] Find error:', err); }); } } } filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); }); function updateFilterSummary() { const is4chan = !!applied.externalUrl; const isDef = applied.mode === defaultMode && applied.mime === '' && applied.order === 'random' && applied.tags.length === 0 && !is4chan; filterOpenBtn.classList.toggle('has-filter', !isDef); filterSummary.classList.toggle('show', !isDef); if (!isDef) { // Check if current filters exactly match a saved preset const tagsKey = t => [...t].map(s => s.toLowerCase()).sort().join(','); const matchedPreset = getPresets().find(p => p.mode === applied.mode && (p.mime || '') === applied.mime && p.order === applied.order && tagsKey(p.tags) === tagsKey(applied.tags) ); if (matchedPreset && !is4chan) { filterSummary.textContent = matchedPreset.name; } else { const parts = []; if (is4chan) parts.push('4chan'); if (applied.mode !== defaultMode) parts.push(modeLabels[applied.mode]); if (applied.mime) parts.push(applied.mime); if (applied.order !== 'random') parts.push(applied.order); parts.push(...applied.tags); filterSummary.textContent = parts.join(' · '); } } } function renderActiveTags() { activeTagsEl.innerHTML = ''; pending.tags.forEach(tag => { const p = document.createElement('span'); p.className = 'active-tag-pill'; p.innerHTML = `${esc(tag)}`; p.addEventListener('click', () => { pending.tags = pending.tags.filter(t => t !== tag); renderActiveTags(); renderSugg(lastSugg); }); activeTagsEl.appendChild(p); }); } function renderSugg(sug) { lastSugg = sug; tagSuggestEl.innerHTML = ''; sug.forEach(s => { const el = document.createElement('span'); el.className = 'tag-suggestion' + (pending.tags.includes(s.normalized) ? ' active' : ''); el.innerHTML = `${esc(s.tag)}${s.uses}`; el.addEventListener('click', () => { if (pending.tags.includes(s.normalized)) pending.tags = pending.tags.filter(t => t !== s.normalized); else pending.tags.push(s.normalized); renderActiveTags(); renderSugg(lastSugg); }); tagSuggestEl.appendChild(el); }); } async function fetchTagSugg(q) { if (tagCache[q]) { renderSugg(tagCache[q]); return; } try { const r = await fetch(`/api/v2/scroller/tags?q=${encodeURIComponent(q)}`); tagCache[q] = await r.json(); renderSugg(tagCache[q]); } catch {} } tagInput.addEventListener('input', () => { const v = tagInput.value.trim(); tagClear.classList.toggle('show', !!v); clearTimeout(tagTimer); tagSuggIdx = -1; // reset keyboard nav on new input if (!v) { tagSuggestEl.innerHTML = ''; return; } tagTimer = setTimeout(() => fetchTagSugg(v), 260); }); tagClear.addEventListener('click', () => { tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; tagSuggIdx = -1; tagInput.focus(); }); function setSuggFocus(idx) { const items = tagSuggestEl.querySelectorAll('.tag-suggestion'); items.forEach((el, i) => el.classList.toggle('keyboard-focus', i === idx)); if (items[idx]) items[idx].scrollIntoView({ block: 'nearest' }); } tagInput.addEventListener('keydown', e => { const items = tagSuggestEl.querySelectorAll('.tag-suggestion'); if (!items.length) return; if (e.key === 'ArrowRight') { e.preventDefault(); tagSuggIdx = (tagSuggIdx + 1) % items.length; setSuggFocus(tagSuggIdx); } else if (e.key === 'ArrowLeft') { e.preventDefault(); tagSuggIdx = (tagSuggIdx <= 0 ? items.length : tagSuggIdx) - 1; setSuggFocus(tagSuggIdx); } else if (e.key === 'Enter' && tagSuggIdx >= 0) { e.preventDefault(); items[tagSuggIdx]?.click(); // toggle the highlighted suggestion tagSuggIdx = -1; } else if (e.key === 'Escape') { tagSuggIdx = -1; setSuggFocus(-1); tagSuggestEl.innerHTML = ''; } }); // ── Keyboard ────────────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (!document.body.classList.contains('scroller-active')) return; const active = document.activeElement; const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable); // 'c' toggles comments — only when NOT typing (so it never fires inside the comment input) if ((e.key === 'c' || e.key === 'C') && !isTyping && !tagBar?.classList.contains('open')) { e.preventDefault(); if (commentsPanel.classList.contains('open')) closeAllPanels(); else if (currentSlide) openComments(currentSlide.dataset.localId || currentSlide.dataset.id); return; } // Tag bar open or typing: only Escape passes through if (tagBar?.classList.contains('open') || isTyping) { if (e.key === 'Escape') { if (tagBar?.classList.contains('open')) { closeTagBar(); e.preventDefault(); } else closeAllPanels(); } return; } // Filter or comments panel open: only Escape passes through if (filterPanel.classList.contains('open') || commentsPanel.classList.contains('open')) { if (e.key === 'Escape') closeAllPanels(); return; } const slides = Array.from(feed.querySelectorAll('.scroll-slide')); const idx = currentSlide ? slides.indexOf(currentSlide) : 0; if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); const n = slides[idx + 1]; if (n) n.scrollIntoView({ behavior: 'smooth' }); } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); const p = slides[idx - 1]; if (p) p.scrollIntoView({ behavior: 'smooth' }); } else if (e.key === 'm') { e.preventDefault(); isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); } else if (e.key === ' ') { e.preventDefault(); if (currentMedia) currentMedia.paused ? currentMedia.play().catch(() => {}) : currentMedia.pause(); } else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); } else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); if (applied.externalUrl && chanGalleryBtn) toggleGallery(); } else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); e.stopImmediatePropagation(); // prevent f0ckm.js global 'r' random shortcut from firing // Fetch a truly random item from the server with current filters (async () => { try { const params = new URLSearchParams({ limit: 1, mode: applied.mode, orderby: 'random' }); if (applied.mime) params.set('mime', applied.mime); if (applied.tags.length > 0) params.set('tag', applied.tags.join(',')); if (seenIds.size > 0) { const recent = [...seenIds].slice(-200); params.set('exclude', recent.join(',')); } const resp = await fetch(`/api/v2/scroller/feed?${params}`); const data = await resp.json(); if (data.items && data.items.length > 0) { const item = data.items[0]; seenIds.add(item.id); const slide = createSlide(item); // Insert right after current slide — smooth scroll only travels 1 slide if (currentSlide && currentSlide.nextSibling) { feed.insertBefore(slide, currentSlide.nextSibling); } else { feed.appendChild(slide); } if (currentSlide) deactivateSlide(currentSlide); scrollingToRandom = slide; slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Safety: activate after 1.5s if observer didn't fire setTimeout(() => { if (scrollingToRandom === slide) { scrollingToRandom = null; activateSlide(slide); } }, 1500); } } catch (err) { console.warn('[scroller] random fetch failed', err); } })(); } else if (e.key === 'p' || e.key === 'P') { e.preventDefault(); if (!currentSlide || !window.scrollerLoggedIn) return; const itemId = currentSlide.dataset.localId || currentSlide.dataset.id; if (!itemId) return; const badge = currentSlide.querySelector('.scroll-rating[data-item-id]'); if (badge) { cycleRatingOptimistic(badge, itemId, true); } } else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); } else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); } else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss else if (e.key === 'Escape') { window.location.href = '/'; } }, true); // capture phase — fires before f0ckm.js bubble listeners // ── 4chan thread loading: auto-show for mods, secret '4444' for everyone else function unlock4chan() { const section = document.getElementById('external-source-section'); if (section) section.style.display = ''; if (chanOpenBtn) chanOpenBtn.style.display = ''; } if (window.scrollerIsMod) { unlock4chan(); } else { let fourCount = 0; let fourTimer = null; document.addEventListener('keydown', (e) => { if (!document.body.classList.contains('scroller-active')) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === '4') { fourCount++; clearTimeout(fourTimer); fourTimer = setTimeout(() => { fourCount = 0; }, 1000); if (fourCount >= 4) { fourCount = 0; unlock4chan(); showShareToast('4chan mode unlocked 🔓'); } } else { fourCount = 0; } }); } // ── 4chan Panel ────────────────────────────────────────────────────────── let chanCurrentBoard = 'wsg'; let chanCatalogCache = {}; if (chanOpenBtn && chanPanel && chanBackdrop) { chanOpenBtn.addEventListener('click', () => { openPanel(chanPanel, chanBackdrop); if (!chanCatalogCache[chanCurrentBoard]) loadCatalog(chanCurrentBoard); }); chanBackdrop.addEventListener('click', () => closePanel(chanPanel, chanBackdrop)); // Board pills document.querySelectorAll('#chan-board-pills .filter-pill').forEach(p => { p.addEventListener('click', () => { document.querySelectorAll('#chan-board-pills .filter-pill').forEach(b => b.classList.remove('active')); p.classList.add('active'); chanCurrentBoard = p.dataset.board; if (chanCatalogCache[chanCurrentBoard]) { renderCatalog(chanCurrentBoard, chanCatalogCache[chanCurrentBoard]); } else { loadCatalog(chanCurrentBoard); } }); }); // Custom board input const chanCustomBoard = document.getElementById('chan-custom-board'); const chanCustomBoardBtn = document.getElementById('chan-custom-board-btn'); function loadCustomBoard() { const code = chanCustomBoard?.value.trim().toLowerCase().replace(/\//g, ''); if (!code || !/^[a-z0-9]{1,6}$/.test(code)) { showShareToast('Invalid board code'); return; } document.querySelectorAll('#chan-board-pills .filter-pill').forEach(b => b.classList.remove('active')); chanCustomBoardBtn.classList.add('active'); chanCurrentBoard = code; if (chanCatalogCache[code]) { renderCatalog(code, chanCatalogCache[code]); } else { loadCatalog(code); } } if (chanCustomBoardBtn) chanCustomBoardBtn.addEventListener('click', loadCustomBoard); if (chanCustomBoard) chanCustomBoard.addEventListener('keydown', e => { if (e.key === 'Enter') loadCustomBoard(); }); // URL load button if (chanUrlLoadBtn && chanUrlInput) { chanUrlLoadBtn.addEventListener('click', () => { const val = chanUrlInput.value.trim(); if (!val || !val.includes('boards.4chan.org')) { showShareToast('Invalid 4chan URL'); return; } applied.externalUrl = val; closePanel(chanPanel, chanBackdrop); reloadFeed(); }); chanUrlInput.addEventListener('keydown', e => { if (e.key === 'Enter') chanUrlLoadBtn.click(); }); } } async function loadCatalog(board) { if (chanLoading) chanLoading.style.display = ''; if (chanGrid) chanGrid.innerHTML = ''; try { const resp = await fetch(`/api/v2/scroller/external/4chan/${board}/catalog`); const data = await resp.json(); if (data.success && data.threads) { chanCatalogCache[board] = data.threads; renderCatalog(board, data.threads); } } catch (err) { console.error('[CHAN] Catalog error:', err); if (chanGrid) chanGrid.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.chan_catalog_failed) || 'Failed to load catalog'}
`; } finally { if (chanLoading) chanLoading.style.display = 'none'; } } function renderCatalog(board, threads) { if (!chanGrid) return; chanGrid.innerHTML = ''; const searchInput = document.getElementById('chan-catalog-search'); if (searchInput) searchInput.value = ''; threads.forEach(t => { const card = document.createElement('div'); card.className = 'chan-thread-card' + (t.sticky ? ' sticky' : ''); card.dataset.search = `${(t.sub || '')} ${(t.com || '')}`.toLowerCase(); const thumbUrl = t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : ''; card.innerHTML = ` ${thumbUrl ? `` : '
'}
${esc(t.sub || t.com || 'No subject')}
${t.com && t.sub ? `
${esc(t.com)}
` : ''}
${t.replies} ${t.images} ${t.sticky ? 'Pinned' : ''}
`; card.addEventListener('click', () => { applied.externalUrl = `https://boards.4chan.org/${board}/thread/${t.no}`; applied.order = 'oldest'; closePanel(chanPanel, chanBackdrop); reloadFeed(); }); chanGrid.appendChild(card); }); } // Catalog search filter const chanCatalogSearch = document.getElementById('chan-catalog-search'); if (chanCatalogSearch) { chanCatalogSearch.addEventListener('input', () => { const q = chanCatalogSearch.value.toLowerCase().trim(); if (!chanGrid) return; chanGrid.querySelectorAll('.chan-thread-card').forEach(card => { card.style.display = !q || card.dataset.search.includes(q) ? '' : 'none'; }); }); } // ── Thread Gallery Sidebar ────────────────────────────────────────────── let galleryOpen = false; function toggleGallery() { galleryOpen = !galleryOpen; if (chanGallerySidebar) chanGallerySidebar.classList.toggle('open', galleryOpen); if (chanGalleryBtn) chanGalleryBtn.classList.toggle('active', galleryOpen); document.body.classList.toggle('gallery-open', galleryOpen); if (galleryOpen) populateGallery(); } function populateGallery() { if (!chanGallerySidebar) return; chanGallerySidebar.innerHTML = ''; const slides = feed.querySelectorAll('.scroll-slide'); slides.forEach((slide, i) => { const thumbSrc = slide._thumbnail || ''; if (!thumbSrc) return; const wrap = document.createElement('div'); wrap.className = 'gallery-thumb-wrap'; const img = document.createElement('img'); img.className = 'gallery-thumb'; img.src = thumbSrc; img.loading = 'lazy'; img.alt = ''; if (slide === currentSlide) img.classList.add('active'); img.dataset.slideId = slide.dataset.id || ''; img.addEventListener('click', () => { slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); const idx = document.createElement('span'); idx.className = 'gallery-thumb-idx'; idx.textContent = i + 1; wrap.appendChild(img); wrap.appendChild(idx); chanGallerySidebar.appendChild(wrap); }); // Sync sidebar scroll to active thumbnail const activeTh = chanGallerySidebar.querySelector('.gallery-thumb.active'); if (activeTh) activeTh.scrollIntoView({ block: 'center' }); } function updateGalleryActive(slideId) { if (!chanGallerySidebar || !galleryOpen) return; chanGallerySidebar.querySelectorAll('.gallery-thumb').forEach(th => { const isActive = th.dataset.slideId === slideId; th.classList.toggle('active', isActive); if (isActive) th.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); } if (chanGalleryBtn) { chanGalleryBtn.addEventListener('click', toggleGallery); } // ── Touch: let native CSS scroll-snap handle everything on mobile ───────── // (Removing custom touchstart/touchend handlers that caused jank by fighting // the browser's native momentum scrolling and snap behaviour.) // The IntersectionObserver + fallback scroll listener are sufficient. // ── Notification badge in topbar ────────────────────────────────────────── const notifBadge = document.getElementById('scroller-notif-badge'); if (notifBadge && window.scrollerLoggedIn) { const updateScrollerNotifBadge = (count) => { if (count > 0) { notifBadge.textContent = count > 99 ? '99+' : count; notifBadge.style.display = 'block'; } else { notifBadge.style.display = 'none'; } }; // Fetch unread count from the API const pollNotifCount = async () => { try { const res = await fetch('/api/notifications'); const data = await res.json(); if (data.success) { updateScrollerNotifBadge((data.notifications || []).filter(n => !n.is_read).length); } } catch {} }; // ── Lightweight SSE for instant badge updates ────────────────── const tabId = Math.random().toString(36).slice(2); let sseEs = null; let sseRetryCount = 0; const SSE_MAX_RETRIES = 8; const initScrollerSSE = () => { if (sseEs) sseEs.close(); sseEs = new EventSource(`/api/notifications/stream?tabId=${tabId}`); sseEs.onopen = () => { sseRetryCount = 0; }; sseEs.onmessage = (e) => { try { const data = JSON.parse(e.data); if (data.type === 'notify') { pollNotifCount(); // instant re-fetch on SSE push if (navigator.vibrate) navigator.vibrate([200, 80, 200]); } } catch {} }; sseEs.onerror = () => { sseEs?.close(); sseEs = null; if (document.hidden || sseRetryCount >= SSE_MAX_RETRIES) return; setTimeout(initScrollerSSE, Math.min(Math.pow(2, sseRetryCount++) * 1000, 30000)); }; }; // Signal server we're active (keeps SSE routing correct) fetch(`/api/notifications/active?tabId=${tabId}`).catch(() => {}); initScrollerSSE(); // Fallback poll every 60s in case SSE drops const notifPollTimer = setInterval(pollNotifCount, 60000); pollNotifCount(); // initial count on load // Also honor live hook from f0ckm.js if navigated via PJAX window._scrollerNotifHook = updateScrollerNotifBadge; // ── Scroller Notification Dropdown ────────────────────────────────── const sNotifBtn = document.getElementById('scroller-notif-btn'); const sNotifDropdown = document.getElementById('scroller-notif-dropdown'); const sNotifList = document.getElementById('scroller-notif-list'); const sMarkAll = document.getElementById('scroller-mark-all-read'); // Tab type arrays const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report']; let sActiveTab = 'user'; let sCachedNotifs = []; if (sNotifBtn && sNotifDropdown) { let sNotifOpen = false; function positionDropdown() { const rect = sNotifBtn.getBoundingClientRect(); const dw = sNotifDropdown.offsetWidth || 340; let left = rect.left + rect.width / 2 - dw / 2; // Clamp to viewport if (left + dw > window.innerWidth - 8) left = window.innerWidth - dw - 8; if (left < 8) left = 8; sNotifDropdown.style.top = (rect.bottom + 6) + 'px'; sNotifDropdown.style.left = left + 'px'; sNotifDropdown.style.right = 'auto'; } function toggleScrollerNotifDropdown() { sNotifOpen = !sNotifOpen; if (sNotifOpen) { sNotifDropdown.style.display = ''; positionDropdown(); sNotifDropdown.classList.add('visible'); fetchScrollerNotifs(); } else { sNotifDropdown.classList.remove('visible'); sNotifDropdown.style.display = 'none'; } } // Reposition on resize / layout shift const ro = new ResizeObserver(() => { if (sNotifOpen) positionDropdown(); }); ro.observe(document.getElementById('topbar') || document.body); window.addEventListener('resize', () => { if (sNotifOpen) positionDropdown(); }); function isUserType(type) { return SCROLLER_USER_TYPES.includes(type); } function isSystemType(type) { return SCROLLER_SYSTEM_TYPES.includes(type); } function filterByTab(notifs, tab) { if (tab === 'user') return notifs.filter(n => isUserType(n.type)); if (tab === 'system') return notifs.filter(n => isSystemType(n.type)); return notifs; } function updateScrollerTabBadges(notifs) { const userUnread = notifs.filter(n => isUserType(n.type) && !n.is_read).length; const sysUnread = notifs.filter(n => isSystemType(n.type) && !n.is_read).length; const userBadge = document.getElementById('scroller-notif-tab-badge-user'); const sysBadge = document.getElementById('scroller-notif-tab-badge-system'); if (userBadge) { userBadge.textContent = userUnread; userBadge.style.display = userUnread > 0 ? '' : 'none'; } if (sysBadge) { sysBadge.textContent = sysUnread; sysBadge.style.display = sysUnread > 0 ? '' : 'none'; } } async function fetchScrollerNotifs() { try { const res = await fetch('/api/notifications'); const data = await res.json(); if (data.success) { sCachedNotifs = data.notifications || []; updateScrollerTabBadges(sCachedNotifs); renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); } } catch (e) { console.warn('[SCROLLER] Notif fetch failed:', e); } } function renderScrollerNotifs(notifs) { if (!sNotifList) return; if (!notifs.length) { const emptyMsg = (window.f0ckI18n && window.f0ckI18n.no_notifications) || 'No new notifications'; sNotifList.innerHTML = `
${emptyMsg}
`; return; } const i18n = window.f0ckI18n || {}; sNotifList.innerHTML = notifs.map(n => { let link = `/${n.item_id}`, msg = '', user = n.from_display_name || n.from_user || 'System'; if (n.type === 'approve') { msg = i18n.notif_upload_approved || 'Your Upload has been approved'; user = i18n.notif_system || 'System'; } else if (n.type === 'upload_success') { msg = i18n.notif_upload_success || 'Background upload finished'; user = i18n.notif_system || 'System'; } else if (n.type === 'upload_error') { msg = i18n.notif_upload_error || 'Background upload failed'; user = i18n.notif_system || 'System'; link = n.item_id ? `/${n.item_id}` : '#'; } else if (n.type === 'deny' || n.type === 'item_deleted') { msg = n.type === 'item_deleted' ? (i18n.notif_upload_deleted || `Upload #${n.item_id} deleted`) : (i18n.notif_upload_denied || `Upload #${n.item_id} denied`); user = i18n.notif_moderation || 'Moderation'; } else if (n.type === 'admin_pending') { link = '/mod/approve'; user = i18n.notif_admin || 'Admin'; msg = i18n.notif_upload_pending || 'New upload needs approval'; } else if (n.type === 'report') { link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator'; msg = i18n.notif_new_report || 'New user report'; } else { link = `/${n.item_id}#c${n.reference_id}`; if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you'; else if (n.type === 'subscription') msg = i18n.notif_subscribed || 'commented in a thread you follow'; else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you'; else msg = i18n.notif_commented || 'commented'; } const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`; const thumb = n.item_id ? `
` : ''; return ` ${thumb}
${user}
${msg}
${new Date(n.created_at).toLocaleString()}
`; }).join(''); } // Tab switching sNotifDropdown.querySelectorAll('.notif-tab').forEach(tab => { tab.addEventListener('click', () => { sActiveTab = tab.dataset.tab; sNotifDropdown.querySelectorAll('.notif-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === sActiveTab)); if (sNotifList) sNotifList.dataset.activeTab = sActiveTab; renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); }); }); sNotifBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleScrollerNotifDropdown(); }); // Close on outside click document.addEventListener('click', (e) => { if (!document.body.classList.contains('scroller-active')) return; if (sNotifOpen && !sNotifDropdown.contains(e.target) && !sNotifBtn.contains(e.target)) { sNotifOpen = false; sNotifDropdown.classList.remove('visible'); sNotifDropdown.style.display = 'none'; } }); // Mark all read if (sMarkAll) { sMarkAll.addEventListener('click', async () => { try { await fetch('/api/notifications/read', { method: 'POST' }); updateScrollerNotifBadge(0); sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); updateScrollerTabBadges(sCachedNotifs); renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); } catch (e) {} }); } // Single notif click → mark read sNotifDropdown.addEventListener('click', (e) => { const item = e.target.closest('.notif-item'); if (!item) return; const nid = item.dataset.id; if (nid && item.classList.contains('unread')) { fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {}); item.classList.remove('unread'); // Update cache const cached = sCachedNotifs.find(n => String(n.id) === String(nid)); if (cached) cached.is_read = true; updateScrollerTabBadges(sCachedNotifs); const badge = document.getElementById('scroller-notif-badge'); if (badge) { let c = parseInt(badge.textContent) || 0; if (c > 0) { badge.textContent = --c; if (c === 0) badge.style.display = 'none'; } } } }); } // Cleanup on PJAX navigation away document.addEventListener('pjax:send', () => { clearInterval(notifPollTimer); sseEs?.close(); sseEs = null; }, { once: true }); } // ── Init ────────────────────────────────────────────────────────────────── // Bridge theme.js 't' key toast → scroller toast window._scrollerThemeToast = (themeName) => showShareToast(`🎨 ${themeName}`); volumeSlider.value = volume; syncVolumeUI(); updateFilterSummary(); const restored = tryRestoreFromCache(); if (!restored) { if (chanHashPending) { chanHashPending.then(() => fetchItems()); } else { fetchItems(); } } })();