openvibers/openvibe.community Commits

Preserve exact targets of comment replies (#376)

OP OpenVibers committed 2 days ago · 0fe412736e · 6 files, +39 −13

Changes

ddiff --git a/migrations/0035_comment_reply_targets.sql b/migrations/0035_comment_reply_targets.sql
nnew file mode 100644
iindex 0000000..e3381b1
---- /dev/null
++++ b/migrations/0035_comment_reply_targets.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- A reply stays under its top-level parent, while retaining the exact comment it answered.
++ALTER TABLE comments ADD COLUMN reply_to_id bigint REFERENCES comments(id) ON DELETE SET NULL;
++CREATE INDEX comments_reply_target ON comments(reply_to_id) WHERE reply_to_id IS NOT NULL;
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 2378638..ad50803 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -70,7 +70,7 @@ const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
--const REPLY_PAGE = 20;
++const REPLY_PAGE = 2;
function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch, media = null } = {}) {
const authors = createAuthors({ db, network });
@@@ -95,13 +95,18 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
-- function shapeComment(c, v, projections, votes, threadId) {
++ function shapeComment(c, v, projections, votes, threadId, targets) {
const deleted = !!c.deleted_at;
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
++ const target = c.reply_to_id ? targets.get(c.reply_to_id) : null;
++ const targetAuthor = target && !target.deleted_at ? authors.author(target.author_subject, target.origin, projections) : null;
const out = {
id: c.id,
thread_id: threadId,
parent_id: c.parent_id || null,
++ reply_to_id: c.reply_to_id || null,
++ reply_to: target ? { id: target.id, display_name: target.deleted_at ? 'Deleted comment'
++ : (targetAuthor && (targetAuthor.display_name || targetAuthor.username)) || target.anon_name || 'Member' } : null,
origin: c.origin,
author: a,
anon_name: deleted || c.author_subject || c.origin === 'ai' ? null : (c.anon_name || 'Anonymous'),
@@@ -118,16 +123,17 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),
};
-- if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
++ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId, targets));
return out;
}
async function shapeComments(rows, v, t) {
const all = [];
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
-- const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
++ const targets = await store.replyTargets(db, all.map((c) => c.reply_to_id));
++ const projections = await authors.projectionsFor(all.map((c) => c.author_subject).concat([...targets.values()].map((c) => c.author_subject)));
const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
-- return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));
++ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v), targets));
}
/** A thread by its access id, the only id anyone addresses a thread by (C-14). */
@@@ -358,7 +364,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
}
}
const forumItem = forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`] ? await forumLinks.publicExternalItem(t, fetchImpl) : null;
-- const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });
++ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, reply_to_id: parent ? parent.id : null,
++ author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
},
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 0d87f8a..de3ee8b 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -55,6 +55,13 @@ async function getComment(db, id) {
return row;
}
++async function replyTargets(db, ids) {
++ const unique = [...new Set((ids || []).map(Number).filter(Number.isSafeInteger))];
++ if (!unique.length) return new Map();
++ const rows = await db.prepare(`SELECT id, author_subject, anon_name, origin, deleted_at FROM comments WHERE id IN (${unique.map(() => '?').join(',')})`).all(...unique);
++ return new Map(rows.map((row) => [row.id, row]));
++}
++
/** References staged by the owning service, grouped by comment id. */
async function listAttachments(db, commentIds) {
const out = new Map();
@@@ -99,13 +106,13 @@ async function refIsPublic(db, t) {
}
/** Insert a comment and keep the thread's and parent's counters in step. */
--async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [], forum_item = null }) {
++async function insertComment(db, { thread_id, parent_id = null, reply_to_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [], forum_item = null }) {
return await db.tx(async () => {
const thread = await getThread(db, thread_id);
const staged = await stagedAttachments(db, attachment_ids, author_subject, thread && { service: thread.ref_service, type: thread.ref_type, id: thread.ref_id });
if (!staged) { const err = new Error('Use attachments uploaded for this item'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }
-- const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id')
-- .run(thread_id, parent_id, author_subject, anon_name, origin, message);
++ const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, reply_to_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id')
++ .run(thread_id, parent_id, reply_to_id, author_subject, anon_name, origin, message);
for (const row of staged) {
const claimed = await db.prepare('UPDATE comment_attachments SET comment_id = ? WHERE media_id = ? AND comment_id IS NULL').run(info.lastInsertRowid, row.media_id);
if (!claimed.changes) { const err = new Error('This attachment was already used'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }
@@@ -192,4 +199,4 @@ async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {
return top;
}
--module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, listAttachments, createAttachment, stagedAttachments, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };
++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, replyTargets, listAttachments, createAttachment, stagedAttachments, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex da9485e..d6e5bb2 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -826,7 +826,8 @@ function openapi() {
last_activity_at: nullable('string', { format: 'date-time' }), created_at: str({ format: 'date-time' }),
} },
Comment: { type: 'object', required: ['id', 'message'], properties: {
-- id: { type: 'integer' }, thread_id: str(), parent_id: nullable('integer'), origin: str(), author: nullable('object'),
++ id: { type: 'integer' }, thread_id: str(), parent_id: nullable('integer'), reply_to_id: nullable('integer'),
++ reply_to: nullable('object', { description: 'The exact comment answered, with its author display name' }), origin: str(), author: nullable('object'),
anon_name: nullable('string'), display_name: nullable('string'), message: nullable('string'), deleted: { type: 'boolean' },
score: { type: 'integer' }, upvotes: { type: 'integer' }, downvotes: { type: 'integer' }, my_vote: { type: 'integer' },
created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }), attachments: arrayOf(ref('AiFile')),
ddiff --git a/server/render/comments.js b/server/render/comments.js
iindex 54adc6e..14dd4f8 100644
---- a/server/render/comments.js
++++ b/server/render/comments.js
@@@ -38,6 +38,7 @@ function commentHtml(c) {
const edited = c.edited_at ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(c.edited_at))}">edited</span>` : '';
return `<article class="post comment" id="comment-${c.id}">
<header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header>
++ ${c.reply_to && c.reply_to_id !== c.parent_id ? `<p class="muted small">Replying to ${esc(c.reply_to.display_name)}</p>` : ''}
<p class="comment-text">${esc(c.message)}</p>
${attachmentsHtml(c.attachments)}
${repliesHtml(c)}
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex e651184..ea4c602 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -150,15 +150,21 @@ const { checkCapability } = require('../server/identity/capabilities');
const r1 = (await post(vod.id, { message: 'reply', parent_id: top.id }, { cookie: samJwt })).json().comment;
const r2 = await post(vod.id, { message: 'reply to reply', parent_id: r1.id }, { cookie: alexJwt });
assert.strictEqual(r2.json().comment.parent_id, top.id);
++ assert.strictEqual(r2.json().comment.reply_to_id, r1.id);
++ assert.strictEqual(r2.json().comment.reply_to.display_name, 'Sam');
++ const r3 = (await post(vod.id, { message: 'third reply', parent_id: top.id },
++ { token: svc([WRITE]), headers: { 'x-ov-subject': ids.newId('user') } })).json().comment;
const other = (await resolve({ service: 'live', type: 'stream', id: '456' })).json().thread;
assert.strictEqual((await post(other.id, { message: 'cross', parent_id: top.id }, { cookie: alexJwt })).status, 400);
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const shown = page.comments.find((c) => c.id === top.id);
-- assert.strictEqual(shown.reply_count, 2);
++ assert.strictEqual(shown.reply_count, 3);
assert.deepStrictEqual(shown.replies.map((r) => r.message), ['reply', 'reply to reply']);
assert.ok(page.comments.every((c) => c.parent_id === null), 'only top-level comments at the top');
const replies = (await call(`/api/v1/comments/threads/${vod.id}?parent=${top.id}&after=${r1.id}`)).json();
-- assert.deepStrictEqual(replies.comments.map((c) => c.message), ['reply to reply']);
++ assert.deepStrictEqual(replies.comments.map((c) => c.message), ['reply to reply', 'third reply']);
++ assert.strictEqual(replies.comments[0].reply_to_id, r1.id);
++ assert.strictEqual(replies.comments[1].id, r3.id);
});
await check('pagination: ?after= cursor, oldest first by default, ?sort=new newest first, next_cursor ends', async () => {