openvibers/openvibe.community Commits
Preserve exact targets of comment replies (#376)
Changes
nnew file mode 100644@@@ -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;@@@ -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] };},@@@ -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 };@@@ -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')),@@@ -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)}@@@ -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 () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -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;
@@ -70,7 +70,7 @@ const VISIBILITIES = ['public', 'hidden', 'locked'];
@@ -70,7 +70,7 @@ const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
const MAX_MESSAGE = 5000;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
-const REPLY_PAGE = 20;
+const REPLY_PAGE = 2;
function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch, media = null } = {}) {
function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch, media = null } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
@@ -95,13 +95,18 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -95,13 +95,18 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
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 deleted = !!c.deleted_at;
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
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 = {
const out = {
id: c.id,
id: c.id,
thread_id: threadId,
thread_id: threadId,
parent_id: c.parent_id || null,
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,
origin: c.origin,
author: a,
author: a,
anon_name: deleted || c.author_subject || c.origin === 'ai' ? null : (c.anon_name || 'Anonymous'),
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 =
@@ -118,16 +123,17 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),
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;
return out;
}
}
async function shapeComments(rows, v, t) {
async function shapeComments(rows, v, t) {
const all = [];
const all = [];
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
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);
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). */
/** 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 =
@@ -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 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);
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
@@ -55,6 +55,13 @@ async function getComment(db, id) {
@@ -55,6 +55,13 @@ async function getComment(db, id) {
return row;
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. */
/** References staged by the owning service, grouped by comment id. */
async function listAttachments(db, commentIds) {
async function listAttachments(db, commentIds) {
const out = new Map();
const out = new Map();
@@ -99,13 +106,13 @@ async function refIsPublic(db, t) {
@@ -99,13 +106,13 @@ async function refIsPublic(db, t) {
}
}
/** Insert a comment and keep the thread's and parent's counters in step. */
/** 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 () => {
return await db.tx(async () => {
const thread = await getThread(db, thread_id);
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 });
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; }
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) {
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);
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; }
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 } = {}) {
@@ -192,4 +199,4 @@ async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {
return top;
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 };
@@ -826,7 +826,8 @@ function openapi() {
@@ -826,7 +826,8 @@ function openapi() {
last_activity_at: nullable('string', { format: 'date-time' }), created_at: str({ format: 'date-time' }),
last_activity_at: nullable('string', { format: 'date-time' }), created_at: str({ format: 'date-time' }),
} },
} },
Comment: { type: 'object', required: ['id', 'message'], properties: {
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' },
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' },
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')),
created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }), attachments: arrayOf(ref('AiFile')),
@@ -38,6 +38,7 @@ function commentHtml(c) {
@@ -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>` : '';
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}">
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>
<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>
<p class="comment-text">${esc(c.message)}</p>
${attachmentsHtml(c.attachments)}
${attachmentsHtml(c.attachments)}
${repliesHtml(c)}
${repliesHtml(c)}
@@ -150,15 +150,21 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -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 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 });
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.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;
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);
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 page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const shown = page.comments.find((c) => c.id === top.id);
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.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');
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();
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 () => {
await check('pagination: ?after= cursor, oldest first by default, ?sort=new newest first, next_cursor ends', async () => {