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) 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() { 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 `${name}`; } return match; } 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}`; }; // 3. 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+/)) { 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.replace(mentionRegex, '[@$1](/user/$1)'); // Handle Comment Context Links (>>ID) processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => { const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`; return `>>${id}`; }); 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); // Replicating the structure of comments.js but adapting for the list view // We add a header indicating which item this comment belongs to return `

${this.username}
${timeAgo}
${content}
#${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 = () => { // Prevent multiple instances if already running on this container if (document.getElementById('user-comments-container')) { new UserCommentSystem(); } }; window.addEventListener('DOMContentLoaded', () => { window.initUserComments(); });