openvibers/openvibe.mediahub Commits

Put Community comments and composer on Video and Pics item pages (#406)

OP OpenVibers committed 2 days ago · 05584e54c0 · 18 files, +769 −50

* Render and compose inline Community threads on Video and Pics

* Match picture attachment markup in Video test

Changes

ddiff --git a/public/css/comments.css b/public/css/comments.css
nnew file mode 100644
iindex 0000000..cbfb011
---- /dev/null
++++ b/public/css/comments.css
@@@ -0,0 +1,15 @@
++.ovc-thread { margin: 24px 0; }
++.ovc-head, .ovc-order, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
++.ovc-head { justify-content: space-between; }
++.ovc-comment { padding: 12px 0; border-bottom: 1px solid var(--border, #5555); }
++.ovc-comment header img { border-radius: 50%; object-fit: cover; }
++.ovc-comment time, .ovc-community { font-size: .875em; opacity: .8; }
++.ovc-replies { margin-left: 24px; }
++.ovc-actions form { display: inline; }
++.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }
++.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }
++.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }
++.ovc-order { display: inline-flex; gap: 0; border: 1px solid currentColor; border-radius: 8px; overflow: hidden; }
++.ovc-order a { border: 0; border-radius: 0; }
++.ovc-order a + a { border-left: 1px solid currentColor; }
++.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 12%, transparent); }
ddiff --git a/public/js/comments.js b/public/js/comments.js
nnew file mode 100644
iindex 0000000..1e7c104
---- /dev/null
++++ b/public/js/comments.js
@@@ -0,0 +1,81 @@
++(function () {
++ 'use strict';
++ var thread = document.querySelector('[data-ovc-thread]');
++ if (!thread) return;
++ var allowed = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
++ function read(html) { return new DOMParser().parseFromString(html, 'text/html').querySelector('[data-ovc-thread]'); }
++ function replace(next, url) {
++ if (!next) return false;
++ thread.replaceWith(next); thread = next;
++ if (url) history.pushState(null, '', url);
++ return true;
++ }
++ document.addEventListener('click', async function (event) {
++ var reply = event.target.closest('[data-ovc-reply]');
++ if (reply && thread.contains(reply)) {
++ var form = thread.querySelector('[data-ovt-composer]');
++ var item = reply.closest('.ovc-comment');
++ if (!form || !item) return;
++ event.preventDefault();
++ var parent = form.querySelector('[name="parent_id"]');
++ if (!parent) { parent = document.createElement('input'); parent.type = 'hidden'; parent.name = 'parent_id'; form.appendChild(parent); }
++ parent.value = reply.dataset.ovcReply;
++ var slot = item.querySelector(':scope > [data-ovc-composer-slot]');
++ if (!slot) { slot = document.createElement('div'); slot.dataset.ovcComposerSlot = ''; item.insertBefore(slot, item.querySelector(':scope > .ovc-replies')); }
++ slot.appendChild(form);
++ form.querySelector('textarea').focus();
++ history.replaceState(null, '', reply.href);
++ return;
++ }
++ var more = event.target.closest('[data-ovc-more]');
++ if (more && thread.contains(more)) {
++ event.preventDefault();
++ more.setAttribute('aria-busy', 'true');
++ try {
++ var response = await fetch(more.href, { credentials: 'same-origin' });
++ if (!response.ok) throw new Error('Replies could not be loaded.');
++ var next = read(await response.text());
++ var currentReplies = more.closest('[data-ovc-replies]');
++ var loadedReplies = next && next.querySelector('[data-ovc-replies="' + more.dataset.ovcMore + '"]');
++ if (!loadedReplies) throw new Error('Replies could not be loaded.');
++ currentReplies.replaceWith(loadedReplies);
++ history.pushState(null, '', more.href);
++ } catch (error) { more.textContent = error.message; more.removeAttribute('aria-busy'); }
++ return;
++ }
++ var order = event.target.closest('[data-ovc-order]');
++ if (order && thread.contains(order)) {
++ event.preventDefault();
++ order.setAttribute('aria-busy', 'true');
++ try {
++ var page = await fetch(order.href, { credentials: 'same-origin' });
++ if (!page.ok || !replace(read(await page.text()), order.href)) throw new Error('Comments could not be refreshed.');
++ } catch (error) { order.textContent = error.message; order.removeAttribute('aria-busy'); }
++ }
++ });
++ document.addEventListener('submit', async function (event) {
++ var form = event.target;
++ if (!thread.contains(form) || (!form.matches('[data-ovt-composer]') && !form.closest('.ovc-actions'))) return;
++ var files = form.querySelector('[data-ovt-files]');
++ var note = form.querySelector('[data-ovt-note]');
++ if (files && files.files.length) {
++ var problem = files.files.length > 4 ? 'Attach up to four files.' : '';
++ for (var file of files.files) {
++ if (file.size > 16 * 1024 * 1024) problem = 'A file can be up to 16 MB.';
++ if (!allowed.has(file.type)) problem = 'Attach JPEG, PNG, GIF, WebP, PDF or text files.';
++ }
++ if (problem) { event.preventDefault(); if (note) note.textContent = problem; return; }
++ }
++ event.preventDefault();
++ var button = form.querySelector('[type="submit"]');
++ if (button) button.disabled = true;
++ if (note) note.textContent = 'Sending…';
++ try {
++ var body = form.matches('[data-ovt-composer]') ? new FormData(form) : new URLSearchParams(new FormData(form));
++ var response = await fetch(form.action, { method: 'POST', body: body, credentials: 'same-origin' });
++ var html = await response.text();
++ var next = read(html);
++ if (!response.ok || !replace(next, response.url)) throw new Error('The comment could not be saved. Try again.');
++ } catch (error) { if (note) note.textContent = error.message; if (button) button.disabled = false; }
++ });
++})();
ddiff --git a/server/comments/form.js b/server/comments/form.js
nnew file mode 100644
iindex 0000000..85d5f7a
---- /dev/null
++++ b/server/comments/form.js
@@@ -0,0 +1,27 @@
++'use strict';
++
++const express = require('express');
++const { ID } = require('./inline');
++
++const ACCEPT = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
++const MAX_FILE = 16 * 1024 * 1024;
++const multipart = express.raw({ type: 'multipart/form-data', limit: '65mb' });
++
++async function readCommentForm(req) {
++ if (!Buffer.isBuffer(req.body)) throw new Error('The comment form was not received');
++ const request = new Request('http://localhost/comment', { method: 'POST', headers: { 'content-type': req.get('content-type') }, body: req.body });
++ const form = await request.formData();
++ const message = String(form.get('message') || '').trim();
++ if (!message || message.length > 2000) throw new Error('A comment is 1 to 2,000 characters');
++ const parent = String(form.get('parent_id') || '');
++ if (parent && !ID.test(parent)) throw new Error('That reply target is not valid');
++ const files = form.getAll('files').filter((f) => f && typeof f.arrayBuffer === 'function' && f.size);
++ if (files.length > 4) throw new Error('Attach up to four files');
++ for (const file of files) {
++ if (file.size > MAX_FILE) throw new Error('A file can be up to 16 MB');
++ if (!ACCEPT.has(file.type)) throw new Error('Attach JPEG, PNG, GIF, WebP, PDF or text files');
++ }
++ return { message, parent_id: parent || null, files };
++}
++
++module.exports = { multipart, readCommentForm };
ddiff --git a/server/comments/inline.js b/server/comments/inline.js
nnew file mode 100644
iindex 0000000..7d6b3d7
---- /dev/null
++++ b/server/comments/inline.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++/** Plain, server-rendered Community thread for the Video and Pics item pages. */
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
++const { esc } = require('../render/html');
++
++const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
++const ID = /^\d{1,15}$/;
++const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };
++const author = (c) => c.author || c;
++const username = (c) => author(c).username || c.username || '';
++const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
++const date = (iso) => {
++ const ms = Date.parse(String(iso || ''));
++ if (!Number.isFinite(ms)) return { short: '', full: '' };
++ const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC';
++ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
++ const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)]
++ : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)]
++ : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)];
++ return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full };
++};
++
++function query(base, sort, extra = {}, anchor = 'comments') {
++ const q = new URLSearchParams({ sort, ...extra });
++ return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`;
++}
++
++function renderThread({ answer, base, post, remove, signedIn, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null }) {
++ const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];
++ const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
++ const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);
++ const validReply = ID.test(String(replyId || '')) ? String(replyId) : null;
++ const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null;
++ const signIn = `<p class="ovc-signin">Sign in to comment. <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
++ const composer = (parent = null) => signedIn ? thread.composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', maxlength: 2000,
++ sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
++ attach: { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' },
++ note: 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.',
++ }) : signIn;
++ const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
++ if (!MEDIA_ID.test(String(a.id || ''))) return '';
++ const url = `https://openvibe.media/o/${a.id}`;
++ const title = esc(a.name || 'Attached file');
++ return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/')
++ ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`;
++ }).join('');
++ const comment = (c, root = null) => {
++ const id = String(c.id);
++ if (!ID.test(id)) return '';
++ const gone = !!(c.deleted || c.is_deleted);
++ const user = username(c);
++ const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null;
++ const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null);
++ const when = date(c.created_at);
++ const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;
++ const topId = root || id;
++ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}">↪ @${esc(c.reply_to.display_name || 'Member')}</a>` : '';
++ const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';
++ let replies = '';
++ if (!root) {
++ const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []);
++ const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
++ replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
++ }
++ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer(id)}</div>` : ''}${replies}</article>`;
++ };
++ const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
++ const order = `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`;
++ const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : '';
++ return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : kit.empty({ title: 'Be the first to comment' })}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`;
++}
++
++module.exports = { renderThread, MEDIA_ID, ID };
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex 6fef41b..73e1678 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -161,7 +161,7 @@ function createPicsApi(ctx) {
if (!row) return;
return commentAnswer(res, await comments.read(row.id, req.viewer.token, req.query));
});
-- r.post('/images/:slug/comments', person, async (req, res) => {
++ r.post('/images/:slug/comments', person, limits.budget('pics.image.write'), async (req, res) => {
const row = await load(req, res);
if (!row) return;
const b = req.body || {};
@@@ -171,13 +171,19 @@ function createPicsApi(ctx) {
{ message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] }));
});
const commentFile = express.raw({ type: 'application/octet-stream', limit: '16mb' });
-- r.post('/images/:slug/comment-attachments', person, commentFile, async (req, res) => {
++ r.post('/images/:slug/comment-attachments', person, limits.budget('pics.image.upload'), commentFile, async (req, res) => {
const row = await load(req, res);
if (!row) return;
if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'pics.comment.file', 'Choose a file');
return commentAnswer(res, await comments.upload(row.id, req.viewer.token, req.body,
String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100)));
});
++ r.delete('/images/:slug/comments/:commentId', person, limits.budget('pics.image.write'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return;
++ if (!/^\d{1,15}$/.test(req.params.commentId)) return problem(req, res, 400, 'pics.comment.id', 'Invalid comment');
++ return commentAnswer(res, await comments.remove(req.params.commentId, req.viewer.token));
++ });
r.get('/images/:slug/bytes', person, async (req, res) => {
const row = await load(req, res);
ddiff --git a/server/pics/comments-client.js b/server/pics/comments-client.js
iindex c47795d..a2ab807 100644
---- a/server/pics/comments-client.js
++++ b/server/pics/comments-client.js
@@@ -27,8 +27,8 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
const found = await thread(id, token);
if (!found.id) return found;
const qs = new URLSearchParams();
-- if (query.sort === 'new') qs.set('sort', 'new');
-- if (query.after && /^\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));
++ if (query.sort === 'new' || query.sort === 'top') qs.set('sort', query.sort);
++ if (query.after && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));
if (query.parent && /^\d{1,15}$/.test(String(query.parent))) qs.set('parent', String(query.parent));
if (query.limit) qs.set('limit', String(Math.min(Math.max(Number(query.limit) || 30, 1), 100)));
return await call('GET', `/threads/${encodeURIComponent(found.id)}${qs.size ? `?${qs}` : ''}`, { token });
@@@ -44,7 +44,22 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');
return await call('POST', '/attachments', { token, body: form });
}
-- return { read, add, upload };
++ async function remove(commentId, token) {
++ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { token });
++ }
++ async function replies(id, token, parent) {
++ const all = [];
++ let after = null;
++ while (true) {
++ const result = await read(id, token, { parent, after, limit: 100 });
++ if (result.status !== 200) return result;
++ all.push(...result.body.comments);
++ if (!result.body.next_cursor) return { status: 200, body: { replies: all } };
++ if (result.body.next_cursor === after) return { status: 503, body: { error: 'Replies could not be loaded' } };
++ after = result.body.next_cursor;
++ }
++ }
++ return { read, add, upload, remove, replies };
}
module.exports = { createCommentsClient };
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 8a0a15a..68844ac 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -38,6 +38,10 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const { createPicsApi } = require('./api');
++const { createCommentsClient } = require('./comments-client');
++const { createCommentsClient: createServiceCommentsClient } = require('../video/comments-client');
++const { renderThread } = require('../comments/inline');
++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
const gallery = require('./gallery');
@@@ -56,6 +60,9 @@ const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' }
function createPicsRoutes(ctx) {
const { config, s, media, log } = ctx;
const pics = ctx.pics;
++ const comments = createCommentsClient({ fetchImpl: ctx.fetchImpl || globalThis.fetch });
++ const commentModeration = createServiceCommentsClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch,
++ refOf: (row) => ({ service: 'pics', type: 'picture', id: String(row.id) }) });
const sharing = ctx.sharing;
const cfg = config.pics;
const base = cfg.url;
@@@ -391,6 +398,27 @@ ${raw(showcase.network({
const title = row.title || 'Untitled picture';
const hidden = row.state !== 'active';
const commentsAvailable = !hidden && !!(sharing && await sharing.canView({ kind: 'anonymous' }, 'picture', row.id).catch(() => false));
++ const sort = req.query.sort === 'new' ? 'new' : 'top';
++ let commentsHtml = '';
++ if (commentsAvailable) {
++ const result = await comments.read(row.id, req.viewer && req.viewer.token, { sort, after: req.query.after });
++ if (result.status === 200) {
++ const answer = result.body;
++ if (owner || (signedIn(req) && pics.isStaff(req.viewer))) {
++ for (const c of answer.comments) {
++ c.can_delete = true;
++ for (const reply of c.replies || []) reply.can_delete = true;
++ }
++ }
++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
++ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);
++ const more = belongs ? await comments.replies(row.id, req.viewer && req.viewer.token, expandedId) : null;
++ commentsHtml = renderThread({ answer, base: `/i/${row.slug}`, post: `/i/${row.slug}/comments?sort=${sort}`,
++ remove: `/i/${row.slug}/comments/delete?sort=${sort}`, signedIn: signedIn(req) && answer.viewer.can_comment !== false,
++ sort, replyId: req.query.reply, expandedId: belongs ? expandedId : null,
++ expandedReplies: more && more.status === 200 ? more.body.replies : null });
++ } else commentsHtml = '<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>';
++ }
// ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.
const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
@@@ -399,8 +427,8 @@ ${raw(showcase.network({
jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
-- scripts: ['js/pics.js', 'js/pics-gallery.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
-- ...(owner ? { localStyles: ['css/labels.css'] } : {}),
++ scripts: ['js/pics.js', 'js/pics-gallery.js', 'js/comments.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js'],
++ localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
@@@ -423,7 +451,7 @@ ${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
--${commentsAvailable ? html`<section class="ovp-section ovp-comments" aria-labelledby="h-comments"><div class="ovp-comments-head"><h2 id="h-comments">Comments</h2><button type="button" class="ovk-btn ovk-btn--quiet" data-comments="${row.slug}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}
++${raw(commentsHtml)}
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<div class="ovp-link-grid">
@@@ -469,6 +497,43 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
}, status);
}
++ const commentFailure = (req, res, slug, detail, status = 422) => page(req, res, {
++ title: 'Comment not sent', cache: cache.htmlHeaders({ private: true }),
++ body: html`<h1>Comment not sent</h1><p>${detail}</p><p><a href="/i/${slug}#comments">Go back</a></p>`,
++ }, status);
++ r.post('/i/:slug/comments', mustSignIn('Sign in to comment.'), budgeted('pics.image.write'), commentMultipart, async (req, res) => {
++ const { row, status } = await picture(req, res);
++ if (!row || status !== 200) return notFound(req, res);
++ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;
++ if (!await sharing.canView({ kind: 'anonymous' }, 'picture', row.id)) return notFound(req, res);
++ let form;
++ try { form = await readCommentForm(req); }
++ catch (err) { return commentFailure(req, res, row.slug, err.message); }
++ const attachments = [];
++ for (const file of form.files) {
++ const uploaded = await comments.upload(row.id, req.viewer.token, Buffer.from(await file.arrayBuffer()), file.name, file.type);
++ if (uploaded.status !== 201 || !uploaded.body.attachment) return commentFailure(req, res, row.slug, uploaded.body.detail || uploaded.body.error || 'The attachment could not be saved', uploaded.status);
++ attachments.push(uploaded.body.attachment);
++ }
++ const result = await comments.add(row.id, req.viewer.token, { message: form.message, parent_id: form.parent_id, attachments });
++ if (result.status !== 201) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be saved', result.status);
++ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);
++ });
++ r.post('/i/:slug/comments/delete', mustSignIn('Sign in to remove a comment.'), budgeted('pics.image.write'), async (req, res) => {
++ const { row, status } = await picture(req, res);
++ if (!row || status !== 200) return notFound(req, res);
++ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;
++ const id = String(req.body.comment_id || '');
++ if (!/^\d{1,15}$/.test(id)) return commentFailure(req, res, row.slug, 'Invalid comment');
++ let result;
++ if (pics.isOwner(row, whoOf(req)) || (signedIn(req) && pics.isStaff(req.viewer))) {
++ try { await commentModeration.remove(row, req.viewer.subject, true, id, req.ip); result = { status: 200 }; }
++ catch (err) { result = { status: err.status || 503, body: { detail: err.message } }; }
++ } else result = await comments.remove(id, req.viewer.token);
++ if (result.status !== 200) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be removed', result.status);
++ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);
++ });
++
r.get('/i/:slug', async (req, res) => {
const { row, status, denied } = await picture(req, res);
if (denied && row) {
ddiff --git a/server/video/comments-client.js b/server/video/comments-client.js
nnew file mode 100644
iindex 0000000..08c0d03
---- /dev/null
++++ b/server/video/comments-client.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/** Community's thread for a MediaHub upload, addressed through this item's visibility checks. */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++class CommentError extends Error {
++ constructor(status, message) { super(message); this.status = status; }
++}
++
++function createCommentsClient({ config, fetchImpl = globalThis.fetch, refOf = (row) => ({ service: 'media-hub', type: 'video', id: String(row.id), label: String(row.title || 'Video').slice(0, 200) }) } = {}) {
++ const origin = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
++ let tokens = null;
++ const ref = refOf;
++ async function call(method, path, { subject = null, staff = false, body = null, ip = null } = {}) {
++ let token;
++ try {
++ if (!tokens) tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.community', fetch: fetchImpl });
++ token = await tokens.getToken();
++ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }
++ const form = body instanceof FormData;
++ let res;
++ try {
++ res = await fetchImpl(`${origin}/api/v1/comments${path}`, { method, redirect: 'error', signal: AbortSignal.timeout(form ? 120000 : 5000),
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(subject ? { 'X-OV-Subject': subject } : {}),
++ ...(staff ? { 'X-OV-Staff': '1' } : {}), ...(ip ? { 'X-Forwarded-For': ip } : {}),
++ ...(body && !form ? { 'Content-Type': 'application/json' } : {}) },
++ body: form ? body : body ? JSON.stringify(body) : undefined });
++ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) throw new CommentError(res.status, data && (data.detail || data.error) || 'Comments are unavailable right now');
++ return data;
++ }
++ async function thread(row, ip) {
++ const data = await call('POST', '/threads/resolve', { body: { ref: ref(row) }, ip });
++ const id = data && data.thread && data.thread.access_id;
++ if (!/^cth_[A-Za-z0-9_-]{22}$/.test(String(id || ''))) throw new CommentError(503, 'Comments are unavailable right now');
++ return id;
++ }
++ async function read(row, { subject = null, staff = false, sort = 'top', after = null, parent = null, limit = 30, ip = null } = {}) {
++ const id = await thread(row, ip);
++ const q = new URLSearchParams({ sort, limit: String(limit) });
++ if (after) q.set('after', after);
++ if (parent) q.set('parent', parent);
++ const answer = await call('GET', `/threads/${id}?${q}`, { subject, staff, ip });
++ if (answer.thread && answer.thread.visibility === 'hidden' && !staff) throw new CommentError(404, 'Comments are hidden here');
++ return answer;
++ }
++ async function upload(row, subject, file, ip) {
++ const form = new FormData();
++ form.set('ref', JSON.stringify(ref(row)));
++ form.set('file', file, file.name || 'file');
++ return await call('POST', '/attachments', { subject, body: form, ip });
++ }
++ async function add(row, subject, body, ip) {
++ const id = await thread(row, ip);
++ return await call('POST', `/threads/${id}/comments`, { subject, body, ip });
++ }
++ async function remove(row, subject, staff, commentId, ip) {
++ const found = await call('GET', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });
++ const linked = found && found.thread && found.thread.ref;
++ const expected = ref(row);
++ if (!linked || linked.service !== expected.service || linked.type !== expected.type || linked.id !== expected.id) throw new CommentError(404, 'Comment not found');
++ if (!staff && (!found.comment.author || found.comment.author.subject !== subject)) throw new CommentError(403, 'Only the author can remove this comment');
++ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });
++ }
++ return { read, upload, add, remove, CommentError };
++}
++
++module.exports = { createCommentsClient, CommentError };
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex f6398e1..5dc702b 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -85,19 +85,19 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
const refreshing = new Map(); // url → the load that refreshes it
-- async function getJson(path) {
++ async function getJson(path, { fresh = false } = {}) {
const url = `${config.video.liveUrl}${path}`;
-- const cached = hit(url);
++ const cached = fresh ? undefined : hit(url);
if (cached !== undefined) return cached;
const entry = cache.get(url);
-- if (entry && Date.now() - entry.at < SERVE_STALE_MS) {
++ if (!fresh && entry && Date.now() - entry.at < SERVE_STALE_MS) {
if (!refreshing.has(url)) refreshing.set(url, load(url).catch(() => {}).finally(() => refreshing.delete(url)));
return entry.value;
}
-- return load(url);
++ return load(url, fresh);
}
-- async function load(url) {
++ async function load(url, fresh = false) {
let res;
try {
res = await fetchImpl(url, {
@@@ -108,19 +108,24 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
++ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
return unavailable(url, err && err.name);
}
if (res.status === 404) return null;
-- if (!res.ok) return unavailable(url, `HTTP ${res.status}`);
++ if (!res.ok) {
++ if (fresh) throw new LiveUnavailableError(`OpenVibe.Live answered ${res.status}; try again in a minute.`);
++ return unavailable(url, `HTTP ${res.status}`);
++ }
let body;
try {
body = await res.json();
} catch {
++ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer with JSON.');
return unavailable(url, 'not JSON');
}
-- return remember(url, body);
++ return fresh ? body : remember(url, body);
}
/**
@@@ -256,8 +261,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
clip(id) {
return getJson(`/api/clips/${id}`);
},
-- comments(kind, id) {
-- return getJson(`/api/comments/${kind}/${id}`);
++ comments(kind, id, { sort = 'top', after = null } = {}) {
++ const q = new URLSearchParams({ inline: '1', sort });
++ if (after) q.set('after', after);
++ return getJson(`/api/comments/${kind}/${id}?${q}`, { fresh: true });
++ },
++ commentsAs(token, kind, id, opts = {}) {
++ const q = new URLSearchParams({ inline: '1', sort: opts.sort || 'top' });
++ if (opts.after) q.set('after', opts.after);
++ return asPerson(token, 'GET', `/api/comments/${kind}/${id}?${q}`);
++ },
++ commentReplies(token, commentId) {
++ const path = `/api/comments/${commentId}/replies`;
++ return token ? asPerson(token, 'GET', path) : getJson(path, { fresh: true });
++ },
++ postComment(token, kind, id, body, viewerIp) {
++ return asPerson(token, 'POST', `/api/comments/${kind}/${id}`, body, { viewerIp });
++ },
++ uploadCommentFile(token, kind, id, file, viewerIp) {
++ const form = new FormData();
++ form.set('file', file, file.name || 'file');
++ return asPerson(token, 'POST', `/api/comments/${kind}/${id}/attachments`, form, { viewerIp });
++ },
++ deleteComment(token, commentId, viewerIp) {
++ return asPerson(token, 'DELETE', `/api/comments/${commentId}`, null, { viewerIp });
},
commentsPage(kind, id, query = {}) {
const qs = new URLSearchParams();
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex a7e3306..43d3a81 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -22,6 +22,7 @@
* the sitemap. A private item is missing (Live already answers 404) and becomes a 404 here.
*/
const express = require('express');
++const rateLimit = require('express-rate-limit');
const path = require('path');
const cache = require('openvibe-shared/cache-policy');
const securityTxt = require('../http/security-txt');
@@@ -46,6 +47,8 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
++const { renderThread } = require('../comments/inline');
++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -665,6 +668,9 @@ function createVideoRoutes(ctx) {
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
++ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';
++ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;
++ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });
// The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,
// CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.
r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));
@@@ -734,6 +740,26 @@ function createVideoRoutes(ctx) {
return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);
}
const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);
++ async function liveThread(req, res, kind, id) {
++ const opts = { sort: commentSort(req), after: commentAfter(req) };
++ return signedIn(req) ? asPerson(req, res, (token) => live.commentsAs(token, kind, id, opts)) : live.comments(kind, id, opts);
++ }
++ async function liveThreadHtml(req, res, kind, id, basePath, answer) {
++ if (!answer) return raw('<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>');
++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
++ const belongs = expandedId && answer.comments && answer.comments.some((c) => String(c.id) === expandedId);
++ let expandedReplies = null;
++ if (belongs) {
++ try {
++ const out = signedIn(req) ? await asPerson(req, res, (token) => live.commentReplies(token, expandedId)) : await live.commentReplies(null, expandedId);
++ expandedReplies = out && out.replies;
++ } catch { /* the first two remain visible */ }
++ }
++ const endpoint = `/${kind === 'clip' ? 'c' : 'v'}/${id}/comments`;
++ return raw(renderThread({ answer, base: basePath, post: `${endpoint}?sort=${commentSort(req)}`, remove: `${endpoint}/delete?sort=${commentSort(req)}`,
++ signedIn: signedIn(req) && (!answer.viewer || answer.viewer.can_comment !== false), sort: commentSort(req),
++ replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));
++ }
/** A signed-in person's own view of an item Live hides from everyone else (private), or null. */
async function ownView(req, res, kind, id) {
if (!signedIn(req)) return null;
@@@ -974,6 +1000,39 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++ r.post('/:kind(v|c)/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {
++ const kind = req.params.kind === 'c' ? 'clip' : 'vod';
++ const id = String(req.params.id || '');
++ const back = `/watch?v=${encodeURIComponent(String(req.query.v || ''))}`;
++ if (!live.isId(id)) return notFound(req, res);
++ if (!sameSite(req, res, back)) return undefined;
++ let form;
++ try { form = await readCommentForm(req); }
++ catch (err) { return page(req, res, { title: 'Comment not sent', body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="/${req.params.kind}/${id}#comments">Go back</a></p>` }, 422); }
++ try {
++ await asPerson(req, res, async (token) => {
++ const attachments = [];
++ for (const file of form.files) {
++ const out = await live.uploadCommentFile(token, kind, id, file, req.ip);
++ if (!out || !out.attachment) throw new Error('The file could not be attached');
++ attachments.push(out.attachment);
++ }
++ return live.postComment(token, kind, id, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);
++ });
++ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);
++ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }
++ });
++ r.post('/:kind(v|c)/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!live.isId(id)) return notFound(req, res);
++ if (!sameSite(req, res, `/${req.params.kind}/${id}`)) return undefined;
++ const commentId = String(req.body.comment_id || '');
++ if (!/^\d{1,15}$/.test(commentId)) return notFound(req, res);
++ try {
++ await asPerson(req, res, (token) => live.deleteComment(token, commentId, req.ip));
++ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);
++ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }
++ });
r.post('/watch/like', async (req, res) => {
if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });
if (!sameSite(req, res, '/')) return undefined;
@@@ -1454,7 +1513,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const [m, cm, replay, saveForm, up, hist, more, agent] = await Promise.all([
// A private recording's moments are Live's answer to its owner only.
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
-- Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
++ Promise.resolve().then(() => liveThread(req, res, 'vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
upNext(req, 'vod', id),
@@@ -1470,16 +1529,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
if (agentSteps) mems = agentSteps.filter((s) => s.status === 'started' && Number.isFinite(Number(s.offset_seconds)))
.map((s) => ({ offset_seconds: Number(s.offset_seconds), description: String(s.title || '').slice(0, 120) }));
let threadUrl = null;
-- let commentRows = [];
-- let commentsOk = false;
-- if (cm !== fail) {
-- try {
-- threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
-- const list = Array.isArray(cm && cm.comments) ? cm.comments : [];
-- commentRows = list.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20);
-- commentsOk = true;
-- } catch { threadUrl = null; }
-- }
++ if (cm !== fail) threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
++ const commentsHtml = await liveThreadHtml(req, res, 'vod', id, watchPath, cm === fail ? null : cm);
let chatRows = null;
if (replay !== fail) {
@@@ -1519,15 +1570,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
noindex: unlisted || ownOnly || thin,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
-- styles: ['kit.css'],
++ styles: ['kit.css', 'thread.css'],
description: about,
canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
-- scripts: ['js/video.js'],
-- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
++ scripts: ['js/video.js', 'js/comments.js'],
++ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
@@@ -1546,7 +1597,7 @@ ${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-so
</div>
${manageable ? ownerPanel('vod', id, vod) : ''}
${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}
--${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
++<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
<aside class="ovv-col ovv-col--side">
${side || ''}
@@@ -1593,7 +1644,7 @@ ${more || ''}
? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
: Promise.resolve('none');
const [cm, saveForm, up, sourceVod, more] = await Promise.all([
-- Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
++ Promise.resolve().then(() => liveThread(req, res, 'clip', id)).catch(() => null),
playlistForm(req, 'clip', id),
upNext(req, 'clip', id),
sourceAnswer,
@@@ -1601,7 +1652,7 @@ ${more || ''}
]);
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
-- const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;
++ const commentsHtml = await liveThreadHtml(req, res, 'clip', id, watchPath, cm);
const unlisted = clip.visibility === 'unlisted';
// AI clips stay out of search, as on Live: noindex and no structured data.
@@@ -1644,15 +1695,15 @@ ${more || ''}
noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
-- styles: ['kit.css'],
++ styles: ['kit.css', 'thread.css'],
description: clip.ai_overview || factualDescription('clip', clip),
canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
jsonLd: videoLd({ base, path: watchPath, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null) }),
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
-- scripts: ['js/video.js'],
-- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
++ scripts: ['js/video.js', 'js/comments.js'],
++ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
@@@ -1671,7 +1722,7 @@ ${channelRow(clip.source_streamer_username || clip.username, clip.source_streame
</div>
${manageable ? ownerPanel('clip', id, clip) : ''}
${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}
--${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
++<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
<aside class="ovv-col ovv-col--side">
${side || ''}
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex b198a34..3e7e267 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -20,11 +20,15 @@
* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
*/
const express = require('express');
++const rateLimit = require('express-rate-limit');
const cache = require('openvibe-shared/cache-policy');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice, askAgent } = require('../render/html');
const { UploadError } = require('./uploads');
const { ShareError } = require('openvibe-sdk/share');
++const { createCommentsClient, CommentError } = require('./comments-client');
++const { renderThread } = require('../comments/inline');
++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const REASON_TEXT = {
child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
@@@ -164,6 +168,10 @@ function createUploadApi({ ctx, config }) {
function mountUploadPages(r, h) {
const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip } = h;
const up = ctx.videoUploads;
++ const comments = createCommentsClient({ config: ctx.config, fetchImpl: ctx.fetchImpl || globalThis.fetch });
++ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });
++ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';
++ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const GB = 1024 ** 3;
@@@ -359,6 +367,34 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
return row;
}
++ async function uploadThread(req, row, watchPath) {
++ if (row.state !== 'active' || row.status !== 'ready') return '';
++ const subject = signedIn(req) ? req.viewer.subject : null;
++ const staff = signedIn(req) && (isStaff(req) || row.owner === ownerOf(req));
++ try {
++ const answer = await comments.read(row, { subject, staff, sort: commentSort(req), after: commentAfter(req), ip: req.ip });
++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
++ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);
++ let expandedReplies = null;
++ if (belongs) {
++ expandedReplies = [];
++ let cursor = null;
++ do {
++ const more = await comments.read(row, { subject, staff, parent: expandedId, after: cursor, limit: 100, ip: req.ip });
++ expandedReplies.push(...more.comments);
++ if (more.next_cursor === cursor) break;
++ cursor = more.next_cursor;
++ } while (cursor);
++ }
++ const shown = row.visibility === 'private' ? { ...answer, thread: {} } : answer;
++ return raw(renderThread({ answer: shown, base: watchPath, post: `/u/${row.id}/comments?sort=${commentSort(req)}`,
++ remove: `/u/${row.id}/comments/delete?sort=${commentSort(req)}`, signedIn: Boolean(subject) && answer.viewer.can_comment !== false,
++ sort: commentSort(req), replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));
++ } catch (err) {
++ return raw(`<section class="ovc-thread" id="comments"><h2>Comments</h2><p>${err instanceof CommentError && err.status === 404 ? 'Comments are hidden here.' : 'Comments are unavailable right now.'}</p></section>`);
++ }
++ }
++
/** A query as the tail of /watch?v=<id> (its own ?v= is never carried): ?t= a moment, ?list= a playlist. */
function queryTail(query) {
const qs = new URLSearchParams();
@@@ -421,6 +457,7 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
const thumb = httpsUrl(row.thumbnail_url);
// The page's one address: /watch?v=<watch id> (its /u/<id> redirects here until an id exists).
const watchPath = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
++ const commentsHtml = await uploadThread(req, row, watchPath);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
@@@ -445,11 +482,12 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
title: row.title,
-- styles: ['kit.css'],
++ styles: ['kit.css', 'thread.css'],
++ localStyles: ['css/comments.css'],
description: about,
canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
-- scripts: ready ? ['js/video.js'] : [],
++ scripts: ready ? ['js/video.js', 'js/comments.js'] : [],
player: !!(ready && vod),
body: html`<div class="ovv-watch">
<div class="ovv-main">
@@@ -474,6 +512,7 @@ ${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"
${manage && ai.overview !== row.description ? html`<form method="post" action="/u/${row.id}/manage"><input type="hidden" name="description" value="${ai.overview}"><button class="ovv-btn" type="submit">Use this as the description</button></form>` : ''}</section>` : ''}
${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${ai.segments.length} lines)</span></summary><div class="ovv-transcript-body">${ai.segments.map((x) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${x.start}">${vttTime(x.start).slice(0, 8)}</button> <span>${x.text}</span></p>`)}</div></details>` : ''}
${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}
++<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
<aside class="ovv-side">
${inList.html}
@@@ -516,6 +555,42 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
return x && x.ai.chapters.length ? sendVtt(res, renderChaptersVtt(x.ai.chapters.map((c) => ({ offset_seconds: c.start, description: c.title })), x.row.duration_seconds)) : notFoundVtt(req, res);
});
++ const commentFailure = (req, res, row, err) => page(req, res, { title: 'Comment not sent',
++ body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}#comments">Go back</a></p>`,
++ }, err.status && err.status >= 400 && err.status <= 599 ? err.status : 422);
++ const commentReturn = (row, sort) => {
++ const path = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
++ return `${path}${path.includes('?') ? '&' : '?'}sort=${sort}#comments`;
++ };
++ r.post('/u/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {
++ const row = await visibleUpload(req, res, req.params.id);
++ if (!row) return undefined;
++ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;
++ if (row.state !== 'active' || row.status !== 'ready') return notFound(req, res);
++ try {
++ const form = await readCommentForm(req);
++ const attachments = [];
++ for (const file of form.files) {
++ const out = await comments.upload(row, req.viewer.subject, file, req.ip);
++ if (!out || !out.attachment) throw new CommentError(503, 'The attachment could not be saved');
++ attachments.push(out.attachment);
++ }
++ await comments.add(row, req.viewer.subject, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);
++ return res.redirect(303, commentReturn(row, commentSort(req)));
++ } catch (err) { return commentFailure(req, res, row, err); }
++ });
++ r.post('/u/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {
++ const row = await visibleUpload(req, res, req.params.id);
++ if (!row) return undefined;
++ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;
++ const id = String(req.body.comment_id || '');
++ if (!/^\d{1,15}$/.test(id)) return notFound(req, res);
++ try {
++ await comments.remove(row, req.viewer.subject, isStaff(req) || row.owner === ownerOf(req), id, req.ip);
++ return res.redirect(303, commentReturn(row, commentSort(req)));
++ } catch (err) { return commentFailure(req, res, row, err); }
++ });
++
r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
const row = await up.byId(req.params.id);
const who = whoOf(req);
ddiff --git a/test/comments-inline-page.test.js b/test/comments-inline-page.test.js
nnew file mode 100644
iindex 0000000..2a31f24
---- /dev/null
++++ b/test/comments-inline-page.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const posts = [];
++ const live = http.createServer((req, res) => {
++ const path = new URL(req.url, 'http://local').pathname;
++ const json = (body) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
++ if (path === '/api/vods/101') return json({ vod: { id: 101, title: 'A recording', visibility: 'public', username: 'maker',
++ created_at: '2026-11-10T12:00:00Z', duration_seconds: 60, playback_url: 'https://openvibe.media/v/101' }, clips: [] });
++ if (path === '/api/comments/vod/101' && req.method === 'POST') {
++ const chunks = [];
++ req.on('data', (chunk) => chunks.push(chunk));
++ req.on('end', () => { posts.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); res.statusCode = 201; json({ comment: { id: 8 } }); });
++ return;
++ }
++ if (path === '/api/comments/vod/101') return json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },
++ comments: [{ id: 7, message: 'Good video', username: 'reader', display_name: 'Reader', created_at: '2026-11-10T12:00:00Z', reply_count: 0 }], viewer: { can_comment: true } });
++ return json({});
++ });
++ await new Promise((resolve) => live.listen(0, '127.0.0.1', resolve));
++ const url = `http://127.0.0.1:${live.address().port}`;
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: url,
++ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: url, MEDIAHUB_SEARCH_URL: url, MEDIAHUB_VIDEO_SEARCH: 'off' } });
++ const videoRequest = (path, { method = 'GET', headers = {}, body = null } = {}) => new Promise((resolve, reject) => {
++ const target = new URL(t.base);
++ const request = http.request({ hostname: target.hostname, port: target.port, path, method,
++ headers: { Host: 'openvibe.video', ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (response) => {
++ const chunks = [];
++ response.on('data', (chunk) => chunks.push(chunk));
++ response.on('end', () => resolve({ status: response.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
++ });
++ request.on('error', reject);
++ request.end(body);
++ });
++ try {
++ await check('recording renders the Community thread and a signed-out sign-in action', async () => {
++ const r = await videoRequest('/v/101');
++ assert.equal(r.status, 200, `${t.logs().slice(-1200)}\n${r.text.slice(0, 400)}`);
++ assert.match(r.text, /class="ovc-thread"/);
++ assert.match(r.text, /Good video/);
++ assert.match(r.text, /Sign in to comment/);
++ });
++ await check('signed-in recording shows the shared composer and posts through Live', async () => {
++ const person = t.network.addUser('threadwriter');
++ const cookie = t.signIn(person);
++ const page = await videoRequest('/v/101', { headers: { Cookie: cookie } });
++ assert.equal(page.status, 200, page.text.slice(0, 300));
++ assert.match(page.text, /data-ovt-composer/);
++ const boundary = 'inline-thread-test';
++ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);
++ const sent = await videoRequest('/v/101/comments?sort=new', { method: 'POST', body,
++ headers: { Cookie: cookie, Origin: 'https://openvibe.video', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });
++ assert.equal(sent.status, 303, `${sent.text.slice(0, 300)}\n${t.logs().slice(-600)}`);
++ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });
++ });
++ } finally { await t.close(); await new Promise((resolve) => live.close(resolve)); }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/comments-inline.test.js b/test/comments-inline.test.js
nnew file mode 100644
iindex 0000000..729046b
---- /dev/null
++++ b/test/comments-inline.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { renderThread } = require('../server/comments/inline');
++
++const answer = {
++ thread: { id: 'cth_1234567890123456789012' }, viewer: { can_comment: true },
++ comments: [{ id: 10, message: 'A <picture>', author: { username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.network/avatar/alice' },
++ created_at: '2026-11-10T12:00:00Z', reply_count: 3, can_delete: true,
++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'image.png', media_type: 'image/png' },
++ { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', name: 'notes.pdf', media_type: 'application/pdf' }],
++ replies: [{ id: 11, parent_id: 10, reply_to_id: 10, reply_to: { display_name: 'Alice' }, message: 'First', author: { username: 'bob', display_name: 'Bob' } },
++ { id: 12, parent_id: 10, reply_to_id: 11, reply_to: { display_name: 'Bob' }, message: 'Second', author: { username: 'alice', display_name: 'Alice' } }],
++ }],
++};
++const options = { answer, base: '/watch?v=12345678901', post: '/u/vup_1/comments', remove: '/u/vup_1/comments/delete', sort: 'top' };
++const signedOut = renderThread({ ...options, signedIn: false });
++assert.match(signedOut, /class="ovc-thread"/);
++assert.match(signedOut, /Sign in to comment/);
++assert.doesNotMatch(signedOut, /data-ovt-composer/);
++assert.match(signedOut, /sort=new/);
++assert.match(signedOut, /1 more reply/);
++assert.match(signedOut, /href="https:\/\/openvibe.network\/@alice"/);
++assert.match(signedOut, /class="ovc-target"/);
++assert.match(signedOut, /&lt;picture&gt;/);
++assert.match(signedOut, /<img src="https:\/\/openvibe.media\/o\/med_/);
++assert.match(signedOut, /notes.pdf/);
++assert.match(signedOut, /%2Fwatch%3Fv%3D12345678901%26sort%3Dtop/);
++const signedIn = renderThread({ ...options, signedIn: true, replyId: 11 });
++assert.match(signedIn, /data-ovt-composer/);
++assert.match(signedIn, /name="parent_id" value="11"/);
++assert.match(signedIn, /data-ovt-files/);
++assert.match(signedIn, /name="comment_id" value="10"/);
++assert.match(renderThread({ ...options, answer: { ...answer, comments: [] }, signedIn: false }), /Be the first to comment/);
++process.stdout.write('Inline thread renders replies, targets, attachments, order, composer and signed-out state\n');
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a5f9968..382db15 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -42,7 +42,8 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true,
++ fetchImpl: opts.fetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/pics-comments-client.test.js b/test/pics-comments-client.test.js
iindex fe0db1d..a7ba491 100644
---- a/test/pics-comments-client.test.js
++++ b/test/pics-comments-client.test.js
@@@ -18,6 +18,7 @@ const { createCommentsClient } = require('../server/pics/comments-client');
assert.equal(form.get('file').name, 'look.png');
return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };
}
++ if (init.method === 'DELETE') return { status: 200, json: async () => ({ ok: true }) };
if (init.method === 'GET') return { status: 200, json: async () => ({ comments: [{ id: 4, message: 'hello' }], next_cursor: null }) };
return { status: 201, json: async () => ({ comment: { id: 5, parent_id: 4 } }) };
};
@@@ -32,6 +33,10 @@ const { createCommentsClient } = require('../server/pics/comments-client');
assert.deepEqual(JSON.parse(calls[3].init.body), { message: 'reply', parent_id: 4, attachments: [] });
const uploaded = await client.upload(picture, token, Buffer.from('look'), 'look.png', 'image/png');
assert.equal(uploaded.status, 201);
++ const removed = await client.remove('4', token);
++ assert.equal(removed.status, 200);
++ await client.read(picture, token, { sort: 'top' });
++ assert.ok(calls.some((c) => c.url.includes('sort=top')));
assert.ok(calls.every((c) => c.init.headers.Authorization === `Bearer ${token}`));
process.stdout.write('Pics comments client read, reply, and upload pass\n');
})().catch((err) => { process.stderr.write(`${err.stack}\n`); process.exitCode = 1; });
ddiff --git a/test/pics-inline-page.test.js b/test/pics-inline-page.test.js
nnew file mode 100644
iindex 0000000..b263c25
---- /dev/null
++++ b/test/pics-inline-page.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const posts = [];
++ const fetchImpl = async (url, init) => {
++ if (!url.startsWith('https://openvibe.community/api/v1/comments')) return fetch(url, init);
++ if (url.endsWith('/threads/resolve')) return Response.json({ thread: { id: 'cth_1234567890123456789012' } });
++ if (init.method === 'GET') {
++ const q = new URL(url).searchParams;
++ if (q.has('parent')) return Response.json({ comments: [{ id: 3, parent_id: 1, message: 'third reply', author: { username: 'reader', display_name: 'Reader' } }], next_cursor: null });
++ return Response.json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },
++ comments: [{ id: 1, message: 'On the picture', author: { username: 'reader', display_name: 'Reader' }, created_at: '2026-11-10T12:00:00Z',
++ reply_count: 3, replies: [{ id: 2, parent_id: 1, reply_to_id: 1, reply_to: { display_name: 'Reader' }, message: 'first reply' }],
++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6', name: 'look.png', media_type: 'image/png' }] }],
++ viewer: { can_comment: true }, next_cursor: null });
++ }
++ if (init.method === 'POST') { posts.push(JSON.parse(init.body)); return Response.json({ comment: { id: 4 } }, { status: 201 }); }
++ throw new Error(`Unexpected Community call: ${url}`);
++ };
++ const t = await boot({ fetchImpl });
++ const person = t.network.addUser('picturewriter');
++ const request = (path, { method = 'GET', body = null, headers = {}, signedIn = false } = {}) => new Promise((resolve, reject) => {
++ const target = new URL(t.base);
++ const req = http.request({ hostname: target.hostname, port: target.port, path, method,
++ headers: { Host: 'openvibe.pics', ...(signedIn ? { Cookie: t.signIn(person) } : {}), ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (res) => {
++ const chunks = [];
++ res.on('data', (part) => chunks.push(part));
++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, location: res.headers.location }); });
++ });
++ req.on('error', reject);
++ req.end(body);
++ });
++ try {
++ const photo = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#a84' } }).jpeg().toBuffer();
++ const uploaded = await request('/api/v1/images', { method: 'POST', body: photo, signedIn: true,
++ headers: { Origin: 'https://openvibe.pics', 'Content-Type': 'image/jpeg' } });
++ assert.equal(uploaded.status, 201, uploaded.text.slice(0, 400));
++ const slug = JSON.parse(uploaded.text).slug;
++ await check('picture page renders comments, replies, target and attachment for signed-out visitors', async () => {
++ const page = await request(`/i/${slug}`);
++ assert.equal(page.status, 200, page.text.slice(0, 400));
++ for (const value of ['ovc-thread', 'On the picture', 'first reply', 'ovc-target', 'ovc-more', 'ovc-attach', 'Sign in to comment'])
++ assert.ok(page.text.includes(value), value);
++ });
++ await check('picture composer posts through the Pics proxy to Community', async () => {
++ const page = await request(`/i/${slug}`, { signedIn: true });
++ assert.match(page.text, /data-ovt-composer/);
++ const boundary = 'pics-comment-test';
++ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);
++ const sent = await request(`/i/${slug}/comments`, { method: 'POST', body, signedIn: true,
++ headers: { Origin: 'https://openvibe.pics', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });
++ assert.equal(sent.status, 303, sent.text.slice(0, 400));
++ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/video-comments-client.test.js b/test/video-comments-client.test.js
nnew file mode 100644
iindex 0000000..8d5c9db
---- /dev/null
++++ b/test/video-comments-client.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const { createCommentsClient } = require('../server/video/comments-client');
++
++(async () => {
++ const calls = [];
++ const id = 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const row = { id, title: 'Unlisted test' };
++ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const ref = { service: 'media-hub', type: 'video', id, label: row.title };
++ const reply = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
++ const fetchImpl = async (url, init) => {
++ calls.push({ url, init });
++ if (url === 'http://network/oauth/token') return reply({ access_token: 'service-token', expires_in: 300 });
++ assert.equal(init.headers.Authorization, 'Bearer service-token');
++ if (url.endsWith('/threads/resolve')) {
++ assert.deepEqual(JSON.parse(init.body).ref, ref);
++ return reply({ thread: { access_id: 'cth_1234567890123456789012' } });
++ }
++ if (url.endsWith('/attachments')) {
++ assert.deepEqual(JSON.parse(init.body.get('ref')), ref);
++ return reply({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6' } }, 201);
++ }
++ if (url.endsWith('/123')) return reply({ comment: { author: { subject } }, thread: { ref } });
++ if (url.includes('/threads/cth_') && init.method === 'GET') return reply({ thread: { ref }, comments: [], next_cursor: null, viewer: { can_comment: true } });
++ if (init.method === 'DELETE') return reply({ ok: true });
++ if (init.method === 'POST') return reply({ comment: { id: 123 } }, 201);
++ throw new Error(url);
++ };
++ const client = createCommentsClient({ config: { networkInternalUrl: 'http://network', oauth: { clientId: 'media-hub', clientSecret: 'test' } }, fetchImpl });
++ const answer = await client.read(row, { subject, sort: 'top' });
++ assert.deepEqual(answer.comments, []);
++ assert.ok(calls.some((c) => c.url.includes('sort=top')));
++ await client.upload(row, subject, new File(['image'], 'look.png', { type: 'image/png' }));
++ await client.add(row, subject, { message: 'hello' });
++ await client.remove(row, subject, true, '123');
++ assert.ok(calls.some((c) => c.init.headers['X-OV-Subject'] === subject));
++ assert.ok(calls.some((c) => c.init.headers['X-OV-Staff'] === '1'));
++ console.log('Video Community proxy read, upload, post and scoped removal pass');
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 3e5702f..ae1f83f 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -80,9 +80,12 @@ function startLive() {
};
const comments101 = [
-- { id: 'c1', message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false },
-- { id: 'c2', message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },
-- { id: 'c3', message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },
++ { id: 1, message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false,
++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'look.png', media_type: 'image/png' }],
++ replies: [{ id: 4, message: 'First reply', display_name: 'Alice', username: 'alice', created_at: '2026-10-12T10:01:00.000Z', reply_to_id: 1, reply_to: { display_name: 'Commenter' } },
++ { id: 5, message: 'Second reply', display_name: 'Bob', username: 'bob', created_at: '2026-10-12T10:02:00.000Z', reply_to_id: 4, reply_to: { display_name: 'Alice' } }] },
++ { id: 2, message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },
++ { id: 3, message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },
];
const chatMessages101 = [
{ id: 'm1', timestamp: '2026-10-12 09:29:59', username: 'early', display_name: 'Early', message: 'EARLY_MSG_XYZ', message_type: 'chat', user_id: 900001, anon_id: 'anon-PRIVATE-1', metadata: { note: 'meta-PRIVATE-1' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
@@@ -767,7 +770,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
});
-- await check('the VOD page shows the newest comments and the Community link', async () => {
++ await check('the VOD page renders replies, targets, attachments, order and the Community link', async () => {
const r = await getVideo('/v/101');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('Comments'));
@@@ -775,17 +778,21 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('other_user'), 'username fallback for the author');
assert.ok(r.text.includes('Nice &lt;b&gt;stream&lt;/b&gt;!'), 'comment text is escaped');
assert.ok(!r.text.includes('Nice <b>stream</b>!'), 'raw comment HTML never appears');
-- assert.ok(r.text.includes('3 replies'), 'reply count when present');
-- assert.ok(r.text.includes('<time class="ovv-comment-date"'), 'a relative date is shown');
-- assert.strictEqual(countOccurrences(r.text, 'class="ovv-comment"'), 2, 'the deleted comment is not rendered');
++ assert.ok(r.text.includes('1 more reply'), 'the remaining replies load separately');
++ assert.ok(r.text.includes('class="ovc-target"'), 'the exact reply target is shown');
++ assert.ok(r.text.includes('class="ovc-attach ovc-attach--image"'), 'the picture attachment is shown');
++ assert.ok(r.text.includes('<time datetime='), 'a relative date is shown');
++ assert.strictEqual(countOccurrences(r.text, 'class="ovc-comment"'), 5, 'top-level comments and first replies are rendered');
++ assert.ok(r.text.includes('class="ovc-order"'), 'Top and Newest are server-rendered');
++ assert.ok(r.text.includes('Sign in to comment.'), 'the signed-out composer spot has a sign-in action');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
});
-- await check('a VOD with no comments shows the empty line and the link', async () => {
++ await check('a VOD with no comments uses the kit empty state and keeps the Community link', async () => {
const r = await getVideo('/v/102');
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('No comments yet.'));
++ assert.ok(r.text.includes('Be the first to comment'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-102"'));
});