aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js9
-rw-r--r--src/calls.js102
-rw-r--r--src/features/ark.js17
-rw-r--r--src/features/messages.js1003
-rw-r--r--src/features/zaps.js34
-rw-r--r--src/hls-player.js7
-rw-r--r--src/i18n.js37
-rw-r--r--src/live.js19
-rw-r--r--src/lnurl.js6
-rw-r--r--src/media-timeline.js140
-rw-r--r--src/media-upload.js13
-rw-r--r--src/nostr.js9
-rw-r--r--src/style.css50
13 files changed, 1285 insertions, 161 deletions
diff --git a/src/app.js b/src/app.js
index 81718b1..eb51e2f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -12,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -3895,6 +3895,7 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
// a phone video is shrunk on the device first (see video-compress.js);
// a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
@@ -3903,15 +3904,17 @@ async function publicMediaUpload(file) {
const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
if (stream) {
mediaProgress('upload', 0);
- const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
}
mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized'));
if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
throw new Error(t('msgUploadFailed'));
diff --git a/src/calls.js b/src/calls.js
index 6f18aec..8ff32e9 100644
--- a/src/calls.js
+++ b/src/calls.js
@@ -89,6 +89,7 @@ export function createCalls(env) {
function stopMedia(c) {
for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} }
+ for (const t of [c.cam, c.screen]) if (t) { try { t.stop(); } catch {} } // the camera set aside while sharing
try { c.pc && c.pc.close(); } catch {}
}
function end(reason, c = call) {
@@ -111,11 +112,21 @@ export function createCalls(env) {
const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 });
c.pc = pc;
c.remote = new MediaStream();
- pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); };
+ pc.ontrack = (e) => {
+ for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t);
+ // a picture that stops coming (they stopped sharing, camera and all) mutes
+ e.track.onmute = e.track.onunmute = changed;
+ changed();
+ };
c.trickle = env.trickle ? env.trickle() !== false : true;
c.sent = [];
pc.onicecandidate = (e) => {
- if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return;
+ // the routes found before the offer or answer went ride inside it;
+ // only the late ones go one by one (one signed message each — dozens
+ // of them got the household's IP rate-limited on the relays, and the
+ // answer refused with them)
+ if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return;
+ if (c.described.includes(e.candidate.candidate)) return;
c.sent.push(e.candidate);
sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {});
};
@@ -160,7 +171,7 @@ export function createCalls(env) {
const pc = c.pc;
const enough = () => pc.iceGatheringState === 'complete'
|| (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
- if (!c.trickle && !enough()) {
+ if (!enough()) {
await new Promise((resolve) => {
const check = () => { if (enough()) resolve(); };
pc.addEventListener('icegatheringstatechange', check);
@@ -168,7 +179,10 @@ export function createCalls(env) {
setTimeout(resolve, 1500);
});
}
- return pc.localDescription.sdp;
+ const sdp = pc.localDescription.sdp;
+ // what it carries isn't sent again; what comes later is (trickle)
+ c.described = sdp;
+ return sdp;
}
async function addCandidates(c) {
const list = [...(early.get(c.peer) || []), ...c.ice];
@@ -251,6 +265,82 @@ export function createCalls(env) {
for (const t of call.local.getVideoTracks()) t.enabled = !on;
changed();
}
+ // A voice call turned video: our camera into the call, by a new offer
+ // (NIP-AC's renegotiate). Into the video transceiver their camera already
+ // came by if there is one, else a new one. The other side sees us at once
+ // and gets the same button for theirs.
+ async function addVideo() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || !c.local || c.local.getVideoTracks().length) return;
+ try {
+ const fresh = await env.media('video-only', c.facing);
+ const track = fresh.getVideoTracks()[0];
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ // a new stream object, so the preview picks the camera up
+ c.local = new MediaStream([...c.local.getTracks(), track]);
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); tr.direction = 'sendrecv'; try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} }
+ else c.pc.addTrack(track, c.local);
+ c.type = 'video'; c.camOff = false;
+ changed();
+ await renegotiate(c);
+ } catch {}
+ }
+ async function renegotiate(c) {
+ c.renegotiating = true;
+ await c.pc.setLocalDescription(await c.pc.createOffer());
+ await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp);
+ }
+ // Your screen in the call: in the camera's place if it's on (a swap, no new
+ // offer), else into the video slot by a renegotiation, as addVideo does.
+ // Stopping — here or from the browser's own Stop sharing — brings the
+ // camera back if it was on, else stops sending a picture.
+ async function shareScreen() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || c.sharing) return;
+ let track;
+ try { track = (await env.media('screen')).getVideoTracks()[0]; } catch { return; }
+ if (!track) return;
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ try { track.contentHint = 'detail'; } catch {}
+ const cam = c.local.getVideoTracks()[0] || null;
+ Object.assign(c, { sharing: true, screen: track, cam });
+ c.local = new MediaStream([...c.local.getAudioTracks(), track]);
+ let renego = false;
+ const sender = c.pc.getSenders().find((x) => x.track && x.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ else {
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); if (tr.direction !== 'sendrecv') { tr.direction = 'sendrecv'; renego = true; } }
+ else { c.pc.addTrack(track, c.local); renego = true; }
+ }
+ c.type = 'video';
+ track.onended = () => { if (call === c && c.screen === track) stopShare(); };
+ changed();
+ if (renego) await renegotiate(c).catch(() => {});
+ }
+ async function stopShare() {
+ const c = call;
+ if (!c || !c.sharing) return;
+ const track = c.screen, cam = c.cam;
+ Object.assign(c, { sharing: false, screen: null, cam: null });
+ try { track.stop(); } catch {}
+ if (!c.pc || c.state === 'ended') return;
+ const sender = c.pc.getSenders().find((x) => x.track === track);
+ if (cam && cam.readyState === 'live') {
+ if (sender) await sender.replaceTrack(cam).catch(() => {});
+ c.local = new MediaStream([...c.local.getAudioTracks(), cam]);
+ changed();
+ return;
+ }
+ c.local = new MediaStream(c.local.getAudioTracks());
+ changed();
+ if (sender) {
+ await sender.replaceTrack(null).catch(() => {});
+ const tr = c.pc.getTransceivers().find((x) => x.sender === sender);
+ if (tr && !tr.stopped) { tr.direction = 'recvonly'; await renegotiate(c).catch(() => {}); }
+ }
+ }
// the other camera: the sender's track swapped, no new offer needed
async function flip() {
const c = call;
@@ -357,6 +447,8 @@ export function createCalls(env) {
await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content });
await c.pc.setLocalDescription(await c.pc.createAnswer());
await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp);
+ // they turned their camera on: this is a video call now
+ if (c.type !== 'video' && /^m=video [1-9]/m.test(ev.content)) { c.type = 'video'; changed(); }
} catch {}
return;
}
@@ -365,7 +457,7 @@ export function createCalls(env) {
return {
get call() { return call; },
- start, accept, reject, hangup, setMuted, setCamOff, flip, handle,
+ start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, shareScreen, stopShare, handle,
// a sign-out or account switch: whatever was going on ends here
stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); },
};
diff --git a/src/features/ark.js b/src/features/ark.js
index abb297a..3ef6cea 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1,3 +1,4 @@
+import { momentTags } from '../media-timeline.js';
// Ark feature — off-chain payments via an ASP (Second's bark/captaind
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
@@ -1731,8 +1732,8 @@ export function arkFeature(ctx) {
// 2. lightning: profile lud16/lud06, via the zap flow
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
- function startNpubPay(pk, npub, eventId = null, autoSat = 0) {
- const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat };
+ function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) {
+ const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat };
// A one-tap post zap has no card: it resolves and pays off-screen, so a
// second tap — the same post again, or another — never evicts the one
// already in the air. Everything else takes the send screen's zap slot.
@@ -1814,7 +1815,7 @@ export function arkFeature(ctx) {
z.lnOk = true;
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; }
}
// 3. on-chain fallback (a one-tap zap can't take the send form)
if (onchain && !(z.autoSat && z.eventId)) {
@@ -1934,7 +1935,7 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1977,7 +1978,7 @@ export function arkFeature(ctx) {
wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
}).catch(() => {});
const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
@@ -2065,7 +2066,7 @@ export function arkFeature(ctx) {
// Only someone with a Lightning address gets the Lightning door (the
// lookup normally hands such a person straight to the zaps feature).
noAddr && z.lnOk && ctx.hook('canLnZap')
- ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
@@ -3929,9 +3930,9 @@ export function arkFeature(ctx) {
// ⚡ button) — through the same resolution ladder as a pasted npub. The
// event id rides into the receipt's e tag; callers fall back to the
// lnZapNpub hook when ark can't serve this build/wallet.
- zapNpub(pk, npub, eventId, autoSat) {
+ zapNpub(pk, npub, eventId, autoSat, moment) {
if (!arkAvailable() || !wallet.nostrFetch) return false;
- startNpubPay(pk, npub, eventId || null, autoSat || 0);
+ startNpubPay(pk, npub, eventId || null, autoSat || 0, moment);
return true;
},
sendFormExtras() {
diff --git a/src/features/messages.js b/src/features/messages.js
index 4820baa..02e418d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,4 @@
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js';
import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
@@ -53,8 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
-import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js';
-import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -63,7 +64,10 @@ import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+// coinos's own relay and primal: both take DM gift wraps and pass call
+// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
+// refused or ignored the household IP's publishes, call answers among them.
+const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -3922,7 +3926,8 @@ export function messagesFeature(ctx) {
try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
if (on) {
node.classList.add('live-ring');
- if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); };
+ // (on that stream's own page, the face is a way out: to their profile)
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); };
}
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -4503,7 +4508,56 @@ export function messagesFeature(ctx) {
// Where an event about a note goes. A zap receipt is money and has its own
// bookkeeping (see below); a like and a boost are just tallies of who.
+ const timedActivity = new Map();
+ function rememberMoment(ev) {
+ const moment = readMoment(ev);
+ if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return;
+ const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1];
+ if (!target) return;
+ if (!timedActivity.has(target)) timedActivity.set(target, new Map());
+ const items = timedActivity.get(target);
+ if (items.has(ev.id)) return;
+ items.set(ev.id, { id: ev.id, ev, moment,
+ icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩',
+ text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content });
+ if (items.size > 300) items.delete(items.keys().next().value);
+ if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value);
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._timeline) v.dispatchEvent(new Event('mediaactivity')); }));
+ }
+ function attachMediaTimeline(player, url, explicitEvent) {
+ if (player._timeline) return;
+ player._timeline = true;
+ const event = () => {
+ const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post'));
+ return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null;
+ };
+ const box = player.parentElement;
+ box._skipMorph = true;
+ box.append(mediaTimeline(player, url, { h, event, label: t,
+ moments: (id, file) => [...(timedActivity.get(id)?.values() || [])]
+ .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
+ .sort((a, b) => a.moment.seconds - b.moment.seconds)
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })),
+ action: (kind, ev, moment, button) => {
+ if (signinAsk()) return;
+ if (kind === 'comment') {
+ player.pause();
+ if (pager) leavePager();
+ replyToNote(ev, moment);
+ } else if (kind === 'reaction') {
+ ui.reactPick = { ...ev, mediaMoment: moment }; render();
+ } else {
+ if (wallet.watchOnly) { zapLockedAsk(); return; }
+ if (pager && !(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
+ zapNote(ev.pubkey, ev, button.getBoundingClientRect(), moment);
+ recheckZap(ev.id);
+ }
+ },
+ }));
+ player.dispatchEvent(new Event('timeupdate'));
+ }
function noteEvent(ev) {
+ rememberMoment(ev);
if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; }
if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; }
if (ev.kind === 7 || ev.kind === 6) {
@@ -4541,6 +4595,7 @@ export function messagesFeature(ctx) {
// root and reply (or the bare positional e tags of older clients), NIP-22's
// E and e. A 'mention' e tag is a quote, not a reply.
function noteReply(ev) {
+ rememberMoment(ev);
let changed = false;
for (const x of ev.tags || []) {
if (!x[1] || x[1] === ev.id) continue;
@@ -4561,6 +4616,7 @@ export function messagesFeature(ctx) {
}
function noteReceipt(ev) {
+ rememberMoment(ev);
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
@@ -7788,7 +7844,7 @@ export function messagesFeature(ctx) {
// A bare npub/nprofile — with or without an @ in front, no nostr: prefix —
// is how Vector (and people) write a mention; it resolves to the name too.
const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])';
- const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
@@ -8065,7 +8121,7 @@ export function messagesFeature(ctx) {
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
- onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
+ onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); },
// a touch's tap was taken at pointerup (below): its click, if one comes, is spent
onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); },
onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
@@ -8514,6 +8570,121 @@ export function messagesFeature(ctx) {
// who is on now (host -> their stream), for the rings round faces: worked
// out again when a stream changes and every half minute (one goes stale)
let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ // 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
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ 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.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]);
+ liveHostsAt = 0;
+ render();
+ 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)); }
+ }
+ // 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) {
@@ -8564,14 +8735,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();
+ 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)))
@@ -8600,17 +8775,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) {
@@ -8623,19 +8835,22 @@ export function messagesFeature(ctx) {
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
+ // a profile's Streams tab: their past streams, as the Live chip's tiles
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)));
+ return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile));
}
// a profile's "on now" line
function liveBanner(pk) {
askLiveOf(pk);
const ev = liveNowOf(pk);
- if (!ev) return null;
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
h('span', { class: 'live-badge live' }, t('liveBadge')),
h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
@@ -8678,34 +8893,201 @@ export function messagesFeature(ctx) {
const [, pk, ...rest] = s.a.split(':');
const d = rest.join(':');
takeLive([s.seed]);
- const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' };
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
let mine = []; try { mine = wallet.nostrRelays(); } catch {}
- const relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
const add = (e) => {
if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
scheduleRepaint();
};
w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ // who's here: NIP-53 presence (kind 10312, the stream in its a tag) —
+ // Shosho, zap.stream and others say it, and so do we while it's open
+ w.here = new Map(); // pubkey -> when they last said so
+ w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => {
+ if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return;
+ w.here.set(e.pubkey, e.created_at);
+ scheduleRepaint();
+ }));
+ sayHere(w);
return w;
}
+ // Presence counts for half an hour (some clients say it once, on joining);
+ // ours is said again every five minutes while the stream is open, and
+ // taken back (an empty one) on leaving. Only with a key held here: a
+ // signer app would be asked to sign every five minutes.
+ const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800;
+ async function presence(w, here) {
+ const id = await identity().catch(() => null);
+ if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return;
+ const ev = liveSeen.get(w.a);
+ if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over
+ const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer);
+ if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; }
+ publishOn(w.relays, evt).catch(() => {});
+ }
+ function sayHere(w) {
+ presence(w, true);
+ w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000);
+ }
+ // the people known to be watching (fresh presence), newest first
+ const watchersOf = (w) => {
+ const since = Date.now() / 1000 - PRESENCE_FRESH;
+ return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : [];
+ };
+ // "4 watching": hover names who (as many as are known), a tap shows their faces
+ function watchingLine(w, n, cls = 'small muted') {
+ const who = watchersOf(w);
+ const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : '');
+ return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined,
+ role: who.length ? 'button' : undefined,
+ onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined },
+ t('liveWatching', { n: Math.max(n || 0, who.length) }));
+ }
+ function watchersRow(w) {
+ const who = watchersOf(w);
+ if (!w || !w.showWho || !who.length) return null;
+ return h('div', { class: 'live-who', 'data-key': 'live-who' },
+ ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) },
+ avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk)))));
+ }
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
function stopLivePlayer(w = liveWatch) {
if (!w) return;
if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; }
+ clearInterval(w.rtcBeat);
const v = w.video;
w.video = null;
- if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} }
+ if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer
+ // the sender's tracks, we answer. Resolves once connected (rejects after
+ // 8 s, or if there's no WebRTC for it); after that, a lost connection or a
+ // new publish (the sender reconnected) mounts the player again.
+ async function rtcWatch(w, el, src, go) {
+ const base = src.replace(/live\.m3u8$/, '');
+ const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const media = new MediaStream();
+ pc.ontrack = (e) => {
+ media.addTrack(e.track);
+ if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); }
+ };
+ const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) });
+ if (!r.ok) throw new Error('watch ' + r.status);
+ const j = await r.json();
+ if (w.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp });
+ await pc.setLocalDescription(await pc.createAnswer());
+ const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) });
+ if (!r2.ok) throw new Error('answer ' + r2.status);
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('rtc timeout')), 8000);
+ const on = () => {
+ if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); }
+ else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); }
+ };
+ pc.addEventListener('connectionstatechange', on);
+ on();
+ });
+ if (w.pc !== pc) return;
+ w.rtcFails = 0;
+ const again = (giveUp) => {
+ if (w.pc !== pc || w.video !== el) return;
+ if (giveUp || ++w.rtcFails > 3) w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint();
+ };
+ pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); });
+ // counted as watching (no playlist is asked for), and told of a new publish
+ w.rtcBeat = setInterval(() => {
+ fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json())
+ .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {});
+ }, 10_000);
}
function stopLiveWatch() {
const w = liveWatch;
if (!w) return;
liveWatch = null;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
for (const stop of w.stops) { try { stop(); } catch {} }
+ clearInterval(w.hereTimer);
+ if (w.said) presence(w, false); // gone
stopLivePlayer(w);
}
// What to play: the stream while it's on, its recording once it's over.
- const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : '');
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // 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) => (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).
@@ -8732,8 +9114,27 @@ export function messagesFeature(ctx) {
el.addEventListener('hlsfatal', fail);
el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
- if (!isHls(src) || canHls()) { el.src = src; go(); return; }
- import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => {
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ // Ours, on: WebRTC first (a fraction of a second behind), HLS if it
+ // can't be had — no WebRTC for the stream, or no connection in 8 s
+ if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) {
+ rtcWatch(w, el, src, go).catch(() => {
+ if (w.video !== el) return;
+ w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint(); // mounted again, as HLS
+ });
+ return;
+ }
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
w.hls = hls; go();
@@ -8754,31 +9155,70 @@ export function messagesFeature(ctx) {
if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
}
- async function sendLiveChat(w, text) {
+ async function sendLiveChat(w, text, reply) {
const id = await requireIdentity();
const ev = liveSeen.get(w.a);
const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
- tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] };
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
render();
if (!(await publishOn(relays, evt))) {
w.lines.delete(evt.id);
- w.draft = text;
- toast(t('msgSendFailed'));
- render();
+ throw new Error(t('msgSendFailed'));
}
}
- function liveLine(ev) {
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
if (ev.kind === LIVE_CHAT_KIND) {
if (hiddenPk(ev.pubkey)) return null;
- return keyed(h('div', { class: 'live-line' },
- avatar(ev.pubkey, 'chat-avatar mini'),
- h('div', { class: 'live-line-body' },
- h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ',
- ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id);
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
}
const z = zapLine(ev, decodeBolt11);
if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
@@ -8800,16 +9240,25 @@ export function messagesFeature(ctx) {
const ev = liveSeen.get(s.a) || s.seed;
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 reactions = liveReactions([...w.actions.values()]);
+ 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') : 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 = (w.draft || '').trim();
- if (!text) return;
- w.draft = '';
- const inp = document.querySelector('.live-input');
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
- sendLiveChat(w, text).catch(() => { w.draft = text; render(); });
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
};
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
@@ -8818,13 +9267,19 @@ export function messagesFeature(ctx) {
mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
: backBtn(() => { ui.liveStream = null; }),
h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
liveBadge(ev)),
mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
- h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ watchingLine(w, bc.viewers || 0),
bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
: bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
- h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
@@ -8834,31 +9289,44 @@ export function messagesFeature(ctx) {
src && !w.err
? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
- : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))),
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
avatar(host, 'chat-avatar mini'),
h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
- n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ n != null && status === 'live' ? watchingLine(w, 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,
- h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => {
+ // ours to answer for: anyone signed in can report it, an admin can take it down
+ 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 && 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)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) },
+ h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': 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),
+ } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))),
+ watchersRow(w),
+ 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', {
class: 'live-chat', 'data-key': 'live-chat:' + s.a,
onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
}, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
- isVisitor() ? null : h('div', { class: 'row gap6' },
- h('input', {
- type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500',
- onInput: (e) => { w.draft = e.target.value; },
- onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } },
- }),
- h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
...noteOverlays());
}
@@ -8896,7 +9364,10 @@ export function messagesFeature(ctx) {
async function bcPreview() {
const b = bc;
if (!b) return;
- bcStopMedia();
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
b.err = null;
try {
let media;
@@ -8904,8 +9375,8 @@ export function messagesFeature(ctx) {
media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
- // the browser's own "Stop sharing" ends the stream
- media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); });
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
} else {
media = await navigator.mediaDevices.getUserMedia({
video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
@@ -8913,11 +9384,15 @@ export function messagesFeature(ctx) {
});
}
if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
b.media = media;
- // a switch mid-stream: the recorder carries on with the new picture
- if (b.phase === 'live') bcRecord(b, true);
+ // a switch mid-stream: the recorder carries on with the new picture,
+ // and the WebRTC copy swaps it in where it is
+ if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); }
} catch (e) {
- if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
}
render();
}
@@ -8975,7 +9450,21 @@ export function messagesFeature(ctx) {
b.ws = ws;
bcRecord(b);
b.reconnecting = false;
- if (info.id !== b.id) toast(t('goLiveRestarted'));
+ // The server couldn't take us back (it restarted, or we were gone
+ // too long): this is a new stream at a new address. The old one is
+ // over — said so, with no recording (there's next to nothing in
+ // it) — and the new one is announced as soon as it plays.
+ if (info.id !== b.id) {
+ toast(t('goLiveRestarted'));
+ const old = { ...b, recording: null };
+ Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id });
+ bcRtc(b).catch(() => {});
+ bcAnnounce(old, 'ended').catch(() => {});
+ bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => {
+ // the streamer's own view of it follows to the new address
+ if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); }
+ }).catch(() => {});
+ }
render();
return;
} catch {}
@@ -8984,6 +9473,48 @@ export function messagesFeature(ctx) {
b.reconnecting = false;
if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
}
+ // The stream as WebRTC, for the app's own viewers: the picture also goes to
+ // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from
+ // there a fraction of a second behind — HLS is seconds behind. Best effort:
+ // the HLS above is the stream either way, and the recording.
+ async function bcRtc(b, tries = 0) {
+ if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return;
+ bcRtcStop(b);
+ const id = b.id;
+ const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const again = () => {
+ if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return;
+ bcRtcStop(b);
+ setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1));
+ };
+ pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; };
+ try {
+ const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0];
+ const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] });
+ const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' });
+ await pc.setLocalDescription(await pc.createOffer());
+ const u = liveBase() + '/live/' + id + '/rtc/publish';
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] });
+ const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' },
+ body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) });
+ if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server
+ if (!r.ok) throw new Error('publish ' + r.status);
+ const j = await r.json();
+ if (b.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp });
+ } catch { again(); }
+ }
+ function bcRtcSwap(b) {
+ if (!b.pc || !b.media) return;
+ for (const tr of b.pc.getTransceivers()) {
+ const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind;
+ const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null;
+ if (kind) tr.sender.replaceTrack(next || null).catch(() => {});
+ }
+ }
+ function bcRtcStop(b) {
+ if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; }
+ }
// the announcement: your 30311, and its copies as the stream goes on
async function bcAnnounce(b, status) {
const id = await requireIdentity();
@@ -8994,14 +9525,27 @@ export function messagesFeature(ctx) {
['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
if (me.picture) tags.push(['image', me.picture]);
if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
- if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))], ['recording', b.recording]);
- const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags };
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
+ if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
takeLive([evt]);
- b.ev = evt;
+ if (bc === b) b.ev = evt;
scheduleRepaint();
return publishOn(relays, evt);
}
+ async function bcPlaying(b) {
+ const url = b.liveUrl;
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ }
async function startBroadcast() {
const b = bc;
if (!b || b.phase !== 'setup' || !b.media) return;
@@ -9014,16 +9558,13 @@ export function messagesFeature(ctx) {
b.phase = 'live';
b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
bcRecord(b);
+ bcRtc(b).catch(() => {});
try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
// the stream is a few seconds behind the camera: announced once it has
// three segments — Chrome's own player won't start a live playlist with
// fewer (it fails to parse it), so a viewer who comes at once would get
// an error, not the stream
- for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) {
- await new Promise((r) => setTimeout(r, 500));
- const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
- if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
- }
+ await bcPlaying(b);
if (bc !== b || b.phase !== 'live') return;
await bcAnnounce(b, 'live');
b.tick = setInterval(() => bcTick(b), 60_000);
@@ -9053,6 +9594,7 @@ export function messagesFeature(ctx) {
b.phase = 'ended';
clearInterval(b.tick);
try { b.rec && b.rec.stop(); } catch {}
+ bcRtcStop(b);
try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
bcStopMedia();
@@ -9076,6 +9618,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' },
@@ -9083,6 +9634,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: '' }))
@@ -9094,7 +9647,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'))));
}
@@ -9110,6 +9663,21 @@ export function messagesFeature(ctx) {
};
let calls = null;
const callInbox = new Map(); // peer -> their inbox relays, for this session
+ // Relays that turned a call message away are left out of the next ones: a
+ // relay that won't take the kind (nip17.com: "only kind 1059") for a day,
+ // one rate-limiting or banning us for ten minutes. Kept on this device.
+ const CALL_SHUN_KEY = 'coinos-call-shun';
+ let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until
+ function callRefused(url, msg) {
+ const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg);
+ callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000);
+ try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {}
+ }
+ function callRelaysOf(relays) {
+ const now = Date.now();
+ const ok = relays.filter((r) => !(callShun[r] > now));
+ return ok.length ? ok : relays; // all of them shunned: try them anyway
+ }
async function inboxFor(pk) {
if (callInbox.has(pk)) return callInbox.get(pk);
const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
@@ -9138,6 +9706,7 @@ export function messagesFeature(ctx) {
const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
}
// who may ring: someone you follow, or someone you've written to
@@ -9158,9 +9727,18 @@ export function messagesFeature(ctx) {
calls = createCalls({
me: () => dmSubKeys()[0],
sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
send: async (pk, signed) => {
- const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth });
- if (!ok) throw new Error('not delivered');
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ const relays = callRelaysOf(await inboxFor(pk));
+ if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return;
+ }
+ throw new Error('not delivered');
},
iceServers: callIce,
media: callMedia,
@@ -9372,6 +9950,7 @@ export function messagesFeature(ctx) {
micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
};
@@ -9384,7 +9963,9 @@ export function messagesFeature(ctx) {
queueMicrotask(mountCallMedia);
if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
const video = c.type === 'video';
- const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0;
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
: c.state === 'starting' || c.state === 'offering' ? t('callCalling')
@@ -9404,7 +9985,7 @@ export function messagesFeature(ctx) {
avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
h('div', { class: 'call-name' }, displayName(c.peer)),
h('div', { class: 'call-status' }, status)),
- video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
c.state === 'incoming'
? h('div', { class: 'call-bar' },
btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
@@ -9412,8 +9993,14 @@ export function messagesFeature(ctx) {
: c.state === 'ended' ? null
: h('div', { class: 'call-bar' },
btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
- video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
- video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
}
@@ -9518,6 +10105,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)),
h('audio', {
+ onLoadedmetadata: e => attachMediaTimeline(e.target, url),
controls: true, preload: 'none', src: url,
onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; },
}));
@@ -9841,6 +10429,14 @@ export function messagesFeature(ctx) {
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
+ } else if (/^wss?:\/\//i.test(part)) {
+ // a relay named in a post opens what's on it (the raw firehose)
+ const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part);
+ const relay = normRelay(addr);
+ out.push(relay ? h('a', {
+ href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'),
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); },
+ }, addr) : addr, tail);
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
@@ -9914,15 +10510,15 @@ export function messagesFeature(ctx) {
ui.screen = 'accounts';
render();
}
- function zapNote(pk, ev, origin) {
+ function zapNote(pk, ev, origin, moment = null) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
- ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return;
+ ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return;
}
markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally
- if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
+ if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) {
settleZap(ev.id, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -9964,7 +10560,7 @@ export function messagesFeature(ctx) {
const { pk, npub, eventId } = s;
closeZapSetup();
markZapPending(eventId, n, s.origin);
- if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
+ if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) {
settleZap(eventId, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -10018,13 +10614,14 @@ export function messagesFeature(ctx) {
// A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
- function replyToNote(ev) {
+ function replyToNote(ev, moment = null) {
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
+ s.mediaMoment = moment;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
@@ -10168,7 +10765,7 @@ export function messagesFeature(ctx) {
const id = await requireIdentity();
const partial = {
kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000),
- tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG],
+ tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
myReactEv.set(ev.id, evt.id);
@@ -11344,6 +11941,12 @@ export function messagesFeature(ctx) {
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
: h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
+ readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const m = readMoment(ev);
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null,
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -11697,6 +12300,8 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
+ note.tags.push(...momentTags(s.mediaMoment));
+ if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content;
const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -11763,6 +12368,7 @@ export function messagesFeature(ctx) {
class: 'col thread-reply' + (joined ? ' joined' : ''),
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
+ s.mediaMoment ? h('div', { class: 'small muted' }, t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds)) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -11828,7 +12434,7 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; render(); },
+ onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -11844,7 +12450,7 @@ export function messagesFeature(ctx) {
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
- s.replying = false; s.preview = false; // sent: the box folds away
+ s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
@@ -12202,7 +12808,6 @@ 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
@@ -12392,7 +12997,9 @@ export function messagesFeature(ctx) {
const c = notesFor(pk);
// Posts | Replies: the replies are their side of other people's
// conversations, apart from what they posted themselves
- const tab = profTab(pk);
+ // Streams joins the tabs once they've streamed
+ const streamed = pastOf(pk).length > 0;
+ const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
@@ -12400,15 +13007,16 @@ export function messagesFeature(ctx) {
// (five pages at most per tab: someone who never replies must not
// have their whole history paged through looking for one)
const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 });
- if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
+ if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
fill.n++;
queueMicrotask(() => loadOlderNotes(pk).catch(() => {}));
}
const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' },
- ...['posts', 'replies'].map((k) => h('button', {
+ ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', {
type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k,
onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); },
- }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies'))));
+ }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams'))));
+ if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk));
// while loading with nothing to show: an invisible copy of the
// empty-state line holds the height, so the page doesn't jump when
// the answer lands (no spinner). With posts already in hand — from
@@ -12719,12 +13327,15 @@ export function messagesFeature(ctx) {
function appBottomNav() {
const visitor = isVisitor();
const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
- const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ // a stream (or Go live) over the shell is a place of its own: no button
+ // is "here", so the one tapped goes there (Feed under a stream thought it
+ // was home and only scrolled the hidden feed)
+ const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet';
const leavePage = () => {
stopFeedWatch(); stopNotifWatch();
// Top-level destinations must leave any feature page covering the shell.
- for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch',
+ for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch',
'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
@@ -13526,6 +14137,7 @@ export function messagesFeature(ctx) {
for (const u of missing) tileLooked.add(u);
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
+ if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
@@ -13540,6 +14152,7 @@ export function messagesFeature(ctx) {
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
+ onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
mediaMode === 'images' || poster
? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false',
@@ -13615,6 +14228,51 @@ export function messagesFeature(ctx) {
}
}
+ // Opening a clip from the grid: on Adam's phone a player made at the tap
+ // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting
+ // its sound ~0.3 — the clip's opening already in the cache saved nothing),
+ // and 1.8 s when the host's connection had to be made too. So the grid's
+ // clip hosts are connected to ahead (a preconnect: cheap), and the
+ // player starts loading when the finger comes down on the tile — the
+ // pager adopts that same element (pagerSlide), so the load overlaps the
+ // tap and the pager's opening. One the tap doesn't follow (a scroll) is
+ // let go.
+ const preconnected = new Set();
+ function preconnect(url) {
+ let origin; try { origin = new URL(url, location.href).origin; } catch { return; }
+ if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return;
+ preconnected.add(origin);
+ const l = document.createElement('link');
+ l.rel = 'preconnect'; l.href = origin;
+ document.head.append(l);
+ }
+ let early = null; // { url, v, timer }
+ function earlyPlayer(url) {
+ if (frugalData() || (early && early.url === url)) return;
+ dropEarly();
+ const v = document.createElement('video');
+ v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true;
+ v.setAttribute('src', playSrc(url));
+ v.dataset.early = '1'; // (the pager's adoption of it, for tests)
+ // on the page (out of sight) while it waits: a detached player isn't loaded everywhere
+ v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none';
+ document.body.append(v);
+ early = { url, v, timer: setTimeout(dropEarly, 2500) };
+ }
+ function dropEarly() {
+ if (!early) return;
+ clearTimeout(early.timer);
+ try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {}
+ early = null;
+ }
+ function takeEarly(url) {
+ if (!early || early.url !== url) return null;
+ const { v } = early;
+ clearTimeout(early.timer);
+ early = null;
+ return v;
+ }
+
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
@@ -13765,7 +14423,10 @@ export function messagesFeature(ctx) {
// was tried: it pulled the whole file down.) On a connection to be
// careful with the poster stays: the clip may never load (a heavy one
// waits for a tap), and the still is what there is.
- const media = video
+ // (the clip a grid tile was pressed on: the player already loading since the finger came down)
+ const adopted = video && !frugalData() ? takeEarly(url) : null;
+ if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); }
+ const media = adopted || (video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined })
: h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async',
@@ -13777,7 +14438,7 @@ export function messagesFeature(ctx) {
let host = ''; try { host = new URL(url).hostname; } catch {}
m.closest('.mp-slide').classList.add('mp-gone');
m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
- } });
+ } }));
const caption = mediaCaption(ev, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
@@ -13817,6 +14478,7 @@ export function messagesFeature(ctx) {
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
slide.append(seekBar(media));
+ media.addEventListener('loadedmetadata', () => attachMediaTimeline(media, url, ev));
}
return slide;
}
@@ -13825,6 +14487,12 @@ export function messagesFeature(ctx) {
// (the clip holds still meanwhile, and the time shows); the drag is the
// bar's alone, never the pager's swipe or its tap.
const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
+ // up for a few seconds, and on while a finger is on it
+ function revealSeek(bar) {
+ bar.classList.add('reveal');
+ clearTimeout(bar._hide);
+ bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500);
+ }
function seekBar(v) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
@@ -13855,6 +14523,7 @@ export function messagesFeature(ctx) {
if (!d || d.id !== e.pointerId) return;
bar._drag = null;
bar.classList.remove('drag');
+ if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag
if (e.type === 'pointerup') to(frac(e));
if (d.playing) v.play().catch(() => {});
};
@@ -13904,6 +14573,7 @@ export function messagesFeature(ctx) {
const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; };
const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up);
if (back) out.push(back); // (the one before: ready again for the swipe back)
+ out.back = back || null;
return out;
}
function pagerAttach(slide) {
@@ -13926,9 +14596,41 @@ export function messagesFeature(ctx) {
im.src = full;
}, 300);
}
+ // A player's first play with sound waits on the phone's audio output to
+ // start: on Adam's Android ~250 ms of a still picture, every clip, warm
+ // or not (muted, 60 ms; a muted start then unmuted freezes the picture
+ // for those 250 ms instead). A player that has played with sound resumes
+ // in 20-80 ms, even many seconds later. So a warm player plays at a
+ // volume nobody hears until its picture moves (a frame or two in: the
+ // audio's start is the wait) and pauses there — not back to 0: a seek
+ // undoes it. Only while the pager has sound on, and the browser lets it.
+ // Having played, the player no longer stops reading at its first frame —
+ // it fetches the rest of the file — so only a clip known to be small
+ // enough to be worth that is primed (most are 3-25 MB).
+ const PRIME_MAX = 25e6;
+ function primeSound(s, v) {
+ if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return;
+ const d = videoInfo.get(v.getAttribute('data-src')) || {};
+ if (!(d.size > 0 && d.size <= PRIME_MAX)) return;
+ const go = () => {
+ if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return;
+ v._primed = true;
+ v.muted = false; v.volume = 0.001;
+ const done = () => {
+ clearTimeout(v._primeTimer);
+ if (pager && pager.cur !== s) { try { v.pause(); } catch {} }
+ v.volume = 1;
+ if (pager && pager.cur !== s) v.muted = true;
+ };
+ v._primeTimer = setTimeout(done, 2000);
+ const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); };
+ v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); });
+ };
+ if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true });
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
- if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
+ if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} }
}
// Flipping through clips is asking to watch them: whatever they cost, the
// one on screen plays at once (streaming the rest as it goes), and the
@@ -13956,8 +14658,25 @@ export function messagesFeature(ctx) {
if (j === i) return;
const v = s.querySelector('video.mp-media');
if (eager && k >= 0) {
+ // the next clip and the one before: a player of its own, warm — once
+ // its opening is in the cache, given the file to read up to its
+ // first frame (preload 'metadata': Chrome reads ~2 s of it and
+ // stops) and paused there, so the swipe onto it is only play and
+ // sound (TikTok's trick). The one just left keeps the player it
+ // played in (a swipe back to it took 0.7 s on a phone, built anew).
+ // The others ahead get their opening only.
+ const warm = k === 0 || s === ahead.back;
+ if (warm && v.getAttribute('src')) return; // warm already
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ if (warm) head.then(() => {
+ const now = pager && pager.cur && pagerAhead(pager.cur);
+ if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return;
+ v.preload = 'metadata';
+ v.muted = true;
+ pagerAttach(s);
+ primeSound(s, v);
+ });
return;
}
if (k === 0) {
@@ -14156,6 +14875,9 @@ export function messagesFeature(ctx) {
else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
}
v.muted = !pagerLoud;
+ v.volume = 1;
+ clearTimeout(v._primeTimer);
+ clockStart(slide, v);
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
if (err && err.name === 'NotAllowedError' && !v.muted) {
@@ -14165,6 +14887,37 @@ export function messagesFeature(ctx) {
}
});
}
+ // How long from landing on a clip to its picture moving, kept for the
+ // last hundred (coinosPagerStarts() in the console, and dlog), with what
+ // shape the player was in when the swipe landed (warm: it had its frame).
+ const pagerStarts = [];
+ function clockStart(slide, v) {
+ const t0 = slide._arriveAt;
+ slide._arriveAt = 0;
+ if (!t0) return;
+ const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState;
+ const primed = !!v._primed;
+ const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0;
+ const d = videoInfo.get(url) || {};
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ const done = () => {
+ const ms = Math.round(performance.now() - t0);
+ pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url });
+ if (pagerStarts.length > 100) pagerStarts.shift();
+ dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host);
+ };
+ const tick = () => {
+ if (!v.isConnected || performance.now() - t0 > 20000) return;
+ if (v.currentTime > 0 && !v.paused) { done(); return; }
+ if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
+ };
+ tick();
+ }
+ if (typeof window !== 'undefined') window.coinosPagerStarts = () => {
+ const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b);
+ const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null;
+ return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() };
+ };
function pagerSound(on) {
pagerLoud = on;
if (!pager) return;
@@ -14499,7 +15252,7 @@ export function messagesFeature(ctx) {
pendingTap = setTimeout(() => {
pendingTap = 0;
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed)
tapDoneAt = Date.now();
}, 120);
}, { passive: true });
@@ -14514,11 +15267,16 @@ export function messagesFeature(ctx) {
if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
}, true);
- el.addEventListener('click', (e) => pagerTap(e.target));
+ el.addEventListener('click', (e) => pagerTap(e.target, e.clientY));
// a tap shows (or hides) who posted it and what they said
- function pagerTap(target) {
+ function pagerTap(target, y) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
+ // The seek bar is out of sight while a clip plays (Adam, 2026-10-19:
+ // only when someone goes for it, or pauses): a tap near the foot of
+ // the screen, where it sits, brings it up without pausing.
+ const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; }
if (pager && video) {
// A clip (from the feed or the media grid): a tap anywhere but the
// seek bar and the buttons pauses it and brings up who posted it,
@@ -14573,6 +15331,7 @@ export function messagesFeature(ctx) {
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ slide._arriveAt = performance.now();
pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();
@@ -15964,6 +16723,13 @@ export function messagesFeature(ctx) {
render();
}
+ const chatBubbleClick = (open) => (e) => {
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ open();
+ };
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -16012,13 +16778,10 @@ export function messagesFeature(ctx) {
// ends in a click too, and must not pop the sheet over the copy.
// A custom emoji is an <img> too, but tapping it should open the
// sheet like tapping any other word.
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
replyQuote(room, chId, m),
...noteBody(text, 0, em),
@@ -16409,13 +17172,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''),
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
src ? h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
@@ -16606,13 +17366,10 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-line' }, h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
}, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
@@ -16762,7 +17519,9 @@ export function messagesFeature(ctx) {
if (ZAP_KINDS.includes(ev.kind)) {
const actor = zapperOf(ev), sats = receiptSats(ev);
if (!actor || my.includes(actor) || !sats) return null;
- return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at };
+ // a zap on a stream names it by address (a 30311), not by an e tag
+ const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null;
+ return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at };
}
if (my.includes(ev.pubkey)) return null;
if (ev.kind === 7) {
@@ -16951,6 +17710,10 @@ export function messagesFeature(ctx) {
if (target) openNoteThread(target);
// a note we couldn't find is asked for again on tap, hints and all
else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {});
+ // a zap on a stream opens the stream; one on your profile (no post
+ // at all) opens the zapper's — the tap used to do nothing
+ else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); }
+ else { stopNotifWatch(); openProfile(x.actor); }
}
};
return h('div', {
@@ -17397,7 +18160,7 @@ export function messagesFeature(ctx) {
stCache = null; stAt = 0; // the next account's state, not this one's
if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
profBlob = null;
- reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ timedActivity.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d364738..76296cf 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -132,7 +132,7 @@ export function zapsFeature(ctx) {
if (profile && profile.name) z.name = profile.name;
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; }
- z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped
+ z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped
z.address = lnTarget.address || z.address;
}
@@ -196,6 +196,16 @@ export function zapsFeature(ctx) {
render();
}
+ // A zap request is signed as the person zapping: the login identity when
+ // there is one (the face their chat line and the streamer's overlay
+ // show), else the wallet's own key. It used to be the wallet key always,
+ // and a signed-in person's zaps showed under a stranger's npub.
+ async function zapSigner() {
+ const id = hook('nostrLoginIdentity');
+ const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null));
+ return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e);
+ }
+
// One-tap zap at the user's default amount: resolve, invoice, pay through
// the ark seam (its quote + retries included), and report by toast — the
// whole flow without a single screen.
@@ -203,7 +213,7 @@ export function zapsFeature(ctx) {
const profile = await wallet.nostrProfile(target.pk);
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) throw new Error(t('lnZapNoAddress'));
- const t2 = { ...lnTarget, eventId: target.eventId || null };
+ const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment };
const p = await fetchPayParams(t2.url);
const msat = sats * 1000;
if (msat < p.minSendable || msat > p.maxSendable) {
@@ -214,10 +224,11 @@ export function zapsFeature(ctx) {
}
let zapRequest = null;
if (p.allowsNostr && t2.pk && wallet.nostrSign) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32,
- recipientPk: t2.pk, eventId: t2.eventId || null, comment: '',
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '',
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 });
@@ -272,10 +283,11 @@ export function zapsFeature(ctx) {
let zapRequest = null;
const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign;
if (canZap) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32,
- recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment,
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment,
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment });
@@ -585,12 +597,12 @@ export function zapsFeature(ctx) {
// Ark's "no Ark address published" screen offers a Lightning fallback,
// which lands here with the recipient's pubkey (and, when the zap was
// aimed at a note, its event id for the zap request's e tag).
- lnZapNpub(pk, npub, eventId, autoSat) {
+ lnZapNpub(pk, npub, eventId, autoSat, moment) {
if (!canPay() || !wallet.nostrProfile) return false;
if (autoSat && hook('arkReady')) {
// the chip on the zapped note is pulsing since the tap — tell it how
// this went, either way
- autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat)
+ autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat)
.then(() => hook('zapSettled', eventId, true, autoSat))
.catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); });
return true;
@@ -599,7 +611,7 @@ export function zapsFeature(ctx) {
hook('zapSettled', eventId, false);
if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; }
}
- begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
+ begin({ kind: 'npub', pk, eventId: eventId || null, moment }, shortNpub(npub || npubOf(pk)));
return true;
},
resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; },
diff --git a/src/hls-player.js b/src/hls-player.js
index d0878e2..18292dd 100644
--- a/src/hls-player.js
+++ b/src/hls-player.js
@@ -2,10 +2,13 @@
// <video> through Media Source. Chrome and Safari play the playlist as a
// plain src, so this file is fetched only when needed. The single-file
// build replaces it with a stub (attachHls → null): no stream there.
-export async function attachHls(video, src) {
+// A stream that's on (`live`) is held near its live edge: started 1.5 s
+// back, played up to 1.1× when it drifts past 5 s.
+export async function attachHls(video, src, { live = false } = {}) {
const { default: Hls } = await import('hls.js');
if (!Hls.isSupported()) return null;
- const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 });
+ const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30,
+ ...(live ? { liveSyncDuration: 1.5, liveMaxLatencyDuration: 5, maxLiveSyncPlaybackRate: 1.1 } : {}) });
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (_, d) => {
diff --git a/src/i18n.js b/src/i18n.js
index 562043d..d780d00 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -35,6 +35,9 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
+ mediaSeek: 'Seek',
+ mediaAtTime: 'at this time',
+ mediaCommentAt: 'Comment at',
arkHistoryIncomplete: 'History incomplete',
arkDetailsNotSynced: 'Payment details not synced',
arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.',
@@ -252,6 +255,7 @@ const STR = {
msgUploadFailed: 'Upload failed. Check your connection and try again',
mediaUploadTooBig: 'That file is too big for the available media hosts',
mediaUploadUnsupported: 'That file format is not supported',
+ mediaUploadUnauthorized: 'The media server denied this upload. Check that your Nostr identity is allowed to upload there.',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
@@ -435,6 +439,7 @@ const STR = {
profLists: 'Lists',
searchTopicRow: 'Posts on this topic',
searchTopicFor: 'Posts tagged #{q}',
+ relayLinkOpen: 'See what’s posted on this relay',
alertsTitle: 'Notifications',
alertsSub: 'Zaps, reactions and replies to your posts',
alertsNew: '{n} new',
@@ -462,6 +467,33 @@ const STR = {
livePlanned: 'SOON',
liveEnded: 'ENDED',
livePast: 'Past streams',
+ liveReport: 'Report this stream',
+ liveReportTitle: 'Report this stream',
+ liveReportDesc: 'coinos carries this stream. Tell us what’s wrong and we’ll look at it right away.',
+ liveReason_csam: 'Sexual content involving a minor',
+ liveReason_sexual: 'Sexual content',
+ liveReason_violence: 'Violence or gore',
+ liveReason_illegal: 'Something else illegal',
+ liveReason_spam: 'Spam or a scam',
+ liveReason_other: 'Something else',
+ liveReportTextPh: 'Anything we should know (optional)',
+ liveReportSend: 'Send report',
+ liveReported: 'Reported. Thank you.',
+ liveTakeDown: 'Take down',
+ liveTakeDownAsk: 'Take this stream down? It ends now, and its recording is kept from everyone (preserved for reporting).',
+ liveBanAsk: 'Also stop this account from streaming again?',
+ liveTakenDown: 'Taken down.',
+ liveTakenDownBanned: 'Taken down, and the account can’t stream again.',
+ liveRemoved: 'This stream was taken down.',
+ liveDelete: 'Delete recording',
+ liveDeleteAsk: 'Delete this stream’s recording? Nobody will be able to watch it again.',
+ liveDeleted: 'Recording deleted.',
+ liveDeletedSay: 'The streamer deleted this recording.',
+ goLiveNewName: 'Your name can go live a day after you claimed it: from {when}.',
+ goLiveBanned: 'This account can’t go live on coinos.',
+ liveWatchRecording: 'Watch the recording',
+ liveStaleMine: '“{title}” still says live, but nothing is going out.',
+ liveNotYours: 'Only the account that started this stream can end it.',
liveRecordingBadge: 'RECORDING',
liveUntitled: 'Live stream',
liveWatching: '{n} watching',
@@ -513,6 +545,10 @@ const STR = {
callUnmute: 'Unmute',
callCamOff: 'Turn camera off',
callCamOn: 'Turn camera on',
+ goLiveToCamera: 'Back to camera',
+ callShare: 'Share screen',
+ callStopShare: 'Stop sharing',
+ callAddVideo: 'Turn on camera',
callFlip: 'Switch camera',
callMinimize: 'Minimize',
callEnded: 'Call ended',
@@ -858,6 +894,7 @@ const STR = {
profNotesNone: 'No recent posts found.',
profTabPosts: 'Posts',
profTabReplies: 'Replies',
+ profTabStreams: 'Streams',
profRepliesNone: 'No recent replies found.',
profReplyTag: 'reply',
msgSearching: 'Searching…',
diff --git a/src/live.js b/src/live.js
index da18f19..d31c548 100644
--- a/src/live.js
+++ b/src/live.js
@@ -80,3 +80,22 @@ export function zapLine(ev, decode) {
const zapAddrs = new Map();
export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); };
export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;
+
+// One current reaction per person/message, independent of relay arrival order.
+// A deletion only removes reactions signed by its own author.
+export function liveReactions(events) {
+ const deleted = new Set();
+ for (const ev of events) if (ev.kind === 5)
+ for (const tag of ev.tags || []) if (tag[0] === 'e') deleted.add(ev.pubkey + ':' + tag[1]);
+ const out = new Map();
+ for (const ev of events) {
+ if (ev.kind !== 7 || deleted.has(ev.pubkey + ':' + ev.id)) continue;
+ const target = (ev.tags || []).filter(t => t[0] === 'e').at(-1)?.[1];
+ if (!target) continue;
+ const by = out.get(target) || new Map();
+ const old = by.get(ev.pubkey);
+ if (!old || ev.created_at > old.created_at || (ev.created_at === old.created_at && ev.id > old.id)) by.set(ev.pubkey, ev);
+ out.set(target, by);
+ }
+ return out;
+}
diff --git a/src/lnurl.js b/src/lnurl.js
index 7b1a798..134edcf 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -1,3 +1,4 @@
+import { momentTags } from './media-timeline.js';
// LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable.
//
// The wallet already pays bolt11 invoices over a Boltz submarine swap; this
@@ -123,14 +124,15 @@ export async function fetchPayParams(url) {
}
// A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event.
-export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) {
+export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn, moment }) {
const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]];
if (lnurlBech32) tags.push(['lnurl', lnurlBech32]);
if (eventId) tags.push(['e', eventId]);
// a live stream's address too, so its chat and zap.stream count the zap
const addr = zapAddrOf(eventId);
if (addr) tags.push(['a', addr]);
- return signFn({ kind: 9734, content: comment || '', tags });
+ tags.push(...momentTags(moment));
+ return signFn({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: comment || '', tags });
}
// GET the callback for a bolt11. When zapRequest is present this is a real zap;
diff --git a/src/media-timeline.js b/src/media-timeline.js
new file mode 100644
index 0000000..bc9f4c7
--- /dev/null
+++ b/src/media-timeline.js
@@ -0,0 +1,140 @@
+// Timestamp annotations retain the usual Nostr event target. `media` is a
+// client extension; the r tag is a portable media-fragment link.
+export function mediaMoment(url, seconds) {
+ try {
+ const u = new URL(url);
+ if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null;
+ u.hash = '';
+ return { url: u.href, seconds: Math.floor(seconds) };
+ } catch { return null; }
+}
+export function momentTags(moment) {
+ const m = moment && mediaMoment(moment.url, moment.seconds);
+ return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : [];
+}
+export function readMoment(event, depth = 0) {
+ if (depth > 1) return null;
+ if (event?.kind === 9735) {
+ try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; }
+ }
+ const tag = event?.tags?.find(t => t[0] === 'media');
+ return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null;
+}
+export function mediaClock(seconds) {
+ const s = Math.max(0, Math.floor(seconds || 0));
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
+}
+export function waveformFromTags(event, url) {
+ const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url));
+ const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9);
+ if (!raw) return null;
+ const values = raw.trim().split(/\s+/).map(Number);
+ return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null;
+}
+export function waveformPeaks(buffer, count = 96) {
+ const peaks = new Array(count).fill(0);
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const data = buffer.getChannelData(ch);
+ for (let i = 0; i < count; i++) {
+ const end = Math.floor((i + 1) * data.length / count);
+ for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j]));
+ }
+ }
+ return peaks;
+}
+const waves = new Map();
+let decoding = Promise.resolve();
+export function loadWaveform(url) {
+ if (waves.has(url)) return waves.get(url);
+ const job = decoding.then(async () => {
+ const limit = 20 * 1024 * 1024;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30000);
+ let ctx;
+ try {
+ const response = await fetch(url, { signal: controller.signal, credentials: 'omit' });
+ if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size');
+ const reader = response.body.getReader(), parts = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > limit) { await reader.cancel(); throw new Error('size'); }
+ parts.push(value);
+ }
+ const Audio = globalThis.AudioContext || globalThis.webkitAudioContext;
+ ctx = new Audio({ sampleRate: 8000 });
+ const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer());
+ return waveformPeaks(buffer);
+ } catch { return null; }
+ finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); }
+ });
+ decoding = job.then(() => {});
+ waves.set(url, job);
+ if (waves.size > 24) waves.delete(waves.keys().next().value);
+ return job;
+}
+
+// The live player owns this subtree, so morphing cannot replace its handlers
+// with closures pointing at a detached player.
+export function mediaTimeline(player, url, { h, event, moments, action, label }) {
+ const audio = player.tagName === 'AUDIO';
+ const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() });
+ box._skipMorph = true;
+ const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' });
+ const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true });
+ const seek = h('div', { class: 'media-seek' }, bars, range);
+ const time = h('span', { class: 'small muted' }, '0:00');
+ const marks = h('div', { class: 'media-marks' });
+ const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' });
+ const actions = h('div', { class: 'row media-moment-actions' }, time);
+ for (const [kind, icon] of [['comment', '↩'], ['reaction', '♡'], ['zap', '⚡']]) {
+ actions.append(h('button', { type: 'button', class: 'btn-sm', 'aria-label': label(kind === 'comment' ? 'msgReply' : kind === 'reaction' ? 'postLike' : 'zapTitle') + ' ' + label('mediaAtTime'), onClick: e => {
+ e.stopPropagation();
+ const ev = event();
+ const m = mediaMoment(url, player.currentTime);
+ if (ev && m) action(kind, ev, m, e.currentTarget);
+ } }, icon + ' ' + label('mediaAtTime')));
+ }
+ box.append(seek, actions, marks, detail);
+ let waveStarted = false, signature = '';
+ const draw = values => {
+ if (!values) return;
+ const max = Math.max(...values) || 1;
+ const stride = Math.max(1, Math.ceil(values.length / 96));
+ bars.replaceChildren();
+ for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' }));
+ seek.classList.add('has-wave');
+ };
+ const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); };
+ range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000));
+ const update = () => {
+ const ev = event();
+ const finite = Number.isFinite(player.duration) && player.duration > 0;
+ actions.hidden = !ev || !finite;
+ range.disabled = !finite;
+ range.value = finite ? player.currentTime / player.duration * 1000 : 0;
+ range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0));
+ seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%');
+ time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0);
+ for (const b of actions.querySelectorAll('button')) b.title = b.textContent + ' ' + mediaClock(player.currentTime);
+ if (audio && !waveStarted && finite) {
+ waveStarted = true;
+ const published = waveformFromTags(ev, url);
+ if (published) draw(published);
+ else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw);
+ }
+ const items = ev ? moments(ev.id, url) : [];
+ const next = items.map(x => x.id).join(',');
+ if (signature !== next) {
+ signature = next;
+ marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark btn-sm',
+ title: x.text, onClick: () => { jump(x.moment.seconds); detail.textContent = x.text || x.icon; } }, mediaClock(x.moment.seconds) + ' ' + x.icon)));
+ }
+ };
+ // Fetching/decoding starts only once the user plays (or explicitly seeks).
+ for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update);
+ actions.hidden = true;
+ return box;
+}
diff --git a/src/media-upload.js b/src/media-upload.js
index d7a99e1..f851943 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -20,6 +20,16 @@ export class MediaUploadError extends Error {
}
}
+// Private Blossom hosts authorize the user's Nostr identity, which can differ
+// from the wallet's sync key. Resolve once for the whole upload (including HLS).
+export async function mediaUploadSigner(wallet, hook) {
+ const id = hook('nostrLoginIdentity');
+ if (!id) return (event) => wallet.nostrSign(event);
+ const signer = id.signer || await hook('nostrLoginResume');
+ if (!signer) throw new MediaUploadError('no-identity');
+ return (event) => signer.signEvent(event);
+}
+
async function fileHash(file) {
const hash = sha256.create();
const chunk = 4 * 1024 * 1024;
@@ -64,7 +74,7 @@ function putWithProgress(url, { body, headers, signal }, onProgress) {
}
// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
-// always-available wallet identity, while tests need no secret key.
+// signed-in Nostr identity, while tests need no secret key.
export async function uploadPublicMedia(file, signEvent, {
servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
@@ -112,6 +122,7 @@ export async function uploadPublicMedia(file, signEvent, {
}
}
const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
+ if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details);
if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details);
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
diff --git a/src/nostr.js b/src/nostr.js
index 8dcab01..c77fc27 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -498,7 +498,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) {
});
} catch {} finally { await Promise.all(pending); }
}
-export async function publishOn(relays, evt, { onauth = null } = {}) {
+export async function publishOn(relays, evt, { onauth = null, onrefused = null } = {}) {
try {
const live = liveRelays(relays);
// A rejected publish is a relay refusing the event (policy, rate limit,
@@ -520,7 +520,12 @@ export async function publishOn(relays, evt, { onauth = null } = {}) {
// "relay connection errored/closed" means the socket never came up —
// that's a sick relay, not a rejected event.
if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg);
- else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ else {
+ console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ // the relay said no to this event (policy, a rate limit, a ban): a
+ // caller can stop asking it
+ if (onrefused) try { onrefused(live[i], msg); } catch {}
+ }
return false;
}));
// The first acceptance IS delivery — one durable copy is enough, and
diff --git a/src/style.css b/src/style.css
index 95ce04c..6e1097a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -896,9 +896,6 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
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; }
@@ -923,14 +920,15 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; }
.live-frame { width: 100%; height: 100%; }
.live-video { width: 100%; height: 100%; display: block; background: #000; }
-.live-still { border-radius: 0; height: 100%; display: flex; align-items: center; justify-content: center; }
+.live-still { border-radius: 0; height: 100%; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; }
+.live-rec-link { background: var(--paper); color: var(--ink); text-decoration: none; }
.live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; }
/* the top padding is headroom for a hat on the first line's face: a scroller clips what rises out of it */
.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; }
.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
.live-zap .live-line-body b { color: var(--accent); }
-.live-input { min-width: 0; }
+.live-chat .chat-row { padding-right: 2px; }
.bc-preview.mirror { transform: scaleX(-1); }
.bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; }
.go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); }
@@ -1731,7 +1729,10 @@ body::after {
position: absolute; left: 16px; right: 16px; bottom: var(--mp-seek-lift); z-index: 3; height: 28px;
display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; opacity: 0;
}
-.mp-seek.ready { visibility: visible; opacity: 1; transition: opacity .18s; }
+/* out of sight (and out of reach: a tap there must not jump the clip) while
+ it plays; up when paused (.show), after a tap near the foot (.reveal), or held */
+.mp-seek.ready { visibility: visible; opacity: 0; pointer-events: none; transition: opacity .18s; }
+.media-pager.show .mp-seek.ready, .mp-seek.ready.reveal, .mp-seek.ready.drag { opacity: 1; pointer-events: auto; }
/* only the clip on screen's bar, and not while the pager is in motion (a
finger, a glide, a flight): it goes at once and fades back in on landing */
.mp-slide:not(.mp-cur) .mp-seek, .media-pager.moving .mp-seek { opacity: 0; transition: none; pointer-events: none; }
@@ -1923,7 +1924,11 @@ body::after {
.call-overlay { position: fixed; inset: 0; z-index: 1000; background: #101318; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: space-between;
padding: max(28px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); }
-.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; display: none; }
+/* the whole of their picture, whatever its shape and the window's: a phone's
+ portrait camera filled a laptop's wide window by cropping to a forehead */
+.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: none; }
+/* a call over the whole window: the page under it stays put */
+html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: auto; }
.call-overlay.has-video .call-remote { display: block; }
.call-overlay.has-video .call-who { position: relative; text-shadow: 0 1px 3px rgba(0, 0, 0, .7); }
.call-overlay.has-video .call-who .call-avatar { display: none; }
@@ -1934,6 +1939,7 @@ body::after {
.call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover;
border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; }
.call-local.mirror { transform: scaleX(-1); }
+.call-local.sharing { aspect-ratio: 16 / 10; object-fit: contain; background: #000; }
.call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; }
.call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; }
@@ -1954,3 +1960,33 @@ body::after {
.call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; }
.call-log-pill svg { width: 16px; height: 16px; }
.chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
+
+/* Timestamp actions and the audio waveform share the player's lifetime. */
+.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); }
+.media-moment-actions { gap: 6px; flex-wrap: wrap; align-items: center; }
+.media-moment-actions[hidden] { display: none; }
+.media-moment-actions > span { margin-right: auto; font-variant-numeric: tabular-nums; }
+.media-moment-actions button { font-size: 12px; min-height: 32px; }
+.media-seek { position: relative; height: 42px; --played: 0%; }
+.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px;
+ background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); }
+.media-wave:empty { height: 4px; top: 19px; }
+.media-wave i { flex: 1; background: var(--accent); border-radius: 2px; min-width: 1px; }
+.has-wave .media-wave { background: none; }
+.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; }
+.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; }
+.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+.media-marks { display: flex; gap: 6px; overflow-x: auto; padding-top: 5px; }
+.media-mark { flex-shrink: 0; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.media-marks:empty { display: none; }
+.mp-slide > .media-timeline { position: absolute; left: 16px; right: 16px; bottom: calc(var(--mp-seek-lift) + 30px); z-index: 4;
+ padding: 6px; border-radius: 8px; background: rgba(0,0,0,.7); color: #fff; display: none; }
+.media-pager.show .mp-cur > .media-timeline { display: block; }
+.mp-slide > .media-timeline .media-seek { display: none; }
+.media-pager.videos.show .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 130px); }
+
+.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; }
+.media-moment-detail:empty { display: none; }
+.live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
+.live-who { display: flex; flex-wrap: wrap; gap: 6px; }
+.live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; }