openvibers/openvibe.live Commits
A live channel link shows the stream, not a section under it (#778)
Changes
@@@ -926,7 +926,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI_applyChannelTabMeta(data);_applyBotEmbed(data.bot_embed, username);_offerBotBind(username);-- _applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab++ _applyChannelHashTab(liveStreams.length > 0); // deep-link: #ai-timeline / #about / #videos … opens that tab (live: marks it)// The Agents tab is revealed only when the channel has agent streams this viewer may see (S2)._agentsPaging = { username: null, grid: null };try { loadChannelAgents(username); } catch {}@@@ -1410,13 +1410,18 @@ function _setChannelOwnerActions(username) {}}++/** The channel's own address, with no section: what Share, history and announced links carry (ask 285). */++function _channelAddress() {++ return `${location.origin}${location.pathname}`;++}++/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */function updateShareButton(stream) {const b = document.getElementById('ch-btn-share');if (!b) return;const dvr = (typeof dvrState !== 'undefined' && dvrState) ? dvrState : null;const recording = dvr && dvr.active && dvr.vodId ? videoHref('vod', dvr.vodId) : '';-- b.dataset.url = recording || `${location.origin}${location.pathname}`;++ b.dataset.url = recording || _channelAddress();const s = stream || currentStreamData || null;const name = (s && (s.display_name || s.username)) || '';const title = (s && s.title) || name || document.title;@@@ -2213,10 +2218,11 @@ function _markChannelJump(tab, fromTap = false) {if (Math.abs(target - strip.scrollLeft) > 2) strip.scrollTo({ left: target, behavior: _channelReduceMotion() ? 'auto' : 'smooth' });}_updateChannelTabArrows();-- // The address names the section in view once the person is among the sections (or tapped one); at the top of the-- // page it stays clean. What is written here is remembered, so the channel's next load does not take it as a link to-- // that section and scroll there (11-10: the page jumped to About by itself, with #about in the address).-- const want = fromTap || window.scrollY > 120 ? `#${tab}` : '';++ // The address follows a section the person chose (a jump link) or one it already named while they scroll past++ // the sections; a clean address stays clean. What is written here is remembered, so the channel's next load does++ // not take it as a link to that section and scroll there (11-10: the page jumped to About by itself).++ if (fromTap) _chHashMark = '';++ const want = _channelSectionHash(tab, fromTap);if (location.hash !== want) { _chHashWritten = want; history.replaceState(history.state, '', `${location.pathname}${location.search}${want}`); }}function _updateChannelTabArrows() {@@@ -2291,9 +2297,14 @@ function _syncChannelSections() {}let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;let _chSpyFrame = 0;++let _chHashMark = ''; // a section a live link named: its jump link stays marked while the page is still at the topfunction _channelSectionInView() {_chSpyFrame = 0;if (Date.now() < _chJumpUntil) return;++ if (_chHashMark) {++ if (window.scrollY <= 120) return;++ _chHashMark = '';++ }const panels = Array.from(document.querySelectorAll('#page-channel .ch-tab-panel')).filter(p => !p.hidden).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);if (!panels.length) return;@@@ -2360,18 +2371,60 @@ async function _loadGameBadge(username) {}}--// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and--// scrolls the tab content into view (used by "view full AI timeline" links, deep links, etc.).++// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load: offline it++// scrolls the tab content into view; live, the stream stays first and the tab is only marked (ask 285).const CHANNEL_TAB_HASHES = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];let _chHashWritten = ''; // the last section this page wrote into the address itself (not a link somebody followed)--function _applyChannelHashTab() {++++/**++ * The section to put in the address: one the person chose (a jump link), or the section the address already names++ * while they scroll past the sections. Scrolling never adds a section to a clean channel address — a copied or++ * shared link would then land on that section instead of the stream (ask 285).++ */++function _channelSectionHash(tab, fromTap = false) {++ const named = CHANNEL_TAB_HASHES.includes((location.hash || '').replace(/^#/, '').toLowerCase());++ if (fromTap) return `#${tab}`;++ if (!named) return '';++ if (window.scrollY > 120) return `#${tab}`;++ // At the top, keep a section somebody linked to (the load still has to open it); clear only one this page wrote.++ return _chHashWritten ? '' : location.hash;++}++++function _applyChannelHashTab(live = false) {const h = (location.hash || '').replace(/^#/, '').toLowerCase();if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;if (_chHashWritten === `#${h}`) return;const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel-- // Jump to the section once it has had a beat to render its content.-- setTimeout(() => switchChannelTab(h, btn, true), 200);++ const path = location.pathname;++ const open = () => {++ if (location.pathname !== path) return; // the person moved on while the sections were settling++ if (live) {++ // A link to a live channel shows the stream first (ask 285): the section's jump link is marked and its++ // content loads, but the page does not scroll under the player. The address loses the section too, so a++ // copy or a share of this visit stays the channel.++ if (location.hash) {++ _chHashWritten = '';++ history.replaceState(history.state, '', _channelAddress());++ }++ _chHashMark = h;++ switchChannelTab(h, btn, false);++ return;++ }++ switchChannelTab(h, btn, true);++ };++ // Jump to the section once it has had a beat to render its content. Offline, the sections are held out of the++ // layout until the channel is ready (_channelReady), and scrollIntoView on a display:none panel does nothing:++ // wait for ch-ready so /@name#about really opens About (ask 285).++ if (live) return setTimeout(open, 200);++ setTimeout(() => {++ const page = document.getElementById('page-channel');++ if (!page || page.classList.contains('ch-ready')) return open();++ let tries = 0;++ const wait = setInterval(() => {++ if (page.classList.contains('ch-ready') || ++tries >= 30) { clearInterval(wait); open(); }++ }, 100);++ }, 200);}let _chTabLoaded = {};let _chAnalyticsDays = 7;@@@ -616,7 +616,9 @@ function _recordHistory(pageId) {_historyTimer = setTimeout(() => {const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');if (!tok || !currentUser) return;-- const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url: location.href };++ // A channel is remembered as the channel, not as the section its scroll bar last named (ask 285).++ const url = type === 'stream' ? `${location.origin}${location.pathname}` : location.href;++ const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url };const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };if (window.OpenVibeHistory) return send();if (document.getElementById('ov-history-loader')) return;@@@ -2784,7 +2784,14 @@ function _chatEvSafeUrl(u) {let x; try { x = new URL(String(u || '')); } catch { x = new URL(String(u || ''), window.location.origin); }if (x.protocol !== 'https:' && x.origin !== window.location.origin) return null;const h = x.hostname.toLowerCase();-- if (x.origin === window.location.origin || h === 'openvibe.live' || h.endsWith('.openvibe.live')) return x.href;++ if (x.origin === window.location.origin || h === 'openvibe.live' || h.endsWith('.openvibe.live')) {++ // A go-live card opens the channel, never a section a shared address may have named (ask 285).++ if (/^\/@[A-Za-z0-9_]{3,24}(?:\/[^/?#]+)?\/?$/.test(x.pathname)) {++ x.hash = '';++ x.searchParams.delete('section');++ }++ return x.href;++ }} catch { /* not a URL */ }return null;}nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * A link to a live channel shows the stream, not a section under it (ask 285).++ *++ * The channel page names the section in view while the person scrolls, and a shared link copied from the address bar++ * could carry that #about. This pins the two halves of the fix:++ *++ * - the page only writes a section into the address when the person chose one (a jump link), or when the address++ * already named one; scrolling over a clean address leaves it clean;++ * - opening /@name#about while the channel is live marks About's jump link and loads it, but does not scroll under++ * the stream and drops the section from the address; offline it opens and scrolls as before;++ * - the Share button, the cross-site history entry and a go-live card's Watch link all name the channel, no section.++ *++ * node test/channel-hash-live.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');++const channelJs = read('public/js/app-channel.js');++const chatJs = read('public/js/chat.js');++const appJs = read('public/js/app.js');++++/** One top-level function's source, braces matched (so a test runs the code the page runs). */++function source(src, name) {++ const start = src.indexOf(`function ${name}(`);++ assert.ok(start >= 0, `${name} is in the page's script`);++ let depth = 0;++ for (let i = src.indexOf('{', start); i < src.length; i++) {++ if (src[i] === '{') depth++;++ else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);++ }++ throw new Error(`${name} never closes`);++}++const fn = source(channelJs, '_channelSectionHash');++const applyFn = source(channelJs, '_applyChannelHashTab');++const addressFn = source(channelJs, '_channelAddress');++const shareFn = source(channelJs, 'updateShareButton');++const cardFn = source(chatJs, '_chatEvSafeUrl');++++const sectionHash = (hash, scrollY, fromTap, written = '') => new Function('env', `with (env) { ${fn}; return _channelSectionHash('videos', ${fromTap}); }`)({++ location: { hash }, window: { scrollY }, _chHashWritten: written,++ CHANNEL_TAB_HASHES: ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'],++});++++// ── Scrolling never adds a section to a clean address, and never eats an incoming one ──++assert.strictEqual(sectionHash('', 900, false), '', 'scrolling over a clean channel address leaves it clean');++assert.strictEqual(sectionHash('', 0, true), '#videos', 'a jump link the person chose does name its section');++assert.strictEqual(sectionHash('#about', 900, false), '#videos', 'an address already naming a section follows the one in view');++assert.strictEqual(sectionHash('#about', 0, false), '#about', 'a section somebody linked to survives until the load opens it');++assert.strictEqual(sectionHash('#about', 0, false, '#about'), '', 'a section this page wrote is cleared at the top');++++// ── A live channel opened with a section shows the stream first; offline it opens the section ──++function openChannel({ live, hash }) {++ const location = { origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash };++ const calls = { switched: [], replaced: [] };++ const btn = { style: { display: '' } };++ new Function('env', `with (env) { ${fn}; ${addressFn}; ${applyFn}; _applyChannelHashTab(${live}); }`)({++ location,++ CHANNEL_TAB_HASHES: ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'],++ _chHashWritten: '',++ document: { querySelector: () => btn, getElementById: () => ({ classList: { contains: () => true } }) },++ setTimeout: (run) => run(),++ switchChannelTab: (tab, b, jump) => calls.switched.push({ tab, jump }),++ history: { state: null, replaceState: (state, title, url) => { calls.replaced.push(url); location.hash = ''; } },++ });++ return { location, calls };++}++{++ const live = openChannel({ live: true, hash: '#about' });++ assert.deepStrictEqual(live.calls.switched, [{ tab: 'about', jump: false }], 'live: About is marked, not scrolled to');++ assert.deepStrictEqual(live.calls.replaced, ['https://openvibe.live/@alice'], 'live: the section leaves the address');++ assert.strictEqual(live.location.hash, '', 'live: a copy of the address is the channel');++}++{++ const offline = openChannel({ live: false, hash: '#about' });++ assert.deepStrictEqual(offline.calls.switched, [{ tab: 'about', jump: true }], 'offline: About opens and scrolls as before');++ assert.deepStrictEqual(offline.calls.replaced, [], 'offline: the link keeps the section it named');++ assert.strictEqual(offline.location.hash, '#about');++}++++// ── The Share button, cross-site history and a go-live card name the channel, no section ──++const shareUrl = (hash) => {++ const b = { dataset: {}, removeAttribute() {} };++ new Function('env', `with (env) { ${addressFn}; ${shareFn}; updateShareButton(null); }`)({++ location: { origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash },++ currentStreamData: null,++ document: { title: 'Alice', getElementById: (id) => (id === 'ch-btn-share' ? b : null), querySelector: () => null },++ });++ return b.dataset.url;++};++assert.strictEqual(shareUrl('#about'), 'https://openvibe.live/@alice', 'Share does not hand over the section');++assert.strictEqual(shareUrl(''), 'https://openvibe.live/@alice');++++assert.match(appJs, /const url = type === 'stream' \? `\$\{location\.origin\}\$\{location\.pathname\}` : location\.href;/,++ 'a channel is remembered (cross-site history) as the channel, not the section in view');++++const cardUrl = (url) => new Function('env', `with (env) { ${cardFn}; return _chatEvSafeUrl(${JSON.stringify(url)}); }`)({ window: { location: { origin: 'https://openvibe.live' } } });++assert.strictEqual(cardUrl('https://openvibe.live/@alice#about'), 'https://openvibe.live/@alice', 'a go-live card opens the channel');++assert.strictEqual(cardUrl('https://openvibe.live/@alice?section=videos'), 'https://openvibe.live/@alice', 'a section query is not part of the card');++assert.strictEqual(cardUrl('https://openvibe.live/vod/5#t=10'), 'https://openvibe.live/vod/5#t=10', 'a moment on a recording keeps its time');++++console.log('channel hash, live: the stream first, no section in a shared or announced link');@@@ -34,11 +34,12 @@ assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet ch-action"'), 'the kitassert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');--// What it shares: the channel's own public address (never an internal or an API URL).--assert.ok(-- appChannel.includes('b.dataset.url = recording || `${location.origin}${location.pathname}`;'),-- "the button shares the channel page's own address",--);++// What it shares: the channel's own public address (never an internal or an API URL), with no section hash.++assert.ok(appChannel.includes('b.dataset.url = recording || _channelAddress();'), "the button shares the channel page's own address");++const addressFn = /function _channelAddress\(\) \{[\s\S]*?\n\}/.exec(appChannel);++assert.ok(addressFn, '_channelAddress is in app-channel.js');++const shareAddress = new Function('location', `${addressFn[0]}; return _channelAddress();`)({ origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash: '#about' });++assert.strictEqual(shareAddress, 'https://openvibe.live/@alice', 'a section hash is not shared');assert.ok(appChannel.includes("b.dataset.timeFrom = '#video-element'"), 'a recording that plays can be shared from the moment it plays');assert.ok(appChannel.includes("videoHref('vod', dvr.vodId)"), 'a recording that plays is shared as that recording');
@@ -926,7 +926,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -926,7 +926,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_applyChannelTabMeta(data);
_applyChannelTabMeta(data);
_applyBotEmbed(data.bot_embed, username);
_applyBotEmbed(data.bot_embed, username);
_offerBotBind(username);
_offerBotBind(username);
- _applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
+ _applyChannelHashTab(liveStreams.length > 0); // deep-link: #ai-timeline / #about / #videos … opens that tab (live: marks it)
// The Agents tab is revealed only when the channel has agent streams this viewer may see (S2).
// The Agents tab is revealed only when the channel has agent streams this viewer may see (S2).
_agentsPaging = { username: null, grid: null };
_agentsPaging = { username: null, grid: null };
try { loadChannelAgents(username); } catch {}
try { loadChannelAgents(username); } catch {}
@@ -1410,13 +1410,18 @@ function _setChannelOwnerActions(username) {
@@ -1410,13 +1410,18 @@ function _setChannelOwnerActions(username) {
}
}
}
}
+/** The channel's own address, with no section: what Share, history and announced links carry (ask 285). */
+function _channelAddress() {
+ return `${location.origin}${location.pathname}`;
+}
+
/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */
/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */
function updateShareButton(stream) {
function updateShareButton(stream) {
const b = document.getElementById('ch-btn-share');
const b = document.getElementById('ch-btn-share');
if (!b) return;
if (!b) return;
const dvr = (typeof dvrState !== 'undefined' && dvrState) ? dvrState : null;
const dvr = (typeof dvrState !== 'undefined' && dvrState) ? dvrState : null;
const recording = dvr && dvr.active && dvr.vodId ? videoHref('vod', dvr.vodId) : '';
const recording = dvr && dvr.active && dvr.vodId ? videoHref('vod', dvr.vodId) : '';
- b.dataset.url = recording || `${location.origin}${location.pathname}`;
+ b.dataset.url = recording || _channelAddress();
const s = stream || currentStreamData || null;
const s = stream || currentStreamData || null;
const name = (s && (s.display_name || s.username)) || '';
const name = (s && (s.display_name || s.username)) || '';
const title = (s && s.title) || name || document.title;
const title = (s && s.title) || name || document.title;
@@ -2213,10 +2218,11 @@ function _markChannelJump(tab, fromTap = false) {
@@ -2213,10 +2218,11 @@ function _markChannelJump(tab, fromTap = false) {
if (Math.abs(target - strip.scrollLeft) > 2) strip.scrollTo({ left: target, behavior: _channelReduceMotion() ? 'auto' : 'smooth' });
if (Math.abs(target - strip.scrollLeft) > 2) strip.scrollTo({ left: target, behavior: _channelReduceMotion() ? 'auto' : 'smooth' });
}
}
_updateChannelTabArrows();
_updateChannelTabArrows();
- // The address names the section in view once the person is among the sections (or tapped one); at the top of the
- // page it stays clean. What is written here is remembered, so the channel's next load does not take it as a link to
- // that section and scroll there (11-10: the page jumped to About by itself, with #about in the address).
- const want = fromTap || window.scrollY > 120 ? `#${tab}` : '';
+ // The address follows a section the person chose (a jump link) or one it already named while they scroll past
+ // the sections; a clean address stays clean. What is written here is remembered, so the channel's next load does
+ // not take it as a link to that section and scroll there (11-10: the page jumped to About by itself).
+ if (fromTap) _chHashMark = '';
+ const want = _channelSectionHash(tab, fromTap);
if (location.hash !== want) { _chHashWritten = want; history.replaceState(history.state, '', `${location.pathname}${location.search}${want}`); }
if (location.hash !== want) { _chHashWritten = want; history.replaceState(history.state, '', `${location.pathname}${location.search}${want}`); }
}
}
function _updateChannelTabArrows() {
function _updateChannelTabArrows() {
@@ -2291,9 +2297,14 @@ function _syncChannelSections() {
@@ -2291,9 +2297,14 @@ function _syncChannelSections() {
}
}
let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
let _chSpyFrame = 0;
let _chSpyFrame = 0;
+let _chHashMark = ''; // a section a live link named: its jump link stays marked while the page is still at the top
function _channelSectionInView() {
function _channelSectionInView() {
_chSpyFrame = 0;
_chSpyFrame = 0;
if (Date.now() < _chJumpUntil) return;
if (Date.now() < _chJumpUntil) return;
+ if (_chHashMark) {
+ if (window.scrollY <= 120) return;
+ _chHashMark = '';
+ }
const panels = Array.from(document.querySelectorAll('#page-channel .ch-tab-panel'))
const panels = Array.from(document.querySelectorAll('#page-channel .ch-tab-panel'))
.filter(p => !p.hidden).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
.filter(p => !p.hidden).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
if (!panels.length) return;
if (!panels.length) return;
@@ -2360,18 +2371,60 @@ async function _loadGameBadge(username) {
@@ -2360,18 +2371,60 @@ async function _loadGameBadge(username) {
}
}
}
}
-// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and
-// scrolls the tab content into view (used by "view full AI timeline" links, deep links, etc.).
+// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load: offline it
+// scrolls the tab content into view; live, the stream stays first and the tab is only marked (ask 285).
const CHANNEL_TAB_HASHES = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
const CHANNEL_TAB_HASHES = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
let _chHashWritten = ''; // the last section this page wrote into the address itself (not a link somebody followed)
let _chHashWritten = ''; // the last section this page wrote into the address itself (not a link somebody followed)
-function _applyChannelHashTab() {
+
+/**
+ * The section to put in the address: one the person chose (a jump link), or the section the address already names
+ * while they scroll past the sections. Scrolling never adds a section to a clean channel address — a copied or
+ * shared link would then land on that section instead of the stream (ask 285).
+ */
+function _channelSectionHash(tab, fromTap = false) {
+ const named = CHANNEL_TAB_HASHES.includes((location.hash || '').replace(/^#/, '').toLowerCase());
+ if (fromTap) return `#${tab}`;
+ if (!named) return '';
+ if (window.scrollY > 120) return `#${tab}`;
+ // At the top, keep a section somebody linked to (the load still has to open it); clear only one this page wrote.
+ return _chHashWritten ? '' : location.hash;
+}
+
+function _applyChannelHashTab(live = false) {
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
if (_chHashWritten === `#${h}`) return;
if (_chHashWritten === `#${h}`) return;
const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);
const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);
if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel
if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel
- // Jump to the section once it has had a beat to render its content.
- setTimeout(() => switchChannelTab(h, btn, true), 200);
+ const path = location.pathname;
+ const open = () => {
+ if (location.pathname !== path) return; // the person moved on while the sections were settling
+ if (live) {
+ // A link to a live channel shows the stream first (ask 285): the section's jump link is marked and its
+ // content loads, but the page does not scroll under the player. The address loses the section too, so a
+ // copy or a share of this visit stays the channel.
+ if (location.hash) {
+ _chHashWritten = '';
+ history.replaceState(history.state, '', _channelAddress());
+ }
+ _chHashMark = h;
+ switchChannelTab(h, btn, false);
+ return;
+ }
+ switchChannelTab(h, btn, true);
+ };
+ // Jump to the section once it has had a beat to render its content. Offline, the sections are held out of the
+ // layout until the channel is ready (_channelReady), and scrollIntoView on a display:none panel does nothing:
+ // wait for ch-ready so /@name#about really opens About (ask 285).
+ if (live) return setTimeout(open, 200);
+ setTimeout(() => {
+ const page = document.getElementById('page-channel');
+ if (!page || page.classList.contains('ch-ready')) return open();
+ let tries = 0;
+ const wait = setInterval(() => {
+ if (page.classList.contains('ch-ready') || ++tries >= 30) { clearInterval(wait); open(); }
+ }, 100);
+ }, 200);
}
}
let _chTabLoaded = {};
let _chTabLoaded = {};
let _chAnalyticsDays = 7;
let _chAnalyticsDays = 7;
@@ -616,7 +616,9 @@ function _recordHistory(pageId) {
@@ -616,7 +616,9 @@ function _recordHistory(pageId) {
_historyTimer = setTimeout(() => {
_historyTimer = setTimeout(() => {
const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');
const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');
if (!tok || !currentUser) return;
if (!tok || !currentUser) return;
- const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url: location.href };
+ // A channel is remembered as the channel, not as the section its scroll bar last named (ask 285).
+ const url = type === 'stream' ? `${location.origin}${location.pathname}` : location.href;
+ const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url };
const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };
const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };
if (window.OpenVibeHistory) return send();
if (window.OpenVibeHistory) return send();
if (document.getElementById('ov-history-loader')) return;
if (document.getElementById('ov-history-loader')) return;
@@ -2784,7 +2784,14 @@ function _chatEvSafeUrl(u) {
@@ -2784,7 +2784,14 @@ function _chatEvSafeUrl(u) {
let x; try { x = new URL(String(u || '')); } catch { x = new URL(String(u || ''), window.location.origin); }
let x; try { x = new URL(String(u || '')); } catch { x = new URL(String(u || ''), window.location.origin); }
if (x.protocol !== 'https:' && x.origin !== window.location.origin) return null;
if (x.protocol !== 'https:' && x.origin !== window.location.origin) return null;
const h = x.hostname.toLowerCase();
const h = x.hostname.toLowerCase();
- if (x.origin === window.location.origin || h === 'openvibe.live' || h.endsWith('.openvibe.live')) return x.href;
+ if (x.origin === window.location.origin || h === 'openvibe.live' || h.endsWith('.openvibe.live')) {
+ // A go-live card opens the channel, never a section a shared address may have named (ask 285).
+ if (/^\/@[A-Za-z0-9_]{3,24}(?:\/[^/?#]+)?\/?$/.test(x.pathname)) {
+ x.hash = '';
+ x.searchParams.delete('section');
+ }
+ return x.href;
+ }
} catch { /* not a URL */ }
} catch { /* not a URL */ }
return null;
return null;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * A link to a live channel shows the stream, not a section under it (ask 285).
+ *
+ * The channel page names the section in view while the person scrolls, and a shared link copied from the address bar
+ * could carry that #about. This pins the two halves of the fix:
+ *
+ * - the page only writes a section into the address when the person chose one (a jump link), or when the address
+ * already named one; scrolling over a clean address leaves it clean;
+ * - opening /@name#about while the channel is live marks About's jump link and loads it, but does not scroll under
+ * the stream and drops the section from the address; offline it opens and scrolls as before;
+ * - the Share button, the cross-site history entry and a go-live card's Watch link all name the channel, no section.
+ *
+ * node test/channel-hash-live.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
+const channelJs = read('public/js/app-channel.js');
+const chatJs = read('public/js/chat.js');
+const appJs = read('public/js/app.js');
+
+/** One top-level function's source, braces matched (so a test runs the code the page runs). */
+function source(src, name) {
+ const start = src.indexOf(`function ${name}(`);
+ assert.ok(start >= 0, `${name} is in the page's script`);
+ let depth = 0;
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
+ }
+ throw new Error(`${name} never closes`);
+}
+const fn = source(channelJs, '_channelSectionHash');
+const applyFn = source(channelJs, '_applyChannelHashTab');
+const addressFn = source(channelJs, '_channelAddress');
+const shareFn = source(channelJs, 'updateShareButton');
+const cardFn = source(chatJs, '_chatEvSafeUrl');
+
+const sectionHash = (hash, scrollY, fromTap, written = '') => new Function('env', `with (env) { ${fn}; return _channelSectionHash('videos', ${fromTap}); }`)({
+ location: { hash }, window: { scrollY }, _chHashWritten: written,
+ CHANNEL_TAB_HASHES: ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'],
+});
+
+// ── Scrolling never adds a section to a clean address, and never eats an incoming one ──
+assert.strictEqual(sectionHash('', 900, false), '', 'scrolling over a clean channel address leaves it clean');
+assert.strictEqual(sectionHash('', 0, true), '#videos', 'a jump link the person chose does name its section');
+assert.strictEqual(sectionHash('#about', 900, false), '#videos', 'an address already naming a section follows the one in view');
+assert.strictEqual(sectionHash('#about', 0, false), '#about', 'a section somebody linked to survives until the load opens it');
+assert.strictEqual(sectionHash('#about', 0, false, '#about'), '', 'a section this page wrote is cleared at the top');
+
+// ── A live channel opened with a section shows the stream first; offline it opens the section ──
+function openChannel({ live, hash }) {
+ const location = { origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash };
+ const calls = { switched: [], replaced: [] };
+ const btn = { style: { display: '' } };
+ new Function('env', `with (env) { ${fn}; ${addressFn}; ${applyFn}; _applyChannelHashTab(${live}); }`)({
+ location,
+ CHANNEL_TAB_HASHES: ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'],
+ _chHashWritten: '',
+ document: { querySelector: () => btn, getElementById: () => ({ classList: { contains: () => true } }) },
+ setTimeout: (run) => run(),
+ switchChannelTab: (tab, b, jump) => calls.switched.push({ tab, jump }),
+ history: { state: null, replaceState: (state, title, url) => { calls.replaced.push(url); location.hash = ''; } },
+ });
+ return { location, calls };
+}
+{
+ const live = openChannel({ live: true, hash: '#about' });
+ assert.deepStrictEqual(live.calls.switched, [{ tab: 'about', jump: false }], 'live: About is marked, not scrolled to');
+ assert.deepStrictEqual(live.calls.replaced, ['https://openvibe.live/@alice'], 'live: the section leaves the address');
+ assert.strictEqual(live.location.hash, '', 'live: a copy of the address is the channel');
+}
+{
+ const offline = openChannel({ live: false, hash: '#about' });
+ assert.deepStrictEqual(offline.calls.switched, [{ tab: 'about', jump: true }], 'offline: About opens and scrolls as before');
+ assert.deepStrictEqual(offline.calls.replaced, [], 'offline: the link keeps the section it named');
+ assert.strictEqual(offline.location.hash, '#about');
+}
+
+// ── The Share button, cross-site history and a go-live card name the channel, no section ──
+const shareUrl = (hash) => {
+ const b = { dataset: {}, removeAttribute() {} };
+ new Function('env', `with (env) { ${addressFn}; ${shareFn}; updateShareButton(null); }`)({
+ location: { origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash },
+ currentStreamData: null,
+ document: { title: 'Alice', getElementById: (id) => (id === 'ch-btn-share' ? b : null), querySelector: () => null },
+ });
+ return b.dataset.url;
+};
+assert.strictEqual(shareUrl('#about'), 'https://openvibe.live/@alice', 'Share does not hand over the section');
+assert.strictEqual(shareUrl(''), 'https://openvibe.live/@alice');
+
+assert.match(appJs, /const url = type === 'stream' \? `\$\{location\.origin\}\$\{location\.pathname\}` : location\.href;/,
+ 'a channel is remembered (cross-site history) as the channel, not the section in view');
+
+const cardUrl = (url) => new Function('env', `with (env) { ${cardFn}; return _chatEvSafeUrl(${JSON.stringify(url)}); }`)({ window: { location: { origin: 'https://openvibe.live' } } });
+assert.strictEqual(cardUrl('https://openvibe.live/@alice#about'), 'https://openvibe.live/@alice', 'a go-live card opens the channel');
+assert.strictEqual(cardUrl('https://openvibe.live/@alice?section=videos'), 'https://openvibe.live/@alice', 'a section query is not part of the card');
+assert.strictEqual(cardUrl('https://openvibe.live/vod/5#t=10'), 'https://openvibe.live/vod/5#t=10', 'a moment on a recording keeps its time');
+
+console.log('channel hash, live: the stream first, no section in a shared or announced link');
@@ -34,11 +34,12 @@ assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet ch-action"'), 'the kit
@@ -34,11 +34,12 @@ assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet ch-action"'), 'the kit
assert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');
assert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');
assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');
assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');
-// What it shares: the channel's own public address (never an internal or an API URL).
-assert.ok(
- appChannel.includes('b.dataset.url = recording || `${location.origin}${location.pathname}`;'),
- "the button shares the channel page's own address",
-);
+// What it shares: the channel's own public address (never an internal or an API URL), with no section hash.
+assert.ok(appChannel.includes('b.dataset.url = recording || _channelAddress();'), "the button shares the channel page's own address");
+const addressFn = /function _channelAddress\(\) \{[\s\S]*?\n\}/.exec(appChannel);
+assert.ok(addressFn, '_channelAddress is in app-channel.js');
+const shareAddress = new Function('location', `${addressFn[0]}; return _channelAddress();`)({ origin: 'https://openvibe.live', pathname: '/@alice', search: '', hash: '#about' });
+assert.strictEqual(shareAddress, 'https://openvibe.live/@alice', 'a section hash is not shared');
assert.ok(appChannel.includes("b.dataset.timeFrom = '#video-element'"), 'a recording that plays can be shared from the moment it plays');
assert.ok(appChannel.includes("b.dataset.timeFrom = '#video-element'"), 'a recording that plays can be shared from the moment it plays');
assert.ok(appChannel.includes("videoHref('vod', dvr.vodId)"), 'a recording that plays is shared as that recording');
assert.ok(appChannel.includes("videoHref('vod', dvr.vodId)"), 'a recording that plays is shared as that recording');