From 73030636812a20fd025b6c558695b50224bdfb0e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 13:26:56 -0700 Subject: Live streams have their own chip, after Videos: a grid of the feed's streams on now, a red dot on the chip while someone in the feed is streaming — no longer a strip above every feed's posts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/features/messages.js | 31 +++++++++++++++++++------------ src/i18n.js | 2 ++ src/style.css | 6 +++--- tools/live-stream-test.js | 19 +++++++++++++++---- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/features/messages.js b/src/features/messages.js index e239aee..8f0ed42 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7666,6 +7666,7 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; + if (mediaMode === 'live') return; if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) @@ -8569,13 +8570,13 @@ export function messagesFeature(ctx) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } - // the row of streams above a feed's posts - function liveStrip(def) { - if (mediaMode === 'articles') return null; - refreshLive(); - const evs = liveForFeed(def).slice(0, 12); - if (!evs.length) return null; - return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => { + // the feed's Live chip: its streams on now, as a grid of tiles + function liveGrid(def) { + const evs = liveForFeed(def).slice(0, 60); + if (!evs.length) return liveAsking + ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')); + return h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map((ev) => { const host = liveHost(ev), n = liveViewers(ev); return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, @@ -12757,7 +12758,7 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {} // Notes and Conversations are lists of posts (the feed's own state, or its // Conversations view); the rest are grids read from the media index. const listView = () => mediaMode === 'all' || mediaMode === 'replies'; @@ -13455,8 +13456,13 @@ export function messagesFeature(ctx) { class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, onClick: () => setMediaMode(m), }, label); + // Live wears a red dot while someone in the feed is streaming + const def = feedDef(curFeedId); + const onAir = def && liveForFeed(def).length > 0; return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), + b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')), + b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -14880,8 +14886,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), - mediaModes(), - liveStrip(def), + (refreshLive(), mediaModes()), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -14910,6 +14915,7 @@ export function messagesFeature(ctx) { : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) + : mediaMode === 'live' ? liveGrid(def) : (c.booting || c.status === 'loading') && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length @@ -14921,7 +14927,8 @@ export function messagesFeature(ctx) { // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) + mediaMode === 'live' ? null + : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) diff --git a/src/i18n.js b/src/i18n.js index b7432b0..db290bb 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -348,10 +348,12 @@ const STR = { mediaConversations: "Conversations", mediaImages: "Images", mediaVideos: "Videos", + mediaLive: "Live", mediaArticles: "Articles", mediaNoArticles: "No articles in this feed yet.", mediaNoImages: "No pictures in this feed yet.", mediaNoVideos: "No vertical videos in this feed yet.", + mediaNoLive: "Nobody in this feed is live right now.", feedRelays: 'Relays', feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.', feedAllToggle: 'Every post on these relays (a firehose)', diff --git a/src/style.css b/src/style.css index 235cb4b..fcef5a0 100644 --- a/src/style.css +++ b/src/style.css @@ -894,9 +894,9 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .chat-avatar.live-ring:not(.mini)::after { content: 'LIVE'; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%); padding: 0 4px; border-radius: 3px; font-size: 9px; font-weight: 700; line-height: 13px; letter-spacing: .04em; color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; } -.live-strip { display: flex; gap: 10px; overflow-x: auto; padding: 2px 0 4px; scrollbar-width: none; } -.live-strip::-webkit-scrollbar { display: none; } -.live-tile { flex: 0 0 168px; display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; +.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; } +.live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 5px; vertical-align: 1px; } +.live-tile { display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; } .live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--line) center / cover no-repeat; } diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index 4b8172d..88d2b17 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -140,19 +140,28 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); console.log('\n[the feed]'); - check('a followed host\'s stream shows above the posts', await waitFor(() => !!document.querySelector('.live-strip .live-tile'), 15000)); - const tile = await page.evaluate(() => [...document.querySelectorAll('.live-strip .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' '))); + check('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => { + const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode); + return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot'); + }, 15000)); + check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip')))); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); + check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 15000)); + const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' '))); check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile)); check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream')))); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000)); check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000)); await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click()); check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000)); await page.goBack(); - await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-strip'), 5000); + await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); + await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 5000); console.log('\n[the stream\'s page]'); - await page.evaluate(() => document.querySelector('.live-strip .live-tile').click()); + await page.evaluate(() => document.querySelector('.live-grid .live-tile').click()); check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname)); check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; }))); check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM)); @@ -170,6 +179,8 @@ try { await page.goBack(); check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000)); check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected))); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); + await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000); await page.evaluate(() => document.querySelector('.notes-feed .live-card').click()); check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)); await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); }); -- cgit v1.2.3