openvibers/openvibe.mediahub Commits
Comments: the inline thread renderer and stylesheet come from openvibe-shared (#436)
Changes
ddeleted file mode 100644@@@ -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; }@@@ -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;ddeleted file mode 100644@@@ -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 };@@@ -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>` : ''}@@@ -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="ovvnoindex: 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="ovvimage: 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">@@@ -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>` : htindex: 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' } : {}),ddeleted file mode 100644@@@ -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, /<picture>/);--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');
deleted file mode 100644
deleted file mode 100644
@@ -1,24 +0,0 @@
@@ -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; }
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
const express = require('express');
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 ACCEPT = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
const MAX_FILE = 16 * 1024 * 1024;
const MAX_FILE = 16 * 1024 * 1024;
deleted file mode 100644
deleted file mode 100644
@@ -1,78 +0,0 @@
@@ -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 };
@@ -40,7 +40,7 @@ const store = require('./store');
@@ -40,7 +40,7 @@ const store = require('./store');
const { createPicsApi } = require('./api');
const { createPicsApi } = require('./api');
const { createCommentsClient } = require('./comments-client');
const { createCommentsClient } = require('./comments-client');
const { createCommentsClient: createServiceCommentsClient } = require('../video/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 { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const { createPicVariants } = require('./variants');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
@@ -430,8 +430,8 @@ ${raw(showcase.network({
@@ -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,
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}.`,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
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,
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') : ''}
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>` : ''}
${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>` : ''}
@@ -48,7 +48,7 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');
@@ -48,7 +48,7 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
const { mountDownloads, downloadMenu } = require('./downloads');
const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
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 { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -1578,7 +1578,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1578,7 +1578,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
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,
description: about,
canonical: `${base}${watchPath}`,
canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
...(ownOnly || vod.is_recording ? {} : {
@@ -1586,7 +1586,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js', 'js/comments.js'],
scripts: ['js/video.js', 'js/comments.js'],
- localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
+ localStyles: [...(manageable ? ['css/labels.css'] : [])],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
@@ -1703,7 +1703,7 @@ ${more || ''}
@@ -1703,7 +1703,7 @@ ${more || ''}
noindex: unlisted || ownOnly || aiClip,
noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
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),
description: clip.ai_overview || factualDescription('clip', clip),
canonical: `${base}${watchPath}`,
canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
...(ownOnly || aiClip ? {} : {
@@ -1711,7 +1711,7 @@ ${more || ''}
@@ -1711,7 +1711,7 @@ ${more || ''}
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js', 'js/comments.js'],
scripts: ['js/video.js', 'js/comments.js'],
- localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
+ localStyles: [...(manageable ? ['css/labels.css'] : [])],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
@@ -27,7 +27,7 @@ const { html, raw, notice, askAgent } = require('../render/html');
@@ -27,7 +27,7 @@ const { html, raw, notice, askAgent } = require('../render/html');
const { UploadError } = require('./uploads');
const { UploadError } = require('./uploads');
const { ShareError } = require('openvibe-sdk/share');
const { ShareError } = require('openvibe-sdk/share');
const { createCommentsClient, CommentError } = require('./comments-client');
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 { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const REASON_TEXT = {
const REASON_TEXT = {
@@ -482,8 +482,8 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -482,8 +482,8 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
index: listed, noindex: !listed,
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
title: row.title,
title: row.title,
- styles: ['kit.css', 'thread.css'],
- localStyles: ['css/comments.css'],
+ styles: ['kit.css', 'thread.css', 'thread-inline.css'],
+ localStyles: [],
description: about,
description: about,
canonical: `${ctx.config.video.url}${watchPath}`,
canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
deleted file mode 100644
deleted file mode 100644
@@ -1,40 +0,0 @@
@@ -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, /<picture>/);
-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');