openvibers/openvibe.live Commits
Channel pages: Ask your agent chip beside Share (#677)
Changes
@@@ -133,6 +133,9 @@stream's own action row while the channel is live and in the header's actions when it isnot, so the page always has one Share button and never two. --><span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>++ <!-- Ask your agent (openvibe-shared 4.135.0): the SEO renderer fills this with the channel's++ own chip, live or offline; app-channel.js keeps it beside Share in the same row. -->++ <span id="ch-ask-slot"></span><span class="ch-follower-count" id="ch-follower-count">0 followers</span><a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a><button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>@@@ -1253,9 +1253,14 @@ function _placeShareButton(where) {if (!b) return;const live = where !== 'offline';const host = live ? document.getElementById('ch-share-slot') : document.querySelector('#ch-offline-area .ch-actions');-- if (!host || b.parentElement === host) return;-- if (live) host.appendChild(b);-- else host.insertBefore(b, host.firstChild);++ if (!host) return;++ if (b.parentElement !== host) {++ if (live) host.appendChild(b);++ else host.insertBefore(b, host.firstChild);++ }++ // The Ask your agent chip (openvibe-shared 4.135.0) sits beside Share, in the same row, live or offline.++ const ask = document.getElementById('ch-ask-slot');++ if (ask && ask.parentElement !== host) host.insertBefore(ask, b.nextSibling);b.className = 'ovk-btn ovk-btn--quiet';b.title = live ? 'Share this stream' : 'Share this channel';}@@@ -16,6 +16,7 @@*/'use strict';const seo = require('openvibe-shared/seo');++const kit = require('openvibe-shared/kit');const { esc } = seo;const { setRobotsHeader } = require('../web/robots-header');const db = require('../db/database');@@@ -356,11 +357,16 @@ async function _channelMeta(username, page = 1) {}body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;++ // The Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the stream while live, the channel offline.++ const askText = liveNow++ ? `About this stream on OpenVibe.Live: ${name} — ${clean(liveNow.title || `${name} is streaming`, 110)}. `++ : `About ${name}'s channel on OpenVibe.Live. `;++return {title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,canonicalPath, image, ogType: 'profile',robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',-- jsonLd: ld, snapshot: body,++ jsonLd: ld, snapshot: body, askText,cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,};}@@@ -874,6 +880,10 @@ function render(meta, urlPath) {if (!html) return null;html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');++ // The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,++ // filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.++ if (meta.askText) html = html.replace('<span id="ch-ask-slot"></span>', `<span id="ch-ask-slot">${kit.askAgent({ text: meta.askText })}</span>`);++// Only the <head> is rewritten, so only the <head> is scanned.//// These six patterns used to run over the whole document. index.html is ~378KB and the headnnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): one [data-ov-ask] beside Share, with the++ * stream's channel and title while live ("About this stream on OpenVibe.Live: …"), the channel's own line offline++ * ("About <name>'s channel on OpenVibe.Live. "), and kit.askAgent's escaping. The fragment leaves the slot; the SEO++ * renderer fills it where the channel and its stream are known.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const express = require('express');++const db = require('../server/db/database');++const media = require('../server/media-client');++const follows = require('../server/social/network-follows');++++(async () => {++ await db.initDb();++ const raw = db.getDb();++ const addUser = (id, username, displayName) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE++ VALUES (?, ?, ?, ?, 'x', 'user', '2025-01-01 00:00:00')`).run(id, username, displayName, `${username}@x`);++ await addUser(1, 'alice', 'Alice');++ await addUser(2, 'bob', 'Bob');++ await addUser(3, 'escapee', 'Al<b> & "Co"');++ for (const id of [1, 2, 3]) await db.ensureChannel(id);++ const alice = await db.getChannelByUserId(1);++ await db.createStream({ user_id: 1, channel_id: alice.id, title: 'Live & loud', protocol: 'webrtc' });++ const escapee = await db.getChannelByUserId(3);++ await db.createStream({ user_id: 3, channel_id: escapee.id, title: 'Sneaky "title" & <tag>', protocol: 'webrtc' });++++ // No upstreams in this test: the channel page only needs the account and its streams.++ media.listVods = async () => ({ vods: [] });++ media.listClips = async () => ({ clips: [] });++ follows.followerCount = async () => 0;++++ const app = express();++ require('../server/seo').register(app);++ app.use((req, res) => res.status(404).type('text').send('not found'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const page = async (p) => {++ const r = await fetch(base + p, { headers: { accept: 'text/html,application/xhtml+xml' } });++ return { status: r.status, text: await r.text() };++ };++ const chips = (text) => text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];++++ try {++ const live = await page('/@alice');++ assert.strictEqual(live.status, 200);++ const asks = chips(live.text);++ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));++ assert.ok(asks[0].includes('data-ov-ask="About this stream on OpenVibe.Live: Alice — Live & loud. "'), asks[0]);++ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');++ assert.ok(live.text.includes('id="ch-ask-slot"'), 'the fragment slot is filled');++++ const offline = await page('/@bob');++ assert.strictEqual(offline.status, 200);++ const offAsks = chips(offline.text);++ assert.strictEqual(offAsks.length, 1, 'one Ask your agent chip offline too');++ assert.ok(offAsks[0].includes('data-ov-ask="About Bob's channel on OpenVibe.Live. "'), offAsks[0]);++++ const nasty = await page('/@escapee');++ const nastyAsks = chips(nasty.text);++ assert.strictEqual(nastyAsks.length, 1, 'one Ask your agent chip');++ assert.ok(nastyAsks[0].includes('data-ov-ask="About this stream on OpenVibe.Live: Al<b> & "Co" — Sneaky "title" & <tag>. "'), nastyAsks[0]);++ assert.ok(!nastyAsks[0].includes('<tag>'), 'the channel and stream names are escaped in the chip');++++ // The client keeps the chip beside Share in whichever action row is shown (live vs offline).++ const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');++ assert.match(client, /ch-ask-slot/);++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8'), /id="ch-ask-slot"/);++ console.log('ask agent channel chip ok');++ } finally {++ await new Promise((r) => server.close(r));++ if (db.closeDb) await db.closeDb().catch(() => {});++ process.exit(0);++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -133,6 +133,9 @@
@@ -133,6 +133,9 @@
stream's own action row while the channel is live and in the header's actions when it is
stream's own action row while the channel is live and in the header's actions when it is
not, so the page always has one Share button and never two. -->
not, so the page always has one Share button and never two. -->
<span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>
<span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>
+ <!-- Ask your agent (openvibe-shared 4.135.0): the SEO renderer fills this with the channel's
+ own chip, live or offline; app-channel.js keeps it beside Share in the same row. -->
+ <span id="ch-ask-slot"></span>
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
<button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
@@ -1253,9 +1253,14 @@ function _placeShareButton(where) {
@@ -1253,9 +1253,14 @@ function _placeShareButton(where) {
if (!b) return;
if (!b) return;
const live = where !== 'offline';
const live = where !== 'offline';
const host = live ? document.getElementById('ch-share-slot') : document.querySelector('#ch-offline-area .ch-actions');
const host = live ? document.getElementById('ch-share-slot') : document.querySelector('#ch-offline-area .ch-actions');
- if (!host || b.parentElement === host) return;
- if (live) host.appendChild(b);
- else host.insertBefore(b, host.firstChild);
+ if (!host) return;
+ if (b.parentElement !== host) {
+ if (live) host.appendChild(b);
+ else host.insertBefore(b, host.firstChild);
+ }
+ // The Ask your agent chip (openvibe-shared 4.135.0) sits beside Share, in the same row, live or offline.
+ const ask = document.getElementById('ch-ask-slot');
+ if (ask && ask.parentElement !== host) host.insertBefore(ask, b.nextSibling);
b.className = 'ovk-btn ovk-btn--quiet';
b.className = 'ovk-btn ovk-btn--quiet';
b.title = live ? 'Share this stream' : 'Share this channel';
b.title = live ? 'Share this stream' : 'Share this channel';
}
}
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
*/
*/
'use strict';
'use strict';
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const kit = require('openvibe-shared/kit');
const { esc } = seo;
const { esc } = seo;
const { setRobotsHeader } = require('../web/robots-header');
const { setRobotsHeader } = require('../web/robots-header');
const db = require('../db/database');
const db = require('../db/database');
@@ -356,11 +357,16 @@ async function _channelMeta(username, page = 1) {
@@ -356,11 +357,16 @@ async function _channelMeta(username, page = 1) {
}
}
body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
+ // The Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the stream while live, the channel offline.
+ const askText = liveNow
+ ? `About this stream on OpenVibe.Live: ${name} — ${clean(liveNow.title || `${name} is streaming`, 110)}. `
+ : `About ${name}'s channel on OpenVibe.Live. `;
+
return {
return {
title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,
title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,
canonicalPath, image, ogType: 'profile',
canonicalPath, image, ogType: 'profile',
robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',
robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',
- jsonLd: ld, snapshot: body,
+ jsonLd: ld, snapshot: body, askText,
cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,
cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,
};
};
}
}
@@ -874,6 +880,10 @@ function render(meta, urlPath) {
@@ -874,6 +880,10 @@ function render(meta, urlPath) {
if (!html) return null;
if (!html) return null;
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
+ // The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,
+ // filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.
+ if (meta.askText) html = html.replace('<span id="ch-ask-slot"></span>', `<span id="ch-ask-slot">${kit.askAgent({ text: meta.askText })}</span>`);
+
// Only the <head> is rewritten, so only the <head> is scanned.
// Only the <head> is rewritten, so only the <head> is scanned.
//
//
// These six patterns used to run over the whole document. index.html is ~378KB and the head
// These six patterns used to run over the whole document. index.html is ~378KB and the head
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): one [data-ov-ask] beside Share, with the
+ * stream's channel and title while live ("About this stream on OpenVibe.Live: …"), the channel's own line offline
+ * ("About <name>'s channel on OpenVibe.Live. "), and kit.askAgent's escaping. The fragment leaves the slot; the SEO
+ * renderer fills it where the channel and its stream are known.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const express = require('express');
+const db = require('../server/db/database');
+const media = require('../server/media-client');
+const follows = require('../server/social/network-follows');
+
+(async () => {
+ await db.initDb();
+ const raw = db.getDb();
+ const addUser = (id, username, displayName) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
+ VALUES (?, ?, ?, ?, 'x', 'user', '2025-01-01 00:00:00')`).run(id, username, displayName, `${username}@x`);
+ await addUser(1, 'alice', 'Alice');
+ await addUser(2, 'bob', 'Bob');
+ await addUser(3, 'escapee', 'Al<b> & "Co"');
+ for (const id of [1, 2, 3]) await db.ensureChannel(id);
+ const alice = await db.getChannelByUserId(1);
+ await db.createStream({ user_id: 1, channel_id: alice.id, title: 'Live & loud', protocol: 'webrtc' });
+ const escapee = await db.getChannelByUserId(3);
+ await db.createStream({ user_id: 3, channel_id: escapee.id, title: 'Sneaky "title" & <tag>', protocol: 'webrtc' });
+
+ // No upstreams in this test: the channel page only needs the account and its streams.
+ media.listVods = async () => ({ vods: [] });
+ media.listClips = async () => ({ clips: [] });
+ follows.followerCount = async () => 0;
+
+ const app = express();
+ require('../server/seo').register(app);
+ app.use((req, res) => res.status(404).type('text').send('not found'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const page = async (p) => {
+ const r = await fetch(base + p, { headers: { accept: 'text/html,application/xhtml+xml' } });
+ return { status: r.status, text: await r.text() };
+ };
+ const chips = (text) => text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];
+
+ try {
+ const live = await page('/@alice');
+ assert.strictEqual(live.status, 200);
+ const asks = chips(live.text);
+ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));
+ assert.ok(asks[0].includes('data-ov-ask="About this stream on OpenVibe.Live: Alice — Live & loud. "'), asks[0]);
+ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');
+ assert.ok(live.text.includes('id="ch-ask-slot"'), 'the fragment slot is filled');
+
+ const offline = await page('/@bob');
+ assert.strictEqual(offline.status, 200);
+ const offAsks = chips(offline.text);
+ assert.strictEqual(offAsks.length, 1, 'one Ask your agent chip offline too');
+ assert.ok(offAsks[0].includes('data-ov-ask="About Bob's channel on OpenVibe.Live. "'), offAsks[0]);
+
+ const nasty = await page('/@escapee');
+ const nastyAsks = chips(nasty.text);
+ assert.strictEqual(nastyAsks.length, 1, 'one Ask your agent chip');
+ assert.ok(nastyAsks[0].includes('data-ov-ask="About this stream on OpenVibe.Live: Al<b> & "Co" — Sneaky "title" & <tag>. "'), nastyAsks[0]);
+ assert.ok(!nastyAsks[0].includes('<tag>'), 'the channel and stream names are escaped in the chip');
+
+ // The client keeps the chip beside Share in whichever action row is shown (live vs offline).
+ const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
+ assert.match(client, /ch-ask-slot/);
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8'), /id="ch-ask-slot"/);
+ console.log('ask agent channel chip ok');
+ } finally {
+ await new Promise((r) => server.close(r));
+ if (db.closeDb) await db.closeDb().catch(() => {});
+ process.exit(0);
+ }
+})().catch((e) => { console.error(e); process.exit(1); });