From db3eda32859130781ee25aa462f2ae5839f3feb3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 23:04:35 -0700 Subject: Streams on coinos's server: a ⚑ Report button (what's wrong, a note) for anyone signed in, and Take down for the admin (asks whether to bar the account too); a taken-down stream says so and plays nothing. Go live asks the server first: a name claimed less than a day ago is told when it can, and a barred account that it can't, Start off. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/features/messages.js | 74 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 5 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index b39bd02..9604f6e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8558,6 +8558,53 @@ export function messagesFeature(ctx) { await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt); } catch (e) { toast(e.message || String(e)); } } + // ---- moderation: a viewer reports a stream we carry; an admin takes it + // down (the server ends it and keeps it from everyone, for reporting) + const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; + const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; }; + async function livePost(path, body) { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] }); + const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status); + return j; + } + const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other']; + async function sendLiveReport(ev) { + const r = ui.liveReport; + if (!r || !r.reason) return; + r.busy = true; render(); + try { + await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() }); + ui.liveReport = null; + toast(t('liveReported')); + } catch (e) { r.busy = false; toast(e.message || String(e)); } + render(); + } + function liveReportPanel(ev) { + const r = ui.liveReport; + return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' }, + h('strong', {}, t('liveReportTitle')), + h('div', { class: 'small muted' }, t('liveReportDesc')), + ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }), + h('span', {}, t('liveReason_' + x)))), + h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')), + h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel')))); + } + async function takeDown(ev) { + if (!confirm(t('liveTakeDownAsk'))) return; + const ban = confirm(t('liveBanAsk')); + try { + await livePost('/live/' + ourStreamId(ev) + '/remove', { ban }); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'removed'; k.at = Date.now(); } + toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown')); + render(); + } catch (e) { toast(e.message || String(e)); } + } function liveHostNow(pk) { if (!liveSeen.size) return null; if (Date.now() - liveHostsAt > 30_000) { @@ -8608,7 +8655,7 @@ 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(); + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince() && liveServerState(ev) !== 'removed'; const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; function pastForFeed(def) { const fol = followsNow().set; @@ -8761,7 +8808,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) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + const livePlayable = (ev) => (liveServerState(ev) === 'removed' ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); // The