3624 lines
165 KiB
JavaScript
3624 lines
165 KiB
JavaScript
/**
|
||
* 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=<id> 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, '>').replace(/"/g, '"');
|
||
}
|
||
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() {
|
||
// Strip the leading '#' and allow numeric IDs or board/postid format
|
||
const raw = location.hash.replace(/^#/, '').trim();
|
||
if (/^\d+$/.test(raw)) return raw;
|
||
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
||
return '';
|
||
}
|
||
let lastPushedHash = location.hash;
|
||
function pushHash(id) {
|
||
if (!id) return;
|
||
const newHash = '#' + id;
|
||
if (newHash === lastPushedHash) return;
|
||
lastPushedHash = newHash;
|
||
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
|
||
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 || location.hash.replace('#', '');
|
||
if (!id) return;
|
||
lastPushedHash = '#' + 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 = `<div class="scroll-btn-icon"><i class="fa-solid fa-clover" style="color:#789922"></i></div>`;
|
||
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 = `<div class="scroll-btn-icon"><i class="fa-solid fa-grip"></i></div>`;
|
||
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 = `<div style="color:rgba(255,255,255,.35);font-size:.79rem;padding:4px 0;">${(window.f0ckI18n && window.f0ckI18n.no_presets) || 'No saved presets yet.'}</div>`;
|
||
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 =
|
||
'<div class="preset-row-info" style="flex:1;min-width:0;cursor:pointer;">' +
|
||
'<div class="preset-row-name">' + esc(preset.name) + '</div>' +
|
||
'<div class="preset-row-meta">' + esc(buildMeta(preset)) + '</div>' +
|
||
'</div>' +
|
||
'<button class="preset-row-edit" title="Edit tags"><i class="fa-solid fa-pen"></i></button>' +
|
||
'<button class="preset-row-del" title="Delete preset"><i class="fa-solid fa-trash"></i></button>';
|
||
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) =>
|
||
'<span class="preset-editor-tag">' + esc(t) +
|
||
'<button class="preset-editor-tag-del" data-ti="' + ti + '" title="Remove">×</button></span>'
|
||
).join('')
|
||
: '<span style="font-size:.72rem;color:rgba(255,255,255,.3)">No tags</span>';
|
||
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 = `<i class="fa-solid fa-arrows-rotate" style="margin-right:6px"></i>${(window.f0ckI18n && window.f0ckI18n.update_preset) || 'Update & apply preset'} <span style="display:block;font-size:.68rem;opacity:.6;margin-top:2px">${(window.f0ckI18n && window.f0ckI18n.update_preset_sub) || 'Save changes and reload feed now'}</span>`;
|
||
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) {
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
target.classList.add('highlight-comment');
|
||
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ── 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 <source> 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) {
|
||
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) => {
|
||
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 = '<i class="fa-solid fa-play"></i>'; }
|
||
else { media.pause(); icon.innerHTML = '<i class="fa-solid fa-pause"></i>'; }
|
||
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 = `<iframe allow="autoplay; encrypted-media" allowfullscreen></iframe>`;
|
||
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 = `<span class="scroll-rating ${esc(item.rating_class)}" data-item-id="${item.id}" data-rating="${esc(item.rating_class)}">${esc(item.rating_label)}</span>`;
|
||
const ocHtml = item.is_oc ? `<span class="scroll-oc"><i class="fa-solid fa-star" style="font-size:.6rem"></i> OC</span>` : '';
|
||
const badgesHtml = `<div class="scroll-badges">${ratingHtml}${ocHtml}</div>`;
|
||
// Build clickable tag pills
|
||
const tagsHtml = item.tags
|
||
? `<div class="scroll-tags"><i class="fa-solid fa-tag" style="font-size:.6rem;margin-right:4px;opacity:.5"></i>${
|
||
item.tags.split(', ').map(t => `<span class="scroll-tag-pill" data-tag="${esc(t.trim())}">${esc(t.trim())}</span>`).join('')
|
||
}</div>`
|
||
: '';
|
||
meta.innerHTML = `
|
||
<div class="scroll-meta-inner" style="position:relative;">
|
||
<button class="js-speed-hold-btn js-meta-speed-btn" aria-hidden="true" tabindex="-1"
|
||
style="position:absolute; bottom:100%; left:-16px; width:100px; height:100vh;
|
||
background:none; border:none; padding:0; cursor:pointer; opacity:0;
|
||
pointer-events:all;"></button>
|
||
${badgesHtml}
|
||
<div class="scroll-meta-top">
|
||
<a href="/user/${esc(item.username)}" class="scroll-user-link">
|
||
<img class="scroll-avatar" src="${esc(item.avatar)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
|
||
</a>
|
||
<div>
|
||
<a href="/user/${esc(item.username)}" class="scroll-user-link">
|
||
<div class="scroll-username" style="${colorStyle}">${esc(item.display_name || item.username)}</div>
|
||
</a>
|
||
<div class="scroll-timeago">${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}</div>
|
||
</div>
|
||
</div>
|
||
${tagsHtml}
|
||
${item.is_external && item.external_board && item.external_tid
|
||
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
|
||
: (item.local_id
|
||
? `<a class="scroll-id-link" href="/${item.local_id}" target="_blank">#${item.local_id}</a>`
|
||
: `<a class="scroll-id-link" href="/abyss#${item.id}">#${item.id}</a>`)}
|
||
</div>`;
|
||
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 = `
|
||
<button class="js-speed-hold-btn" aria-hidden="true" tabindex="-1"
|
||
style="position:absolute; bottom:100%; left:-10px; right:-10px; height:100vh;
|
||
background:none; border:none; padding:0; cursor:pointer; opacity:0;
|
||
pointer-events:all; flex-shrink:0;"
|
||
title=""></button>
|
||
${window.scrollerLoggedIn ? `
|
||
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
||
<span class="scroll-btn-label"></span>
|
||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||
</button>` : ''}
|
||
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
||
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
||
<span class="scroll-btn-label"></span>
|
||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||
</button>
|
||
${window.scrollerLoggedIn ? `
|
||
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
||
<span class="scroll-btn-label"></span>
|
||
</button>` : ''}
|
||
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
||
<span class="scroll-btn-label">${_i.share_label || 'Share'}</span>
|
||
</button>
|
||
${item.is_external ? (
|
||
item.local_id
|
||
? `<a class="scroll-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
||
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
||
</a>`
|
||
: `<button class="scroll-btn rehost-btn" data-id="${item.id}" title="${_i.add_to_site || 'Add to site'}">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-plus"></i></div>
|
||
<span class="scroll-btn-label">${_i.add_label || 'Add'}</span>
|
||
</button>`
|
||
) : `
|
||
<a class="scroll-btn" href="/${item.id}" target="_blank" title="${_i.open_post || 'Open post'}">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-up-right-from-square"></i></div>
|
||
<span class="scroll-btn-label">${_i.open_label || 'Open'}</span>
|
||
</a>`}
|
||
`;
|
||
slide.appendChild(actions);
|
||
|
||
// 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 = `<div class="scroll-btn-icon"><i class="fa-solid fa-clover" style="color:#789922"></i></div>`;
|
||
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 = `<div class="scroll-btn-icon"><i class="fa-solid fa-grip"></i></div>`;
|
||
gb.addEventListener('click', () => chanGalleryBtn.click());
|
||
actions.appendChild(gb);
|
||
}
|
||
}
|
||
|
||
// Progress bar (z:10)
|
||
const pBar = document.createElement('div'); pBar.className = 'scroll-progress-bar';
|
||
pBar.innerHTML = '<div class="scroll-progress-fill"></div><div class="scroll-progress-thumb"></div>';
|
||
slide.appendChild(pBar);
|
||
|
||
setupTapOverlay(slide);
|
||
// ── Hold-to-2× speed logic (shared by multiple triggers) ──
|
||
let speedTimer = null;
|
||
let speedActive = false;
|
||
let speedEndedAt = 0;
|
||
|
||
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;
|
||
const ind = document.getElementById('speed-indicator');
|
||
if (ind) ind.classList.remove('show');
|
||
}
|
||
};
|
||
|
||
const wireSpeedHold = (el) => {
|
||
if (!el) return;
|
||
el.addEventListener('pointerdown', () => {
|
||
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) {
|
||
media.playbackRate = 2;
|
||
const ind = document.getElementById('speed-indicator');
|
||
if (ind) ind.classList.add('show');
|
||
}
|
||
}, 150);
|
||
}, { passive: true });
|
||
};
|
||
|
||
wireSpeedHold(actions.querySelector('.js-speed-hold-btn'));
|
||
wireSpeedHold(slide.querySelector('.js-meta-speed-btn'));
|
||
|
||
|
||
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', async 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; }
|
||
try {
|
||
const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||
method: 'PUT',
|
||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
rBadge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||
rBadge.textContent = data.rating_label;
|
||
rBadge.dataset.rating = data.rating_class;
|
||
}
|
||
} catch {}
|
||
});
|
||
}
|
||
|
||
// 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')
|
||
};
|
||
})
|
||
.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 = [
|
||
'<div style="position:absolute;inset:0;display:flex;flex-direction:column;',
|
||
'align-items:center;justify-content:center;gap:18px;z-index:2;">',
|
||
'<i class="fa-solid fa-lock" style="font-size:3rem;color:rgba(255,255,255,.35);"></i>',
|
||
'<p style="color:rgba(255,255,255,.55);font-size:1rem;font-weight:600;margin:0;',
|
||
'text-align:center;line-height:1.5;">This post is currently unavailable</p>',
|
||
'</div>',
|
||
].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(() => { 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'}`
|
||
})
|
||
});
|
||
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 = `
|
||
<a href="/${data.item_id}" target="_blank" class="scroll-btn success" style="text-decoration:none;">
|
||
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
||
<span class="scroll-btn-label">View</span>
|
||
</a>
|
||
`;
|
||
}, 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 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 = `<i class="fa-solid fa-binoculars"></i><p>${(window.f0ckI18n && window.f0ckI18n.nothing_found) || 'Nothing found with current filters'}</p><button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">${(window.f0ckI18n && window.f0ckI18n.adjust_filters) || 'Adjust filters'}</button>`;
|
||
|
||
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(/<br\s*\/?>/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 = `
|
||
<img class="comment-avatar" src="${esc(av)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
|
||
<div class="comment-body">
|
||
<div class="comment-username" style="${nc}">${esc(uname)}</div>
|
||
<div class="comment-content" data-raw="${esc(c.content || '')}">${renderCommentContent(c.content || '')}</div>
|
||
<div class="comment-meta">
|
||
<span class="comment-time">${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')}</span>
|
||
${canReply ? `
|
||
<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>
|
||
<button class="comment-quote-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.quote || 'Quote'}</button>
|
||
` : ''}
|
||
</div>
|
||
</div>`;
|
||
// 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 = `<i class="fa-solid fa-triangle-exclamation"></i><br>${(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 = `<img src="${esc(av)}" onerror="this.src='/a/default.png'" alt=""><span class="share-user-row-name">${esc(u.display_name || u.user)}</span>`;
|
||
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 <img> 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(
|
||
`(?<![\\(\\[\\"])(${domainOrRel}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`,
|
||
'gi'
|
||
);
|
||
|
||
// Escape HTML first, then process line by line
|
||
const normalized = text.replace(/<br\s*\/?>/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 ? `<img src="${esc(url)}" alt=":${esc(name)}:" title=":${esc(name)}:" style="height:1.6em;vertical-align:middle;display:inline-block;margin:0 1px">` : m;
|
||
});
|
||
return `<span class="scroller-greentext">>${withEmoji}</span>`;
|
||
}
|
||
// Blank lines → small spacer
|
||
if (!trimmed) return '<span style="display:block;height:.3em"></span>';
|
||
// 1. Replace image URLs from allowed hosts FIRST (before emoji, so we never
|
||
// accidentally re-match the .png inside an already-inserted <img> src)
|
||
let out = line.replace(imageRe, (_, url) => {
|
||
const fullUrl = url.startsWith('http') || url.startsWith('//') || url.startsWith('/') ? url : '//' + url;
|
||
return `<img src="${fullUrl}" alt="" style="max-width:100%;max-height:320px;border-radius:6px;display:block;margin:4px 0" loading="lazy">`;
|
||
});
|
||
// 2. Replace custom emoji shortcodes with images (runs on raw text so it
|
||
// won't see the <img> tags inserted above)
|
||
out = out.replace(/:([a-z0-9_]+):/g, (m, name) => {
|
||
const url = customEmojis[name];
|
||
return url ? `<img src="${esc(url)}" alt=":${esc(name)}:" title=":${esc(name)}:" style="height:1.8em;vertical-align:middle;display:inline-block;margin:0 2px">` : m;
|
||
});
|
||
|
||
// 3. Replace >>ID patterns with context links
|
||
out = out.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||
return `<a href="#c${id}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||
});
|
||
|
||
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 + '<br>';
|
||
}).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) => `<span class="scroller-spoiler">${c}</span>`); } 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) => `<span class="scroller-blur">${c}</span>`); } 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 = `<div style="color:rgba(255,255,255,.4);font-size:.8rem;padding:8px">${(window.f0ckI18n && window.f0ckI18n.no_custom_emojis) || 'No custom emojis'}</div>`;
|
||
}
|
||
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 = `<img class="mention-avatar" src="${esc(av)}" onerror="this.src='/a/default.png'">${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 = `<span style="font-size:1.2rem;line-height:1">${char}</span><span style="font-size:.8rem;color:rgba(255,255,255,.65)">:${code}:</span>`;
|
||
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 = `<img src="${esc(url)}" style="width:24px;height:24px;object-fit:contain;border-radius:4px" alt="${esc(name)}"><span style="font-size:.8rem;color:rgba(255,255,255,.65)">:${esc(name)}:</span>`;
|
||
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 = `<i class="fa-solid fa-tag" style="font-size:.65rem;color:rgba(255,255,255,.3)"></i>${esc(r.tag)}<span class="scroll-tag-sugg-count">${r.uses ?? ''}</span>`;
|
||
|
||
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 = '<i class="fa-solid fa-check"></i>';
|
||
setTimeout(() => { addTagSendBtn.innerHTML = '<i class="fa-solid fa-plus"></i>'; }, 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)}<i class="fa-solid fa-xmark"></i>`;
|
||
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)}<span class="uses">${s.uses}</span>`;
|
||
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.id;
|
||
if (!itemId) return;
|
||
fetch(`/api/v2/tags/${itemId}/cycle-rating`, {
|
||
method: 'PUT',
|
||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.success) return;
|
||
// Update the badge on the slide immediately
|
||
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
||
if (badge) {
|
||
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||
badge.textContent = data.rating_label;
|
||
badge.dataset.rating = data.rating_class;
|
||
}
|
||
const labels = { sfw: '🛡 SFW', nsfw: '🔥 NSFW', nsfl: '💀 NSFL' };
|
||
showShareToast(labels[data.rating_class] ?? data.rating_label);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
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 = `<div style="text-align:center;padding:20px;color:rgba(255,255,255,.4)">${(window.f0ckI18n && window.f0ckI18n.chan_catalog_failed) || 'Failed to load catalog'}</div>`;
|
||
} 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 ? `<img class="chan-thread-thumb" src="${thumbUrl}" loading="lazy" alt="" onerror="this.style.display='none'">` : '<div class="chan-thread-thumb" style="background:rgba(255,255,255,.06)"></div>'}
|
||
<div class="chan-thread-info">
|
||
<div class="chan-thread-sub">${esc(t.sub || t.com || 'No subject')}</div>
|
||
${t.com && t.sub ? `<div class="chan-thread-com">${esc(t.com)}</div>` : ''}
|
||
<div class="chan-thread-stats">
|
||
<span><i class="fa-solid fa-comment"></i>${t.replies}</span>
|
||
<span><i class="fa-solid fa-image"></i>${t.images}</span>
|
||
${t.sticky ? '<span><i class="fa-solid fa-thumbtack"></i>Pinned</span>' : ''}
|
||
</div>
|
||
</div>`;
|
||
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 = `<div class="notif-empty">${emptyMsg}</div>`;
|
||
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 ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
||
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||
${thumb}
|
||
<div class="notif-content">
|
||
<div class="notif-user"><strong ${n.username_color ? `style="color:${n.username_color}"` : ''}>${user}</strong></div>
|
||
<div class="notif-msg">${msg}</div>
|
||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||
</div>
|
||
</a>`;
|
||
}).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();
|
||
}
|
||
}
|
||
|
||
})();
|