openvibers/openvibe.mediahub Commits

Comments: the inline thread renderer and stylesheet come from openvibe-shared (#436)

OP OpenVibers committed 7 h ago · 243cb63509 · 7 files, +12 −154

Changes

ddiff --git a/public/css/comments.css b/public/css/comments.css
ddeleted file mode 100644
iindex 30855a4..0000000
---- a/public/css/comments.css
++++ /dev/null
@@@ -1,24 +0,0 @@
--/* The inline Community thread on Video and Pics item pages: a quiet head (the title, then Top and Newest only when
-- there is something to order), the composer or one sign-in line, the comments, and a small link to the discussion. */
--.ovc-thread { margin: 24px 0; }
--.ovv-panel > .ovc-thread { margin: 0; }
--.ovc-head, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
--.ovc-head { justify-content: space-between; min-height: 40px; margin-bottom: 8px; }
--.ovc-head h2 { margin: 0; font-size: 1.125rem; }
--.ovc-signin, .ovc-none { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0; }
--.ovc-none { margin-top: 12px; opacity: .75; }
--.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 { 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: 2px; padding: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 8%, transparent); }
--.ovc-order a { min-height: 36px; padding: 4px 14px; border: 0; border-radius: 999px; font-size: .875rem; }
--.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 16%, transparent); }
--.ovc-community { margin: 12px 0 0; font-size: .875em; }
--.ovc-community a { color: inherit; opacity: .8; text-decoration: none; }
--.ovc-community a::after { content: ' \2192'; }
--.ovc-community a:hover, .ovc-community a:focus-visible { opacity: 1; text-decoration: underline; }
ddiff --git a/server/comments/form.js b/server/comments/form.js
iindex 305132d..f745439 100644
---- a/server/comments/form.js
++++ b/server/comments/form.js
@@@ -1,7 +1,7 @@
'use strict';
const express = require('express');
--const { ID } = require('./inline');
++const ID = /^\d{1,15}$/; // the comment service's ids (the thread renderer carries the same rule)
const ACCEPT = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
const MAX_FILE = 16 * 1024 * 1024;
ddiff --git a/server/comments/inline.js b/server/comments/inline.js
ddeleted file mode 100644
iindex 6cc7812..0000000
---- a/server/comments/inline.js
++++ /dev/null
@@@ -1,78 +0,0 @@
--'use strict';
--
--/** Plain, server-rendered Community thread for the Video and Pics item pages. */
--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;
-- // Signed out, the composer's spot is one line; with no comments yet that line is the whole empty state (no second
-- // box saying the same thing).
-- const signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first 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));
-- // Top and Newest only when there is something to put in order.
-- const order = comments.length < 2 && !(answer && answer.next_cursor) ? '' : `<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>` : (signedIn ? '<p class="ovc-none">Be the first to comment.</p>' : '')}${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/pages.js b/server/pics/pages.js
iindex 065253b..fb3fd19 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -40,7 +40,7 @@ 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 { renderThread } = require('openvibe-shared/thread');
const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
@@@ -430,8 +430,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', 'js/comments.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js'],
-- localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],
++ scripts: ['js/pics.js', 'js/pics-gallery.js', 'js/comments.js'], styles: ['kit.css', 'thread.css', 'thread-inline.css'], sharedScripts: ['kit-web.js'],
++ localStyles: [...(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.turned ? notice('Turned a quarter. Every size of the picture follows.', '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>` : ''}
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 3ccabe9..1a9cc88 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -48,7 +48,7 @@ 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 { renderThread } = require('openvibe-shared/thread');
const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@@ -1578,7 +1578,7 @@ ${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', 'thread.css'],
++ styles: ['kit.css', 'thread.css', 'thread-inline.css'],
description: about,
canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
@@@ -1586,7 +1586,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js', 'js/comments.js'],
-- localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
++ localStyles: [...(manageable ? ['css/labels.css'] : [])],
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
@@@ -1703,7 +1703,7 @@ ${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', 'thread.css'],
++ styles: ['kit.css', 'thread.css', 'thread-inline.css'],
description: clip.ai_overview || factualDescription('clip', clip),
canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
@@@ -1711,7 +1711,7 @@ ${more || ''}
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js', 'js/comments.js'],
-- localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
++ localStyles: [...(manageable ? ['css/labels.css'] : [])],
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 4ee3a6d..81e972d 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -27,7 +27,7 @@ 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 { renderThread } = require('openvibe-shared/thread');
const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const REASON_TEXT = {
@@@ -482,8 +482,8 @@ ${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', 'thread.css'],
-- localStyles: ['css/comments.css'],
++ styles: ['kit.css', 'thread.css', 'thread-inline.css'],
++ localStyles: [],
description: about,
canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
ddiff --git a/test/comments-inline.test.js b/test/comments-inline.test.js
ddeleted file mode 100644
iindex 5e8dff0..0000000
---- a/test/comments-inline.test.js
++++ /dev/null
@@@ -1,40 +0,0 @@
--'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.doesNotMatch(signedOut, /class="ovc-order"/, 'one top-level comment has nothing to put in order');
--const two = renderThread({ ...options, answer: { ...answer, comments: [...answer.comments, { ...answer.comments[0], id: 20, replies: [], reply_count: 0 }] }, signedIn: false });
--assert.match(two, /class="ovc-order"/);
--assert.match(two, /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"/);
--const emptyOut = renderThread({ ...options, answer: { ...answer, comments: [] }, signedIn: false });
--assert.match(emptyOut, /<p class="ovc-signin">Be the first to comment\. <a /, 'signed out with no comments: one line, sign-in beside it');
--assert.doesNotMatch(emptyOut, /ovk-empty/, 'no second box saying the same thing');
--assert.match(renderThread({ ...options, answer: { ...answer, comments: [] }, signedIn: true }), /<p class="ovc-none">Be the first to comment\.<\/p>/);
--process.stdout.write('Inline thread renders replies, targets, attachments, order, composer and signed-out state\n');