openvibers/openvibe.live Commits

Chat's update card for viewers, and the streamer's "Buttons for your viewers" on the dashboard (#845)

OP OpenVibers committed 1 days ago · f1707d5b6a · 6 files, +214 −3

* Chat's "updates shipped" card: leave out docs-only commits and PR numbers

The card in every channel's chat listed commit subjects as they are, so viewers
read "README: the event list no longer includes merge events (#841)". A commit
that only touches the docs (README, CHANGELOG, docs, AGENTS, CONTRIBUTING) is
not news to the people in chat, and "(#843)" is our number, not theirs: the card
now leaves the first out and drops the second. The count says the rest.

Test: test/chat-deploy-card.test.js.

* Dashboard: "Buttons for your viewers", the streamer's Elements library

Live #842 shipped the Elements API and the viewers' deck with no place for a
streamer to see them: Design's "Install" put elements in a library nobody could
open, and the deck (off until the streamer turns it on) could only be turned on
through the API.

The Controls tab now opens on "Buttons for your viewers": the switch that shows
them under the stream (with what it means right now), each button with its own
label as a chip, what it does or costs, an On switch and Remove, and where to
get more: "Make one with AI" (Design's composer on the Element kind), "Free ones
in the gallery", "See them on your channel". Names and labels are text nodes.

Test: test/dashboard-elements.test.js.

* Dashboard Elements card: an icon the OpenVibe glyphs draw (fa-grip)

Changes

ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex 338ec05b..99402f18 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -529,3 +529,33 @@
.dash-bench-form label { margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); }
.dash-bench-form .btn { justify-self: start; min-height: 40px; margin-top: 10px; }
#dash-bench-status:empty { display: none; }
++
++/* Buttons for your viewers (Controls tab, dashboard.js loadDashElements): the deck switch, each button with its own
++ look as a chip, an On switch and Remove, then where to get more. 44 px targets on a phone. */
++.dash-elements > p.muted { margin: 0 0 12px; font-size: 0.88rem; }
++.dash-el-switch { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); cursor: pointer; }
++.dash-el-switch input { flex: none; width: 22px; height: 22px; accent-color: var(--accent); }
++.dash-el-switch b { display: block; font-size: 0.95rem; }
++.dash-el-switch small { display: block; margin-top: 2px; color: var(--text-muted); font-size: 0.8rem; }
++.dash-el-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
++.dash-el-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 12px; }
++.dash-el-row.is-off { opacity: 0.6; }
++.dash-el-chip { max-width: 9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 7px 12px; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font-size: 0.8rem; font-weight: 700; }
++.dash-el-text { min-width: 0; }
++.dash-el-text b { display: block; font-size: 0.92rem; overflow-wrap: anywhere; }
++.dash-el-text small { display: block; color: var(--text-muted); font-size: 0.8rem; line-height: 1.35; }
++.dash-el-on { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; }
++.dash-el-on input { width: 18px; height: 18px; accent-color: var(--accent); }
++.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 10px; background: none; color: var(--text-muted); cursor: pointer; }
++.dash-el-del:hover, .dash-el-del:focus-visible { color: #f87171; background: color-mix(in srgb, #f87171 12%, transparent); }
++.dash-el-empty { padding: 14px; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-secondary); font-size: 0.88rem; }
++.dash-el-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }
++.dash-el-actions .btn { min-height: 40px; }
++.dash-el-status:empty { display: none; }
++.dash-el-status.is-bad { color: #f87171; }
++@media (max-width: 520px) {
++ .dash-el-row { grid-template-columns: auto minmax(0, 1fr) auto; }
++ .dash-el-chip { grid-row: span 2; align-self: start; max-width: 6.5em; }
++ .dash-el-on { grid-column: 2; justify-self: start; min-height: 36px; }
++ .dash-el-del { grid-column: 3; grid-row: 1 / span 2; }
++}
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 889cf0a6..c5566e98 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -122,6 +122,24 @@
</div>
<!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
++ <!-- Buttons for your viewers (OpenVibe Elements): the streamer's library, the deck switch, and where to get
++ more. Live #842 shipped the API and the viewers' deck with no place for the streamer to see or turn
++ them on (the 11-12 review). dashboard.js loadDashElements draws it with DOM nodes. -->
++ <div class="dash-card dash-elements" id="dash-card-elements">
++ <h3><i class="fa-solid fa-hand-pointer" aria-hidden="true"></i> Buttons for your viewers</h3>
++ <p class="muted">Buttons people press under your stream: move your robot, play a sound, start a poll and more. Every press checks who may press, your cooldowns and channel points.</p>
++ <label class="dash-el-switch" for="dash-el-enabled">
++ <input type="checkbox" id="dash-el-enabled" disabled>
++ <span><b>Show them under my stream</b><small id="dash-el-enabled-note">Loading…</small></span>
++ </label>
++ <ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>
++ <p class="dash-el-actions">
++ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>
++ <a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>
++ <a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>
++ </p>
++ <p class="dash-el-status muted" id="dash-el-status" role="status"></p>
++ </div>
<div class="dash-card" id="dash-card-bot-embed" style="display:none">
<h3><i class="fa-solid fa-robot"></i> Your robot</h3>
<p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 437f50f3..e00679d1 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3043,7 +3043,12 @@ function renderDeployCard(data, opts = {}) {
if (!container) return;
_deployCss();
const seen = new Set();
-- const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));
++ // What changed for the people in chat: a commit that only touched the docs (README, CHANGELOG, docs/) is not news
++ // to a viewer, and "(#843)" is our number, not theirs (the 11-12 review: "README: the event list no longer includes
++ // merge events (#841)" was in a channel's chat).
++ const DOCS_ONLY = /^(readme|changelog|docs?|agents(\.md)?|contributing)\b\s*[:\-]/i;
++ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !DOCS_ONLY.test(c.subject) && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject))
++ .map(c => ({ ...c, subject: String(c.subject).replace(/\s*\(#\d+\)\s*$/, '') }));
if (!commits.length) return;
const fresh = new Set(data.fresh || []);
const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex bbafd421..df4931ff 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {
home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-bench-stream', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
-- controls: ['dash-card-bot-embed', 'dash-card-tokens'],
++ controls: ['dash-card-elements', 'dash-card-bot-embed', 'dash-card-tokens'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
@@@ -129,7 +129,116 @@ async function loadDashVibe() {
} catch (err) { status.textContent = (err && err.message) || 'That did not save.'; }
});
}
--window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
++window._dashTabLoaders.controls = () => { loadDashElements(); _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
++
++/* ── Buttons for your viewers (OpenVibe Elements) ─────────────────
++ The library from GET /api/me/elements, the deck switch (PUT /api/me/elements/settings), each button on or off
++ (PATCH active) and removed (DELETE). Made with AI or installed on OpenVibe.Design; nothing here is drawn from
++ markup an element carries: names and labels are text nodes. */
++function _dashElNode(tag, cls, text) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = String(text);
++ return n;
++}
++function _dashElSay(text, bad) {
++ const s = document.getElementById('dash-el-status');
++ if (s) { s.textContent = text || ''; s.classList.toggle('is-bad', !!bad); }
++}
++/** One line on what a button does: its own description, else what pressing costs and how often. */
++function _dashElLine(e) {
++ if (e.description) return e.description;
++ const c = (e.recipe && e.recipe.conditions) || {};
++ const bits = [];
++ if (c.cost && c.cost.points) bits.push(`${Number(c.cost.points).toLocaleString()} points a press`);
++ if (c.cooldown && c.cooldown.overall_seconds) bits.push(`once every ${c.cooldown.overall_seconds} s`);
++ if (c.role && c.role !== 'everyone') bits.push(`${c.role}s only`);
++ return bits.join(' · ') || 'Anyone signed in can press it.';
++}
++function _dashElRow(e) {
++ const li = _dashElNode('li', 'dash-el-row' + (e.active ? '' : ' is-off'));
++ const look = (e.look && e.look.label) || e.name || 'Button';
++ const chip = _dashElNode('span', 'dash-el-chip', look);
++ chip.setAttribute('aria-hidden', 'true');
++ const text = _dashElNode('span', 'dash-el-text');
++ text.append(_dashElNode('b', '', e.name || look), _dashElNode('small', '', _dashElLine(e)));
++ const on = document.createElement('input');
++ on.type = 'checkbox';
++ on.checked = !!e.active;
++ on.setAttribute('aria-label', `${e.name || look}: on your channel`);
++ on.addEventListener('change', async () => {
++ on.disabled = true;
++ try {
++ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'PATCH', body: { active: on.checked } });
++ li.classList.toggle('is-off', !on.checked);
++ _dashElSay(on.checked ? `${e.name} is on your channel.` : `${e.name} is off; viewers do not see it.`);
++ } catch (err) { on.checked = !on.checked; _dashElSay(err.message || 'That could not be saved.', true); }
++ finally { on.disabled = false; }
++ });
++ const sw = _dashElNode('label', 'dash-el-on');
++ sw.append(on, _dashElNode('span', '', 'On'));
++ const del = _dashElNode('button', 'btn-icon dash-el-del');
++ del.type = 'button';
++ del.title = 'Remove';
++ del.setAttribute('aria-label', `Remove ${e.name || look}`);
++ del.innerHTML = '<i class="fa-solid fa-trash-can" aria-hidden="true"></i>';
++ del.addEventListener('click', async () => {
++ if (!confirm(`Remove "${e.name || look}" from your channel? You can install it again from the gallery.`)) return;
++ del.disabled = true;
++ try {
++ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'DELETE' });
++ li.remove();
++ _dashElSay(`${e.name || look} is removed.`);
++ if (!document.querySelector('#dash-el-list .dash-el-row')) _dashElEmpty();
++ } catch (err) { del.disabled = false; _dashElSay(err.message || 'That could not be removed.', true); }
++ });
++ li.append(chip, text, sw, del);
++ return li;
++}
++function _dashElEmpty() {
++ const list = document.getElementById('dash-el-list');
++ if (!list) return;
++ const li = _dashElNode('li', 'dash-el-empty');
++ li.append(_dashElNode('b', '', 'No buttons yet.'), _dashElNode('span', '', ' Make one by describing it, or install free ones (there is a pack for the LOOI robot).'));
++ list.replaceChildren(li);
++}
++function _dashElNote(enabled, count) {
++ const note = document.getElementById('dash-el-enabled-note');
++ if (!note) return;
++ note.textContent = enabled
++ ? (count ? 'On: viewers see your buttons under the stream.' : 'On, but there are no buttons yet: add one below.')
++ : 'Off: viewers see nothing until you turn this on.';
++}
++async function loadDashElements() {
++ const box = document.getElementById('dash-el-enabled');
++ const list = document.getElementById('dash-el-list');
++ if (!box || !list || !currentUser) return;
++ const see = document.getElementById('dash-el-see');
++ if (see && currentUser.username) { see.href = `/@${encodeURIComponent(currentUser.username)}`; see.hidden = false; }
++ try {
++ const out = await api('/me/elements');
++ const elements = (out && out.elements) || [];
++ box.checked = !!out.enabled;
++ box.disabled = false;
++ _dashElNote(!!out.enabled, elements.length);
++ if (elements.length) list.replaceChildren(...elements.map(_dashElRow)); else _dashElEmpty();
++ } catch (err) {
++ _dashElSay(err.message || 'Your buttons could not be read just now.', true);
++ return;
++ }
++ if (box.dataset.wired) return;
++ box.dataset.wired = '1';
++ box.addEventListener('change', async () => {
++ box.disabled = true;
++ try {
++ const out = await api('/me/elements/settings', { method: 'PUT', body: { enabled: box.checked } });
++ box.checked = !!(out && out.enabled);
++ _dashElNote(box.checked, document.querySelectorAll('#dash-el-list .dash-el-row').length);
++ _dashElSay(box.checked ? 'Your buttons show under your stream.' : 'Your buttons are hidden from viewers.');
++ } catch (err) { box.checked = !box.checked; _dashElSay(err.message || 'That could not be saved.', true); }
++ finally { box.disabled = false; }
++ });
++}
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadDashTipsPage'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
ddiff --git a/test/chat-deploy-card.test.js b/test/chat-deploy-card.test.js
nnew file mode 100644
iindex 00000000..7f57d78c
---- /dev/null
++++ b/test/chat-deploy-card.test.js
@@@ -0,0 +1,22 @@
++'use strict';
++/**
++ * The "updates shipped" card in chat (public/js/chat.js renderDeployCard) says what changed for the people in chat:
++ * a docs-only commit is left out, and a subject's "(#843)" PR number is dropped (11-12 review: "README: the event list
++ * no longer includes merge events (#841)" was in a channel's chat).
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
++const m = chat.match(/const DOCS_ONLY = (\/.+\/i);/);
++assert.ok(m, 'the card has its docs-only rule');
++const DOCS_ONLY = new Function(`return ${m[1]};`)();
++for (const s of ['README: the event list no longer includes merge events (#841)', 'CHANGELOG: drop the account merge entry (#268)', 'docs: deploy kinds', 'Docs - the WHIP guide'])
++ assert.ok(DOCS_ONLY.test(s), `left out: ${s}`);
++for (const s of ['Go live: allow the camera kit from openvibe.cam (#843)', 'Elements stage 1: the Viewer Deck (#842)', 'Readmes everywhere get a search box', 'Documentation page loads faster'])
++ assert.ok(!DOCS_ONLY.test(s), `kept: ${s}`);
++assert.ok(chat.includes(".replace(/\\s*\\(#\\d+\\)\\s*$/, '')"), 'the PR number is dropped from each subject');
++const strip = (s) => s.replace(/\s*\(#\d+\)\s*$/, '');
++assert.strictEqual(strip('Go live: allow the camera kit from openvibe.cam (it was refused on every browser) (#843)'), 'Go live: allow the camera kit from openvibe.cam (it was refused on every browser)');
++console.log('chat-deploy-card: docs-only commits left out, PR numbers dropped');
ddiff --git a/test/dashboard-elements.test.js b/test/dashboard-elements.test.js
nnew file mode 100644
iindex 00000000..bb167095
---- /dev/null
++++ b/test/dashboard-elements.test.js
@@@ -0,0 +1,27 @@
++'use strict';
++/**
++ * Buttons for your viewers on the dashboard (Controls tab): the streamer's Elements library, which Live #842 shipped
++ * as an API with no page (the 11-12 review: Design's "Install" put elements in a library nobody could see, and the
++ * deck stayed off). The card turns the deck on, turns each button on or off, removes one, and says where to get more.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++const html = read('public/fragments/dashboard.html');
++const js = read('public/js/dashboard.js');
++const css = read('public/css/features/dashboard.css');
++
++assert.ok(html.includes('id="dash-card-elements"') && html.includes('Buttons for your viewers'), 'the card is in the dashboard');
++assert.ok(/controls:\s*\['dash-card-elements'/.test(js), 'first on the Controls tab');
++assert.ok(/_dashTabLoaders\.controls = \(\) => \{ loadDashElements\(\);/.test(js), 'loaded when Controls opens');
++for (const call of ["api('/me/elements')", "api('/me/elements/settings', { method: 'PUT'", "{ method: 'PATCH', body: { active: on.checked } }", "{ method: 'DELETE' }"])
++ assert.ok(js.includes(call), `calls ${call}`);
++assert.ok(!js.includes("api('/api/me/elements") && !js.includes('api(`/api/me/elements'), 'api() adds /api itself');
++assert.ok(html.includes('https://openvibe.design/create?kind=element') && html.includes('https://openvibe.design/gallery'), 'where to get more: made with AI, or free in the gallery');
++// Names and labels come from what people made: text nodes, never markup.
++const card = js.slice(js.indexOf('function _dashElNode'), js.indexOf('async function loadDashElements'));
++assert.ok(!/innerHTML\s*=\s*`[^`]*\$\{e\./.test(card) && !/innerHTML\s*=\s*[^;]*\be\.(name|look|description)/.test(card), 'no element text goes in as markup');
++assert.ok(css.includes('.dash-el-row') && css.includes('.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px;'), 'the rows and a 44 px remove button');
++console.log('dashboard-elements: the streamer sees, turns on and removes the buttons viewers press');