openvibers/openvibe.live Commits

A live channel link shows the stream, not a section under it (#778)

OP OpenVibers committed 3 days ago · 67baf7e2c3 · 5 files, +185 −18

Changes

ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex bf746219..482190a7 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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 top
function _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;
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 21299dd6..b48d08da 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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;
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex eaa13dcf..e643f20f 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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;
}
ddiff --git a/test/channel-hash-live.test.js b/test/channel-hash-live.test.js
nnew file mode 100644
iindex 00000000..ee385523
---- /dev/null
++++ b/test/channel-hash-live.test.js
@@@ -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');
ddiff --git a/test/share-sheet.test.js b/test/share-sheet.test.js
iindex 81fcfd9b..f6f42880 100644
---- a/test/share-sheet.test.js
++++ b/test/share-sheet.test.js
@@@ -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(!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');