if (!window.UserCommentSystem) { window.UserCommentSystem = class UserCommentSystem { constructor() { this.container = document.getElementById('user-comments-container'); this.username = this.container ? this.container.dataset.user : null; this.page = 1; this.loading = false; this.finished = false; this.userColor = null; this.customEmojis = UserCommentSystem.emojiCache || {}; this.icons = { reply: ``, link: `` }; if (this.username) { this.init(); } // Handle live updates for edited comments this.editListener = (e) => this.handleLiveEdit(e.detail); window.addEventListener('f0ck:comment_edited', this.editListener); } handleLiveEdit(data) { if (!this.container || !document.body.contains(this.container)) { window.removeEventListener('f0ck:comment_edited', this.editListener); return; } const el = document.getElementById('c' + data.comment_id); if (el && this.container.contains(el)) { const contentEl = el.querySelector('.comment-content'); if (contentEl) { contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id); el.classList.remove('new-item-fade'); void el.offsetWidth; el.classList.add('new-item-fade'); } } } async init() { await this.loadEmojis(); this.loadMore(); this.loadMore(); this.bindEvents(); this.startLiveTimestamps(); } async loadEmojis() { if (UserCommentSystem.emojiCache) { this.customEmojis = UserCommentSystem.emojiCache; return; } try { const res = await fetch('/api/v2/emojis'); const data = await res.json(); if (data.success) { this.customEmojis = {}; data.emojis.forEach(e => { this.customEmojis[e.name] = e.url; }); UserCommentSystem.emojiCache = this.customEmojis; } } catch (e) { console.error("Failed to load emojis", e); } } bindEvents() { window.addEventListener('scroll', () => { if (this.loading || this.finished) return; if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) { this.loadMore(); } }); // Listen for mode changes document.addEventListener('f0ck:modeChange', (e) => { // Check if this instance is still active if (!document.body.contains(this.container)) return; window.f0ckDebug('Mode changed, reloading comments...'); this.container.innerHTML = ''; this.page = 1; this.finished = false; this.loadMore(); }); } async loadMore() { if (this.loading || this.finished) return; this.loading = true; const loader = document.createElement('div'); loader.className = 'loader-placeholder'; loader.innerText = 'Loading...'; loader.style.textAlign = 'center'; loader.style.padding = '10px'; this.container.appendChild(loader); try { const mode = window.activeMode || 'sfw'; const res = await fetch('/user/' + encodeURIComponent(this.username) + '/comments?page=' + this.page + '&json=true&mode=' + mode); const json = await res.json(); loader.remove(); if (json.success && json.comments.length > 0) { if (json.user && json.user.username_color) { this.userColor = json.user.username_color; } json.comments.forEach(c => { window.f0ckDebug('Raw Comment Content (ID ' + c.id + '):', c.content); const html = this.renderComment(c); this.container.insertAdjacentHTML('beforeend', html); }); this.page++; } else { this.finished = true; if (this.page === 1 && (!json.comments || json.comments.length === 0)) { this.container.innerHTML = '
No comments found.
'; } } } catch (e) { console.error(e); loader.remove(); } finally { this.loading = false; } } renderEmoji(match, name) { if (this.customEmojis && this.customEmojis[name]) { return `${match}`; } return match; } renderCommentAttachments(files, content = '') { if (!files || files.length === 0) return ''; const items = files.map(f => { const url = `/c/${f.dest}`; if (content && content.includes(url)) return ''; // Skip if already rendered in content if (f.mime && f.mime.startsWith('image/')) { return `${this.escapeHtml(f.original_filename || 'image')}`; } else if (f.mime && f.mime.startsWith('video/')) { return `
`; } else if (f.mime && f.mime.startsWith('audio/')) { return `
`; } return ''; }).join(''); return items ? `
${items}
` : ''; } renderCommentPoll(poll, commentId) { if (!poll) return ''; const i18n = window.f0ckI18n || {}; const session = window.f0ckSession || {}; const total = poll.total_votes || 0; const voted = !!poll.user_vote_option_id; const expired = poll.expires_at && new Date(poll.expires_at) < new Date(); const isAnon = poll.is_anonymous !== false; const optionsHtml = (poll.options || []).map(opt => { const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0; const isVoted = poll.user_vote_option_id === opt.id; const clickable = session.logged_in && !expired && !voted; const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0) ? `
${opt.voters.map(v => { const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null }; const name = String(u.username || ''); const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png'; return name ? `${this.escapeHtml(name)}` : ''; }).join('')}
` : ''; return `
${this.escapeHtml(opt.text)} ${pct}% ${isVoted ? `` : ''} ${voterAvatars}
`; }).join(''); const anonBadge = isAnon ? `` : ``; return `
${this.escapeHtml(poll.question)}
${optionsHtml}
`; } renderCommentContent(content, itemId = null) { if (!content) return ''; // Anti-recursion / Performance safeguard for extremely long comments if (content.length > 50000) { console.warn('UserComments: Comment too long, skipping markdown'); return `
${this.escapeHtml(content)}
`; } if (typeof marked === 'undefined') { return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n)); } try { // 1. Initial escaping using native method. Restore > for markdown markers. let escaped = this.escapeHtml(content).replace(/>/g, ">"); // 2. Mentions const mentionRegex = /(?|<\/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://'); const isSameSite = href.startsWith(siteOrigin); let displayText = text; if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) { try { const url = new URL(href.startsWith('http') ? href : siteOrigin + (href.startsWith('/') ? '' : '/') + href); displayText = url.pathname + url.search + url.hash; } catch (e) {} } if (isExternal && !isSameSite) { return `${displayText}`; } return `${displayText}`; }; renderer.image = (href, title, text) => { const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || ''); const imgHtml = `${text || ''}`; if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) { const filename = src.substring(3); // Remove '/c/' return `${imgHtml}`; } return imgHtml; }; // Pre-compile regexes for image/video/audio embeds matching comments.js const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const allowedHosts = [escapedSiteHost]; 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('|'); const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(? { const trimmed = line.trimStart(); if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) { const quoteContent = line.substring(line.indexOf('>') + 1); return `>${quoteContent}`; } // Per-line limit if (line.length > 10000) return line; if (!line.trim()) return ' '; let processedLine = line; // Handle Mentions processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => { const user = g1 || g2; return `@${user}`; }); // Handle Comment Context Links (>>ID) processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => { const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`; return `>>${id}`; }); // Handle Image Embeds processedLine = processedLine.replace(imageRegex, (match, url) => { let fullUrl = url; if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) { fullUrl = '//' + url; } return `![image](${fullUrl})`; }); // Handle Raw Video/Audio links so Marked converts them to processedLine = processedLine.replace(rawVideoRegex, (match, url) => { let fullUrl = url; if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url; return `[video](${fullUrl})`; }); processedLine = processedLine.replace(rawAudioRegex, (match, url) => { let fullUrl = url; if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url; return `[audio](${fullUrl})`; }); const escapedAsterisks = processedLine.replace(/\*/g, '\\*'); let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/

|<\/p>/g, ''); // 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) => this.renderEmoji(m, n)); } return rendered; }); let html = renderedLines.join('\n'); // Handle spoilers [spoiler]text[/spoiler] (supports nesting) let prevMd; let iterations = 0; const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi; do { prevMd = html; html = html.replace(spoilerRegex, (match, content) => { return `${content}`; }); iterations++; } while (html !== prevMd && iterations < 10); // Handle blur [blur]text[/blur] (supports nesting) const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi; iterations = 0; do { prevMd = html; html = html.replace(blurRegex, (match, content) => { return `${content}`; }); iterations++; } while (html !== prevMd && iterations < 10); if (window.Sanitizer && typeof Sanitizer.clean === 'function') { html = Sanitizer.clean(html); } return html; } catch (e) { console.error('UserCommentSystem Markdown Render Error:', e); return this.escapeHtml(content); } } renderComment(c) { const timeAgo = this.timeAgo(c.created_at); const fullDate = new Date(c.created_at).toISOString(); const content = this.renderCommentContent(c.content, c.item_id); return `

${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; } startLiveTimestamps() { // Update timestamps every 30 seconds setInterval(() => { const timestamps = this.container ? this.container.querySelectorAll('.comment-time.timeago') : []; timestamps.forEach(el => { const dateStr = el.getAttribute('tooltip'); if (dateStr) { el.textContent = this.timeAgo(dateStr); } }); }, 30000); } timeAgo(date) { if (window.f0ckTimeAgo) return window.f0ckTimeAgo(date); const seconds = Math.floor((new Date() - new Date(date)) / 1000); if (seconds < 5) return 'just now'; const intervals = [ { label: 'year', seconds: 31536000 }, { label: 'month', seconds: 2592000 }, { label: 'day', seconds: 86400 }, { label: 'hour', seconds: 3600 }, { label: 'minute', seconds: 60 }, { label: 'second', seconds: 1 } ]; for (const interval of intervals) { const count = Math.floor(seconds / interval.seconds); if (count >= 1) { return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; } } return 'just now'; } escapeHtml(unsafe) { if (!unsafe) return ''; const div = document.createElement('div'); div.textContent = unsafe; return div.innerHTML; } } } // Initializer for AJAX and standard load window.initUserComments = () => { const container = document.getElementById('user-comments-container'); if (container && !container.dataset.initialized) { container.dataset.initialized = 'true'; new UserCommentSystem(); } }; if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', () => { window.initUserComments(); }); } else { window.initUserComments(); }