aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 13:26:56 -0700
committerAdam Soltys <[email protected]>2026-10-18 13:26:56 -0700
commit73030636812a20fd025b6c558695b50224bdfb0e (patch)
tree72e8cb352f9b95bfda482cf4f01a6c74349f04d2
parentd86f7cb039bbd4bf2873be332af278bc6372aeae (diff)
downloadcoinosv3-73030636812a20fd025b6c558695b50224bdfb0e.tar.gz
coinosv3-73030636812a20fd025b6c558695b50224bdfb0e.zip
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.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/features/messages.js31
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css6
-rw-r--r--tools/live-stream-test.js19
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(); });