openvibers/openvibe.mediahub Commits

The one Share sheet on every MediaHub page (#290)

OP OpenVibers committed 7 days ago · 01a60055f3 · 15 files, +104 −114

* The one Share sheet on every MediaHub page

Replace the site's own share links with the shared sheet button (openvibe-shared 4.114.0): the watch page's share box (the link field and the copy buttons) is gone, and the copy/share JavaScript and its CSS with it.

- server/video/watch-ui.js, server/render/html.js: one shareButton helper for the whole service
- server/video/pages.js, server/video/upload-routes.js: the watch, clip, upload and channel pages carry one button, with the page's own public address and the player's moment (data-time-from)
- server/pics/pages.js: the picture page carries one button, with the picture as its image
- server/http/pages.js: the owner's file page and the public share page carry one button
- tests: the old share markup assertions are updated, and each page is checked for one button with the right address

* One Share button everywhere: the shared kit's

The Share button is now the shared kit's (openvibe-shared 4.114.0 kit.share): the same quiet kit button with the same share icon on every OpenVibe site, instead of a button each site dressed on its own.

- server/render/html.js: shareButton() builds the kit's button (and adds data-time-from for the pages that play a video)
- server/video/layout.js, server/render/layout.js: the pages load /shared/kit.css, so the button looks the same here as everywhere else
- placement: the watch, clip and upload pages keep it in their own actions row; the channel page and the picture page carry it right under the title's meta line; the drive's file and share pages keep it beside their Download

* The kit's stylesheet only on the pages that share

The home page was carrying /shared/kit.css (42 KB) for a button it does not have: cssFiles 3 > 2, cssRawKB 80.6 > 40, cssBrotliKB 16.8 > 9. The budget stays where it was.

- server/video/layout.js: the layout carries the shared stylesheets a page asks for (o.styles)
- the watch, clip, upload and channel pages ask for kit.css; the picture page already had it
- server/render/layout.js: the drive's layout does not add it to every page
- server/http/pages.js: the owner's file page and the public share page ask for it

The pages that carry the button, measured with openvibe-shared/perf-budget:
  /v/501       css 4 files 125.1 KB (25.4 br)   app.css + video.css + kit.css + player.css
  /files/:id   css 2 files  52.8 KB (11.1 br)   app.css + kit.css
  / (video)    css 2 files  69.0 KB (13.6 br)   unchanged
  / (drive)    css 2 files  39.1 KB ( 8.4 br)   unchanged — the budget test's page

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 3634161..13697b0 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -348,10 +348,6 @@ a.ovv-channel-name:hover { text-decoration: underline; }
/* A clip's credit: the recording it was cut from, and a link back to it. */
.ovv-source { margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
.ovv-source i { color: var(--ovv-faint); }
--/* The share box: the page's own link, and a link to the moment it is playing (a moment, not a clip). */
--.ovv-share-url { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--ovv-line-strong); border-radius: 8px; background: var(--bg-tertiary, #16223a); color: inherit; font-size: .82rem; }
--.ovv-share-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; }
--
/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */
.ovv-pop { position: relative; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex cfc4140..7a7a7b9 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -242,60 +242,6 @@
if (window.location.hash.length > 1) open(window.location.hash.slice(1));
}
-- // Share: the page's own link, and the link to the moment it is playing. The link is written in a field on the page,
-- // so it can be copied without JavaScript; these buttons (copy, copy at this moment) appear when JavaScript is there.
-- function share(video) {
-- const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;
-- const fieldOf = function (b) {
-- const id = b.getAttribute('data-ovv-share-field');
-- return id ? document.getElementById(id) : null;
-- };
-- const momentUrl = function (url) {
-- try {
-- const u = new URL(url);
-- if (video && video.currentTime > 1 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));
-- return u.toString();
-- } catch (e) { return url; }
-- };
-- const copied = function (b, url) {
-- const label = b.innerHTML;
-- const field = fieldOf(b);
-- if (field) field.value = url;
-- const said = function (text) {
-- b.innerHTML = text;
-- setTimeout(function () { b.innerHTML = label; }, 2000);
-- };
-- if (!navigator.clipboard || !navigator.clipboard.writeText) {
-- // No clipboard to write to: the field is selected, so the reader can copy it by hand.
-- if (field) { field.focus(); field.select(); }
-- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link ready to copy');
-- return;
-- }
-- navigator.clipboard.writeText(url).then(function () {
-- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied');
-- }, function () { window.prompt('Copy this link', url); });
-- };
-- document.querySelectorAll('[data-ovv-share]').forEach(function (b) {
-- b.hidden = false;
-- b.addEventListener('click', function () {
-- const url = b.getAttribute('data-ovv-share');
-- const field = fieldOf(b);
-- if (field) field.value = url;
-- if (navigator.share && window.matchMedia('(hover: none)').matches) {
-- navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
-- return;
-- }
-- copied(b, url);
-- });
-- });
-- document.querySelectorAll('[data-ovv-share-at]').forEach(function (b) {
-- b.hidden = false;
-- b.addEventListener('click', function () {
-- copied(b, momentUrl(b.getAttribute('data-ovv-share-at') || ''));
-- });
-- });
-- }
--
// The transcript follows the video (the line being said is marked and kept in view inside its panel, unless the
// reader scrolled it lately or is searching), and its search box keeps only the lines that have the words.
function transcript(video) {
@@@ -505,7 +451,6 @@
copyButtons();
tabs();
openers();
-- share(document.querySelector('video[data-ovv-video]'));
const video = document.querySelector('video[data-ovv-video]');
transcript(video);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 308a046..7ab73fd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { TWIN_PATHS } = require('../drive/agent-pages');
const { sameOrigin } = require('./principal');
--const { html, raw, table, notice, time, badge } = require('../render/html');
++const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
const { send } = require('../render/layout');
const rules = require('../drive/rules');
const { isStaff } = require('../drive/staff');
@@@ -325,11 +325,12 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
return page(req, res, {
title: row.name,
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
body: html`<h1>${row.name}</h1>
${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
--<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>
++<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: rules.isPreviewable(row.content_type) ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '' }))}</p>
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
<form method="post" action="/files/${row.id}/share">
<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
@@@ -604,12 +605,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
if (!file) return shareGone(req, res, share);
return page(req, res, {
title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
body: html`<h1>${file.name}</h1>
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
--<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
++<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: file.name, text: `${file.name} — shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.`, image: rules.isPreviewable(file.content_type) ? `${config.baseUrl}/s/${share.slug}/preview?w=640` : '' }))}</p>
${mine ? '' : reportForm(share)}
<p class="small muted">${REPORT_NOTE}</p>`,
});
@@@ -618,10 +620,11 @@ ${mine ? '' : reportForm(share)}
const folder = await drive.ownFolder(share.owner, share.folder_id);
return page(req, res, {
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
--<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>
++<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)} ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: folder ? folder.name : 'A shared folder', text: `A folder shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.` }))}</p>
${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
${mine ? '' : reportForm(share)}`,
});
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 6d6d7f9..d8c7670 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
--const { html, raw, notice, time } = require('../render/html');
++const { html, raw, notice, time, shareButton } = require('../render/html');
const labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
@@@ -356,6 +356,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
${labelsUi.chips(labelsUi.inForce(row))}
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
++${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct }))}
${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>
ddiff --git a/server/render/html.js b/server/render/html.js
iindex 1dfe9ed..792cc46 100644
---- a/server/render/html.js
++++ b/server/render/html.js
@@@ -21,7 +21,44 @@ function render(v) {
return esc(v);
}
++const kit = require('openvibe-shared/kit');
++
const code = (s) => html`<code>${s}</code>`;
++
++/**
++ * The Share button, built by the shared kit (openvibe-shared 4.114.0): the same quiet button with the same share
++ * icon on every OpenVibe site. The navbar fetches the sheet the first time the button is pressed.
++ *
++ * url: the page's own public address — the link that is shared
++ * title: what the thing is called
++ * text: a line sent with it (the thing's own summary)
++ * image: a picture of it (a thumbnail), for the places that take one
++ * embed: an embeddable player address, for the places that take one
++ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"
++ */
++function shareButton({ title = '', url = '', text = '', image = '', embed = '', timeFrom = null, small = false }) {
++ const line = String(text || '').trim().replace(/\s+/g, ' ').slice(0, 240);
++ const button = kit.share({ title, url, text: line, image, embed, small });
++ return timeFrom ? button.replace('data-ov-share-sheet', `data-ov-share-sheet data-time-from="${esc(timeFrom)}"`) : button;
++}
++
++/**
++ * The Share button: the one share sheet every OpenVibe site opens (openvibe-shared 4.114.0). The navbar fetches
++ * the sheet the first time the button is pressed, so the button itself only says what is being shared:
++ *
++ * url: the page's own public address — the link that is shared
++ * title: what the thing is called
++ * text: a line sent with it (the thing's own summary)
++ * image: a picture of it (a thumbnail), for the places that take one
++ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"
++ *
++ * Nothing is stored and no clip is made: a moment is the page's own address with a time on it.
++ */
++function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, cls = '', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {
++ const line = String(text || '').trim().replace(/\s+/g, ' ');
++ const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
++ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
++}
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column
@@@ -43,4 +80,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
--module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton };
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 602ce6d..3671346 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -64,6 +64,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 79c6748..7ffc54b 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
--const { actionMenu, menuLinks, companion, whatItIs, sharePanel, lengthWords, dayLabel, streamDate } = require('./watch-ui');
++const { actionMenu, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -792,7 +792,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
-- const uploadCard = mountUploadPages(r, { 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, sharePanel });
++ const uploadCard = mountUploadPages(r, { 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 });
// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
@@@ -1289,6 +1289,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'],
description: about,
...(ownOnly || vod.is_recording ? {} : {
jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
@@@ -1312,7 +1313,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${sharePanel({ url: `${base}/v/${id}`, id, at: Number(req.query.t) || null })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ url: `${base}/v/${id}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
@@@ -1413,6 +1414,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
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'],
description: clip.ai_overview || factualDescription('clip', clip),
...(ownOnly || aiClip ? {} : {
jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
@@@ -1435,7 +1437,7 @@ ${clipCredit}
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
--<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${sharePanel({ url: `${base}/c/${id}`, id, at: Number(req.query.t) || null, what: 'clip' })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ url: `${base}/c/${id}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
@@@ -1599,8 +1601,9 @@ ${up.html}
return page(req, res, {
index: true,
title: `@${username}`,
++ styles: ['kit.css'],
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
-- body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
++ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 6ec1a9a..f4425f4 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -22,7 +22,7 @@
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const { sameOrigin } = require('../http/principal');
--const { html, notice } = require('../render/html');
++const { html, raw, notice } = require('../render/html');
const { UploadError } = require('./uploads');
const REASON_TEXT = {
@@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
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, sharePanel } = 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 } = h;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@@ -279,6 +279,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
title: row.title,
++ styles: ['kit.css'],
description: about,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
scripts: ready ? ['js/video.js'] : [],
@@@ -299,7 +300,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
--${saveForm || sharePanel ? html`<div class="ovv-byline-actions">${saveForm}${sharePanel ? sharePanel({ url: `${ctx.config.video.url}/u/${row.id}`, id: row.id, at: Number(req.query.t) || null }) : ''}</div>` : ''}
++<div class="ovv-byline-actions">${saveForm}${raw(shareButton({ url: `${ctx.config.video.url}/u/${row.id}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
ddiff --git a/server/video/watch-ui.js b/server/video/watch-ui.js
iindex 3acafcb..4738049 100644
---- a/server/video/watch-ui.js
++++ b/server/video/watch-ui.js
@@@ -13,12 +13,13 @@
*
* whatItIs({ … }) the one line under a watch page's title that says what the video is: a stream recording, a clip
* or an upload, when it was streamed or published, how long it is and how many people watched
-- * sharePanel({ … }) the share box: the page's own link, and a link to the moment it is playing (no clip, nothing
-- * stored)
++ * shareButton({ … }) the Share button: the one share sheet every OpenVibe site opens (openvibe-shared
++ * 4.114.0). The navbar fetches the sheet the first time the button is pressed; the button
++ * itself only says what is being shared.
*
* Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.
*/
--const { html } = require('../render/html');
++const { html, shareButton } = require('../render/html');
/** Seconds → "1:57", or "0:01:57" when the video runs an hour or more (every time in it then lines up). */
function clockOf(seconds, long = false) {
@@@ -102,29 +103,7 @@ function isoOf(v) {
return String(v == null ? '' : v);
}
--/**
-- * The share box: the page's own link, and a link to the moment the video is playing. The link is written out in a
-- * field, so it can be copied without JavaScript; the buttons (copy, copy at this moment) appear when JavaScript is
-- * there. Nothing is stored and no clip is made: it is the page's address with a time on it.
-- *
-- * url: the page's own address (with ?t= when the page was opened at a moment)
-- * at: the moment in the video the page was opened at (seconds), or null
-- */
--function sharePanel({ url, id, at = null, what = 'video' }) {
-- const field = `ovv-share-${id}`;
-- const withTime = at != null && Number(at) > 0 ? `${url}?t=${Math.floor(Number(at))}` : url;
-- return html`<details class="ovv-pop ovv-share" data-ovv-menu><summary class="ovv-btn" title="Share a link to this ${what}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</summary>
--<div class="ovv-pop-panel" role="group" aria-label="Share this ${what}">
--<p class="ovv-pop-title">Share this ${what}</p>
--<label class="sr-only" for="${field}">The link to this video</label>
--<input class="ovv-share-url" id="${field}" type="text" readonly value="${withTime}">
--<p class="ovv-fine">A plain link: it opens this page. Put <code>?t=125</code> on the end and the video opens at 2:05 — a moment, not a clip: nothing is stored and nothing is cut.</p>
--<p class="ovv-share-buttons">
--<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share="${url}" data-ovv-share-field="${field}">Copy the link</button>
--<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share-at="${url}" data-ovv-share-field="${field}">Copy the link at this moment</button>
--</p>
--</div></details>`;
--}
++
/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {
@@@ -217,4 +196,4 @@ ${t.body}
</section>`;
}
--module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, sharePanel, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
++module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, shareButton, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
iindex a2379c9..59a1bd4 100644
---- a/test/drive-pages.test.js
++++ b/test/drive-pages.test.js
@@@ -76,6 +76,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);
});
++ await check('the file page carries one Share button, with the file page\'s own address', async () => {
++ const file = (await upload(t, kim, bytes(80, 'shared'), { name: 'shared.txt', contentType: 'text/plain' })).json().file;
++ const r = await t.get(`/files/${file.id}`, { as: kim });
++ assert.strictEqual(r.status, 200);
++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
++ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/files/${file.id}"`), buttons[0]);
++ });
++
await check('everything a person typed is escaped, everywhere it is shown', async () => {
const nastyName = '<script>alert("x")</script>.txt';
const nastyFolder = '<img src=x onerror="alert(1)">';
ddiff --git a/test/drive-public-shares.test.js b/test/drive-public-shares.test.js
iindex 0ace540..b12d0da 100644
---- a/test/drive-public-shares.test.js
++++ b/test/drive-public-shares.test.js
@@@ -24,6 +24,10 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
const page = await t.get(`/s/${slug}`);
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));
++ // One Share button, with the share page's own address.
++ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(buttons.length, 1, 'one Share button on the share page: ' + buttons.join(' | '));
++ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/s/${slug}"`), buttons[0]);
const dl = await t.get(`/s/${slug}/download`);
assert.strictEqual(dl.status, 200);
assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex 631355a..40f1e42 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -150,6 +150,11 @@ function multipart(fields, file) {
assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');
assert.ok(page.text.includes('Report this picture'));
assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');
++ // One Share button: the picture's own page, with the picture as its image.
++ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
++ assert.ok(buttons[0].includes(`data-url="https://openvibe.pics/i/${slug}"`), buttons[0]);
++ assert.ok(buttons[0].includes(`data-image="https://openvibe.pics${direct}"`), buttons[0]);
assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
});
ddiff --git a/test/video-downloads.test.js b/test/video-downloads.test.js
iindex 59301ef..e1e9788 100644
---- a/test/video-downloads.test.js
++++ b/test/video-downloads.test.js
@@@ -158,7 +158,7 @@ function unzip(buf) {
assert.ok(!r.text.includes('ovv-panel-dl'), 'no line of links under a panel any more');
// The layout: tabs beside the player, one action row, the stream's parts in About.
assert.strictEqual((r.text.match(/role="tab"/g) || []).length, 3, 'Chat, Chapters, Transcript');
-- assert.ok(r.text.includes('data-ovv-share="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');
++ assert.ok(r.text.includes('data-ov-share-sheet data-url="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');
assert.ok(r.text.includes('<a href="/v/6025">Part 1</a>') && r.text.includes('<b>Part 2 (this one)</b>'), 'the other part of the stream');
// Chat replay sits in the part's own time: 12 s in, not 721 s.
assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
ddiff --git a/test/video-outcomes.test.js b/test/video-outcomes.test.js
iindex 68f637e..d77e2d6 100644
---- a/test/video-outcomes.test.js
++++ b/test/video-outcomes.test.js
@@@ -175,22 +175,24 @@ function startLive() {
assert.ok(!line.includes('A long recording'), 'no title from a recording we cannot see');
});
-- await check('outcome 3: the page carries a plain link, and says how to make a link to a moment', async () => {
++ await check('outcome 3: the page carries one Share button, with the page\'s own link', async () => {
const r = await get('/v/501');
-- const field = /<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(r.text);
-- assert.ok(field, 'the link is written out on the page');
-- assert.strictEqual(field[1], 'https://openvibe.video/v/501');
-- const panel = r.text.slice(r.text.indexOf('ovv-share'), r.text.indexOf('</details>', r.text.indexOf('ovv-share')));
-- assert.ok(panel.includes('?t=125'), 'the panel says how to link to a moment: ' + panel);
-- assert.ok(panel.includes('nothing is stored'), 'a moment is not a clip: ' + panel);
-- assert.ok(panel.includes('data-ovv-share-at="https://openvibe.video/v/501"'), 'the button that copies the link at the moment it plays');
-- // The page opened at a moment: the field shows that link.
-- const at = await get('/v/501?t=125');
-- assert.strictEqual(/<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(at.text)[1], 'https://openvibe.video/v/501?t=125');
-- // An upload's watch page carries the same plain link.
++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
++ assert.ok(buttons[0].includes('data-url="https://openvibe.video/v/501"'), buttons[0]);
++ assert.ok(buttons[0].includes('data-time-from=".ovv-player"'), 'the sheet can share the moment it plays: ' + buttons[0]);
++ assert.ok(!r.text.includes('data-ovv-share'), 'the old share box is gone');
++ // A clip's page carries the same button, with the clip's own link.
++ const clip = await get('/c/602');
++ const clipButtons = clip.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(clipButtons.length, 1, 'one Share button on the clip page: ' + clipButtons.join(' | '));
++ assert.ok(clipButtons[0].includes('data-url="https://openvibe.video/c/602"'), clipButtons[0]);
++ // An upload's watch page carries the same button.
const up = await get('/u/vup_01J0000000000000000000000B');
assert.strictEqual(up.status, 200);
-- assert.ok(/<input class="ovv-share-url"[^>]*value="https:\/\/openvibe\.video\/u\/vup_01J0000000000000000000000B"/.test(up.text), 'the upload page has the link too');
++ const upButtons = up.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(upButtons.length, 1, 'one Share button on the upload page: ' + upButtons.join(' | '));
++ assert.ok(upButtons[0].includes('data-url="https://openvibe.video/u/vup_01J0000000000000000000000B"'), upButtons[0]);
});
await check('outcome 7: every export says what it is for, and the transcript says it was written by AI', async () => {
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex ce533f4..adf4a5b 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -430,6 +430,10 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('/c/201'));
assert.match(r.text, /<img src="[^"]*\/avatar\/streamer" alt="" loading="lazy">/, 'the person\'s picture over the letter');
++ // One Share button, with the channel page's own public address.
++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
++ assert.ok(buttons[0].includes('data-url="https://openvibe.video/@streamer"'), buttons[0]);
});
await check('an unknown /api path on the video host is a JSON 404, not a page', async () => {