From 5bb86f7028b052db51db05d707437a8dd35a4c04 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 3 Jun 2026 11:08:37 +0200 Subject: [PATCH] Testing: fixing user comments page and profile respects now sidebar width --- public/s/js/comments.js | 34 +----- public/s/js/user_comments.js | 195 +++++++++++++++++++++++-------- src/comment_upload_handler.mjs | 3 +- src/inc/routes/comments.mjs | 111 +++++++++++++++++- views/comments_user-partial.html | 2 +- views/comments_user.html | 2 + 6 files changed, 264 insertions(+), 83 deletions(-) diff --git a/public/s/js/comments.js b/public/s/js/comments.js index cd69076..2d66e02 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2051,39 +2051,7 @@ class CommentSystem { } } - return ` -
-
- ${comment.username ? `` : ''} - - ${comment.username ? `` : ''} -
-
-
-
- ${pinnedBadge}${comment.username ? `${this.escapeHtml(comment.display_name || comment.username)}` : 'System'} - ${contextMarker} - ${backlinkHtml} -
- ${timeAgo} -
-
${content}
- ${this.renderCommentAttachments(comment.files, comment.content)} - ${this.renderCommentPoll(comment.poll, comment.id, comment.username)} - -
- #${comment.id} -
- ${repliesHtml} - `; + return `
${comment.username ? `` : ''}${comment.username ? `` : ''}
${pinnedBadge}${comment.username ? `${this.escapeHtml(comment.display_name || comment.username)}` : 'System'}${contextMarker}${backlinkHtml}
${timeAgo}
${content}
${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}
#${comment.id}
${repliesHtml}`; } timeAgo(date) { diff --git a/public/s/js/user_comments.js b/public/s/js/user_comments.js index 2ca187c..8758c13 100644 --- a/public/s/js/user_comments.js +++ b/public/s/js/user_comments.js @@ -1,12 +1,15 @@ -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 || {}; +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: ``, @@ -23,7 +26,10 @@ class UserCommentSystem { } handleLiveEdit(data) { - if (!this.container) return; + 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'); @@ -37,7 +43,7 @@ class UserCommentSystem { } async init() { - this.loadEmojis(); + await this.loadEmojis(); this.loadMore(); this.loadMore(); this.bindEvents(); @@ -129,11 +135,74 @@ class UserCommentSystem { renderEmoji(match, name) { if (this.customEmojis && this.customEmojis[name]) { - return `${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 ''; @@ -152,7 +221,7 @@ class UserCommentSystem { let escaped = this.escapeHtml(content).replace(/>/g, ">"); // 2. Mentions - const mentionRegex = /(?${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(); @@ -203,7 +298,13 @@ class UserCommentSystem { if (line.length > 10000) return line; if (!line.trim()) return ' '; - let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)'); + 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) => { @@ -211,6 +312,28 @@ class UserCommentSystem { 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, ''); @@ -264,35 +387,7 @@ class UserCommentSystem { 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} -
- `; + return `
${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; } startLiveTimestamps() { @@ -336,15 +431,21 @@ class UserCommentSystem { 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')) { + const container = document.getElementById('user-comments-container'); + if (container && !container.dataset.initialized) { + container.dataset.initialized = 'true'; new UserCommentSystem(); } }; -window.addEventListener('DOMContentLoaded', () => { +if (document.readyState === 'loading') { + window.addEventListener('DOMContentLoaded', () => { + window.initUserComments(); + }); +} else { window.initUserComments(); -}); +} diff --git a/src/comment_upload_handler.mjs b/src/comment_upload_handler.mjs index 08a9563..8233bb5 100644 --- a/src/comment_upload_handler.mjs +++ b/src/comment_upload_handler.mjs @@ -12,7 +12,6 @@ const sendJson = (res, data, code = 200) => { res.end(JSON.stringify(data)); }; -// One-time migration: ensure comment_files table exists db`CREATE TABLE IF NOT EXISTS public.comment_files ( id SERIAL PRIMARY KEY, comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE, @@ -29,6 +28,8 @@ db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { }); db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { }); db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { }); db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { }); +db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { }); +db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { }); /** * Parse multipart form data supporting multiple files with the same field name. diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index a19bc6d..a331d36 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -205,13 +205,122 @@ export default (router, tpl) => { // Let's modify comments content in-place (or new array) before mapping const mentionsProcessed = await f0cklib.processMentions(comments); - const processedComments = mentionsProcessed.map(c => { + let processedComments = mentionsProcessed.map(c => { return { ...c, content: c.content }; }); + // Fetch file attachments for all fetched comments + if (processedComments.length > 0) { + const commentIds = processedComments.map(c => c.id); + try { + const files = await db` + SELECT id, comment_id, dest, mime, size, original_filename + FROM comment_files + WHERE comment_id = ANY(${commentIds}::int[]) + ORDER BY id ASC + `; + const filesMap = new Map(); + for (const f of files) { + if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []); + filesMap.get(f.comment_id).push(f); + } + for (const c of processedComments) { + c.files = filesMap.get(c.id) || []; + } + } catch (e) { + for (const c of processedComments) c.files = []; + } + + // Fetch poll data for comments + if (cfg.websrv.enable_comment_polls) { + try { + const commentIds = processedComments.map(c => c.id); + const pollRows = await db` + SELECT + cp.id as poll_id, + cp.comment_id, + cp.question, + cp.expires_at, + COALESCE(cp.is_anonymous, true) as is_anonymous, + json_agg( + json_build_object( + 'id', cpo.id, + 'text', cpo.text, + 'sort_order', cpo.sort_order, + 'vote_count', COALESCE(vote_counts.cnt, 0) + ) ORDER BY cpo.sort_order ASC, cpo.id ASC + ) AS options, + COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes + FROM comment_polls cp + JOIN comment_poll_options cpo ON cpo.poll_id = cp.id + LEFT JOIN ( + SELECT option_id, COUNT(*) AS cnt + FROM comment_poll_votes + GROUP BY option_id + ) vote_counts ON vote_counts.option_id = cpo.id + WHERE cp.comment_id = ANY(${commentIds}::int[]) + GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous + `; + // For non-anonymous polls, fetch voter names + const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id); + let votersByOption = new Map(); + if (nonAnonIds.length > 0) { + const voterRows = await db` + SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file + FROM comment_poll_votes cpv + JOIN public."user" u ON u.id = cpv.user_id + LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id + WHERE cpv.poll_id = ANY(${nonAnonIds}::int[]) + `; + for (const v of voterRows) { + if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []); + votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file }); + } + } + const pollMap = new Map(); + for (const p of pollRows) { + const options = p.is_anonymous + ? p.options + : p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] })); + pollMap.set(p.comment_id, { + id: p.poll_id, + question: p.question, + expires_at: p.expires_at, + is_anonymous: p.is_anonymous, + options, + total_votes: parseInt(p.total_votes) || 0, + user_vote_option_id: null + }); + } + // Fill in per-user poll votes if logged in + if (req.session && pollRows.length > 0) { + const pollIds = pollRows.map(p => p.poll_id); + try { + const votes = await db` + SELECT poll_id, option_id FROM comment_poll_votes + WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id} + `; + const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id])); + for (const [comment_id, poll] of pollMap.entries()) { + poll.user_vote_option_id = voteMap.get(poll.id) || null; + } + } catch (e) { /* graceful */ } + } + for (const c of processedComments) { + c.poll = pollMap.get(c.id) || null; + } + } catch (e) { + console.error('[USER_COMMENTS] Poll fetch error:', e.message); + for (const c of processedComments) c.poll = null; + } + } else { + for (const c of processedComments) c.poll = null; + } + } + if (isJson) { return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, diff --git a/views/comments_user-partial.html b/views/comments_user-partial.html index ce910b2..acc8fc4 100644 --- a/views/comments_user-partial.html +++ b/views/comments_user-partial.html @@ -25,4 +25,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/views/comments_user.html b/views/comments_user.html index 0dc07d1..f9f0704 100644 --- a/views/comments_user.html +++ b/views/comments_user.html @@ -1,7 +1,9 @@ @include(snippets/header) +
@include(comments_user-partial)
+
@include(snippets/footer) \ No newline at end of file