diff options
| author | Adam Soltys <[email protected]> | 2026-10-19 11:15:35 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-19 11:15:35 -0700 |
| commit | 38d99f6e7f61bfc854e0349ac75d33f2b76ae1f8 (patch) | |
| tree | fe329311849295e9828ff23922974d06b02b06cd /src/features | |
| parent | 0898bb16eb598215ab66c0e86c28a9da063c9357 (diff) | |
| download | coinosv3-38d99f6e7f61bfc854e0349ac75d33f2b76ae1f8.tar.gz coinosv3-38d99f6e7f61bfc854e0349ac75d33f2b76ae1f8.zip | |
Streams: Delete recording, and Past streams that scroll on.
- **Delete recording:** your own ended stream's page has the button. The live server lets the recording go, and the stream is announced again without it, so other apps drop the link too. A deleted one leaves Past streams and says the streamer deleted it.
- **Admin:** the Take down button no longer shows on the admin's own streams.
- **Past streams** scroll on: 24 at a time as the grid's foot comes near, then older ones from the relays (before the oldest held), until a page brings nothing. They're no longer cut off at a month.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 96 |
1 files changed, 82 insertions, 14 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 969f0c1..1f728e5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8539,7 +8539,9 @@ export function messagesFeature(ctx) { } return k.state; } - const serverOver = (ev) => { const st = liveServerState(ev); return st === 'ended' || st === 'removed'; }; + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); // Your own stream announced live that this tab isn't sending (the app was @@ -8553,7 +8555,7 @@ export function messagesFeature(ctx) { const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); - if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); takeLive([evt]); @@ -8609,6 +8611,28 @@ export function messagesFeature(ctx) { render(); } catch (e) { toast(e.message || String(e)); } } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } function liveHostNow(pk) { if (!liveSeen.size) return null; if (Date.now() - liveHostsAt > 30_000) { @@ -8659,14 +8683,18 @@ export function messagesFeature(ctx) { .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) .finally(() => { pastAsking--; scheduleRepaint(); }); } - const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince() && liveServerState(ev) !== 'removed'; + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; - function pastForFeed(def) { + const pastPeople = (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 }]); + return people && people.slice(0, 400); + }; + const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]); + function pastForFeed(def) { + const ps = pastPeople(def); + if (ps && !ps.length) return []; + askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 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))) @@ -8695,17 +8723,54 @@ export function messagesFeature(ctx) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } + // Past streams scroll on: more tiles as the grid's foot comes near, and + // once those we hold run out, older ones from the relays (before the oldest + // asked so far) until a page brings nothing back. + const PAST_STEP = 24; + const pastPages = new Map(); // feed id -> { shown, until, end, busy } + const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; }; + function morePast() { + if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return; + const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]'); + if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return; + const def = feedDef(), p = pastPage(def), all = pastForFeed(def); + if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; } + if (p.end || p.busy || pastAsking) return; + const ps = pastPeople(def); + if (ps && !ps.length) { p.end = true; return; } + // older than the oldest we hold (the first ask's page may stop short of its month) + if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at)); + p.busy = true; + scheduleRepaint(); + Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => []))) + .then((rs) => { + const evs = rs.flat(); + if (!evs.length) { p.end = true; return; } + p.until = Math.min(...evs.map((e) => e.created_at)); + takeLive(evs); + p.shown += PAST_STEP; + }) + .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on + } + let pastScrollQueued = false; + if (typeof document !== 'undefined') document.addEventListener('scroll', () => { + if (pastScrollQueued || mediaMode !== 'live') return; + pastScrollQueued = true; + requestAnimationFrame(() => { pastScrollQueued = false; morePast(); }); + }, { capture: true, passive: true }); // the feed's Live chip: its streams on now, as a grid of tiles function liveGrid(def) { const evs = liveForFeed(def).slice(0, 60); - const past = pastForFeed(def).slice(0, 60); + const pp = pastPage(def); + const past = pastForFeed(def).slice(0, pp.shown); + setTimeout(morePast, 0); // a short grid fills the screen without a scroll 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); + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null, + pastAsking || pp.busy ? 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) { @@ -8923,7 +8988,7 @@ export function messagesFeature(ctx) { // that gets a link out — a player can't play a page. const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; - const livePlayable = (ev) => (liveServerState(ev) === 'removed' ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); // The <video> is the page's own once it plays: Chrome takes the playlist as // its src; anywhere without HLS of its own, hls.js feeds it. The morph // leaves it alone from then on (it would strip the src hls.js set). @@ -9080,7 +9145,7 @@ export function messagesFeature(ctx) { const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); const draftKey = 'live:' + w.a; const reply = w.lines.get(w.reply); - const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; const send = () => { const text = draftFor(draftKey).trim(); @@ -9135,10 +9200,13 @@ export function messagesFeature(ctx) { h('span', { style: 'margin-left:auto' }), canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, // ours to answer for: anyone signed in can report it, an admin can take it down - ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && liveServerState(ev) !== 'removed' + ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev) ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, - ourStreamId(ev) && mePk() === LIVE_ADMIN && liveServerState(ev) !== 'removed' + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', onClick: () => deleteRecording(ev) }, t('liveDelete')) : null, h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { const url = location.origin + '/' + packNaddr(s.a); try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} |
