openvibers/openvibe.live Commits

Channel pages: Ask your agent chip beside Share (#677)

OP OpenVibers committed 6 days ago · b5eee9e218 · 4 files, +108 −4

The channel fragment leaves ch-ask-slot; the SEO renderer fills it with kit.askAgent, with the stream and its title while live and the channel's own line offline. app-channel.js moves the chip with Share between the live info bar and the offline header. kit.askAgent escapes the channel and stream names.

Changes

ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex b3ae75fc..35fe1fa8 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -133,6 +133,9 @@
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. -->
<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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 88b93293..ccf209a3 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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';
}
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 9b2ebd62..7e2c7f3c 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -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 head
ddiff --git a/test/ask-agent.test.js b/test/ask-agent.test.js
nnew file mode 100644
iindex 00000000..ff22ed6f
---- /dev/null
++++ b/test/ask-agent.test.js
@@@ -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 &amp; 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&#39;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&lt;b&gt; &amp; &quot;Co&quot; — Sneaky &quot;title&quot; &amp; &lt;tag&gt;. "'), 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); });