aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-19 11:15:35 -0700
committerAdam Soltys <[email protected]>2026-10-19 11:15:35 -0700
commit38d99f6e7f61bfc854e0349ac75d33f2b76ae1f8 (patch)
treefe329311849295e9828ff23922974d06b02b06cd /src/features/messages.js
parent0898bb16eb598215ab66c0e86c28a9da063c9357 (diff)
downloadcoinosv3-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/messages.js')
-rw-r--r--src/features/messages.js96
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 {}