diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 22:36:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 22:36:04 -0700 |
| commit | 1842b5ebbfd9e47323f6175babc222970a21a966 (patch) | |
| tree | 48043f40b9f62fdb88edaffcb4f47d7963207e2c /src | |
| parent | 3154e5a24613bf276c6a878cb47ba72831f41e84 (diff) | |
| download | coinosv3-1842b5ebbfd9e47323f6175babc222970a21a966.tar.gz coinosv3-1842b5ebbfd9e47323f6175babc222970a21a966.zip | |
Past streams: under the Live chip, the feed's streams of the last month that left a recording (newest first, when each ended); a profile lists its person's too. A tap plays the recording.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 73 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 4 |
3 files changed, 66 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 8f0ed42..4820baa 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8544,11 +8544,41 @@ export function messagesFeature(ctx) { Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; return Promise.all([ - queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000), - queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000), ]); }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); } + // Past streams: ended ones that left a recording, the last month of them. + // A feed asks for its people's (all of them for Everything), a profile for + // its person's; opening one plays the recording (livePlayable). + const PAST_DAYS = 30; + const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400; + const pastAsked = new Map(); // what was asked -> when + let pastAsking = 0; + function askPast(key, filters) { + if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return; + pastAsked.set(key, Date.now()); + pastAsking++; + Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => []))) + .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) + .finally(() => { pastAsking--; scheduleRepaint(); }); + } + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince(); + const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; + function pastForFeed(def) { + const fol = followsNow().set; + const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; + if (people && !people.length) return []; + const ps = people && people.slice(0, 400); + askPast('feed:' + def.id, ps ? [{ authors: ps, limit: 200 }, { '#p': ps, limit: 200 }] : [{ limit: 300 }]); + const set = ps && new Set(ps); + return [...liveSeen.values()] + .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey))) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + } + const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev)) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); function liveNowOf(pk) { @@ -8573,17 +8603,33 @@ export function messagesFeature(ctx) { // 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) }, - liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null), - h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), - h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); - })); + const past = pastForFeed(def).slice(0, 60); + return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' }, + evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile)) + : 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')), + past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null, + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) + : pastAsking ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); + } + // a stream's tile: on now (how many watch) or past (when it ended) + function liveTile(ev) { + const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(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) }, + now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')), + now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null) + : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))), + h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); + } + // a profile's past streams, a row to scroll through + function pastStreams(pk) { + const past = pastOf(pk).slice(0, 20); + if (!past.length) return null; + return h('div', { class: 'col', style: 'gap:6px', 'data-key': 'live-past-of:' + pk }, + h('div', { class: 'small muted' }, t('livePast')), + h('div', { class: 'live-past-row' }, ...past.map(liveTile))); } // a profile's "on now" line function liveBanner(pk) { @@ -12156,6 +12202,7 @@ export function messagesFeature(ctx) { // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), ui.profEdit ? null : liveBanner(pk), + ui.profEdit ? null : pastStreams(pk), mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive }, h('span', { class: 'go-live-dot' }), t('goLive')) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and diff --git a/src/i18n.js b/src/i18n.js index a06c35c..562043d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -461,6 +461,8 @@ const STR = { liveBadge: 'LIVE', livePlanned: 'SOON', liveEnded: 'ENDED', + livePast: 'Past streams', + liveRecordingBadge: 'RECORDING', liveUntitled: 'Live stream', liveWatching: '{n} watching', liveWatch: 'Watch', diff --git a/src/style.css b/src/style.css index 808934f..95ce04c 100644 --- a/src/style.css +++ b/src/style.css @@ -895,6 +895,10 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } 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-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; } +.live-past-title { margin: 6px 0 0; font-size: 15px; } +.live-past-row { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } +.live-past-row::-webkit-scrollbar { display: none; } +.live-past-row .live-tile { flex: 0 0 160px; } .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; } |
