aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 22:36:04 -0700
committerAdam Soltys <[email protected]>2026-10-18 22:36:04 -0700
commit1842b5ebbfd9e47323f6175babc222970a21a966 (patch)
tree48043f40b9f62fdb88edaffcb4f47d7963207e2c /src
parent3154e5a24613bf276c6a878cb47ba72831f41e84 (diff)
downloadcoinosv3-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.js73
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css4
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; }