openvibers/openvibe.community Commits
Add Top ordering to comment threads (#382)
Changes
@@@ -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@@@ -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),@@@ -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 ?');@@@ -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();
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* /api/v1/comments — typed comment threads other products embed (service.js has the rules).
* /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)
* 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
* :id is the access id (cth_…); the sequential id is for services only
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
@@ -65,6 +65,7 @@ const BROWSER_REF_TYPES = {
@@ -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). */
/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
const SIGNED_IN_ONLY = {
const SIGNED_IN_ONLY = {
live: ['vod', 'clip'],
live: ['vod', 'clip'],
+ 'media-hub': ['video'],
};
};
const VISIBILITIES = ['public', 'hidden', 'locked'];
const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
const MAX_MESSAGE = 5000;
@@ -214,6 +215,12 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -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');
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);
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 {
return {
BROWSER_REF_TYPES,
BROWSER_REF_TYPES,
@@ -283,13 +290,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -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
* 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.
* comment's replies instead.
*/
*/
async get(v, id, q = {}) {
async get(v, id, q = {}) {
const t = await visibleThread(v, id);
const t = await visibleThread(v, id);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
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;
let rows, hasMore;
if (q.parent != null && q.parent !== '') {
if (q.parent != null && q.parent !== '') {
const parent = cursorId(q.parent);
const parent = cursorId(q.parent);
@@ -297,13 +305,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -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');
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 }));
({ rows, hasMore } = await store.listReplies(db, parent, { after, limit }));
} else {
} 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);
const comments = await shapeComments(rows, v, t);
return {
return {
thread: shapeThread(t, v),
thread: shapeThread(t, v),
comments,
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: {
viewer: {
signed_in: !!(v && v.subject),
signed_in: !!(v && v.subject),
can_comment: t.visibility === 'public' || moderator(v),
can_comment: t.visibility === 'public' || moderator(v),
@@ -154,16 +154,21 @@ async function softDeleteComment(db, id, deletedBy = null) {
@@ -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.
* 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 'old' (default): oldest first, cursor = the last id seen (next page: id > after)
* sort 'new': newest first (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).
* Deleted top-level comments appear only while they still have replies (tombstones).
* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
*/
*/
async function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
async function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
const newest = sort === 'new';
const newest = sort === 'new';
+ const top = sort === 'top';
const params = [threadId];
const params = [threadId];
let cursor = '';
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}
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;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
if (hasMore) rows.pop();
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
@@ -169,7 +169,8 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -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 () => {
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;
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 = [];
const seen = [];
let cursor = '';
let cursor = '';
for (let guard = 0; guard < 10; guard++) {
for (let guard = 0; guard < 10; guard++) {
@@ -183,6 +184,14 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -183,6 +184,14 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.deepStrictEqual(newest.comments.map((c) => c.message), ['m7', 'm6']);
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();
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']);
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);
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
});
});
@@ -433,6 +442,16 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -433,6 +442,16 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(byBrowser.status, 201, 'people with the access id may comment');
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 () => {
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 ref = { service: 'live', type: 'vod', id: '7001' };
const form = new FormData();
const form = new FormData();