openvibers/openvibe.community Commits

Add Top ordering to comment threads (#382)

OP OpenVibers committed 2 days ago · 3aae3e8636 · 4 files, +41 −8

* Order comment threads by score for inline Top views

* Require sign-in on MediaHub video threads

Changes

ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex 2fa9be4..2f682e1 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -4,7 +4,7 @@
* /api/v1/comments — typed comment threads other products embed (service.js has the rules).
*
* POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)
-- * GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)
++ * GET /threads/:id thread + first page (?after=<cursor>&sort=old|new|top&limit=, ?parent=<id>)
* :id is the access id (cth_…); the sequential id is for services only
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex ad50803..95f5c6a 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -65,6 +65,7 @@ const BROWSER_REF_TYPES = {
/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
const SIGNED_IN_ONLY = {
live: ['vod', 'clip'],
++ 'media-hub': ['video'],
};
const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
@@@ -214,6 +215,12 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
if (!/^\d{1,15}$/.test(String(after))) fail(400, 'request.invalid_cursor', '`after` is the id of the last comment you have');
return Number(after);
};
++ const topCursor = (after) => {
++ if (after == null || after === '') return null;
++ const match = /^(-?\d{1,12}):(\d{1,15})$/.exec(String(after));
++ if (!match) fail(400, 'request.invalid_cursor', '`after` is the last comment cursor');
++ return { score: Number(match[1]), id: Number(match[2]) };
++ };
return {
BROWSER_REF_TYPES,
@@@ -283,13 +290,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
/**
* GET /threads/:id — the thread and one page of top-level comments (replies nested, up to
-- * REPLY_PAGE each). ?after=<last comment id>&sort=old|new&limit=; ?parent=<id> pages one
++ * REPLY_PAGE each). ?after=<cursor>&sort=old|new|top&limit=; ?parent=<id> pages one
* comment's replies instead.
*/
async get(v, id, q = {}) {
const t = await visibleThread(v, id);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
-- const after = cursorId(q.after);
++ const sort = q.sort === 'new' || q.sort === 'top' ? q.sort : 'old';
++ const after = q.parent == null && sort === 'top' ? topCursor(q.after) : cursorId(q.after);
let rows, hasMore;
if (q.parent != null && q.parent !== '') {
const parent = cursorId(q.parent);
@@@ -297,13 +305,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
if (!p || p.thread_id !== t.id || p.parent_id) fail(404, 'comment.not_found', 'Comment not found');
({ rows, hasMore } = await store.listReplies(db, parent, { after, limit }));
} else {
-- ({ rows, hasMore } = await store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
++ ({ rows, hasMore } = await store.listTopLevel(db, t.id, { after, sort, limit, replyLimit: REPLY_PAGE }));
}
const comments = await shapeComments(rows, v, t);
return {
thread: shapeThread(t, v),
comments,
-- next_cursor: hasMore && rows.length ? String(rows[rows.length - 1].id) : null,
++ next_cursor: hasMore && rows.length ? (sort === 'top' && q.parent == null
++ ? `${rows[rows.length - 1].score}:${rows[rows.length - 1].id}` : String(rows[rows.length - 1].id)) : null,
viewer: {
signed_in: !!(v && v.subject),
can_comment: t.visibility === 'public' || moderator(v),
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex de3ee8b..4e95604 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -154,16 +154,21 @@ async function softDeleteComment(db, id, deletedBy = null) {
* One page of a thread's top-level comments with up to `replyLimit` replies each.
* sort 'old' (default): oldest first, cursor = the last id seen (next page: id > after)
* sort 'new': newest first (next page: id < after)
++ * sort 'top': score descending, then newest first (cursor: score:id)
* Deleted top-level comments appear only while they still have replies (tombstones).
* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
*/
async function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
const newest = sort === 'new';
++ const top = sort === 'top';
const params = [threadId];
let cursor = '';
-- if (after != null) { cursor = newest ? 'AND id < ?' : 'AND id > ?'; params.push(after); }
++ if (after != null) {
++ cursor = top ? 'AND (score < ? OR (score = ? AND id < ?))' : newest ? 'AND id < ?' : 'AND id > ?';
++ params.push(...(top ? [after.score, after.score, after.id] : [after]));
++ }
const rows = await db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}
-- ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...params, limit + 1);
++ ORDER BY ${top ? 'score DESC, id DESC' : `id ${newest ? 'DESC' : 'ASC'}`} LIMIT ?`).all(...params, limit + 1);
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex ea4c602..58fa48c 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -169,7 +169,8 @@ const { checkCapability } = require('../server/identity/capabilities');
await check('pagination: ?after= cursor, oldest first by default, ?sort=new newest first, next_cursor ends', async () => {
const th = (await resolve({ service: 'blog', type: 'post', id: 'pagination' })).json().thread;
-- for (let i = 1; i <= 7; i++) await post(th.id, { message: `m${i}` }, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } });
++ const made = [];
++ for (let i = 1; i <= 7; i++) made.push((await post(th.id, { message: `m${i}` }, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).json().comment);
const seen = [];
let cursor = '';
for (let guard = 0; guard < 10; guard++) {
@@@ -183,6 +184,14 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.deepStrictEqual(newest.comments.map((c) => c.message), ['m7', 'm6']);
const next = (await call(`/api/v1/comments/threads/${th.id}?limit=2&sort=new&after=${newest.next_cursor}`)).json();
assert.deepStrictEqual(next.comments.map((c) => c.message), ['m5', 'm4']);
++ for (const [c, who] of [[made[0], alexJwt], [made[0], samJwt], [made[2], alexJwt]]) {
++ assert.strictEqual((await call(`/api/v1/comments/${c.id}/votes`, { method: 'POST', json: { value: 1 }, cookie: who })).status, 200);
++ }
++ const top = (await call(`/api/v1/comments/threads/${th.id}?limit=2&sort=top`)).json();
++ assert.deepStrictEqual(top.comments.map((c) => c.message), ['m1', 'm3']);
++ const topNext = (await call(`/api/v1/comments/threads/${th.id}?limit=2&sort=top&after=${encodeURIComponent(top.next_cursor)}`)).json();
++ assert.deepStrictEqual(topNext.comments.map((c) => c.message), ['m7', 'm6']);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?sort=top&after=abc`)).status, 400);
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
});
@@@ -433,6 +442,16 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(byBrowser.status, 201, 'people with the access id may comment');
});
++ await check('MediaHub upload threads require service resolve and a signed-in commenter', async () => {
++ const ref = { service: 'media-hub', type: 'video', id: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++ assert.strictEqual((await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).status, 403);
++ const opened = await callL('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, token: svc([WRITE]) });
++ assert.strictEqual(opened.status, 201, opened.text);
++ const id = opened.json().thread.id;
++ assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'drive-by' } })).status, 401);
++ assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'hi' }, cookie: alexJwt })).status, 201);
++ });
++
await check('Live stages a file for its VOD; the author claims it once and removal retires it', async () => {
const ref = { service: 'live', type: 'vod', id: '7001' };
const form = new FormData();