openvibers/openvibe.live Commits
An offline channel offers an alert when it goes live, through OpenVibe.Watch (#585)
Changes
@@@ -1111,6 +1111,11 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:.ch-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 0.85rem; }/* Each item stays on one line; on a phone the row wraps instead ("22 / followers" and "IRL · / inferred" did). */.ch-follower-count { color: var(--text-muted); white-space: nowrap; }++/* "Get an alert" (OpenVibe.Watch) sits in the same quiet row: a link, not a second button beside Follow. */++.ch-watch-alert { color: var(--text-muted); text-decoration: none; white-space: nowrap; }++.ch-watch-alert:hover, .ch-watch-alert:focus-visible { color: var(--accent); }++.ch-watch-alert[hidden] { display: none; }++@media (pointer: coarse) { .ch-watch-alert { position: relative; } .ch-watch-alert::after { content: ''; position: absolute; left: -4px; right: -4px; top: -12px; bottom: -12px; } }.ch-meta .ch-category-badge { white-space: nowrap; }.ch-actions { flex-shrink: 0; }@@@ -252,6 +252,7 @@<div class="ch-next" id="ch-next-offline" hidden></div><div class="ch-meta"><span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>++ <a class="ch-watch-alert" id="ch-watch-offline" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-solid fa-bell" aria-hidden="true"></i> Get an alert</a><a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a><span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span></div>@@@ -947,6 +947,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIif (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);_loadNextStream(ch.username);document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;++ _setWatchAlert(document.getElementById('ch-watch-offline'), ch.username);_loadGameBadge(ch.username);_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);setupFollowBtn(document.getElementById('ch-btn-follow-offline'));@@@ -3253,6 +3254,21 @@ function _wireAboutDrag() {});}++/**++ * "Get an alert" beside the follower count while a channel is offline: OpenVibe.Watch's form, filled in for this++ * channel going live (the live.stream.started event, nothing polled). It reaches people where Live's own follow cannot:++ * an email, a webhook, or one of their agents woken up. Not on one's own channel.++ */++function _setWatchAlert(a, username) {++ if (!a) return;++ const own = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.username === username);++ if (own || !username) { a.hidden = true; return; }++ const q = new URLSearchParams({ template: 'event', event: 'live.stream.started', event_filter: username, name: `@${username} goes live` });++ a.href = `https://openvibe.watch/watches/new?${q}`;++ a.title = `An alert when @${username} goes live: by email, a webhook or your agent (OpenVibe.Watch)`;++ a.hidden = false;++}++// Render a streamer's customizable offline screen into #ch-offline-screen.// image/video → served asset; html → sandboxed iframe (no same-origin, so the// streamer's markup can't touch viewers' session). Falls back to a tasteful default.nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// "Get an alert" on an offline channel (plan §4, Agents and devices: the gap was an entry point on Live's channel++// page): OpenVibe.Watch's form, filled in for this channel going live (live.stream.started, nothing polled), so an++// alert can reach an email, a webhook or an agent. Hidden on one's own channel and until the channel is known.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const ROOT = path.join(__dirname, '..');++const fragment = fs.readFileSync(path.join(ROOT, 'public/fragments/channel.html'), 'utf8');++assert.match(fragment, /<a class="ch-watch-alert" id="ch-watch-offline" href="https:\/\/openvibe\.watch\/" target="_blank" rel="noopener" hidden>/, 'the link is in the offline header, hidden until filled');++++const src = fs.readFileSync(path.join(ROOT, 'public/js/app-channel.js'), 'utf8');++const fn = /function _setWatchAlert\(a, username\) \{[\s\S]*?\n\}/.exec(src);++assert.ok(fn, '_setWatchAlert exists');++assert.match(src, /_setWatchAlert\(document\.getElementById\('ch-watch-offline'\), ch\.username\);/, 'the offline header fills it');++++const run = (viewer, username) => {++ const a = { hidden: true, href: 'https://openvibe.watch/', title: '' };++ const ctx = { currentUser: viewer, URLSearchParams };++ vm.createContext(ctx);++ vm.runInContext(`${fn[0]}\n_setWatchAlert(a, ${JSON.stringify(username)});`, Object.assign(ctx, { a }));++ return a;++};++++const a = run({ username: 'someone' }, 'goosely');++assert.strictEqual(a.hidden, false);++const u = new URL(a.href);++assert.strictEqual(u.origin + u.pathname, 'https://openvibe.watch/watches/new');++assert.deepStrictEqual(Object.fromEntries(u.searchParams), { template: 'event', event: 'live.stream.started', event_filter: 'goosely', name: '@goosely goes live' });++assert.match(a.title, /@goosely goes live: by email, a webhook or your agent/);++assert.strictEqual(run(null, 'goosely').hidden, false, 'signed out too: Watch asks them to sign in');++assert.strictEqual(run({ username: 'goosely' }, 'goosely').hidden, true, 'not on one\'s own channel');++assert.strictEqual(run(null, '').hidden, true, 'not before the channel is known');++console.log('channel watch alert: all checks passed');
@@ -1111,6 +1111,11 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -1111,6 +1111,11 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ch-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 0.85rem; }
.ch-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 0.85rem; }
/* Each item stays on one line; on a phone the row wraps instead ("22 / followers" and "IRL · / inferred" did). */
/* Each item stays on one line; on a phone the row wraps instead ("22 / followers" and "IRL · / inferred" did). */
.ch-follower-count { color: var(--text-muted); white-space: nowrap; }
.ch-follower-count { color: var(--text-muted); white-space: nowrap; }
+/* "Get an alert" (OpenVibe.Watch) sits in the same quiet row: a link, not a second button beside Follow. */
+.ch-watch-alert { color: var(--text-muted); text-decoration: none; white-space: nowrap; }
+.ch-watch-alert:hover, .ch-watch-alert:focus-visible { color: var(--accent); }
+.ch-watch-alert[hidden] { display: none; }
+@media (pointer: coarse) { .ch-watch-alert { position: relative; } .ch-watch-alert::after { content: ''; position: absolute; left: -4px; right: -4px; top: -12px; bottom: -12px; } }
.ch-meta .ch-category-badge { white-space: nowrap; }
.ch-meta .ch-category-badge { white-space: nowrap; }
.ch-actions { flex-shrink: 0; }
.ch-actions { flex-shrink: 0; }
@@ -252,6 +252,7 @@
@@ -252,6 +252,7 @@
<div class="ch-next" id="ch-next-offline" hidden></div>
<div class="ch-next" id="ch-next-offline" hidden></div>
<div class="ch-meta">
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
+ <a class="ch-watch-alert" id="ch-watch-offline" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-solid fa-bell" aria-hidden="true"></i> Get an alert</a>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span>
<span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span>
</div>
</div>
@@ -947,6 +947,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -947,6 +947,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);
if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);
_loadNextStream(ch.username);
_loadNextStream(ch.username);
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
+ _setWatchAlert(document.getElementById('ch-watch-offline'), ch.username);
_loadGameBadge(ch.username);
_loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
@@ -3253,6 +3254,21 @@ function _wireAboutDrag() {
@@ -3253,6 +3254,21 @@ function _wireAboutDrag() {
});
});
}
}
+/**
+ * "Get an alert" beside the follower count while a channel is offline: OpenVibe.Watch's form, filled in for this
+ * channel going live (the live.stream.started event, nothing polled). It reaches people where Live's own follow cannot:
+ * an email, a webhook, or one of their agents woken up. Not on one's own channel.
+ */
+function _setWatchAlert(a, username) {
+ if (!a) return;
+ const own = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.username === username);
+ if (own || !username) { a.hidden = true; return; }
+ const q = new URLSearchParams({ template: 'event', event: 'live.stream.started', event_filter: username, name: `@${username} goes live` });
+ a.href = `https://openvibe.watch/watches/new?${q}`;
+ a.title = `An alert when @${username} goes live: by email, a webhook or your agent (OpenVibe.Watch)`;
+ a.hidden = false;
+}
+
// Render a streamer's customizable offline screen into #ch-offline-screen.
// Render a streamer's customizable offline screen into #ch-offline-screen.
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// streamer's markup can't touch viewers' session). Falls back to a tasteful default.
// streamer's markup can't touch viewers' session). Falls back to a tasteful default.
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// "Get an alert" on an offline channel (plan §4, Agents and devices: the gap was an entry point on Live's channel
+// page): OpenVibe.Watch's form, filled in for this channel going live (live.stream.started, nothing polled), so an
+// alert can reach an email, a webhook or an agent. Hidden on one's own channel and until the channel is known.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const ROOT = path.join(__dirname, '..');
+const fragment = fs.readFileSync(path.join(ROOT, 'public/fragments/channel.html'), 'utf8');
+assert.match(fragment, /<a class="ch-watch-alert" id="ch-watch-offline" href="https:\/\/openvibe\.watch\/" target="_blank" rel="noopener" hidden>/, 'the link is in the offline header, hidden until filled');
+
+const src = fs.readFileSync(path.join(ROOT, 'public/js/app-channel.js'), 'utf8');
+const fn = /function _setWatchAlert\(a, username\) \{[\s\S]*?\n\}/.exec(src);
+assert.ok(fn, '_setWatchAlert exists');
+assert.match(src, /_setWatchAlert\(document\.getElementById\('ch-watch-offline'\), ch\.username\);/, 'the offline header fills it');
+
+const run = (viewer, username) => {
+ const a = { hidden: true, href: 'https://openvibe.watch/', title: '' };
+ const ctx = { currentUser: viewer, URLSearchParams };
+ vm.createContext(ctx);
+ vm.runInContext(`${fn[0]}\n_setWatchAlert(a, ${JSON.stringify(username)});`, Object.assign(ctx, { a }));
+ return a;
+};
+
+const a = run({ username: 'someone' }, 'goosely');
+assert.strictEqual(a.hidden, false);
+const u = new URL(a.href);
+assert.strictEqual(u.origin + u.pathname, 'https://openvibe.watch/watches/new');
+assert.deepStrictEqual(Object.fromEntries(u.searchParams), { template: 'event', event: 'live.stream.started', event_filter: 'goosely', name: '@goosely goes live' });
+assert.match(a.title, /@goosely goes live: by email, a webhook or your agent/);
+assert.strictEqual(run(null, 'goosely').hidden, false, 'signed out too: Watch asks them to sign in');
+assert.strictEqual(run({ username: 'goosely' }, 'goosely').hidden, true, 'not on one\'s own channel');
+assert.strictEqual(run(null, '').hidden, true, 'not before the channel is known');
+console.log('channel watch alert: all checks passed');