(function () {
let customEmojis = {};
let loading = false;
let loadingMore = false;
let currentPage = 1;
let hasMore = true;
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
let lastRenderedIds = ''; // track rendered comment IDs to skip needless re-renders
// Shared cache for activity across AJAX loads
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
const loadEmojis = async () => {
// Prefer CommentSystem's already-fetched cache to avoid a redundant request
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache)
|| null;
if (csCache && Object.keys(csCache).length > 0) {
Object.assign(customEmojis, csCache);
return;
}
// No shared cache yet — fetch directly
if (Object.keys(customEmojis).length > 0) return;
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success) {
data.emojis.forEach(e => {
customEmojis[e.name] = e.url;
});
}
} catch (e) {
console.error("Sidebar Activity: Failed to load emojis", e);
}
};
const renderEmoji = (match, name) => {
if (customEmojis[name]) {
const url = customEmojis[name];
if (url.endsWith('.webm')) {
return ``;
}
return ``;
}
return match;
};
const escapeHtml = (unsafe) => {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
};
const playSidebarEmojiVideos = (container) => {
if (!container) return;
container.querySelectorAll('video.emoji').forEach(v => {
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
});
});
};
const ytOembedCache = new Map(); // videoId -> meta object
const ytOembedPending = new Map(); // videoId -> Promise
const fetchSidebarYoutubeTitles = async (container) => {
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
if (links.length === 0) return;
for (const link of links) {
const videoId = link.dataset.ytId;
if (!videoId) continue;
const titleSpan = link.querySelector('.yt-title');
if (!titleSpan || titleSpan.dataset.loaded === 'true') continue;
let meta = ytOembedCache.get(videoId);
if (!meta) {
if (ytOembedPending.has(videoId)) {
meta = await ytOembedPending.get(videoId);
} else {
const promise = (async () => {
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
// 1. Try client-side oEmbed first (avoids Tor/proxy consent walls)
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`;
const clientResp = await fetch(oembedUrl);
if (clientResp.ok) {
const clientData = await clientResp.json();
if (clientData.title) {
const metaObj = {
title: clientData.title,
site_name: 'youtube.com',
author: clientData.author_name || 'Unknown'
};
// Cache client-side
ytOembedCache.set(videoId, metaObj);
// Push to server cache so other clients/server benefit
try {
const csrf = window.f0ckSession?.csrf_token;
fetch('/api/v2/meta/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {})
},
body: JSON.stringify({ url: ytUrl, meta: metaObj })
}).catch(() => {});
} catch (_) {}
return metaObj;
}
}
} catch (e) { }
// 2. Fall back to backend meta fetch
try {
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
if (r.ok) {
const data = await r.json();
if (data.success && data.meta) {
ytOembedCache.set(videoId, data.meta);
return data.meta;
}
}
} catch (e) { }
return null;
})();
ytOembedPending.set(videoId, promise);
meta = await promise;
ytOembedPending.delete(videoId);
}
}
if (meta && meta.title) {
titleSpan.textContent = meta.title;
} else {
// If title fails, just leave it blank or use a generic label
titleSpan.textContent = 'YouTube Video';
}
titleSpan.dataset.loaded = 'true';
// Re-check overflow since the new text might cause wrapping
const inner = link.closest('.comment-content-inner');
if (inner && typeof checkOverflow === 'function') {
checkOverflow(inner);
}
}
};
// Maximum characters to render in the sidebar per comment
const SIDEBAR_CONTENT_TRUNCATE = 200;
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
if (typeof marked === 'undefined') {
return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
}
try {
// Extract and protect code blocks (```...```) before escaping
const codeBlocks = [];
let processed = content.replace(/```([\s\S]*?)```/g, (match) => {
const placeholder = `BLOCKPORTALX${codeBlocks.length}X`;
codeBlocks.push(marked.parse(match));
return placeholder;
});
let escaped = escapeHtml(processed)
.replace(/>/g, ">"); // Restore > for markdown markers
// Handle Image Embeds (Client-side)
const siteOrigin = window.location.origin;
const escapedSiteUrl = siteOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const allowedHosts = [escapedSiteUrl];
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
window.f0ckAllowedImages.forEach(h => {
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
});
}
const hostsRegexPart = allowedHosts.join('|');
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?|<\/p>/g, '');
return text.split('\n').map(line => {
if (!line.trim()) return '';
return `>${line}`;
}).join('\n');
};
renderer.paragraph = function (text) {
return (typeof text === 'string') ? text : (text.text || '');
};
renderer.link = function (href, title, text) {
if (typeof href === 'object' && href !== null) {
title = href.title; text = href.text || text; href = href.href;
}
if (!href) return text || '';
const titleAttr = title ? ` title="${title}"` : '';
const isExternal = href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
let isSameSite = false;
// Marked greedy autolink fix for spoiler brackets appended to URLs
let extraSuffix = '';
const lowerHref = href.toLowerCase();
if (lowerHref.endsWith('%5b/spoiler%5d')) {
href = href.substring(0, href.length - 14);
text = text.replace(/\[\/spoiler\]/ig, '');
extraSuffix = '[/spoiler]';
} else if (lowerHref.endsWith('[/spoiler]')) {
href = href.substring(0, href.length - 10);
text = text.replace(/\[\/spoiler\]/ig, '');
extraSuffix = '[/spoiler]';
}
if (href.startsWith(siteOrigin) || (href.startsWith('/') && !href.startsWith('//'))) {
isSameSite = true;
} else {
try {
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
const urlObj = new URL(urlToParse, siteOrigin);
isSameSite = (urlObj.hostname === window.location.hostname);
} catch (e) { }
}
let displayText = text;
if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) {
try {
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
const url = new URL(urlToParse.startsWith('http') ? urlToParse : siteOrigin + (urlToParse.startsWith('/') ? '' : '/') + urlToParse);
displayText = url.pathname + url.search + url.hash;
} catch (e) { }
}
const isMention = href.startsWith('/user/') && text.startsWith('@');
if (isExternal && !isSameSite) {
return `${displayText}${extraSuffix}`;
}
return `${displayText}${extraSuffix}`;
};
renderer.image = function (href, title, text) {
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
const alt = text || '';
const ttl = title ? ` title="${title}"` : '';
return ``;
};
// Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '>');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: escapedQuote;
return `>${rendered}`;
}
// Per-line limit to prevent marked.parse recursion on single giant lines
if (line.length > 10000) return line;
if (!line.trim()) return ' ';
// Perform replacements on the single line
let processedLine = line;
// Handle Mentions
const mentionStore = [];
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
const user = g1 || g2 || g4;
const html = `@${user}`;
const idx = mentionStore.length;
mentionStore.push(html);
return `\x02MNT${idx}\x03`;
});
// Handle Comment Context Links (>>ID)
processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => {
const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
return ``;
});
processedLine = processedLine.replace(imageRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
fullUrl = '//' + url;
}
return ``;
});
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
return `[video](${fullUrl})`;
});
// Use marked for each line individually.
// Protect URLs and already-formed Markdown link/image tokens from the
// italic-prevention pass so that underscores in query params
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
const mdProtected = [];
// Match [text](url) /  tokens AND bare http(s) URLs
let mdSafe = processedLine.replace(
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
(match) => {
const idx = mdProtected.length;
mdProtected.push(match);
return `\x02MDURL${idx}\x03`;
}
);
// Escape * and _ only in the non-URL portions
mdSafe = mdSafe
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_');
// Restore protected URLs/tokens
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/
|<\/p>/g, '');
// Restore Mentions
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
if (!trimmed.startsWith('>') || quoteEmojis) {
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
}
return rendered;
});
let md = renderedLines.join('\n');
// YouTube label replacement: show icon + labeled link
md = md.replace(
/]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
(match, videoId) => {
const hrefMatch = match.match(/href="([^"]+)"/i);
const ytHref = hrefMatch ? hrefMatch[1] : '#';
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : ytHref);
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
return ``;
}
);
// Vocaroo label replacement
md = md.replace(
/]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
(match, vocarooId) => {
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
const hrefMatch = match.match(/href="([^"]+)"/i);
const vocaHref = hrefMatch ? hrefMatch[1] : '#';
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : vocaHref);
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
return ``;
}
);
// Abyss label replacement
md = md.replace(
/]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
(match, abyssId) => {
return ``;
}
);
// Build regex for allowed media hosters (video/audio)
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mediaHosts = [escapedSiteHost];
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
window.f0ckAllowedImages.forEach(h => {
const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
mediaHosts.push(`(?:[a-z0-9-]+\\.)*${escaped}`);
});
}
const mediaHostsPart = mediaHosts.join('|');
const mediaDomainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${mediaHostsPart})|(?=\\/[a-zA-Z0-9_\\-]))`;
// Video label replacement: instead of embedding, show a link
const videoEmbedRegex = new RegExp(`]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
md = md.replace(videoEmbedRegex, (match, url) => {
const isConvertedGif = url.endsWith('#gif');
const cleanUrl = url.replace(/#gif$/, '');
// Converted GIFs → inline autoplay in sidebar too
if (isConvertedGif) {
return ``;
}
let isSameSite = false;
try {
const urlToParse = cleanUrl.startsWith('//') ? window.location.protocol + cleanUrl : cleanUrl;
const urlObj = new URL(urlToParse, siteOrigin);
isSameSite = (urlObj.hostname === window.location.hostname);
} catch (e) {
isSameSite = cleanUrl.startsWith(siteOrigin) || (cleanUrl.startsWith('/') && !cleanUrl.startsWith('//'));
}
const label = isSameSite ? 'Video Link' : 'External Video Link';
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : cleanUrl);
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
return ``;
});
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
let prevMd;
let iterations = 0;
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
do {
prevMd = md;
md = md.replace(spoilerRegex, (match, content) => {
return `${content}`;
});
iterations++;
} while (md !== prevMd && iterations < 10);
// Handle blur [blur]text[/blur] (supports nesting)
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
iterations = 0;
do {
prevMd = md;
md = md.replace(blurRegex, (match, content) => {
return `${content}`;
});
iterations++;
} while (md !== prevMd && iterations < 10);
// Restore protected code blocks
md = md.replace(/BLOCKPORTALX(\d+)X/g, (match, index) => {
return codeBlocks[index] || '';
});
if (window.Sanitizer && typeof window.Sanitizer.clean === 'function') {
md = window.Sanitizer.clean(md);
}
// Strip #gif from final text if it leaked through
return md.replace(/#gif/g, '');
} catch (e) {
return content;
}
};
const SIDEBAR_MAX_CHARS = 200;
const SIDEBAR_MAX_EMOJIS = 12;
const renderCommentAttachments = (files, content = '') => {
if (!files || files.length === 0) return '';
const items = files.map(f => {
const url = `/c/${f.dest}`;
if (content.includes(url)) return ''; // Skip if already rendered in content
if (f.mime.startsWith('image/')) {
return ``;
} else if (f.mime.startsWith('video/')) {
return `
`;
} else if (f.mime.startsWith('audio/')) {
return ``;
}
return '';
}).join('');
return items ? `` : '';
};
const renderSidebarPoll = (poll, commentId, itemId) => {
if (!poll) return '';
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
return ``;
};
const renderActivityItem = (c) => {
const itemKey = c.item_slug || c.slug || c.item_id;
const rawContent = c.content || c.body || '';
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
// Robust fallback for is_long if server didn't provide it, plus inline media checks
let isLong = c.is_long;
if (isLong === undefined) {
isLong = rawContent.length > 120
|| rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0);
}
// Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('`;
itemPreview = `
`;
}
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
let bannerFile = c.banner_file;
let bannerPos = c.banner_position;
let bannerSz = c.banner_size;
if (!bannerFile && window.f0ckSession && window.f0ckSession.id && (c.user_id === window.f0ckSession.id || c.username === window.f0ckSession.user)) {
bannerFile = window.f0ckSession.banner_file;
bannerPos = window.f0ckSession.banner_position;
bannerSz = window.f0ckSession.banner_size;
}
const bannerStyle = (bannerEnabled && bannerFile && bannerFile !== 'null')
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const effectiveBannerStyle = isAnonGuest ? '' : bannerStyle;
const authorAvatarHtml = isAnonGuest
? ``
: ``;
const authorNameHtml = isAnonGuest
? ``
: ``;
return `
`;
};
const checkOverflow = (targetElement) => {
const targets = targetElement
? (targetElement.classList.contains('comment-content-inner') ? [targetElement] : targetElement.querySelectorAll('.comment-content-inner'))
: document.querySelectorAll('.sidebar-activity .comment-content-inner');
const results = [];
// Read phase: batch layout reads first
targets.forEach(inner => {
const container = inner.parentElement;
const btn = container ? container.querySelector('.read-more-btn') : null;
if (!btn) return;
const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => {
if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
}
if (media.tagName === 'VIDEO') {
return media.readyState < 1 && media.dataset.error !== 'true'; // HAVE_METADATA
}
return false;
}
);
results.push({
container,
btn,
isExpanded,
scrollHeight,
clientHeight,
hasUnloadedImages
});
});
// Write phase: perform DOM updates after all reads are completed.
// Visibility of the button is controlled entirely by the CSS rule on
// .comment-content.has-overflow > .read-more-btn — no btn.style.display here.
results.forEach(({ container, btn, isExpanded, scrollHeight, clientHeight, hasUnloadedImages }) => {
// Clear any leftover inline display style (e.g. from the HTML template's
// style="display:block") so the CSS class-based rule is the single source of truth.
btn.style.display = '';
if (isExpanded) {
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
return;
}
if (scrollHeight > clientHeight + 2) { // 2px buffer for rounding
// Content overflows — ensure clamped state (may already be set from HTML default)
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
container.classList.add('has-overflow');
} else if (hasUnloadedImages) {
// Images haven't loaded yet — their height is 0 so we can't tell if content
// will overflow. Keep has-overflow set; attachMediaLoadListeners will
// re-run checkOverflow once images finish loading.
} else {
// Content fits and all images are loaded — safe to remove clamped state
container.classList.remove('has-overflow');
}
});
};
const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner.
// Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true';
const inner = media.closest('.comment-content-inner');
if (inner) {
const handler = () => checkOverflow(inner);
const errorHandler = () => {
media.dataset.error = 'true';
checkOverflow(inner);
};
media.addEventListener('load', handler, { once: true });
media.addEventListener('error', errorHandler, { once: true });
media.addEventListener('loadedmetadata', handler, { once: true });
// Also use ResizeObserver to catch layout changes (e.g. cached image layout deferred)
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => checkOverflow(inner));
ro.observe(media);
}
}
});
};
// Event delegation — read-more expands, see-less collapses
document.addEventListener('click', (e) => {
// Read more / See less
const readBtn = e.target.closest('.read-more-btn');
if (readBtn) {
const contentDiv = readBtn.closest('.comment-content');
if (contentDiv) {
contentDiv.classList.toggle('expanded');
checkOverflow(contentDiv); // Re-sync button text and visibility for this element
}
return;
}
});
const SIDEBAR_SKELETON_COUNT = 15;
const showSkeletons = () => {
const container = document.getElementById('sidebar-activity-container');
if (!container) return;
container.innerHTML = `
`;
};
const renderFromCache = (force = false) => {
const container = document.getElementById('sidebar-activity-container');
if (!container || window._sidebarActivityCache.length === 0) return false;
const currentIds = window._sidebarActivityCache.map(c => String(c.id)).join(',');
// If the same comments are already rendered, leave the DOM completely untouched.
// This keeps video stickers playing and avoids any overflow state churn.
// force=true bypasses this guard for cases where content changed without ID changes
// (e.g. emojis just loaded and need to replace raw :codes: in existing comments).
if (!force && currentIds === lastRenderedIds && container.querySelector('.comment')) {
return true;
}
lastRenderedIds = currentIds;
let html = '';
window._sidebarActivityCache.forEach(c => {
html += renderActivityItem(c);
});
container.innerHTML = html;
// Re-append IO sentinel so the scroll observer keeps working after re-renders
if (ioSentinel) {
container.appendChild(ioSentinel);
}
attachMediaLoadListeners(container);
// has-overflow is now set per-comment by the server (via is_long) so the
// correct clamped/unclamped state is baked into the HTML on first paint.
// checkOverflow still runs deferred to handle two edge cases:
// 1. Comments with images whose heights are 0 at inject time — the server
// correctly marks them is_long:true, but images that load extra-tall
// still need a re-check after load (handled by attachMediaLoadListeners).
// 2. Any mis-estimates: content that the heuristic got wrong is corrected
// after layout so measurements are accurate.
// Run checkOverflow synchronously immediately after setting innerHTML.
// This forces a synchronous layout (reflow) but guarantees the browser
// will not paint the intermediate state, completely eliminating any "flash"
// of the button popping in or out.
checkOverflow();
// checkOverflow still runs deferred to handle image loads (attachMediaLoadListeners)
// and a fallback 1s timeout for any incredibly slow layout edge cases.
requestAnimationFrame(() => {
setTimeout(checkOverflow, 1000);
});
fetchSidebarYoutubeTitles(container);
// Auto-play converted GIF videos and webm emoji stickers
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
playSidebarEmojiVideos(container);
return true;
};
const SIDEBAR_PAGE_LIMIT = 15;
const SIDEBAR_INITIAL_LIMIT = 15;
const SIDEBAR_MAX_COMMENTS = 20;
const loadActivity = async (silent = false) => {
const container = document.getElementById('sidebar-activity-container');
if (!container || loading) return;
const hasCache = renderFromCache();
// If no cache and not silent: show skeletons while we fetch.
// On the very first page load the server-rendered skeletons are already there;
// on subsequent loads (e.g. mode change) we inject them programmatically.
if (!hasCache && !silent) {
showSkeletons();
}
loading = true;
currentPage = 1;
hasMore = true;
try {
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const res = await fetch(`/activity?json=true&page=1&limit=${SIDEBAR_INITIAL_LIMIT}&mode=${mode}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
if (data.success && data.comments && data.comments.length > 0) {
window._sidebarActivityCache = data.comments.slice(0, SIDEBAR_MAX_COMMENTS).map(c => ({
...c,
body: c.content || c.body
}));
hasMore = (data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT)
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
renderFromCache(); // no-op if IDs unchanged (renderFromCache self-guards)
} else if (!hasCache) {
container.innerHTML = '' + (window.f0ckI18n?.sidebar_no_activity || 'No recent activity.') + '
';
hasMore = false;
}
} catch (e) {
console.error("Sidebar Activity: Failed to load activity", e);
if (!hasCache) {
container.innerHTML = '' + (window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.') + '
';
}
hasMore = false;
} finally {
loading = false;
}
};
const loadMoreActivity = async () => {
const container = document.getElementById('sidebar-activity-container');
if (!container || loading || loadingMore || !hasMore) return;
loadingMore = true;
// Use the current cache length as the exact offset so there's never a gap,
// regardless of the initial fetch limit.
const nextOffset = window._sidebarActivityCache.length;
// Show a subtle loading row at the bottom
const sentinel = document.createElement('div');
sentinel.id = 'sidebar-load-more-sentinel';
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
sentinel.textContent = window.f0ckI18n?.sidebar_loading_more || 'Loading…';
container.appendChild(sentinel);
try {
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const res = await fetch(`/activity?json=true&offset=${nextOffset}&limit=${SIDEBAR_PAGE_LIMIT}&mode=${mode}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
// Remove sentinel before inserting real content
const s = document.getElementById('sidebar-load-more-sentinel');
if (s) s.remove();
if (data.success && data.comments && data.comments.length > 0) {
currentPage++;
// Append only comments not already in the cache, up to the global cap
const remaining = SIDEBAR_MAX_COMMENTS - window._sidebarActivityCache.length;
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
const newComments = data.comments
.filter(c => !existingIds.has(String(c.id)))
.slice(0, remaining)
.map(c => ({ ...c, body: c.content || c.body }));
window._sidebarActivityCache.push(...newComments);
// Stop loading more if the hard cap is reached
hasMore = data.hasMore === true
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
// Append new items to DOM
let html = '';
newComments.forEach(c => { html += renderActivityItem(c); });
if (html) {
const temp = document.createElement('div');
temp.innerHTML = html;
const addedNodes = [];
while (temp.firstElementChild) {
const node = temp.firstElementChild;
container.appendChild(node);
addedNodes.push(node);
}
// Keep the IO sentinel at the very end so it triggers on the next scroll
if (ioSentinel) container.appendChild(ioSentinel);
addedNodes.forEach(node => {
attachMediaLoadListeners(node);
fetchSidebarYoutubeTitles(node);
playSidebarEmojiVideos(node);
});
// Defer overflow checks to after layout so measurements are accurate.
addedNodes.forEach(node => checkOverflow(node));
requestAnimationFrame(() => {
// Deferred re-check for image comments — mirrors renderFromCache behaviour.
// Images in newly appended nodes may not be laid out yet at append time.
setTimeout(() => addedNodes.forEach(node => checkOverflow(node)), 1000);
});
// Auto-play converted GIF videos and webm emoji stickers
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
}
if (!hasMore) {
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} else {
hasMore = false;
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} catch (e) {
console.error("Sidebar Activity: Failed to load more", e);
const s = document.getElementById('sidebar-load-more-sentinel');
if (s) s.remove();
} finally {
loadingMore = false;
}
};
const handleNewActivity = (data) => {
const container = document.getElementById('sidebar-activity-container');
// 1. Deduplicate: check if this comment ID is already in the cache
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
window.f0ckDebug("Sidebar Activity: Duplicate comment ignored", data.id);
return;
}
// 2. Update cache (prepend, capped at SIDEBAR_MAX_COMMENTS)
const newItem = {
...data,
body: data.body || data.content,
timeago: (window.f0ckI18n && window.f0ckI18n.timeago_just_now) || 'just now'
};
window._sidebarActivityCache.unshift(newItem);
// Trim cache to the hard cap and remove the evicted DOM node (if any)
if (window._sidebarActivityCache.length > SIDEBAR_MAX_COMMENTS) {
const evicted = window._sidebarActivityCache.pop();
if (container && evicted) {
const evictedEl = document.getElementById('sc' + evicted.id);
if (evictedEl) evictedEl.remove();
}
}
// Update DOM if visible
if (container) {
// is_long may be provided by the server (SSE payload) or absent for legacy events.
// Default to true so the button is always visible; checkOverflow will hide it
// if the rendered content is actually short.
const itemWithLong = { ...newItem, is_long: newItem.is_long !== false };
const html = renderActivityItem(itemWithLong);
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstElementChild;
if (node) {
node.classList.add('new-item-fade');
container.prepend(node);
attachMediaLoadListeners(node);
checkOverflow(node);
fetchSidebarYoutubeTitles(container);
playSidebarEmojiVideos(node);
}
}
};
const init = async () => {
// Run emoji loading and activity fetching in parallel — avatars appear
// immediately without waiting for the emoji API to respond first.
// After both settle, if emojis finished after the initial render, re-render
// from cache so custom emoji images show on first page load.
const emojiPromise = loadEmojis();
const activityPromise = loadActivity();
await activityPromise;
await emojiPromise;
// If emojis were not yet available when activity first rendered, re-render now.
if (Object.keys(customEmojis).length > 0 && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: emojis are now loaded, content changed
}
};
// Listen for live activity from f0ckm.js
document.addEventListener('f0ck:activityReceived', (e) => {
window.f0ckDebug("Sidebar Activity: Live update received", e.detail);
handleNewActivity(e.detail);
});
const handleLiveEdit = (data) => {
const container = document.getElementById('sidebar-activity-container');
// 1. Update cache
if (window._sidebarActivityCache) {
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
if (comment) {
comment.content = data.content;
comment.body = data.content;
}
}
// 2. Update DOM if visible
if (container) {
const el = document.getElementById('sc' + data.comment_id);
if (el) {
const inner = el.querySelector('.comment-content-inner');
if (inner) {
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
inner.innerHTML = renderCommentContent(data.content, data.comment_id, comment ? comment.item_id : null);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
attachMediaLoadListeners(inner);
requestAnimationFrame(() => checkOverflow(inner));
fetchSidebarYoutubeTitles(el);
// Auto-play converted GIF videos
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
}
}
}
};
window.addEventListener('f0ck:comment_edited', (e) => {
window.f0ckDebug("Sidebar Activity: Live edit received", e.detail);
handleLiveEdit(e.detail);
});
// When emojis are refreshed (e.g. after sticker pack import), sync the sidebar
// cache and re-render so newly imported stickers appear in existing entries.
window.addEventListener('f0ck:emojis_ready', () => {
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
if (!csCache || Object.keys(csCache).length === 0) return;
// Merge new emojis into local cache (additive — never removes)
const hadBefore = Object.keys(customEmojis).length;
Object.assign(customEmojis, csCache);
const hasNew = Object.keys(customEmojis).length > hadBefore;
if (hasNew && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: new emoji images available, re-render
}
});
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
const getCurrentMimeFilter = () => {
const urlParams = new URLSearchParams(window.location.search);
const qMime = urlParams.get('mime');
if (qMime !== null) return qMime.trim();
const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime='));
if (cookieMime) {
const val = cookieMime.split('=')[1];
if (typeof val === 'string') return decodeURIComponent(val).trim();
}
return '';
};
let lastBoundMime = getCurrentMimeFilter();
const isStrictMode = () => {
return !!(window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true') || window.location.search.includes('strict=1'));
};
let lastBoundStrict = isStrictMode();
// Handle AJAX item loads
document.addEventListener('f0ck:contentLoaded', () => {
const currentMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
lastBoundMode = currentMode;
const currentMime = getCurrentMimeFilter();
const mimeChanged = lastBoundMime !== null && lastBoundMime !== currentMime;
lastBoundMime = currentMime;
const currentStrict = isStrictMode();
const strictChanged = lastBoundStrict !== null && lastBoundStrict !== currentStrict;
lastBoundStrict = currentStrict;
window.f0ckDebug("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "", mimeChanged ? "(Mime changed)" : "", strictChanged ? "(Strict changed)" : "");
if (modeChanged || mimeChanged || strictChanged) {
if (modeChanged) {
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false); // Force reload with loading state
}
recommendationsLoaded = false;
tagFeedLoaded = false;
tagFeedNeedsReload = true;
const activeTabEl = document.querySelector('.sidebar-tab.active')?.dataset.tab;
let savedTab = 'comments';
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
const currentTabName = activeTabEl || savedTab;
if (currentTabName === 'videos' || currentTabName === 'recommendations') {
loadRecommendations(false);
} else if (currentTabName === 'tag' && currentTag) {
loadTagFeed(true);
}
} else {
// Immediately render from cache to avoid flicker
renderFromCache();
// Background sync
loadActivity(true);
}
});
document.addEventListener('f0ck:mimeChanged', (e) => {
const newMime = (e.detail && typeof e.detail.mime !== 'undefined') ? e.detail.mime : getCurrentMimeFilter();
window.f0ckDebug("Sidebar Activity: MIME filter changed", newMime);
lastBoundMime = newMime;
recommendationsLoaded = false;
tagFeedLoaded = false;
tagFeedNeedsReload = true;
const activeTabEl = document.querySelector('.sidebar-tab.active')?.dataset.tab;
let savedTab = 'comments';
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
const currentTabName = activeTabEl || savedTab;
if (currentTabName === 'videos' || currentTabName === 'recommendations') {
loadRecommendations(false);
} else if (currentTabName === 'tag' && currentTag) {
loadTagFeed(true);
}
});
// Sync sidebar and comments-list layout on initial page load (Legacy View Only)
if (typeof syncSidebarAndComments === 'function') {
syncSidebarAndComments();
}
// Video / Media Recommendations Logic
let recommendationsLoading = false;
let recommendationsLoadingMore = false;
let recommendationsLoaded = false;
let currentRecommendations = [];
const seenRecommendationIds = new Set();
const RECOMMENDATIONS_LIMIT = SIDEBAR_MAX_COMMENTS;
let recIoSentinel = null;
let recObserver = null;
const renderVideoCard = (video, options = {}) => {
const videoKey = (window.f0ckSession?.enable_item_slugs !== false && video.slug) ? video.slug : video.id;
const rClass = video.rating_class || 'untagged';
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
let isBlurred = false;
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
const mime = video.mime || '';
const isAudio = mime.startsWith('audio/');
const isVideo = mime.startsWith('video/');
const isImage = mime.startsWith('image/');
let thumbUrl = `/t/${video.id}.webp`;
if (isBlurred) {
thumbUrl = `/t/${video.id}_blur.webp`;
}
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
let displayTitle = '';
if (video.title && video.title.trim()) {
displayTitle = video.title.trim();
} else if (video.tags) {
const rawTags = Array.isArray(video.tags)
? video.tags
: (typeof video.tags === 'string' ? video.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
const formattedTags = rawTags
.map(t => (typeof t === 'object' && t !== null ? (t.tag || t.name || '') : String(t)).trim())
.filter(Boolean)
.map(t => (t.startsWith('#') ? t : '#' + t));
if (formattedTags.length > 0) {
displayTitle = formattedTags.join(' ');
}
}
if (!displayTitle) {
displayTitle = `${videoKey}`;
}
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username);
const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : '';
const timeStr = video.stamp
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(new Date(video.stamp * 1000).toISOString()) : '')
: '';
const fullDate = video.stamp ? new Date(video.stamp * 1000).toLocaleString() : '';
const xdBadge = (video.xd_score && video.xd_score > 0)
? ``
: '';
const ext = (video.mime ? video.mime.split('/')[1] : '')
.replace('jpeg', 'jpg')
.replace('x-shockwave-flash', 'flash')
.replace('x-flac', 'flac')
.replace('mpeg', 'mp3')
.toUpperCase();
const formatBadge = ext ? `` : '';
const overlayIcon = isAudio ? 'fa-solid fa-music' : (isVideo ? 'fa-solid fa-play' : 'fa-solid fa-image');
let thumbContentHtml = '';
if (isAudio && !video.has_coverart) {
thumbContentHtml = `
`;
} else {
thumbContentHtml = `
`;
}
const forYouBadge = video.personalized
? ``
: '';
const isDev = !!(window.f0ckSession && window.f0ckSession.development);
const rawScore = (typeof video.score === 'number')
? video.score
: (typeof video.rank_score === 'number' ? video.rank_score : (video.xd_score || 0));
const formattedScore = (typeof rawScore === 'number')
? (Number.isInteger(rawScore) ? rawScore : +(rawScore.toFixed(1)))
: rawScore;
const devScoreAttr = isDev ? ` data-score="${escapeHtml(String(formattedScore))}"` : '';
const devScoreBadge = isDev
? ``
: '';
const isTagFeed = options.isTagFeed || false;
const tagContext = options.tag || null;
const isStrict = isStrictMode();
const strictSuffix = (isTagFeed && isStrict) ? '?strict=1' : '';
const targetHref = (isTagFeed && tagContext)
? `/tag/${encodeURIComponent(tagContext).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${videoKey}${strictSuffix}`
: `/${videoKey}`;
const activeClass = options.isActive ? ' active-tag-item' : '';
return `
`;
};
const getSessionAffinityParams = () => {
try {
const aff = window.f0ckInterestEngine ? window.f0ckInterestEngine.getTopAffinities() : null;
if (!aff) return '';
let q = '';
if (aff.topTags && aff.topTags.length > 0) {
q += `&session_tags=${encodeURIComponent(aff.topTags.join(','))}`;
}
if (aff.topCreators && aff.topCreators.length > 0) {
q += `&session_creators=${encodeURIComponent(aff.topCreators.join(','))}`;
}
return q;
} catch (_) {
return '';
}
};
const enforceSidebarRecommendationDistribution = (items) => {
if (!Array.isArray(items) || items.length <= 3) return items;
// Collect indices of personalized recommendations in the first 3 items
const recIndicesInFirst3 = [];
for (let i = 0; i < Math.min(3, items.length); i++) {
if (items[i] && items[i].personalized) recIndicesInFirst3.push(i);
}
// If more than 1 recommendation in first 3, swap extras with non-rec items from index >= 3
if (recIndicesInFirst3.length > 1) {
let nonRecIdx = 3;
for (let r = 1; r < recIndicesInFirst3.length; r++) {
const swapSlot = recIndicesInFirst3[r];
while (nonRecIdx < items.length && items[nonRecIdx] && items[nonRecIdx].personalized) {
nonRecIdx++;
}
if (nonRecIdx < items.length) {
const tmp = items[swapSlot];
items[swapSlot] = items[nonRecIdx];
items[nonRecIdx] = tmp;
nonRecIdx++;
}
}
}
return items;
};
const loadRecommendations = async (silent = false) => {
const container = document.getElementById('sidebar-recommendations-container');
if (!container || recommendationsLoading) return;
recommendationsLoading = true;
if (!silent && !recommendationsLoaded) {
container.innerHTML = `
`;
}
try {
seenRecommendationIds.clear();
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const affParams = getSessionAffinityParams();
const mime = getCurrentMimeFilter();
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : '';
const res = await fetch(`/api/v2/recommendations?limit=${RECOMMENDATIONS_LIMIT}&mode=${mode}${mimeParam}${affParams}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const items = data.items || data.videos || [];
if (data.success && items.length > 0) {
enforceSidebarRecommendationDistribution(items);
currentRecommendations = items;
recommendationsLoaded = true;
let html = '';
items.forEach(v => {
seenRecommendationIds.add(v.id);
html += renderVideoCard(v);
});
container.innerHTML = html;
if (silent) {
container.scrollTop = 0;
}
attachRecommendationSentinel();
bindRecommendationEvents();
} else {
container.innerHTML = `
${window.f0ckI18n?.sidebar_no_recommendations || 'No recommendations found.'}
`;
}
} catch (e) {
console.error("Sidebar Recommendations: Failed to load", e);
if (!recommendationsLoaded) {
container.innerHTML = `${window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.'}
`;
}
} finally {
recommendationsLoading = false;
}
};
const loadMoreRecommendations = async () => {
const container = document.getElementById('sidebar-recommendations-container');
if (!container || recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return;
recommendationsLoadingMore = true;
let indicator = document.getElementById('sidebar-recommendations-load-more');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'sidebar-recommendations-load-more';
indicator.style.cssText = 'text-align:center;padding:12px 0;font-size:0.85em;color:#888;';
indicator.innerHTML = '';
}
container.appendChild(indicator);
try {
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const excludeArr = Array.from(seenRecommendationIds).slice(-100);
const affParams = getSessionAffinityParams();
const mime = getCurrentMimeFilter();
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : '';
const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}&continuation=1${affParams}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const items = data.items || data.videos || [];
if (indicator && indicator.parentNode) {
indicator.remove();
}
if (data.success && items.length > 0) {
const freshItems = items.filter(v => !seenRecommendationIds.has(v.id));
if (freshItems.length > 0) {
const temp = document.createElement('div');
let html = '';
freshItems.forEach(v => {
seenRecommendationIds.add(v.id);
html += renderVideoCard(v);
});
temp.innerHTML = html;
while (temp.firstChild) {
container.appendChild(temp.firstChild);
}
attachRecommendationSentinel();
}
}
} catch (e) {
console.error("Sidebar Recommendations: Failed to load more", e);
if (indicator && indicator.parentNode) {
indicator.remove();
}
} finally {
recommendationsLoadingMore = false;
}
};
const attachRecommendationSentinel = () => {
const container = document.getElementById('sidebar-recommendations-container');
if (!container) return;
if (!recIoSentinel) {
recIoSentinel = document.createElement('div');
recIoSentinel.id = 'sidebar-recommendations-io-sentinel';
recIoSentinel.style.height = '1px';
}
container.appendChild(recIoSentinel);
if (typeof IntersectionObserver !== 'undefined') {
if (!recObserver) {
recObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && recommendationsLoaded && !recommendationsLoading && !recommendationsLoadingMore) {
loadMoreRecommendations();
}
}, { root: container, rootMargin: '0px 0px 250px 0px', threshold: 0 });
}
recObserver.disconnect();
recObserver.observe(recIoSentinel);
}
};
const bindRecommendationScrollListener = () => {
const container = document.getElementById('sidebar-recommendations-container');
if (!container) return;
// Fallback for environments without IntersectionObserver
if (typeof IntersectionObserver === 'undefined') {
container.addEventListener('scroll', () => {
if (recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return;
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 150;
if (nearBottom) loadMoreRecommendations();
}, { passive: true });
}
};
const loadVideoRecommendations = loadRecommendations;
// ── Dedicated Tag Feed (Ordered for Tag Views) ───────────────────────
let currentTag = null;
let tagOrder = 'desc'; // 'desc' = newest first (default), 'asc' = oldest first (chronological)
let tagOffset = 0;
let tagTotal = 0;
let tagFeedLoaded = false;
let tagFeedLoading = false;
let tagFeedNeedsReload = false;
let tagHasMore = false;
let tagSentinel = null;
let tagObserver = null;
const getCurrentTag = () => {
const pathMatch = window.location.pathname.match(/^\/tag\/([^/?#]+)/);
if (pathMatch) return decodeURIComponent(pathMatch[1]);
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('tag')) return searchParams.get('tag');
if (window.currentTag) return window.currentTag;
return null;
};
const getCurrentItemIdentifiers = () => {
const ids = new Set();
// 1. From DOM elements on the page (primary/direct)
const idElem = document.querySelector('[data-item-id]');
if (idElem && idElem.dataset.itemId) {
ids.add(String(idElem.dataset.itemId).trim());
}
const idLink = document.querySelector('.id-link');
if (idLink) {
if (idLink.dataset.itemId) ids.add(String(idLink.dataset.itemId).trim());
const txt = idLink.textContent.trim();
if (txt) ids.add(txt);
}
// 2. From URL pathname (/tag/:tag/:slugOrId or /:slugOrId)
const segments = window.location.pathname.split('/').filter(Boolean);
if (segments.length >= 2 && segments[0] === 'tag') {
if (segments[2]) {
ids.add(decodeURIComponent(segments[2]).trim());
}
} else if (segments.length === 1) {
const forbidden = ['s', 'b', 't', 'ca', 'a', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p'];
if (!forbidden.includes(segments[0])) {
ids.add(decodeURIComponent(segments[0]).trim());
}
}
return Array.from(ids).filter(Boolean);
};
const getCurrentItemKey = () => {
const ids = getCurrentItemIdentifiers();
return ids.length > 0 ? ids[0] : null;
};
let isReCenteringFeed = false;
const highlightActiveTagCard = (scrollIntoView = false) => {
const identifiers = getCurrentItemIdentifiers();
const container = document.getElementById('sidebar-tag-container');
if (!container) return;
let matchedCard = null;
container.querySelectorAll('.sidebar-video-card').forEach(card => {
const cardId = card.dataset.id ? String(card.dataset.id).trim() : '';
const cardSlug = card.dataset.slug ? String(card.dataset.slug).trim() : '';
const isMatch = identifiers.length > 0 && (
(cardId && identifiers.includes(cardId)) ||
(cardSlug && identifiers.includes(cardSlug))
);
if (isMatch) {
card.classList.add('active-tag-item');
if (!matchedCard) matchedCard = card;
} else {
card.classList.remove('active-tag-item');
}
});
if (matchedCard && scrollIntoView) {
const isTagTabActive = document.querySelector('.sidebar-tab.active')?.dataset.tab === 'tag' &&
container.style.display !== 'none';
if (isTagTabActive) {
try {
const containerRect = container.getBoundingClientRect();
const cardRect = matchedCard.getBoundingClientRect();
const targetScrollTop = container.scrollTop + (cardRect.top - containerRect.top) - (container.clientHeight / 2) + (cardRect.height / 2);
container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' });
} catch (_) {
matchedCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
} else if (!matchedCard && identifiers.length > 0 && currentTag && !isReCenteringFeed && !tagFeedLoading) {
// Active item belongs to current tag context but is not in currently rendered slice
// (e.g. after pressing Random in tag). Center feed around this active item!
isReCenteringFeed = true;
loadTagFeed(true, identifiers[0]).finally(() => {
isReCenteringFeed = false;
});
}
};
let userManuallySelectedNonTagTab = false;
const updateTagTabVisibility = (forceSwitchToTag = false) => {
const tag = getCurrentTag();
const tagBtn = document.getElementById('sidebar-tab-tag');
if (!tagBtn) return;
if (tag) {
tagBtn.style.display = 'inline-flex';
tagBtn.title = `#${tag}`;
tagBtn.setAttribute('aria-label', `#${tag}`);
const titleNameEl = document.querySelector('.sidebar-tag-name');
if (titleNameEl) titleNameEl.textContent = `#${tag}`;
if (currentTag !== tag) {
currentTag = tag;
tagOffset = 0;
tagFeedLoaded = false;
tagFeedNeedsReload = true;
userManuallySelectedNonTagTab = false;
// When entering or switching tag view (e.g. /tag/cat), automatically jump to the tag tab
switchSidebarTab('tag');
} else {
if (forceSwitchToTag || (!userManuallySelectedNonTagTab && document.querySelector('.sidebar-tab.active')?.dataset.tab !== 'tag')) {
switchSidebarTab('tag');
} else {
highlightActiveTagCard(true);
}
}
} else {
tagBtn.style.display = 'none';
currentTag = null;
tagFeedLoaded = false;
userManuallySelectedNonTagTab = false;
const activeTab = document.querySelector('.sidebar-tab.active')?.dataset.tab;
if (activeTab === 'tag') {
switchSidebarTab('recommendations');
}
}
};
const loadTagFeed = async (reset = false, focusId = null) => {
const container = document.getElementById('sidebar-tag-container');
const itemsContainer = container?.querySelector('.sidebar-tag-items-container');
if (!container || !itemsContainer || tagFeedLoading) return;
if (!currentTag) return;
tagFeedLoading = true;
tagFeedNeedsReload = false;
if (reset) {
tagOffset = 0;
itemsContainer.innerHTML = `
`;
}
try {
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const mime = getCurrentMimeFilter();
const isStrict = isStrictMode();
let url = `/api/v2/tag-feed?tag=${encodeURIComponent(currentTag)}&order=${tagOrder}&offset=${tagOffset}&limit=20&mode=${mode}`;
if (isStrict) {
url += `&strict=1`;
}
if (mime) {
url += `&mime=${encodeURIComponent(mime)}`;
}
if (focusId) {
url += `&focus_id=${encodeURIComponent(focusId)}`;
}
const res = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const items = data.items || [];
tagTotal = data.total || 0;
tagOffset = (data.offset ?? tagOffset) + items.length;
tagHasMore = typeof data.hasMore !== 'undefined' ? !!data.hasMore : (tagOffset < tagTotal);
// Update counter in header
const countEl = container.querySelector('.sidebar-tag-count');
if (countEl) countEl.textContent = `(${tagTotal})`;
// Update order button icon and tooltip
const orderBtn = container.querySelector('.sidebar-tag-order-btn');
if (orderBtn) {
if (tagOrder === 'asc') {
orderBtn.innerHTML = '';
orderBtn.title = 'Chronological (Oldest first) — Click for Newest first';
} else {
orderBtn.innerHTML = '';
orderBtn.title = 'Newest first (default) — Click for Oldest first';
}
}
if (data.success && items.length > 0) {
tagFeedLoaded = true;
const identifiers = getCurrentItemIdentifiers();
let html = '';
items.forEach(v => {
const vId = String(v.id).trim();
const vSlug = String(v.slug || '').trim();
const isActive = identifiers.length > 0 && (
(vId && identifiers.includes(vId)) ||
(vSlug && identifiers.includes(vSlug))
);
html += renderVideoCard(v, { isTagFeed: true, tag: currentTag, isActive });
});
if (reset) {
itemsContainer.innerHTML = html;
setTimeout(() => highlightActiveTagCard(true), 60);
} else {
itemsContainer.insertAdjacentHTML('beforeend', html);
highlightActiveTagCard(false);
}
initTagSentinel();
} else if (reset) {
itemsContainer.innerHTML = `
`;
}
} catch (err) {
console.error('[TAG-FEED] Failed to load tag items:', err);
if (reset) {
itemsContainer.innerHTML = `
`;
}
} finally {
tagFeedLoading = false;
}
};
const loadMoreTagFeed = async () => {
if (tagFeedLoading || !tagHasMore || !currentTag) return;
tagOffset += 20;
await loadTagFeed(false);
};
const initTagSentinel = () => {
const container = document.getElementById('sidebar-tag-container');
const itemsContainer = container?.querySelector('.sidebar-tag-items-container');
if (!container || !itemsContainer) return;
if (tagSentinel && tagSentinel.parentNode) {
tagSentinel.remove();
}
if (!tagHasMore) return;
tagSentinel = document.createElement('div');
tagSentinel.className = 'sidebar-tag-sentinel';
tagSentinel.style.cssText = 'height: 20px; margin: 10px 0;';
itemsContainer.appendChild(tagSentinel);
if (tagObserver) tagObserver.disconnect();
tagObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !tagFeedLoading && tagHasMore) {
loadMoreTagFeed();
}
}, {
root: container,
rootMargin: '200px'
});
tagObserver.observe(tagSentinel);
};
const initTagOrderButton = () => {
const container = document.getElementById('sidebar-tag-container');
if (!container || container.dataset.orderBound === 'true') return;
container.dataset.orderBound = 'true';
const orderBtn = container.querySelector('.sidebar-tag-order-btn');
if (orderBtn) {
orderBtn.addEventListener('click', (e) => {
e.preventDefault();
tagOrder = (tagOrder === 'desc') ? 'asc' : 'desc';
loadTagFeed(true);
});
}
};
const bindTagContainerEvents = () => {
const container = document.getElementById('sidebar-tag-container');
if (!container || container.dataset.eventsBound === 'true') return;
container.dataset.eventsBound = 'true';
container.addEventListener('click', (e) => {
const card = e.target.closest('.sidebar-video-card');
if (card) {
container.querySelectorAll('.sidebar-video-card').forEach(c => c.classList.remove('active-tag-item'));
card.classList.add('active-tag-item');
}
});
};
const switchSidebarTab = (tabName) => {
if (window.clearHoverPreview) {
window.clearHoverPreview();
}
const normalizedTab = (tabName === 'videos' || tabName === 'recommendations')
? 'recommendations'
: (tabName === 'tag' ? 'tag' : tabName);
const tabs = document.querySelectorAll('.sidebar-tab');
const contents = document.querySelectorAll('.sidebar-tab-content');
tabs.forEach(t => {
const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations')
? 'recommendations'
: (t.dataset.tab === 'tag' ? 'tag' : t.dataset.tab);
if (tTab === normalizedTab) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
contents.forEach(c => {
const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations')
? 'recommendations'
: (c.dataset.tabContent === 'tag' ? 'tag' : c.dataset.tabContent);
if (cTab === normalizedTab) {
c.classList.add('active');
c.style.display = 'block';
} else {
c.classList.remove('active');
c.style.display = 'none';
}
});
try {
localStorage.setItem('sidebar_active_tab', normalizedTab);
} catch (_) {}
if (normalizedTab === 'recommendations') {
if (!recommendationsLoaded) {
loadRecommendations();
}
} else if (normalizedTab === 'tag') {
if (tagFeedNeedsReload || !tagFeedLoaded) {
loadTagFeed(true);
} else {
setTimeout(() => highlightActiveTagCard(true), 60);
}
}
};
const initSidebarTabs = () => {
const tabsContainer = document.querySelector('.sidebar-tabs');
if (tabsContainer) {
tabsContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.sidebar-tab');
if (btn && btn.dataset.tab) {
e.preventDefault();
if (btn.dataset.tab !== 'tag') {
userManuallySelectedNonTagTab = true;
} else {
userManuallySelectedNonTagTab = false;
}
switchSidebarTab(btn.dataset.tab);
}
});
}
// Restore persisted active tab if any
let savedTab = 'comments';
try {
savedTab = localStorage.getItem('sidebar_active_tab') || 'comments';
} catch (_) {}
if (getCurrentTag()) {
switchSidebarTab('tag');
} else if (savedTab === 'videos' || savedTab === 'recommendations') {
switchSidebarTab('recommendations');
} else {
switchSidebarTab(savedTab);
}
};
const replaceCardWithNewRandom = async (card) => {
if (!card || card.dataset.swapping === 'true') return;
if (window.clearHoverPreview) {
window.clearHoverPreview();
}
card.dataset.swapping = 'true';
card.classList.add('sidebar-card-swapping');
const clickedId = parseInt(card.dataset.id, 10);
try {
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
const container = document.getElementById('sidebar-recommendations-container');
const visibleIds = [];
if (container) {
container.querySelectorAll('.sidebar-video-card').forEach(c => {
const cid = parseInt(c.dataset.id, 10);
if (cid) visibleIds.push(cid);
});
}
const excludeSet = new Set([...seenRecommendationIds, ...visibleIds]);
if (clickedId) {
excludeSet.add(clickedId);
if (window.f0ckInterestEngine) {
window.f0ckInterestEngine.recordSuggestionClick(clickedId);
}
}
const excludeArr = Array.from(excludeSet).slice(-120);
const cards = Array.from(container ? container.querySelectorAll('.sidebar-video-card') : []);
const cardIndex = cards.indexOf(card);
let preferParam = '';
if (cardIndex >= 0 && cardIndex < 3) {
// Keep exactly 1 recommendation in the first 3 cards
const isPersonalized = card.dataset.personalized === 'true';
preferParam = `&prefer_personalized=${isPersonalized ? 'true' : 'false'}`;
} else if (cardIndex >= 3) {
// Ensure recommendations remain sporadic (never consecutive)
const prevCard = cards[cardIndex - 1];
const nextCard = cards[cardIndex + 1];
const prevIsRec = prevCard && prevCard.dataset.personalized === 'true';
const nextIsRec = nextCard && nextCard.dataset.personalized === 'true';
if (prevIsRec || nextIsRec) {
preferParam = '&prefer_personalized=false';
}
}
const affParams = getSessionAffinityParams();
const mime = getCurrentMimeFilter();
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : '';
const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${preferParam}${affParams}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const items = data.items || data.videos || [];
if (data.success && items.length > 0) {
const newItem = items[0];
seenRecommendationIds.add(newItem.id);
const idx = currentRecommendations.findIndex(v => v.id === clickedId);
if (idx !== -1) {
currentRecommendations[idx] = newItem;
} else {
currentRecommendations.push(newItem);
}
const temp = document.createElement('div');
temp.innerHTML = renderVideoCard(newItem).trim();
const newCard = temp.firstElementChild;
if (newCard && card.parentNode) {
card.replaceWith(newCard);
}
} else {
card.classList.remove('sidebar-card-swapping');
delete card.dataset.swapping;
}
} catch (e) {
console.error("Sidebar Recommendations: Failed to swap clicked card", e);
if (card) {
card.classList.remove('sidebar-card-swapping');
delete card.dataset.swapping;
}
}
};
const bindRecommendationEvents = () => {
const container = document.getElementById('sidebar-recommendations-container');
if (!container || container.dataset.eventsBound === 'true') return;
container.dataset.eventsBound = 'true';
const handleCardAction = (e) => {
// Only respond to primary click (0) or middle-click (1). Ignore right click (2).
if (e.button !== 0 && e.button !== 1) return;
const card = e.target.closest('.sidebar-video-card');
if (card) {
replaceCardWithNewRandom(card);
}
};
container.addEventListener('click', handleCardAction);
container.addEventListener('auxclick', handleCardAction);
};
// Reload recommendations or sync tag feed when user triggers Random (#random, #nav-random, or 'r' key)
const handleRandomAction = () => {
recommendationsLoaded = false;
const tag = getCurrentTag();
if (tag) {
userManuallySelectedNonTagTab = false;
switchSidebarTab('tag');
} else {
let savedTab = 'comments';
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
if (savedTab === 'videos' || savedTab === 'recommendations') {
loadRecommendations(false);
}
}
};
document.addEventListener('f0ck:randomTriggered', handleRandomAction);
document.addEventListener('click', (e) => {
const btn = e.target.closest('#random, #nav-random, a[href="/random"], a[href$="/random"]');
if (btn) {
handleRandomAction();
}
}, true);
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
document.addEventListener('f0ck:modeChanged', (e) => {
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
lastBoundMode = e.detail.mode;
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false);
recommendationsLoaded = false;
tagFeedLoaded = false;
tagFeedNeedsReload = true;
const activeTabEl = document.querySelector('.sidebar-tab.active')?.dataset.tab;
let savedTab = 'comments';
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
const currentTabName = activeTabEl || savedTab;
if (currentTabName === 'videos' || currentTabName === 'recommendations') {
loadRecommendations(false);
} else if (currentTabName === 'tag' && currentTag) {
loadTagFeed(true);
}
});
// Handle strict mode toggle
document.addEventListener('f0ck:strictChanged', (e) => {
const currentStrict = (e.detail && typeof e.detail.strict !== 'undefined') ? !!e.detail.strict : isStrictMode();
window.f0ckDebug("Sidebar Activity: Strict mode change detected", currentStrict);
lastBoundStrict = currentStrict;
tagFeedLoaded = false;
tagFeedNeedsReload = true;
const activeTabEl = document.querySelector('.sidebar-tab.active')?.dataset.tab;
let savedTab = 'comments';
try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {}
const currentTabName = activeTabEl || savedTab;
if (currentTabName === 'tag' && currentTag) {
loadTagFeed(true);
}
});
// When the current user posts a comment, silently refresh sidebar to show it
document.addEventListener('f0ck:commentPosted', () => {
window.f0ckDebug("Sidebar Activity: Own comment posted, refreshing...");
loadActivity(true);
});
// Infinite scroll: load older comments when scrolling near the bottom
const bindScrollListener = () => {
const container = document.getElementById('sidebar-activity-container');
if (!container) return;
// Use IntersectionObserver if available (performant), fallback to scroll event
if (typeof IntersectionObserver !== 'undefined') {
// Create the sentinel once at module level so re-renders can re-append the same node
if (!ioSentinel) {
ioSentinel = document.createElement('div');
ioSentinel.id = 'sidebar-io-sentinel';
ioSentinel.style.height = '1px';
container.appendChild(ioSentinel);
}
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
loadMoreActivity();
}
}, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 });
observer.observe(ioSentinel);
} else {
container.addEventListener('scroll', () => {
if (loading || loadingMore || !hasMore) return;
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;
if (nearBottom) loadMoreActivity();
}, { passive: true });
}
};
// Initial load
const _origInit = init;
const initWithScroll = async () => {
initSidebarTabs();
initTagOrderButton();
bindTagContainerEvents();
updateTagTabVisibility();
document.addEventListener('f0ck:contentLoaded', () => {
updateTagTabVisibility();
setTimeout(() => highlightActiveTagCard(true), 60);
});
window.addEventListener('popstate', () => {
setTimeout(() => {
updateTagTabVisibility();
highlightActiveTagCard(true);
}, 60);
});
bindRecommendationEvents();
bindRecommendationScrollListener();
await _origInit();
bindScrollListener();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWithScroll);
} else {
initWithScroll();
}
// Live updates are handled via SSE (f0ck:activityReceived event)
})();