aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js74
1 files changed, 69 insertions, 5 deletions
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 <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).
@@ -8857,7 +8904,7 @@ export function messagesFeature(ctx) {
const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
const src = livePlayable(ev);
const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean);
- const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : 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 = (w.draft || '').trim();
@@ -8902,11 +8949,17 @@ export function messagesFeature(ctx) {
n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
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'
+ ? 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'
+ ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : 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 {}
} }, '↗')),
- liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
+ ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
h('div', { class: 'card col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('liveChat')),
h('div', {
@@ -9159,6 +9212,15 @@ export function messagesFeature(ctx) {
if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
});
const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ // the server's say first (a name a day old, not barred), so Start doesn't just fail
+ if (name && b.allowed === undefined) {
+ b.allowed = null;
+ signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] })
+ .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }))
+ .then((r) => r.json()).then((j) => { b.allowed = j; render(); })
+ .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start
+ }
+ const refused = b.allowed && b.allowed.ok === false ? b.allowed : null;
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(true),
h('div', { class: 'card col', style: 'gap:12px' },
@@ -9166,6 +9228,8 @@ export function messagesFeature(ctx) {
backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
h('h3', { style: 'margin:0' }, t('goLive'))),
!name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until
+ ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null,
h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
b.media
? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
@@ -9177,7 +9241,7 @@ export function messagesFeature(ctx) {
h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
onInput: (e) => { b.title = e.target.value; } }),
b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
- h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup', onClick: startBroadcast },
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast },
b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
h('div', { class: 'small faint' }, t('goLiveHint'))));
}