diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 180 | ||||
| -rw-r--r-- | src/calls.js | 372 | ||||
| -rw-r--r-- | src/dm-inbox.js | 27 | ||||
| -rw-r--r-- | src/features/ark.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 2202 | ||||
| -rw-r--r-- | src/feed-cache.js | 2 | ||||
| -rw-r--r-- | src/hls-player.js | 17 | ||||
| -rw-r--r-- | src/i18n.js | 93 | ||||
| -rw-r--r-- | src/lang-guess.js | 10 | ||||
| -rw-r--r-- | src/live.js | 82 | ||||
| -rw-r--r-- | src/lnurl.js | 4 | ||||
| -rw-r--r-- | src/media-servers.js | 27 | ||||
| -rw-r--r-- | src/media-upload.js | 49 | ||||
| -rw-r--r-- | src/negentropy.js | 197 | ||||
| -rw-r--r-- | src/nostr.js | 94 | ||||
| -rw-r--r-- | src/style.css | 159 | ||||
| -rw-r--r-- | src/sw-nwc.js | 46 | ||||
| -rw-r--r-- | src/thread-prefetch.js | 8 | ||||
| -rw-r--r-- | src/video-compress.js | 63 |
19 files changed, 3307 insertions, 330 deletions
@@ -1,3 +1,4 @@ +import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js'; // Bitcoin Wallet — UI controller (vanilla DOM, no framework). // // State lives in `ui` + the singleton `wallet`. Mutating handlers call render(), @@ -11,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, MediaUploadError } from './media-upload.js'; +import { uploadPublicMedia, uploadStream, 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'; @@ -853,7 +854,9 @@ function renderInner() { if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); // the wallet beside the feed and chats on a wide screen (its own page needs no twin) const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; - morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]); + // a call, over whatever page is up (the messages feature's) + const callOv = activeAccount() ? featureHook('callOverlay') : null; + morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { @@ -909,7 +912,7 @@ wallet.subscribe(scheduleRender); // lightbox: a full-screen photo is a place too — the phone's Back must close // it, not pop the screen under it (which once walked a viewer straight back // to the start page, so the photo's × then looked like a logout). -const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'goLive', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; @@ -917,6 +920,7 @@ function navSnapshot() { // results streaming in) snapshot only what IDENTIFIES the screen — a // keystroke must never mint a history entry. if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed }; + if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed }; if (ui.userSearch) s.userSearch = { q: '', rows: null }; if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null }; if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' }; @@ -939,7 +943,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push // copy it, send it, reload it — the same paths the deep links already open. // Every other screen keeps whatever path it had (the till at /pos, the // plain app at /); only a path we set ourselves is taken back to /. -const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i; +const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i; function navUrl(snap) { try { const fp = featureHook('navPath', snap); // a feed with a public address @@ -1920,7 +1924,7 @@ function identities() { function clearFeatureNav() { ui.arkCoinsPage = null; ui.arkExitPage = null; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; - ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; + ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null; } // Sign out of the CURRENT identity only. "Log out" locks every account on @@ -3027,11 +3031,33 @@ function registerLinkHandler(scheme) { toast(t('linkHandlerAsked')); } catch (e) { toast(t('linkHandlerRefused')); } } +function mediaServersCard() { + if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n'); + const save = (reset = false) => { + try { + if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; } + else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n'); + toast(t('mediaServersSaved')); + render(); + } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); } + }; + return h('div', { class: 'card col', 'data-key': 'media-servers' }, + h('h3', {}, t('mediaServersTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')), + h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'), + placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false', + onInput: (e) => { ui.mediaServersDraft = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')), + h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset')))); +} + function nostrSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, ...featureAll('nostrSettingsCards'), + mediaServersCard(), // The browser's own "open these links here" registry: njump's "Your // default web app" is a web+nostr: link, and it opens whichever site // asked for that scheme last. Needs a tap (the browser asks). @@ -3220,7 +3246,7 @@ function goHome() { ui.profilePk = null; ui.profEdit = null; ui.userSearch = null; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null; ui.arkExitPage = null; ui.settingsPage = null; @@ -3829,10 +3855,18 @@ function mediaProgress(stage, fraction) { } async function publicMediaUpload(file) { try { - // a phone video is shrunk on the device first (see video-compress.js) + // 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 || '')) { mediaProgress('shrink', 0); - const { compressVideo } = await import('./video-compress.js'); + const { compressVideo, streamVideo } = await import('./video-compress.js'); + 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) }); + 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); @@ -6612,18 +6646,29 @@ if (INTENT_URI) { // The feed scrolls like a phone on a desktop. A mouse wheel's notch gives // it a push that coasts and slows, so a few turns travel a few posts and // quick turns stack up; a trackpad (which already coasts) is left alone. -// And a mouse can grab the feed and flick it: drag up or down, let go while -// moving and it keeps going. A drag that starts sideways is still a text -// selection, and a drag that moved isn't a click on what it started on. +// (A mouse drag used to grab the feed and flick it; it selects text again — +// Adam, 2026-10-18: "i'd rather be able to select text normally".) (() => { if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return; - const SCOPE = '.feed-page, .notes-feed'; + // The wheel coasts whenever a feed or a thread is the page being scrolled + // — wherever the pointer happens to be: over the posts, the header, the + // rail, the wallet column beside the feed on a wide screen (Adam, + // 2026-10-16: "when my cursor is hovered over the right side bar or any + // part of the page that's not the feed, wheeling scrolls without + // coasting. can we make it coast no matter where my pointer is?") — + // except over something that takes the wheel itself: a text box, the + // chips strip, the lightbox, the pager, a box that scrolls on its own. + const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed) const TAU = 325; // ms: how quickly a coast dies down (the phone's feel) const VMAX = 16; // px/ms const inScope = (t) => { const el = t && t.closest ? t : t && t.parentElement; - if (!el || !el.closest(SCOPE)) return false; - if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; + if (!el || !document.querySelector(SCOPE)) return false; + // (A video or an audio player is part of the feed: a notch over a clip + // coasts like one over the words — the wheel does nothing to a player + // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a + // video it doesn't initiate a coast, just a single notch".) + if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; // something inside that scrolls on its own (a caption, a list) keeps its wheel for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false; @@ -6653,16 +6698,28 @@ if (INTENT_URI) { if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); } }; // ---- the wheel - // A turn back against the coast catches the page at once and is a plain - // notch from rest — no flight the other way, since the next couple of - // notches after a reversal (CALM) don't wind the speed up, so a couple of - // correcting notches travel a couple of notches; a spin that keeps going - // winds up again from the third. (A spell of quarter-size "hunting" - // notches ramping back up was tried here and felt like a stutter and a - // slow patch; a 700 ms spell without wind-up after a reversal was tried - // too and a spin begun inside it felt slow — Adam, 2026-10-16.) - const CALM = 2; // notches after a reversal without wind-up - let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above) + // A turn back against a page that is flying is a brake: that notch only + // eases the coast to a stop (a quick easing, not a dead halt), and goes + // nowhere the other way. The next notch the same way is a gentle start + // back — half a notch's travel at half the speed — and the ones after + // wind up as usual. So the reader flying past a post stops on the first + // turn back, creeps back on the second, and gets going on the third. + // Against a coast that is merely strolling, or one that only just died, + // there is nothing worth braking: that turn back is the gentle start + // itself. (A spell of quarter-size "hunting" notches ramping back up over + // five turns was tried here and felt like a stutter and a slow patch; a + // full notch then two without wind-up felt like an overshoot; a gentle + // push straight away from a flight still overshot — Adam, 2026-10-16: + // "the first click of the wheel should just stop the coasting/flying + // maybe with a quick easing rather than a sudden stop and then the next + // notch should start going slowly in reverse and the next ones should + // start speeding up".) + const REVERSE = 0.5; // of a notch: the gentle start back + const BRAKE_V = 1.2; // px/ms: flying, worth a brake (a notch from rest peaks near 0.9) + const BRAKE_TAU = 40; // ms: the brake's easing (a flight at 8 px/ms stops within ~300 px) + const BRAKE_FOR = 2500; // ms: how long after a brake the next notch that way is the gentle start + let brake = null; // { dir, until }: the turn back that braked, awaiting its gentle start + // (lastDir/lastMoveAt: the tick's, above) let smoothUntil = 0; // a trackpad's events were just seen: hands off window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return; @@ -6676,72 +6733,43 @@ if (INTENT_URI) { const now = e.timeStamp; // the way the page is going: its coast, or the coast that only just died const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0; - if (going && dir !== going) { - // A reversal catches the old coast immediately. Discard its fractional - // step and frame clock too: after a busy frame, applying the NEW velocity - // to the OLD elapsed time made the first reverse step jump. Give the - // notch a small response now, then coast the rest of its travel. + const gentle = () => { + // The gentle start back catches whatever coast is left immediately. + // Discard its fractional step and frame clock too: after a busy frame, + // applying the NEW velocity to the OLD elapsed time made the first + // reverse step jump. A small response now, then the rest of its + // (half) travel coasts. stop(); - calmLeft = CALM; - const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16); + brake = null; + const travel = px * 3 * REVERSE; + const first = dir * Math.min(16, Math.abs(travel / TAU) * 16); window.scrollBy({ top: first, behavior: 'instant' }); lastDir = dir; lastMoveAt = now; - coast((px * 3 - first) / TAU, TAU); + coast((travel - first) / TAU, TAU); + }; + if (going && dir !== going && Math.abs(v) >= BRAKE_V) { + // flying: this notch only brakes — the coast eases out over a few + // frames (from now: the frames a busy thread missed don't travel first) + tau = BRAKE_TAU; acc = 0; last = performance.now(); + brake = { dir, until: now + BRAKE_FOR }; + } else if (going && dir !== going) { + gentle(); + } else if (brake && brake.dir === dir && now < brake.until) { + gentle(); // the notch after a brake: the gentle start back } else { + brake = null; // A notch into a page already moving pushes harder: the faster it // goes, the more each turn adds (up to 2.5x), so keeping the wheel // turning winds the speed up instead of settling at a stroll. From // rest a notch is the same push as ever. - const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4); + const gain = 1 + Math.min(1.5, Math.abs(v) / 4); coast(v + (px * 3 * gain) / TAU, TAU); } }, { passive: false }); // anything else that moves the page takes over from a coast window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); window.addEventListener('touchstart', stop, { passive: true }); - // ---- grab and flick - let drag = null; - document.addEventListener('pointerdown', (e) => { - if (raf) stop(); // a click catches a coasting page, like a finger does - if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return; - drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] }; - }); - document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out - document.addEventListener('pointermove', (e) => { - if (!drag) return; - if (!(e.buttons & 1)) { drag = null; return; } - if (drag.sel) return; - if (!drag.on) { - const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y); - if (dx < 6 && dy < 6) return; - // clearly sideways: selecting text (and still not a click on the post) - if (dx > 2 * dy) { drag.sel = true; return; } - drag.on = true; - document.documentElement.classList.add('grab-scrolling'); - try { getSelection().removeAllRanges(); } catch {} - } - window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' }); - drag.ly = e.clientY; - drag.pts.push([e.timeStamp, e.clientY]); - while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift(); - }); - const end = (e) => { - if (!drag) return; - const d = drag; drag = null; - if (!d.on && !d.sel) return; - if (d.on) { - document.documentElement.classList.remove('grab-scrolling'); - const p = d.pts, a = p[0], b = p[p.length - 1]; - // released while moving: throw it; held still first: just let go - if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4); - } - // the press moved: what it started on isn't opened (Chrome still clicks it) - const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; - document.addEventListener('click', eat, { capture: true, once: true }); - setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300); - }; - document.addEventListener('pointerup', end); - document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; }); + document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does })(); window.addEventListener('load', () => { diff --git a/src/calls.js b/src/calls.js new file mode 100644 index 0000000..6f18aec --- /dev/null +++ b/src/calls.js @@ -0,0 +1,372 @@ +// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall +// speak): WebRTC between the two devices, its setup carried over nostr. +// +// Every setup message (offer, answer, ICE candidate, hang-up, reject, +// renegotiate) is an event signed by its sender and wrapped for its one +// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a +// throwaway key, no seal). Relays see only who it's for. The sound and +// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the +// fingerprints that pin that encryption ride in the signed offer and answer, +// so nobody between can stand in for either side. When the two can't reach +// each other directly, TURN relays the (still encrypted) packets. +// +// No DOM here: the app hands in how to sign, send, fetch ICE servers, open +// the camera, and who may ring; it gets back the call's state to draw. +import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure'; +import * as nip44 from 'nostr-tools/nip44'; + +export const CALL_WRAP = 21059; +export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 }; +const KINDS = new Set(Object.values(CALL)); +const ALT = { + [CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate', + [CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation', +}; +export const STALE_S = 20; // older setup messages are a relay replaying the past +export const RING_S = 60; +const RING_MS = RING_S * 1000; +const ENDED_MS = 2500; // how long "Call ended" stays up + +const now = () => Math.floor(Date.now() / 1000); +const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; +export const callIdOf = (ev) => tagOf(ev, 'call-id'); +export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() + : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16))); + +// The unsigned setup message. +export function callEvent(kind, to, callId, content = '', extra = []) { + return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] }; +} +// A signed one, wrapped for one recipient. +export function wrapCall(signed, receiverPk) { + const eph = generateSecretKey(); + return finalizeEvent({ + kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]], + content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)), + }, eph); +} +// A wrap to us opened: the setup message inside, its signature good, or null +// (not a call — Concord's presence beats ride the same kind — or not ours). +// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption. +export async function openCall(wrap, decryptFrom) { + if (!wrap || wrap.kind !== CALL_WRAP) return null; + let inner; + try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; } + if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null; + return inner; +} +// ICE candidates as the spec writes them: JSON of the three fields. +export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex }); + +// The calls of one identity. env: +// me() our pubkey +// sign(template) → signed event (our key or signer) +// send(peerPk, signed) wrap and publish to them (the app picks the relays) +// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least) +// media(type, facing) → MediaStream: microphone, and camera for 'video' +// mayRing(pk) 'ring' (someone followed, or written to), 'quiet' (anyone else: +// no ring, but the call is told of, as missed), or null (muted: nothing) +// changed() the call's state moved: draw it +// ended(record) a call is over: { id, peer, type, dir, reason, connected, seconds, at } for its log +// trickle() false for a signer that's slow or asks each time (a +// bunker, Amber): the candidates go inside the offer or +// answer instead of one signed message each +// warm(pk) a call with them may be coming: look up what sending to +// them takes (their relays, TURN) ahead of it +// RTCPeerConnection (tests may pass their own) +export function createCalls(env) { + const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection; + let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing } + const seen = new Set(); + const early = new Map(); // peer -> candidates that came before their call did + let endTimer = null, ringTimer = null; + const changed = () => { try { env.changed(); } catch {} }; + + async function sendFor(c, kind, to, content = '', extra = []) { + const signed = await env.sign(callEvent(kind, to, c.id, content, extra)); + await env.send(to, signed); + } + + function stopMedia(c) { + for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} } + try { c.pc && c.pc.close(); } catch {} + } + function end(reason, c = call) { + if (!c || c !== call || c.state === 'ended') return; + clearTimeout(ringTimer); + stopMedia(c); + c.state = 'ended'; c.reason = reason; c.endedAt = Date.now(); + changed(); + try { + if (env.ended) env.ended({ id: c.id, peer: c.peer, type: c.type, dir: c.dir, reason, at: c.offerAt || c.at, connected: !!c.connectedAt, + seconds: c.connectedAt ? Math.round((c.endedAt - c.connectedAt) / 1000) : 0 }); + } catch {} + clearTimeout(endTimer); + endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); + } + + // (iceCandidatePoolSize: the routes — TURN's included, which take a round + // trip to allocate — are gathered from the start, not from the answer on) + async function connection(c) { + 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(); }; + c.trickle = env.trickle ? env.trickle() !== false : true; + c.sent = []; + pc.onicecandidate = (e) => { + if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return; + c.sent.push(e.candidate); + sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); + }; + pc.onconnectionstatechange = () => { + if (call !== c) return; + const s = pc.connectionState; + if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); } + else if (s === 'failed') end('failed', c); + }; + if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local); + return pc; + } + // While a call rings: its connection made and the offer set, the routes + // gathering — everything but the microphone and camera, which wait for + // Answer. Answering then only adds them and replies (it used to do all of + // this after the tap, seconds of "Connecting…"). + async function prepare(c) { + const pc = await connection(c); + if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; } + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + return pc; + } + // our tracks into the transceivers the offer made + function attachLocal(c) { + const trs = c.pc.getTransceivers(); + for (const track of c.local.getTracks()) { + const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped); + if (tr) { + tr.sender.replaceTrack(track); + tr.direction = 'sendrecv'; + try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} + } else c.pc.addTrack(track, c.local); + } + } + // Without trickle: the description once it holds routes enough — one + // through the router (srflx) and one through TURN (relay) — or after a + // second and a half. Waiting for gathering to finish took up to 4 s (TURN + // over TCP and TLS is slow to give up), all of it after the tap. + async function described(c) { + 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()) { + await new Promise((resolve) => { + const check = () => { if (enough()) resolve(); }; + pc.addEventListener('icegatheringstatechange', check); + pc.addEventListener('icecandidate', check); + setTimeout(resolve, 1500); + }); + } + return pc.localDescription.sdp; + } + async function addCandidates(c) { + const list = [...(early.get(c.peer) || []), ...c.ice]; + early.delete(c.peer); c.ice = []; + for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} } + } + + // ---- what the person does + async function start(peer, type = 'voice') { + if (call && call.state !== 'ended') throw new Error('busy'); + clearTimeout(endTimer); + const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' }; + changed(); + try { + c.local = await env.media(type, c.facing); + if (call !== c) { stopMedia(c); return c; } + const pc = await connection(c); + await pc.setLocalDescription(await pc.createOffer()); + c.state = 'offering'; + changed(); + await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]); + ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS); + } catch (e) { + end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c); + } + return c; + } + async function accept() { + const c = call; + if (!c || c.state !== 'incoming') return; + clearTimeout(ringTimer); + c.state = 'connecting'; + changed(); + try { + const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]); + c.local = local; + if (call !== c) { stopMedia(c); return; } + let pc = ready; + if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c); + else { // nothing made while it rang (or it failed): all of it now + try { pc && pc.close(); } catch {} + pc = await connection(c); + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + } + await pc.setLocalDescription(await pc.createAnswer()); + const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c))); + await env.send(c.peer, signed); + // our other devices stop ringing: answered here + env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {}); + } catch (e) { + sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {}); + end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c); + } + } + async function reject() { + const c = call; + if (!c || c.state !== 'incoming') return; + sendFor(c, CALL.REJECT, c.peer).catch(() => {}); + env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {}); + end('declined', c); + } + function hangup() { + const c = call; + if (!c || c.state === 'ended') return; + if (c.state === 'incoming') { reject(); return; } + sendFor(c, CALL.HANGUP, c.peer).catch(() => {}); + end('hung-up', c); + } + function setMuted(on) { + if (!call || !call.local) return; + call.muted = on; + for (const t of call.local.getAudioTracks()) t.enabled = !on; + changed(); + } + function setCamOff(on) { + if (!call || !call.local) return; + call.camOff = on; + for (const t of call.local.getVideoTracks()) t.enabled = !on; + changed(); + } + // the other camera: the sender's track swapped, no new offer needed + async function flip() { + const c = call; + if (!c || !c.local || !c.local.getVideoTracks().length) return; + const facing = c.facing === 'user' ? 'environment' : 'user'; + try { + const fresh = await env.media('video-only', facing); + const track = fresh.getVideoTracks()[0]; + const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video'); + if (sender) await sender.replaceTrack(track); + for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); } + track.enabled = !c.camOff; + c.local.addTrack(track); + c.facing = facing; + changed(); + } catch {} + } + + // ---- what arrives + // `maxAge`: an offer the service worker kept from a push may be older than + // a relay's live one (the person had to tap the notification): it still + // rings, for what's left of its minute + async function handle(ev, { maxAge = STALE_S } = {}) { + if (!ev || seen.has(ev.id)) return; + if (now() - ev.created_at > maxAge) return; + seen.add(ev.id); + if (seen.size > 5000) seen.clear(); + const me = env.me(); + const id = callIdOf(ev), from = ev.pubkey, self = from === me; + const c = call; + switch (ev.kind) { + case CALL.OFFER: { + if (self) return; + if (c && c.state !== 'ended') { + if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); } + return; + } + const may = await env.mayRing(from); + if (!may) return; + if (may === 'quiet') { + // not someone who rings us: no ring, but not nothing either — it's + // logged as a call that didn't ring + try { if (env.ended) env.ended({ id, peer: from, type: tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice', dir: 'in', reason: 'unrung', connected: false, seconds: 0, at: ev.created_at * 1000 }); } catch {} + return; + } + clearTimeout(endTimer); + const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice'; + const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; + ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); + changed(); + try { if (env.warm) env.warm(from); } catch {} + n.prep = prepare(n).catch(() => null); + return; + } + case CALL.ANSWER: { + if (!c || c.id !== id) return; + if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it + if (from !== c.peer) return; + if (c.state === 'offering') { + c.state = 'connecting'; changed(); + try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); } + catch { end('error', c); return; } + // Our candidates went out while they rang — to an app that may have + // been closed then (woken by a push, it heard none of them). Once + // more, now that they're listening; a repeat is harmless. + if (c.trickle) for (const cand of c.sent || []) sendFor(c, CALL.ICE, c.peer, candidateJson(cand)).catch(() => {}); + } else if (c.renegotiating) { // their answer to our renegotiation + c.renegotiating = false; + try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {} + } + return; + } + case CALL.ICE: { + if (self) return; + let cand; try { cand = JSON.parse(ev.content); } catch { return; } + if (c && c.id === id && from === c.peer) { + if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} } + else c.ice.push(cand); + } else { + // before the offer that opens its call: kept for when it does + const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50)); + } + return; + } + case CALL.HANGUP: { + if (self || !c || c.id !== id || from !== c.peer) return; + end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c); + return; + } + case CALL.REJECT: { + if (!c || c.id !== id) return; + if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; } + if (from !== c.peer) return; + end(ev.content === 'busy' ? 'busy' : 'peer-declined', c); + return; + } + case CALL.RENEGOTIATE: { + if (self || !c || c.id !== id || from !== c.peer || !c.pc) return; + try { + if (c.pc.signalingState === 'have-local-offer') { + if (me > from) return; // ours wins (the higher key), theirs is dropped + await c.pc.setLocalDescription({ type: 'rollback' }); + } + 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); + } catch {} + return; + } + } + } + + return { + get call() { return call; }, + start, accept, reject, hangup, setMuted, setCamOff, flip, 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/dm-inbox.js b/src/dm-inbox.js index b9eec3a..58fc72d 100644 --- a/src/dm-inbox.js +++ b/src/dm-inbox.js @@ -15,6 +15,8 @@ // notification instead of a filtered one. import { unwrapDM } from './dm.js'; +import { openCall, CALL, RING_S } from './calls.js'; +import * as nip44 from 'nostr-tools/nip44'; const DB = 'coinos-dm-inbox'; const STORE = 'inbox'; @@ -115,3 +117,28 @@ export function shouldNotifyDm(verdict) { if (verdict.followed || verdict.known) return true; return !verdict.hasList; } + +// A call ringing in (NIP-AC), the wrap a push pointed at: opened with the key +// it's for, and worth ringing only as a fresh offer from someone this +// account follows or talks to, and hasn't muted. Returns { offer, name, +// type } to ring with, or null for anything else (a candidate, a stranger, +// a stale one, a wrap we hold no key for); `quiet` for a caller who +// may not ring us (not followed, not written to). +export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) { + for (const rec of inboxes) { + if (!rec || !rec.sk) continue; + let sk; + try { sk = unhex(rec.sk); } catch { continue; } + const ev = await openCall(wrap, async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, pk))); + if (!ev) continue; + const from = ev.pubkey; + if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null; + if ((rec.muted || []).includes(from)) return null; + const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + // someone not followed nor written to doesn't ring — `quiet`: the app + // lists it as a call that didn't ring, next time it opens + const quiet = !(rec.follows || []).includes(from) && !(rec.rings || rec.known || []).includes(from); + return { offer: ev, name: (rec.names || {})[from] || null, type, quiet }; + } + return null; +} diff --git a/src/features/ark.js b/src/features/ark.js index cbfd1b9..1619ce4 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2,6 +2,7 @@ // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. +import { zapAddrOf } from '../live.js'; import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; @@ -1914,7 +1915,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]] : []), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1957,7 +1958,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]] : []), + tags: [['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 }); diff --git a/src/features/messages.js b/src/features/messages.js index 20cfaa6..e239aee 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // // Communities are end-to-end private: there is no global discovery in @@ -17,6 +18,7 @@ import { npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; +import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, @@ -51,6 +53,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 { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -68,6 +72,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st // kind-31990 handler coordinate, which we haven't published. Deliberately // absent from anything encrypted (DMs, community wraps) — those are private // and need no billboard. +const CALL_LOG = 1405; // a call, logged in its conversation (see logCall) const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos']; // A view the URL asks to open (a notification tap): read once, then taken @@ -87,7 +92,7 @@ export function messagesFeature(ctx) { const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), - ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; + ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -462,7 +467,7 @@ export function messagesFeature(ctx) { const feedsChanged = () => { // a feed that takes in your lists is built from the other feeds: a // changed list rebuilds it next time it's opened - for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); } + for (const f of st().feeds) if (expands(f)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); } clearTimeout(feedsPush); feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000); }; @@ -688,23 +693,57 @@ export function messagesFeature(ctx) { healSince = 0; } const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id); - function rememberWrap(id) { - const k = wrapKey(id); - if (!wrapLog) wrapLog = []; - if (!seenWraps.has(k)) wrapLog.push(k); - seenWraps.add(k); + // The wraps this device holds — full id and time — for reconciliation + // (NIP-77) with a relay that speaks it: a subscribe then fetches only the + // wraps we lack instead of the relay's newest two hundred (half a + // megabyte, every boot and every resubscribe), and the live subscription + // opens with limit 0, nothing re-served. The prefix log above stays the + // "skip this one" memory; a wrap it knows but this list doesn't (logged + // before the list existed) is added as the relay re-serves it, once. + const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage) + let wrapItems = null; // id -> created_at, oldest first + function loadWrapItems() { + if (wrapItems) return; + wrapItems = new Map(); + try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {} + } + function noteWrapItem(id, ts) { + if (!id || !ts) return; + loadWrapItems(); + if (wrapItems.has(id)) return; + wrapItems.set(id, ts); + while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value); + scheduleWrapSave(); + } + const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); }; + function scheduleWrapSave() { clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { // another tab may have logged wraps since this one loaded: keep theirs const s = st(); - const mine = new Set(wrapLog); - s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); - wrapLog = s.wraps; + if (wrapLog) { + const mine = new Set(wrapLog); + s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); + wrapLog = s.wraps; + } + if (wrapItems) { + const all = new Map(s.wrapItems || []); + for (const [id, ts] of wrapItems) all.set(id, ts); + s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX); + } save(s); } catch {} }, 1500); } + function rememberWrap(id, ts) { + const k = wrapKey(id); + if (!wrapLog) wrapLog = []; + if (!seenWraps.has(k)) wrapLog.push(k); + seenWraps.add(k); + if (ts) noteWrapItem(id, ts); + scheduleWrapSave(); + } let dmStarted = false; let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt @@ -712,11 +751,16 @@ export function messagesFeature(ctx) { let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; - repaintTimer = setTimeout(() => { + const run = () => { + // not under a pager in motion: the page beneath is out of sight, and + // its repaint (40–55 ms on Adam's phone) dropped frames from flights + // through the pictures (2026-10-17) — it waits for the landing + if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); - }, 80); + }; + repaintTimer = setTimeout(run, 80); }; // Profiles persist across sessions (capped) so known faces paint right @@ -2236,6 +2280,22 @@ export function messagesFeature(ctx) { }, 0); } + // /naddr1… of a live stream (kind 30311) opens the stream, public like a + // note: a visitor watches over the front door. A reload the history + // restores by itself needs nothing. + const urlLive = (() => { + if (typeof location === 'undefined' || urlInvite) return null; + const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); + let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} + return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; + })(); + if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { + try { history.replaceState(null, '', '/'); } catch {} + ui.pubProf = true; + ui.pubNotePending = 'live'; + setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0); + } + // Resolve and open right away — not in init(), which only runs once a // wallet opens (and would replay on every wallet born in this tab). For a // visitor with no wallet the profile renders as a truly public page over @@ -2357,7 +2417,7 @@ export function messagesFeature(ctx) { // never sends what the user turned off (a suppressed-but-delivered push // would earn Chrome's generic "updated in background" nag instead) const want = (k) => s.reasons?.[k] !== false; - const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') }; + const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -2421,6 +2481,7 @@ export function messagesFeature(ctx) { rowT(t('notifDm'), 'dm'), rowT(t('notifMention'), 'mention'), rowT(t('notifReaction'), 'reaction'), + rowT(t('notifCall'), 'call'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -2428,13 +2489,17 @@ export function messagesFeature(ctx) { // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. - let inboxAt = 0; + let inboxAt = 0, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); - if (!id) return; + // the keys not there yet (a wallet still opening): try again shortly — + // this used to count as done for five minutes, and a phone woken by a + // push in that time had no key to open a DM or a call with + if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } + inboxTries = 0; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; @@ -2442,6 +2507,9 @@ export function messagesFeature(ctx) { ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; + // who may ring us when the app is shut: those we've written to (the + // follows ride separately); `known` stays the DM filter's wider list + const rings = known.filter((pk) => wroteTo(pk)); const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); @@ -2450,7 +2518,7 @@ export function messagesFeature(ctx) { await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, - follows, known, names, hasList: !!newest, updated: Date.now(), + follows, known, rings, names, hasList: !!newest, updated: Date.now(), muted: [...mutesNow().set], // no buzz from a muted sender either }); } catch { inboxAt = 0; } @@ -3046,6 +3114,10 @@ export function messagesFeature(ctx) { } else if (got.rumor.kind === 444 && !isMe(got.author)) { // a White Noise invitation: the group itself says who is asking whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); + } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { + // a call, as one of our devices logged it, under the person it was with + const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; + if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } @@ -3055,13 +3127,14 @@ export function messagesFeature(ctx) { async function handleInboxWrap(wrap) { let heal = false; if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { + noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full if (!healing(wrap)) return; heal = true; } seenWraps.add(wrap.id); const r = await openInboxWrap(wrap, heal); if (r === 'stale') return; - if (r === true) { rememberWrap(wrap.id); return; } + if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); @@ -3101,9 +3174,9 @@ export function messagesFeature(ctx) { if (complete && !decryptorsComplete()) break; const r = await openInboxWrap(p.wrap, p.heal); if (r === 'stale') break; // the account changed under this pass - if (r === true) { pendingWraps.delete(id); rememberWrap(id); } + if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later - else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } + else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); @@ -3160,13 +3233,51 @@ export function messagesFeature(ctx) { } bumpMsgRev(); if (swept) save(s); - const dmSub = (relays) => { - const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { - handleInboxWrap(wrap).catch(() => {}); - }, { onclose: noteSubClosed }); + const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); }; + const plainDmSub = (relays) => { + const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; + // Relay by relay. One that speaks negentropy gets the live subscription + // with limit 0 — opened first, so nothing slips between — and a + // reconciliation of the wraps we hold against its newest four hundred, + // the ones we lack fetched by id. One that doesn't (nos.lol has it + // disabled; remembered for a day) is asked the old way, its newest + // wraps re-served and skipped by the prefix log. So is every relay + // while the one-time heal is running: that pass wants them re-served. + const dmSub = (relays) => { + for (const relay of relays) { + if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; } + const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + reconcileWraps(relay).then((ok) => { + if (ok || !dmStarted || !dmUnsubs.includes(u)) return; + // it doesn't speak it after all: the plain subscription, with its backlog + try { u(); } catch {} + allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u); + plainDmSub([relay]); + }).catch(() => {}); + } + }; + async function reconcileWraps(relay) { + const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld()); + if (!r) return false; + dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks'); + for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) { + const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000); + for (const w of evs) onWrap(w); + } + return true; + } dmSub(DM_RELAYS); + // calls ringing in (ephemeral wraps: only what comes while we listen) + const callSub = (relays, authed) => { + const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; + const on = (w) => { onCallWrap(w).catch(() => {}); }; + const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + }; + callSub(DM_RELAYS, false); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those // too, or a compliant sender's DM lands somewhere we never look (a reply @@ -3181,6 +3292,7 @@ export function messagesFeature(ctx) { // in Adam's list, while a cap of three read the first three) .slice(0, 8); ownInbox = extras; + if (extras.length && dmStarted) callSub(extras, true); // inbox relays are often NIP-42 gated: authenticate as the identity if (extras.length && dmStarted) { const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { @@ -3300,6 +3412,10 @@ export function messagesFeature(ctx) { if (rumor.kind === 15) { row.k = 15; row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else if (rumor.kind === CALL_LOG) { + // a call: its who, how and how long are all in its tags + row.k = CALL_LOG; + row.tg = rumor.tags || []; } else { // a message's custom emoji ride along, or they'd be text after a reload const em = [...emojiTagMap(rumor.tags)]; @@ -3800,6 +3916,14 @@ export function messagesFeature(ctx) { node.onclick = (e) => { e.stopPropagation(); openProfile(pk); }; (node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick'); } + // streaming now: a red ring, and the face opens the stream (the name + // beside it still opens the profile, which says they're on too) + let on = null; + 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); }; + } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -3946,7 +4070,7 @@ export function messagesFeature(ctx) { // The same for a post opened into its thread: Back lands on the row the // reader tapped, not the top of the feed. const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last - const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' : ui.profilePk ? 'p:' + ui.profilePk : ''; function rememberPlace(key) { if (pageKey() === key) return; @@ -3977,6 +4101,7 @@ export function messagesFeature(ctx) { } function openProfile(pk) { rememberPlace('p:' + pk); + ui.liveStream = null; ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -4012,6 +4137,48 @@ export function messagesFeature(ctx) { // replies must not crowd its likes and zaps out of it. const REPLY_KINDS = [1, 1111]; const replyIds = new Map(); // note id -> Map(reply id -> author pubkey) + // The replies themselves, as the count fetch brought them — a thread's + // first page, in hand before it is opened: every post on screen has its + // replies asked for (watchZaps), so opening it paints the conversation at + // once and the relays only add to it. Bounded: the targets most recently + // heard from, a few dozen replies each. + const replyEvents = new Map(); // note id -> Map(reply id -> event) + const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60; + function keepReply(target, ev) { + let m = replyEvents.get(target); + if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last + if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); } + replyEvents.set(target, m); + while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value); + } + // The replies in hand for a thread: those naming the root, and those + // naming only the reply they answer (one hop down), oldest first. + function repliesInHand(rootId) { + const out = new Map(); + const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); }; + take(rootId); + for (const rid of [...out.keys()]) take(rid); + return [...out.values()].sort((a, b) => a.created_at - b.created_at); + } + // …into a thread's cache entry: on opening it, and again when the count + // fetch answers for a thread fetched ahead before the answer came + function adoptRepliesInHand(c) { + const have = new Set(c.replies.map((e) => e.id)); + let added = []; + for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); } + if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); } + return added.length; + } + // A thread's rows painted in their final shape: the faces, pictures, + // previews and quotes of what is in hand are fetched ahead (noteReady, as + // the feed does per row), so an opened thread doesn't shift as they land. + const threadWarmed = new WeakSet(); + const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face + function warmThread(evs, deadline = Date.now() + READY_MS) { + const fresh = evs.filter((e) => e && !threadWarmed.has(e)); + for (const e of fresh) threadWarmed.add(e); + return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {}))); + } const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> }) // Resolve from this reaction's own tag: packs can reuse the same shortcode // for different pictures, and an unrelated event must not change its face. @@ -4381,6 +4548,7 @@ export function messagesFeature(ctx) { let m = replyIds.get(x[1]); if (!m) replyIds.set(x[1], m = new Map()); if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; } + keepReply(x[1], ev); } if (changed) scheduleRepaint(); } @@ -4443,6 +4611,8 @@ export function messagesFeature(ctx) { noteCountsReady.add(id); if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false); } + // the open thread takes the replies this answer brought + if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable(); if (c?.root) persistThread(c); saveZapTotals(); scheduleRepaint(); @@ -4708,6 +4878,41 @@ export function messagesFeature(ctx) { const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts'); // what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e)); + // A conversations feed (Amethyst's "Conversations" beside its "New + // Threads"): the replies these people write, each under the post it + // answers, instead of their posts. Asked for as notes and NIP-22 comments; + // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) + const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); + const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' + // (a reply straight to the root), else — the deprecated positional form — + // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. + function parentRefOf(ev) { + if (!ev || !replyish(ev)) return null; + const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]); + let tag; + if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]); + else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1]; + if (!tag) return null; + const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] }; + if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase(); + return ref; + } + // A note already in hand — in a feed, a thread, someone's page — before + // asking any relay for it: the post a reply answers is usually right + // there in the Following feed. + function noteInHand(id) { + for (const c of feedStates.values()) for (const e of c.notes) { + if (e.id === id) return e; + if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; } + } + for (const c of threadCache.values()) { + if (c.root && c.root.id === id) return c.root; + for (const e of c.replies || []) if (e.id === id) return e; + } + for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e; + return null; + } function notesFor(pk) { let c = notesCache.get(pk); if (c) { @@ -4768,26 +4973,24 @@ export function messagesFeature(ctx) { lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); - // back at the top by yourself: the new posts are under your eyes, so - // the notice has done its job (only the pill repaints — the rows are - // keyed and stay put, so nothing moves under a finger) + // Starting to scroll up dismisses the notice: the reader is already + // heading toward the new posts. Keyed rows keep their positions. // Upward only: holding the page still as posts go in above scrolls it // DOWN, and that must not count as having gone up to look at them. const y = window.scrollY || 0, up = y < lastFeedY; lastFeedY = y; - // Scrolling recounts the pill: posts you've gone up past drop off it, - // and once none are left above you the notice is done. + if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) + // Keep tracking which posts are ahead, even after hiding the pill. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { pillFrame = 0; if (!feed) return; markReached(feed); if (!feed.unseen) return; - const n = pillCount(feed); - if (!n) { feed.unseen = 0; render(); } - else if (n !== feed.pillN) render(); + feed.unseen = 0; + render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); - if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return; + if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return; if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); else loadOlderFeed().catch(() => {}); }, { passive: true }); @@ -5177,6 +5380,13 @@ export function messagesFeature(ctx) { // Two feeds everyone has: the people you follow, and everything the coinos // relay carries. Each remembers its own relay choice in the state. const EVERYTHING = 'all'; + // The Conversations view of a feed (the chip beside Notes / Images / + // Videos / Articles): the same people, their replies — a feed state of + // its own under the feed's id with this suffix, its definition derived + // from the feed's (replies: true, see feedKeeps). Nothing is stored + // under it but its notes cache. + const VIEW_SUFFIX = '~replies'; + const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id); // Amethyst's feed model, for the people who asked: everyone you follow plus // everyone on your lists, and on top of that posts from anyone on a // hashtag you follow — your topic feeds' tags and your NIP-51 interests @@ -5233,6 +5443,10 @@ export function messagesFeature(ctx) { ...st().feeds.map(expandFeed)]; }; function feedDef(id = curFeedId) { + if (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) { + const base = feedDef(id.slice(0, -VIEW_SUFFIX.length)); + return base ? { ...base, id, replies: true, view: true } : null; + } if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null; const own = st().feeds.find((f) => f.id === id); return own ? expandFeed(own) : adhocFeeds.get(id) || null; @@ -5607,7 +5821,7 @@ export function messagesFeature(ctx) { const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null; const id = own ? own.id : 'list:' + packKey(p); if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -5654,7 +5868,7 @@ export function messagesFeature(ctx) { onClick: () => { ui.listPick = null; // the editor lives on the feed screen; the profile gives way to it - ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; + ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = true; ui.msgView = 'feed'; openFeedEditor(null); ui.feedEdit.authors = [pk]; @@ -5845,15 +6059,16 @@ export function messagesFeature(ctx) { function feedNow() { if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device - let c = feedStates.get(curFeedId); + const id = viewId(); // the feed, or its Conversations view + let c = feedStates.get(id); if (!c) { let stored = []; - try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {} + try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {} if (!Array.isArray(stored)) stored = []; - if (!stored.length) stored = borrowedNotes(feedDef(curFeedId)); + if (!stored.length) stored = borrowedNotes(feedDef(id)); stored = stored.slice(0, FEED_STORE); - c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() }; - feedStates.set(curFeedId, c); + c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() }; + feedStates.set(id, c); while (feedStates.size > 4) { const oldest = feedStates.keys().next().value; feedStates.get(oldest).stopped = true; @@ -5862,14 +6077,14 @@ export function messagesFeature(ctx) { feed = c; // Only the opening page gates first paint. The next page warms on the // side, so an offscreen image cannot delay the whole feed. - c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => { + c.boot = notesReady(stored.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => { c.booting = false; c.prepared = null; if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint(); }); c.boot.then(() => { if (c.stopped) return; - notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2)); + notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2), { parent: feedReplies(c) }); }); prefetchFeed(c); } @@ -5913,8 +6128,8 @@ export function messagesFeature(ctx) { const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def)); if (!authors.size && !topics.size) return []; const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1]))); - const fits = (e) => ((!authors.size || authors.has(e.pubkey)) - && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)); + const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey)) + && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT))); const seen = new Set(), out = []; for (const o of feedStates.values()) { for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); } @@ -5936,10 +6151,10 @@ export function messagesFeature(ctx) { if (!authors.length && !topics.length) continue; try { const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) => - queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2, + queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat(); if (feedStates.has(def.id)) continue; // opened while we were asking - disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e))); + disk.seed(def.id, events.filter((e) => feedKeeps(def, e) && !hidden(e))); } catch {} } } @@ -5972,7 +6187,7 @@ export function messagesFeature(ctx) { // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; const showFeed = (id) => { - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it switchFeed(id); @@ -6048,7 +6263,7 @@ export function messagesFeature(ctx) { if (!def) return null; const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : ''; if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; - if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null; + if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; @@ -6071,7 +6286,9 @@ export function messagesFeature(ctx) { // beside it). Changing it rebuilds the feed from those relays. // Forget a feed's state so it is rebuilt; a fetch still in flight for the // old one lands nowhere (it would otherwise fill the orphan, not the new). - const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); }; + const dropFeedState = (id) => { + for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); } + }; // Popular's defaults before 2026-10-04: a list saved that matches them // exactly was the defaults, kept by an edit, and follows the new ones. function oldPopularDefault(list) { @@ -6171,7 +6388,7 @@ export function messagesFeature(ctx) { const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : ''); if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -6305,21 +6522,23 @@ export function messagesFeature(ctx) { // publish it); otherwise the metadata alone is fetched — a few KB for a // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. - const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; }; - // A video event's file: of the versions its imeta tags offer, the one - // nearest 1080 on its short side (a phone's screen; not the 4K original), - // never an HLS playlist; else its url tag. + // A video event's file: its HLS stream where the browser plays one (the + // player picks the rendition); else of the versions its imeta tags + // offer, the one nearest 1080 on its short side (a phone's screen; not the + // 4K original); else its url tag. function videoEventFile(ev) { - let best = null; + let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); - if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue; + if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } + if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; @@ -6327,6 +6546,7 @@ export function messagesFeature(ctx) { if (!best || score > best.score) best = { url: f.url, image: f.image, score }; } if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; } + if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) }; if (best) videoUrls.add(best.url); return best; } @@ -6444,7 +6664,15 @@ export function messagesFeature(ctx) { // an iPhone's HEVC (hvc1/hev1 in the sample description) let hevc = false; for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true; - return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) }; + // its length, from the movie header (a long clip plays as HLS: playSrc) + let dur = 0; + for (let j = 4; j + 32 <= b.length && !dur; j++) { + if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm' + const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16)); + const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20); + if (scale && units && units !== 0xffffffff) dur = units / scale; + } + return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) }; } const videoProbes = new Map(); let videoProbing = 0; @@ -6472,6 +6700,7 @@ export function messagesFeature(ctx) { } } async function runVideoProbe(url) { + if (isHls(url)) return probeStream(url); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10_000); try { @@ -6498,7 +6727,7 @@ export function messagesFeature(ctx) { let o = 0; for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); - const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) }; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; if (total > 0) d.size = total; else { // a server that hides Content-Range from scripts (Primal's) still @@ -6517,6 +6746,25 @@ export function messagesFeature(ctx) { } finally { clearTimeout(timer); } return videoInfo.get(url); } + // A stream is measured by a player: its playlist says nothing of the + // picture's size. Where HLS doesn't play, it can't be measured either. + async function probeStream(url) { + if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); } + const v = document.createElement('video'); + v.muted = true; v.preload = 'metadata'; + const ok = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 15_000); + v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = url; + }); + if (ok) { + noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) }); + rememberVideo(url, v.videoWidth, v.videoHeight); + } else noteVideoInfo(url, { failedAt: Date.now() }); + v.removeAttribute('src'); v.load(); + return videoInfo.get(url); + } // How much a clip may cost before it waits for a tap: less on a slow or // metered connection. const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } }; @@ -6531,16 +6779,28 @@ export function messagesFeature(ctx) { // Data Saver on, or a slow link: the connection to be careful with const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs - function videoHeavy(url) { - if (tappedClips.has(url) || largeVideosOk()) return false; + // A clip too big to play as it is: over the budget, past 1080p, its index + // at the end of a big file — or (known) unmeasurable. Such a clip plays as + // an HLS stream where it can (playSrc); else it waits for a tap. + function bigClip(url, known = false) { const d = videoInfo.get(url) || {}; if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; - if (!d.size) return true; // nothing known: don't spend data blind + if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind if (d.size > videoBudget()) return true; // the whole file before the first frame: only worth it for a small one if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true; return false; } + function videoHeavy(url) { + if (tappedClips.has(url) || largeVideosOk()) return false; + const d = videoInfo.get(url) || {}; + if (d.stream) return false; // a stream takes the rendition the connection carries + // one that streams costs what the connection carries: it plays like any + // other (Adam, 2026-10-18) — but not over Data Saver or a slow link, + // where even a stream is the reader's call + if (streamsHere(url) && !frugalData()) return false; + return bigClip(url); + } const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB'; function heavyLabel(url) { const d = videoInfo.get(url) || {}; @@ -6685,12 +6945,21 @@ export function messagesFeature(ctx) { await warmMedia(avatarUrl(p, pk)); if (p?.picture) makeThumb(pk, p); } - async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) { + // parent: the post a reply answers is part of the row (a conversations + // feed), so it arrives with the row; elsewhere a reply's parent is fetched + // only where it is shown (a profile's Replies tab fills its cards in). + async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) { if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; } const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })), ...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))), ...notePreviewUrls(ev.content).map(warmPreview), ...[...emojiTagMap(ev.tags).values()].map(warmMedia)]; + // a reply's parent, shown above it where replies are shown with context + const parent = withParent && !depth ? parentRefOf(ev) : null; + if (parent) { + const quote = quotedNote(parent); + tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1))); + } for (const part of String(ev.content || '').split(NOTE_SPLIT)) { if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, '')); @@ -6708,7 +6977,7 @@ export function messagesFeature(ctx) { await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]); } catch {} finally { clearTimeout(timer); } } - const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); }; + const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); }; const keyed = (node, key) => { node.setAttribute('data-key', key); return node; }; // Keep only the resources this row actually uses. Copying the entire // session media cache into every row grows quadratically as you scroll. @@ -6717,6 +6986,9 @@ export function messagesFeature(ctx) { if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null); return feedPaint.media.get(url); } + // a conversations feed: each reply under the post it answers (remembered + // on the feed's state; a changed definition rebuilds the state) + const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies); function feedRow(c, ev) { let presentation = c.presentations.get(ev.id); if (!presentation) { @@ -6726,7 +6998,7 @@ export function messagesFeature(ctx) { const prev = feedPaint; feedPaint = presentation; try { - const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); + const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id); if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); } return row; } @@ -6853,10 +7125,10 @@ export function messagesFeature(ctx) { const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); }; const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); }; const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } }; - let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e) + const def = feedDef(c.id); + let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); if (!add.length) return false; - const def = feedDef(c.id); if (def && def.curated) { const kept = curateFeed(add); for (const e of add) if (!kept.includes(e)) unstage(e); @@ -6871,7 +7143,7 @@ export function messagesFeature(ctx) { c.booting = true; for (const e of add) unstage(e); admitFeed(add, c); - c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => { + c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => { c.booting = false; c.prepared = null; if (!c.stopped && c === feed) scheduleRepaint(); @@ -6882,7 +7154,7 @@ export function messagesFeature(ctx) { // Each row waits only for its own presentation. A slow photo or quote // cannot hold ready text posts (or their new-post notice) behind it. const ready = await Promise.all(add.map(async (e) => { - await noteReady(e); + await noteReady(e, undefined, 0, { parent: feedReplies(c) }); unstage(e); if (c.stopped) return false; // Cached rows can still be warming. This new row is already prepared @@ -6945,7 +7217,7 @@ export function messagesFeature(ctx) { } const result = mergeFeedWindow(c.notes, c.shown, add, { rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })), - height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE, + height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead holdHead: headBusy(), }); c.deferred = result.deferred; @@ -6966,7 +7238,7 @@ export function messagesFeature(ctx) { // The pill's tap: the posts are already in, so this just goes up to them — // to the OLDEST of them, the one right above where you were reading, so - // you read on upward through the rest. Reaching the top by yourself clears + // you read on upward through the rest. Starting to scroll up by yourself clears // the pill too (see the scroll listener). // Drag the new-posts pill any way but up and let go past a short // distance: it flies off that way and onDismiss runs. A short drag springs @@ -7132,23 +7404,25 @@ export function messagesFeature(ctx) { }, maxWait); return got; } - async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { + // (relays: asked in place of the feed's own — the archives, for history + // the feed's relays have let go) + async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) { const def = feedDef(c.id); if (!def) return false; + if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies const authors = feedAuthors(def), topics = feedTopics(def); const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); const orT = orTopicsOf(def); const orPass = orT.length - ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) + ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) : null; if (!authors.length) { // topics alone: nobody's outbox to read, so the wide relays, one ask; // the firehose asks its relays for everything if (!topics.length && !def.all) return false; - const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c); + return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c); } const readPlan = async (plan) => { await Promise.all(plan.flatMap(({ relays, authors: a }) => { @@ -7161,7 +7435,7 @@ export function messagesFeature(ctx) { }); })); }; - if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]); + if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]); else { const initial = outboxPlan(authors); const reading = readPlan(initial); @@ -7362,22 +7636,23 @@ export function messagesFeature(ctx) { // the same relays the pass above reads, one subscription each. function watchFeed() { stopFeedWatch(); - const def = feedDef(); - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; const c = feedNow(); + const def = feedDef(c.id); // the Conversations view has its own + if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; + const kinds = feedKinds(def); const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } - if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; } + if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } const orT = orTopicsOf(def); - if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, '#t': orT, since }, on)); + if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) - feedUnsubs.push(subscribeOn(relays, { kinds: topics.length ? FEED_KINDS : [...FEED_KINDS, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); + feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -7391,43 +7666,98 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; - if (mediaMode !== 'all') { + if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid'); if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {}); return; } + fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging const rows = document.querySelectorAll('.notes-feed > .row[data-key]'); const last = rows[rows.length - 1]; - if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {}); + if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {}); }, 100); } + // ---- the reader's appetite ------------------------------------------- + // How hungrily the feed is being scrolled: the screens travelled, by the + // reader's own hand, over the last ten seconds. A page of posts is read + // at a screen or two in that time; five screens in it is someone flying + // through, and the feed answers by paging more posts at a time and + // asking for them from further up — up to four pages and four times the + // lead at once. (Adam, 2026-10-16: "could we load posts faster if it + // seems like the user is scrolling a lot? … try always loading more + // posts and loading them sooner when we're further away from the + // bottom".) The trail counts only scrolls within a moment of an input, + // so a page held still under an insert above doesn't look like travel. + const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5; + const scrollTrail = []; // [at, scrollY] + function noteScrollTrail(y) { + const now = Date.now(); + scrollTrail.push([now, y]); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + } + function appetite() { + const now = Date.now(); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + if (scrollTrail.length < 2) return 1; + const H = (typeof window !== 'undefined' && window.innerHeight) || 800; + const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1]; + return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H)))); + } + // how far above the foot of the feed the next page is asked for + const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite(); + // The relays run AHEAD of the rows. The older page used to be asked for + // only once every post in hand was on screen, and shown only once the + // whole pass had answered — so the reader who reached the end of what we + // held waited a relay round trip, up to the five seconds the slowest + // relay in the pass is given. Now a pass for older posts starts as soon + // as what we hold beyond the shown rows thins to a page or two (more + // with the appetite), its rows warming at the door while the reader is + // still on the posts before them; the showing step takes from hand. + function fetchOlderAhead(c) { + if (c.fetching) return c.fetching; + if (c.end || c.booting || c.stopped || c.status !== 'ready') return null; + if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null; + const oldest = c.notes.at(-1); + if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; } + c.fetching = (async () => { + let until = oldest.created_at - 1; + const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip + for (let tries = 0; tries < 3; tries++) { + c.sawAny = 0; + if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true; + // nothing older at all (or no step back): that really is the end + if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; } + until = c.seenBack - 1; + } + return false; + })().catch(() => false).finally(() => { + c.fetching = null; + if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); } + }); + return c.fetching; + } async function loadOlderFeed() { const c = feedNow(); if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return; c.loadingMore = true; if (c === feed) render(); // the footer says so while it happens try { - if (c.shown < c.notes.length) { - const end = Math.min(c.shown + FEED_PAGE, c.notes.length); - const boundary = c.notes[end - 1].id; - await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length))); - c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); - return; - } - if (c.status !== 'ready' || c.end) return; - const oldest = c.notes.at(-1); - if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; } - let until = oldest.created_at - 1; - for (let tries = 0; tries < 3; tries++) { - c.sawAny = 0; - if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; } - // nothing older at all (or no step back): that really is the end - if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; } - until = c.seenBack - 1; + fetchOlderAhead(c); + if (c.shown >= c.notes.length) { + // nothing in hand: the pass underway (started just now, if need be) brings the next page + if (!c.fetching) return; + await c.fetching; + if (c.shown >= c.notes.length) return; } + const page = FEED_PAGE * appetite(); + const end = Math.min(c.shown + page, c.notes.length); + const boundary = c.notes[end - 1].id; + await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) }); + c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); + fetchOlderAhead(c); // and the relays, if that thinned what we hold } finally { c.loadingMore = false; if (c === feed) render(); @@ -7551,6 +7881,9 @@ export function messagesFeature(ctx) { const vmediaOk = (url) => /^https:\/\//i.test(url || ''); // v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served) const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined); + // the clip's very first frame at full size (the pager's slides ahead hold it) + const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined); + const serverFirstFailed = new Set(); // clips the server had no first frame for (this session) const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) @@ -7599,10 +7932,32 @@ export function messagesFeature(ctx) { const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); - // what a player for this clip loads: the copy, once the original is known - // not to show here - const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) - && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A long clip (or a big one whose length isn't known) plays as HLS from + // vmedia: a 360p and a 720p rendition, the player taking the one the + // connection carries, in 4-second pieces made as they're watched — not a + // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you + // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS + // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the + // original as before. + const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; + const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); + const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); + const hlsFailed = new Set(); + let hlsPlays; + const canHls = () => hlsPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } + })(); + const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; + // Long, or too big to play as it is (a 4K clip, a 140 MB one: these + // waited for a tap with their cost on them) — streamed at a rendition the + // connection carries instead. A small short clip plays as it is: one + // request, its own quality, no work for the server. + const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); + // what a player for this clip loads: the stream for a long or big one; + // the copy, once the original is known not to show here + const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url + : streamsHere(url) ? hlsOf(url) + : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. @@ -7618,6 +7973,14 @@ export function messagesFeature(ctx) { }); v.addEventListener('error', () => { if (!onCopy()) return; + // the stream failed: the copy or the original, as it would have been + if (isHls(v.getAttribute('src'))) { + hlsFailed.add(url); + const want = !v.paused; + v.setAttribute('src', playSrc(url)); + if (want) v.play().catch(() => {}); + return; + } h264Failed.add(url); const want = !v.paused; v.setAttribute('src', url); @@ -7674,7 +8037,9 @@ export function messagesFeature(ctx) { // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. - if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) { + // (only a clip the pager has: taken out of full screen for a pager that + // then wouldn't open, a quoted post's clip went nowhere at all) + if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) { const url = el.dataset.clip; Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}) .then(() => { if (!pager && document.contains(el)) openClip(el, url); }); @@ -7948,7 +8313,11 @@ export function messagesFeature(ctx) { function quotedNote(ref) { let c = quoted.get(ref.id); if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c; - if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); } + if (!c) { + const have = noteInHand(ref.id); + if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return c; } + c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); + } c.at = Date.now(); c.promise = (async () => { c.ev = (await findNote(ref).catch(() => null)) || c.ev || null; @@ -8052,6 +8421,7 @@ export function messagesFeature(ctx) { if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); } const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); if (!c.ev && isList) { // a list needs nothing fetched to open: the feed resolves its members return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, @@ -8121,6 +8491,886 @@ export function messagesFeature(ctx) { return out; } + // ---- live streams (NIP-53) ----------------------------------------------- + // What's on now, newest copy of each stream by its address: asked of the + // relays streams are announced on every couple of minutes while a feed or + // profile wants it. A feed shows the ones its people are in (Following: + // your follows; Everything and Popular: all of them) in a row above its + // posts; a profile says when its person is on; a post linking a stream + // shows it as a card. Any of them opens the stream's own screen. + const liveSeen = new Map(); // addr -> newest 30311 + let liveAt = 0, liveAsking = null; + const liveAskedOf = new Map(); // pk -> when their own streams were asked for + function takeLive(evs) { + let changed = false; + for (const [a, ev] of newestByAddr(evs)) { + const cur = liveSeen.get(a); + if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } + } + if (changed) liveHostsAt = 0; + return changed; + } + // 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(); + function liveHostNow(pk) { + if (!liveSeen.size) return null; + if (Date.now() - liveHostsAt > 30_000) { + liveHostsAt = Date.now(); + const now = Date.now() / 1000; + liveHosts = new Map(); + for (const ev of liveSeen.values()) { + if (!isLiveNow(ev, now) || !liveShowable(ev)) continue; + const h = liveHost(ev), cur = liveHosts.get(h); + if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev); + } + } + return liveHosts.get(pk) || null; + } + const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; + function refreshLive() { + if (liveAsking || Date.now() - liveAt < 120_000) return; + liveAt = Date.now(); + liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) + .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) + .catch(() => {}).finally(() => { liveAsking = null; }); + } + // the streams someone is in, asked of their own relays as well (a + // streamer's on a relay we don't otherwise read) + function askLiveOf(pk) { + if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; + liveAskedOf.set(pk, Date.now()); + Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { + const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; + return Promise.all([ + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); + const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); + function liveNowOf(pk) { + const now = Date.now() / 1000; + return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function liveForFeed(def) { + const now = Date.now() / 1000; + const fol = followsNow().set; + const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); + if (people && !people.size) return []; + return [...liveSeen.values()] + .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) + .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); + } + const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; + function liveBadge(ev) { + const s = liveStatus(ev); + return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); + } + // the row of streams above a feed's posts + function liveStrip(def) { + if (mediaMode === 'articles') return null; + refreshLive(); + const evs = liveForFeed(def).slice(0, 12); + if (!evs.length) return null; + return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => { + const host = liveHost(ev), n = liveViewers(ev); + return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, + liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null), + 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 "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) return 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')), + h('span', { class: 'small muted' }, t('liveWatch'))); + } + // a post linking a stream (nostr:naddr of a 30311) + function liveCard(ev) { + const host = liveHost(ev); + return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), + h('div', { class: 'col', style: 'gap:4px;min-width:0' }, + h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); + } + + function openLive(ev) { + rememberPlace('live'); + const a = liveAddr(ev); + takeLive([ev]); + setZapAddr(ev.id, a); + ui.liveStream = { a, seed: ev }; + ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; + render(); + } + // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) + async function openLiveRef(ref) { + const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); + if (ev) openLive(ev); + else toast(t('liveNotFound')); + } + + // The open stream: its chat (1311) and zaps (9735, and coinos's Ark + // receipts) as they come, and the stream event itself as it's refreshed + // (the viewer count, `ended`). + let liveWatch = null; + function watchLive(s) { + if (liveWatch && liveWatch.a === s.a) return liveWatch; + stopLiveWatch(); + 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: '' }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + w.lines.set(e.id, 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: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + return w; + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + for (const stop of w.stops) { try { stop(); } catch {} } + 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) : ''); + // 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). + function mountLivePlayer() { + const w = liveWatch; + const el = document.querySelector('video.live-video:not(.bc-preview)'); + if (!w || !el || el === w.video) return; + stopLivePlayer(w); + const ev = liveSeen.get(w.a); + const src = ev && livePlayable(ev); + if (!src) return; + w.video = el; + el._skipMorph = true; + w.err = null; + // a stream that drops (the streamer's link, a restart) is tried again + const fail = () => { + if (w.video !== el) return; + stopLivePlayer(w); + w.err = 'offline'; + scheduleRepaint(); + setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000); + }; + el.addEventListener('error', fail); + 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) => { + if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } + if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } + w.hls = hls; go(); + }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); }); + } + // after every render: a stream screen gone takes its player and watch with + // it (a <video> taken out of the page plays on, unheard and unseen) + let liveShown = false; + function liveAfterRender() { + // the camera shown before going live: let go of it once that screen is left + if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; } + const pv = document.querySelector('video.bc-preview'); + if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); } + if (!ui.liveStream) { stopLiveWatch(); return; } + if (!liveShown) { stopLivePlayer(); return; } + mountLivePlayer(); + const box = document.querySelector('.live-chat'); + if (box && box._stick !== false) box.scrollTop = box.scrollHeight; + } + + async function sendLiveChat(w, text) { + 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] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + w.draft = text; + toast(t('msgSendFailed')); + render(); + } + } + + function liveLine(ev) { + 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 z = zapLine(ev, decodeBolt11); + if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; + return keyed(h('div', { class: 'live-line live-zap' }, + z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ', + h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })), + z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id); + } + + function liveScreen() { + const s = ui.liveStream; + const w = watchLive(s); + liveShown = true; + // your own stream, going out: the camera itself, not the stream seconds behind it + const mine = !!bc && bc.phase === 'live' && bc.a === s.a; + if (mine) setTimeout(scheduleRepaint, 1000); // the clock + 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') + : !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'); + if (inp) inp.value = ''; + sendLiveChat(w, text).catch(() => { w.draft = text; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + 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')), + 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 })), + 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, + 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: '' }))) + : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a }, + // the frame is keyed by what plays in it: the <video> itself is + // never morphed, so a stream that ends or drops swaps the frame + 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: '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, + 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 () => { + 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('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')))), + ...noteOverlays()); + } + + // ---- going live ----------------------------------------------------------- + // The camera (or the screen) recorded in half-second chunks and sent over a + // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS + // of it; the stream is announced as your kind 30311, refreshed every minute + // with how many watch, and ended with its recording. A coinos name's owner + // only: the server asks the registrar. One at a time. + const liveIngest = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest'; + }; + const recType = () => { + if (typeof MediaRecorder === 'undefined') return null; + return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null; + }; + const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType(); + const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); }; + let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... } + function openGoLive() { + if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; } + bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; + ui.goLive = true; + ui.liveStream = null; + render(); + bcPreview().catch(() => {}); + } + function bcStopMedia() { + if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} } + if (bc) bc.media = null; + } + // the picture to send: the camera (with its microphone), or the screen + // (with the microphone beside it, and the tab's sound when shared) + async function bcPreview() { + const b = bc; + if (!b) return; + bcStopMedia(); + b.err = null; + try { + let media; + if (b.source === 'screen') { + 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(); }); + } else { + media = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, + audio: { echoCancellation: true, noiseSuppression: true }, + }); + } + if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; } + b.media = media; + // a switch mid-stream: the recorder carries on with the new picture + if (b.phase === 'live') bcRecord(b, true); + } catch (e) { + if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + } + render(); + } + async function liveAuth(b) { + const id = await requireIdentity(); + const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + b.pk = id.pubkey; + return btoa(JSON.stringify(evt)); + } + // one connection to the server: its first word is where the stream is + function bcConnect(b, resume) { + return liveAuth(b).then((auth) => new Promise((resolve, reject) => { + const name = (hook('namesAddress') || '').split('@')[0]; + const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : '')); + ws.binaryType = 'arraybuffer'; + let hello = false; + ws.onmessage = (e) => { + if (hello || typeof e.data !== 'string') return; + hello = true; + try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); } + }; + ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); }; + ws.onclose = () => { + if (!hello) { reject(new Error(t('goLiveRefused'))); return; } + if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b); + }; + })); + } + // a recorder over the current picture, its chunks to the server; a new one + // (a new connection, a new camera) starts a fresh recording, header and all + function bcRecord(b, again = false) { + if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} } + if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording + const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 }); + rec.ondataavailable = (e) => { + if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return; + // more than a few seconds unsent: the link can't keep up + b.behind = b.ws.bufferedAmount > 4_000_000; + e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); }); + }; + rec.start(500); + b.rec = rec; + } + async function bcReconnect(b) { + if (b.reconnecting) return; + b.reconnecting = true; + b.ws = null; + render(); + for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) { + try { + const { ws, info } = await bcConnect(b, b.id); + if (bc !== b || b.phase !== 'live') { ws.close(); break; } + b.ws = ws; + bcRecord(b); + b.reconnecting = false; + if (info.id !== b.id) toast(t('goLiveRestarted')); + render(); + return; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + b.reconnecting = false; + if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } + } + // the announcement: your 30311, and its copies as the stream goes on + async function bcAnnounce(b, status) { + const id = await requireIdentity(); + const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])]; + const me = profileOf(id.pubkey) || {}; + const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })], + ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)], + ['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 }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + b.ev = evt; + scheduleRepaint(); + return publishOn(relays, evt); + } + async function startBroadcast() { + const b = bc; + if (!b || b.phase !== 'setup' || !b.media) return; + b.phase = 'starting'; b.err = null; + render(); + try { + const { ws, info } = await bcConnect(b, null); + if (bc !== b) { ws.close(); return; } + Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 }); + b.phase = 'live'; + b.a = LIVE_KIND + ':' + b.pk + ':' + b.id; + bcRecord(b); + 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; + } + if (bc !== b || b.phase !== 'live') return; + await bcAnnounce(b, 'live'); + b.tick = setInterval(() => bcTick(b), 60_000); + ui.goLive = null; + openLive(b.ev); + } catch (e) { + if (bc !== b) return; + b.phase = 'setup'; + b.err = (e && e.message) || t('goLiveRefused'); + try { b.ws && b.ws.close(); } catch {} + render(); + } + } + // every minute: how many watch, into a fresh copy of the announcement + async function bcTick(b) { + if (bc !== b || b.phase !== 'live') return; + try { + const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json(); + b.viewers = st.viewers; + } catch {} + bcAnnounce(b, 'live').catch(() => {}); + } + async function endBroadcast() { + const b = bc; + if (!b || b.phase === 'ended') return; + const was = b.phase; + b.phase = 'ended'; + clearInterval(b.tick); + try { b.rec && b.rec.stop(); } catch {} + 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(); + try { b.lock && b.lock.release(); } catch {} + bc = null; + ui.goLive = null; + render(); + if (was === 'live' && b.id) { + await bcAnnounce(b, 'ended').catch(() => {}); + toast(t('goLiveEnded')); + } + } + // the screen before going live: what to send, a title, the picture itself + function goLiveScreen() { + if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen) + const b = bc; + const name = hook('namesAddress'); + const screenOk = !!navigator.mediaDevices?.getDisplayMedia; + queueMicrotask(() => { + const v = document.querySelector('video.bc-preview'); + 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(); }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(true), + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }), + h('h3', { style: 'margin:0' }, t('goLive'))), + !name ? h('div', { class: 'notice small' }, 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: '' })) + : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))), + h('div', { class: 'row gap6 wrap' }, + h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')), + screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null, + b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null), + 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 }, + b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')), + h('div', { class: 'small faint' }, t('goLiveHint')))); + } + + // ---- calls (NIP-AC: src/calls.js) ------------------------------------------ + // A 📞 or 🎥 in a conversation's header calls that person; a call from + // someone you follow (or talk to) rings over whatever page is up. The setup + // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from + // coinos's live server (Cloudflare's), for the connections that can't go direct. + const liveBase = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return local ? location.origin : APP_BASE; + }; + let calls = null; + const callInbox = new Map(); // peer -> their inbox relays, for this session + async function inboxFor(pk) { + if (callInbox.has(pk)) return callInbox.get(pk); + const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) + .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]); + callInbox.set(pk, p); + return p; + } + async function signAsMe(tmpl) { + const id = await requireIdentity(); + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + } + let iceCache = null; // { servers, until } + async function callIce() { + if (iceCache && Date.now() < iceCache.until) return iceCache.servers; + const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; + try { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] }); + const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }); + const j = await r.json(); + if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN; + iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 }; + return j.iceServers; + } catch { return STUN; } + } + function callMedia(type, facing = 'user') { + 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 }); + return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); + } + // who may ring: someone you follow, or someone you've written to + // who may ring: someone you follow, or someone you've written to. Anyone + // else's call doesn't ring but is told of, as a missed call that didn't + // ring (a wife's call must not vanish because you never followed her); + // a muted person's leaves nothing. + const mayRing = (pk) => hiddenPk(pk) ? null + : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet'; + // a message of ours in the conversation — not a call logged there (a + // stranger's unrung call would otherwise let their next one ring) + function wroteTo(pk) { + for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true; + return false; + } + function callsNow() { + if (calls) return calls; + calls = createCalls({ + me: () => dmSubKeys()[0], + sign: signAsMe, + send: async (pk, signed) => { + const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth }); + if (!ok) throw new Error('not delivered'); + }, + iceServers: callIce, + media: callMedia, + mayRing: async (pk) => mayRing(pk), + changed: () => { callRing(); render(); }, + ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), + // a key of our own signs each candidate at once; a remote signer gets + // them all inside the offer or answer instead + trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, + }); + return calls; + } + // a wrap addressed to us: opened by whichever of our keys it's for + async function onCallWrap(wrap) { + for (const d of dmDecryptors()) { + const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct); + const inner = await openCall(wrap, dec).catch(() => null); + if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } + } + } + // The call log: each call a row in its conversation ("Outgoing video call + // 4:12", "Missed voice call"), kept like a message and sent to our own + // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC + // discussion uses for logs, which other clients pass over) so our other + // devices list it too. A call rung on two of our devices is logged by + // both; the conversation shows it once, by its call-id. + const callStatus = (rec) => rec.connected ? 'answered' + : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed' + : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' + : rec.reason === 'no-answer' ? 'no-answer' + : rec.reason === 'busy' ? 'busy' + : rec.reason === 'hung-up' ? 'cancelled' + : 'failed'; + const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 }; + function callLogText(dir, type, status, seconds) { + const video = type === 'video'; + const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn') + : status === 'missed' ? 'callLogMissed' + : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined') + : status === 'no-answer' ? 'callLogNoAnswer' + : status === 'cancelled' ? 'callLogCancelled' + : status === 'busy' ? 'callLogBusy' : 'callLogFailed'; + const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') }); + return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what; + } + async function logCall(rec) { + if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it + // (a missed call found as the app opens may come before its keys do) + let id = await identity(); + for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); } + if (!id) return; + const status = callStatus(rec); + const rumor = rumorWithId({ + kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000), + content: callLogText(rec.dir, rec.type, status, rec.seconds), + tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status], + ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']], + }); + noteDM(rec.peer, rumor, true); + persistDms(); + // to our other devices — not through Amber, a prompt per call for a log line + if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return; + const toSelf = await wrapDM(id.signer, id.pubkey, rumor); + publishOn(DM_RELAYS, toSelf); + toOwnInbox(toSelf); + } + // A conversation's rows with each call once: its most telling log (one + // device answered it while another only saw it ring) + function foldCallLogs(msgs) { + const best = new Map(); + for (const m of msgs) { + if (m.rumor.kind !== CALL_LOG) continue; + const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id; + const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0; + const cur = best.get(cid); + if (!cur || st2(m) > st2(cur)) best.set(cid, m); + } + return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m); + } + function callLogRow(m, peer) { + const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1]; + const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out'; + const missed = status === 'missed' || (dir === 'in' && status === 'declined'); + // a call that didn't ring (someone you don't follow), saying so — and, + // while that's still so, how to have them ring next time + const unrung = tag('rang') === 'no'; + return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' }, + h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) }, + callIcon(type === 'video' ? 'video' : 'phone'), + h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)), + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))), + unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null); + } + + // the call offer a push brought (sw-nwc.js keeps it): rung now, once + async function pendingCall() { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open('coinos-call'); + // calls the worker saw from people who don't ring us: logged, unrung + const quiet = await cache.match('/__quiet-calls'); + if (quiet) { + await cache.delete('/__quiet-calls'); + for (const offer of (await quiet.json()) || []) { + if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } + const r = await cache.match('/__pending-call'); + if (!r) return; + await cache.delete('/__pending-call'); + const { offer, at } = await r.json(); + if (!offer) return; + if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; } + // rang while the app was shut, and nobody came: a missed call in the conversation + if (mayRing(offer.pubkey)) { + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } catch {} + } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } + function startCall(peer, type) { + if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } + const cur = callsNow().call; + if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; } + ui.callMin = false; + callsNow().start(peer, type).catch(() => {}); + } + + // The ring: a tone (two short bursts every few seconds) and the phone's + // buzz for a call coming in, a softer one while ours rings them; and, with + // the app out of sight, a notification. + let ringCtx = null, ringTimer = null, ringFor = null; + function tone(ctx, freqs, at, len, vol) { + for (const f of freqs) { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = f; o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len); + o.start(at); o.stop(at + len + 0.05); + } + } + function callRing() { + const c = calls && calls.call; + const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null; + if (want === ringFor) return; + ringFor = want; + clearInterval(ringTimer); ringTimer = null; + try { navigator.vibrate && navigator.vibrate(0); } catch {} + // answered, declined or over: its "is calling" notification goes too + if (!want || want.endsWith(':offering')) { + try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {} + } + if (!want) return; + const incoming = c.state === 'incoming'; + const beat = () => { + try { + ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {}); + const t0 = ringCtx.currentTime + 0.05; + if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); } + else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller + } catch {} + if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} } + }; + beat(); + ringTimer = setInterval(beat, incoming ? 3000 : 4000); + if (incoming && typeof document !== 'undefined' && document.hidden) { + try { + if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), { + body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true, + })).catch(() => {}); + } catch {} + } + } + + // the call's sound and pictures into their elements, after each paint + function mountCallMedia() { + const c = calls && calls.call; + const put = (sel, stream) => { + const el = document.querySelector(sel); + if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); } + }; + if (!c) return; + put('audio.call-audio', c.remote); + put('video.call-remote', c.remote); + put('video.call-local', c.local); + } + const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed', + 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded'); + const CALL_ICONS = { + phone: '<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="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>', + video: '<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="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>', + mic: '<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="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>', + 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>', + 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>', + }; + const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] }); + // The call, over any page: who, how it's going, and its buttons; minimized, + // a bar across the top to come back by. + function callOverlay() { + const c = calls && calls.call; + if (!c) return null; + 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; + 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') + : c.state === 'connecting' ? t('callConnecting') + : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000) + : callReason(c.reason); + if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') { + return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound, + h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } }, + h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status))); + } + const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon)); + return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' }, + sound, + video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + h('div', { class: 'call-who' }, + 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, + c.state === 'incoming' + ? h('div', { class: 'call-bar' }, + btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()), + btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept())) + : 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, + btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), + btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -8345,7 +9595,7 @@ export function messagesFeature(ctx) { // key and 16-byte nonce per file, the ciphertext uploaded as an opaque // blob, the key riding inside the (already encrypted) message. The media // host never sees a picture. coinos's own Blossom server stores only sync - // envelopes, so these go to public hosts that take anonymous blobs. + // envelopes, so these go to the configured media hosts or public defaults. const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com']; const MEDIA_MAX = 20 * 1024 * 1024; async function encryptFile(file) { @@ -8372,7 +9622,7 @@ export function messagesFeature(ctx) { const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt); const header = 'Nostr ' + btoa(JSON.stringify(auth)); const urls = []; - for (const server of MEDIA_SERVERS) { + for (const server of mediaServers(MEDIA_SERVERS)) { try { const r = await fetch(server + '/upload', { method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000), @@ -8562,7 +9812,7 @@ export function messagesFeature(ctx) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); else if (ref && ref.type === 'addr') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { @@ -8586,7 +9836,7 @@ export function messagesFeature(ctx) { // The header magnifier's search, opened with a name already typed. function openPeopleSearch(q) { - ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; + ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q, rows: null }; warmSearch(); userSearcher.update(q); @@ -8907,7 +10157,7 @@ export function messagesFeature(ctx) { const next = (cur ? cur + '\n\n' : '') + ref; ui.profCompose = next; setDraft(POST_DRAFT, next); - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -9945,13 +11195,16 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } - function noteRow(pk, ev, name, { open = true, focus = false, joined = false } = {}) { + // context: a reply shows the post it answers above itself (the + // Conversations feed, a profile's Replies tab) — a quote card, which opens + // that post; the reply's own tap opens the thread at the reply. + function noteRow(pk, ev, name, { open = true, focus = false, joined = false, context = false } = {}) { if (ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return h('div', { class: 'row note-post', style: 'display:none' }); const prev = repostBy; repostBy = ev.pubkey; - try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined }); } + try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined, context }); } finally { repostBy = prev; } } // who reposted this, for its header line (only this row: a quote inside draws none) @@ -9976,6 +11229,7 @@ export function messagesFeature(ctx) { const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT && !isVideoEvent(ev) && !zapRec; const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e')); + const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null; const canZap = canZapPk(pk); // an optimistic post mid-publish: visible but not yet a real event — // dimmed, and no thread/reply/zap until its signed self takes over @@ -10015,6 +11269,9 @@ export function messagesFeature(ctx) { h('div', { class: 'note-content' }, reposter ? h('div', { class: 'small faint note-reposted', onClick: (e) => { e.stopPropagation(); openProfile(reposter); } }, h('span', { 'aria-hidden': 'true' }, '\u{1F501} '), t('postRepostedBy', { name: displayName(reposter) })) : null, + parent ? h('div', { class: 'note-context', 'data-context-of': parent.id }, + h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')), + quoteCard(parent, 0)) : null, h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { @@ -10024,7 +11281,7 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (edited ? ' · ' + t('postEditedTag') : ''))), pending ? null : h('button', { // the overflow stays up here; reply, boost, quote, react and zap @@ -10138,6 +11395,7 @@ export function messagesFeature(ctx) { .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId)); if (c) { threadCache.set(requestedRootId, c); + if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c); if (!prefetch) { c.focusId = seed.id; const wasPrefetched = c.prefetched; @@ -10171,9 +11429,15 @@ export function messagesFeature(ctx) { for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() }); const rootId = stored?.rootId || requestedRootId; c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, prefetched: prefetch, - root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} }; + // a root already in hand (the post a conversation row answers sits in + // the Following feed) paints at once; the relays only fill the replies + root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} }; + // the replies the feed already fetched for the count go in now: the + // thread opens whole for any post that has been on screen + adoptRepliesInHand(c); if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed); c.replies.sort((a, b) => a.created_at - b.created_at); + warmThread([c.root, ...c.replies]); threadCache.set(requestedRootId, c); threadCache.set(rootId, c); if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId; @@ -10189,16 +11453,34 @@ export function messagesFeature(ctx) { return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12); }; let relays = await relaysFor(c.root || seed); - const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll + // Replies go in as they arrive, the page repainted in place (not + // after the slowest relay's EOSE, which used to hold the whole + // thread behind a spinner); the root the moment it is found. + const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId); + let paintT = null; + const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); }; + const got = new Set(c.replies.map((e) => e.id)); + const streamed = new Map(); + const take = (e) => { + if (!e || e.id === c.rootId || got.has(e.id)) return; + got.add(e.id); noteForSpam(e); streamed.set(e.id, e); + // painted whole: its face and pictures first (briefly), then the row + warmThread([e], Date.now() + THREAD_ROW_MS).then(() => { + if (c.replies.some((x) => x.id === e.id)) return; + c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); + paint(); + }); + }; + await Promise.all([ + c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll + if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); } + }), // kind 1 replies, and NIP-22 comments naming it as their root - Promise.all([ - queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []), - queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []), - ]).then(([a, b]) => [...(a || []), ...(b || [])]), + queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}), + queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}), ]); - if (!c.root) c.root = (roots || [])[0] || null; - let all = [...c.replies, ...(replies || [])]; + clearTimeout(paintT); paintT = null; + let all = [...c.replies, ...streamed.values()]; // A client that tags only the note it answered leaves the root // unnamed: what we resolved as the root is itself a reply. Climb — // fetch ITS root and re-root the thread there — a few hops at most. @@ -10251,6 +11533,7 @@ export function messagesFeature(ctx) { } function openNoteThread(ev) { rememberPlace('thread'); + ui.liveStream = null; ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true }; ui.profOverThread = false; // a freshly opened thread goes on top render(); @@ -10562,8 +11845,10 @@ export function messagesFeature(ctx) { } }; walk(c.rootId, 0); - if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); - else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); + // No spinner: the post reads at once and its replies come in whole as + // they arrive (Adam: rather have them pop in than wait to learn there + // are none); "no replies" only once the relays have spoken. + if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox(false)); // A tapped reply deep in a long thread has to be ON SCREEN when the // thread opens — highlighting it somewhere below the fold reads as the @@ -10590,7 +11875,7 @@ export function messagesFeature(ctx) { // (or profile) and its scroll place. A thread opened straight from a // link has nothing of ours under it, so no button that invents one. !ctx.canGoBack || ctx.canGoBack() - ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')) + ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back')) : null, ...noteOverlays()); } @@ -10869,6 +12154,9 @@ export function messagesFeature(ctx) { showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null, // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), + ui.profEdit ? null : liveBanner(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 // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become @@ -10995,7 +12283,7 @@ export function messagesFeature(ctx) { // screen router shows an open thread before the chat, so // a reply half-typed under their post used to win here ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; @@ -11009,7 +12297,7 @@ export function messagesFeature(ctx) { // and the Send tab would never appear ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = false; ctx.showSend({ fresh: true }); @@ -11089,7 +12377,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, - ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))), + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) @@ -11117,11 +12405,13 @@ export function messagesFeature(ctx) { if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await upload(file); + // (a URL, or what a stream's upload says of it: its type, size, length) + const got = await upload(file); + const url = typeof got === 'string' ? got : got.url; // A post canceled while its upload was in flight must stay canceled. // `false` lets that composer reject the late result without affecting // the reply composer, which shares this helper. - if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); + if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } @@ -11234,7 +12524,9 @@ export function messagesFeature(ctx) { type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, - gifs.button('post', 'compose-tool gif-btn'))), + gifs.button('post', 'compose-tool gif-btn'), + canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive }, + h('span', { class: 'go-live-dot' })) : null)), mentionList('.post-input'), ui.postPreview ? draftPreview(text) : null, mediaBar(), @@ -11346,7 +12638,7 @@ export function messagesFeature(ctx) { function toFrontDoor() { rememberReturn(); ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null; render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -11465,7 +12757,10 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + // Notes and Conversations are lists of posts (the feed's own state, or its + // Conversations view); the rest are grids read from the media index. + const listView = () => mediaMode === 'all' || mediaMode === 'replies'; const mediaKey = (it) => it.ev.id + '|' + it.url; // the picture an imeta tag names for a clip (NIP-92 `image`), if any const posterOf = (ev, url) => { @@ -11490,7 +12785,7 @@ export function messagesFeature(ctx) { const key = mediaIdxKey(feedId); let r = mediaIdx.get(key); if (r) return r; - r = { key, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { @@ -11501,17 +12796,13 @@ export function messagesFeature(ctx) { if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until; if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end; } - // the bottom of a feed is looked for again after a day (a relay - // added, a follow back-filled) - // (only an end confirmed by repeated empty answers: one empty - // answer was a timeout as often as the bottom, and held for a day) - if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end; + // (the bottom of a feed isn't kept: see crawlMedia) } // kept frames first, so the first paint of the grid is pictures await Promise.all([warmVideoThumbs([...r.videos.values()]), warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]); r.loaded = true; - if (mediaMode !== 'all') scheduleRepaint(); + if (!listView()) scheduleRepaint(); }); return r; } @@ -11520,7 +12811,7 @@ export function messagesFeature(ctx) { r.saveT = setTimeout(() => { const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at); saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles), - art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end }); + art: { until: r.art.until, end: r.art.end }, until: r.until }); }, 1500); } // file the posts that carry media; each post is looked at once @@ -11529,7 +12820,7 @@ export function messagesFeature(ctx) { for (const e of evs) { if (r.checked.has(e.id)) continue; r.checked.add(e.id); - if (!FEED_KINDS.includes(e.kind) || isReply(e)) continue; + if (r.replies ? !replyish(e) : !FEED_KINDS.includes(e.kind) || isReply(e)) continue; for (const kind of ['images', 'videos']) if (!r[kind].has(e.id) && hasMediaOf(e, kind)) { r[kind].set(e.id, e); n++; } } if (n) saveMediaIdx(r); @@ -11544,6 +12835,10 @@ export function messagesFeature(ctx) { if (!def || !feedHasQuery(def)) return Promise.resolve(); if (def.curated) return loadOlderFeed(); // a curated feed pages its own way const r = mediaIndex(c.id); + // A grid is never at its end (Adam, 2026-10-17: "I should never be all + // caught up"): the bottom it found — the relays answering empty — is + // looked past again a few minutes on. + if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; } if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve(); mediaCrawling = (async () => { scheduleRepaint(); @@ -11560,9 +12855,13 @@ export function messagesFeature(ctx) { noteForSpam(ev); indexMedia(r, [ev]); }; - try { await feedPass({ until, limit: 100 }, { sink }, c); } catch {} + try { await feedPass({ until, limit: 100 }, { sink }, c, FEED_KINDS, FEED_LIMIT, r.deep ? TOPIC_ARCHIVE_RELAYS : null); } catch {} if (c.stopped) break; if (saw) { r.empty = 0; r.until = Math.min(oldest, until); continue; } + // Nothing older on the feed's relays: the big ones keep days, so + // the history goes on on the archive relays, which keep years (the + // grid stopped at five clips, "all caught up") + if (!r.deep) { r.deep = true; pass--; continue; } // an empty answer is the bottom only when it repeats: a relay that // timed out, or a socket the phone froze, answers empty too if (++r.empty >= 3) { r.end = Date.now(); break; } @@ -11682,7 +12981,7 @@ export function messagesFeature(ctx) { if (res) { videoThumbs.set(url, URL.createObjectURL(await res.blob())); found++; } } } catch {} - if (found && mediaMode !== 'all') scheduleRepaint(); + if (found && !listView()) scheduleRepaint(); } function keepVideoThumb(v, url) { if (videoThumbs.has(url)) return Promise.resolve(true); @@ -11794,6 +13093,68 @@ export function messagesFeature(ctx) { pumpFrames(); return task; } + // ---- the pager's first-frame posters ------------------------------------- + // The clip ahead holds its own first frame — full size, frame 0, drawn + // from the opening of the file (the bytes headStart put in the cache, + // read again by range) — so a flick to it glides onto the very picture + // the player will show first, and the player's frame replaces it with no + // visible change. (Adam, 2026-10-16: "could we actually have the next + // image or video scroll in while the previous scrolls out? Right now it + // goes black before the next one scrolls in.") A dozen are kept, the + // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the + // same decode when the tile has none yet. + const PAGER_POSTERS = 12, POSTER_W = 1100; + const pagerPosters = new Map(); // url -> object URL, oldest first + const posterGrabs = new Map(); // url -> promise + function pagerPoster(url) { + if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); } + if (posterGrabs.has(url)) return posterGrabs.get(url); + const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url)); + posterGrabs.set(url, task); + return task; + } + async function grabPoster(url) { + const d = videoInfo.get(url) || {}; + if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null; + // no more of the file than the head start fetched: the bytes come from the cache + const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1; + let r; + try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); } + catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket) + if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; } + const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' }); + const src = URL.createObjectURL(blob); + const v = document.createElement('video'); + v.muted = true; v.playsInline = true; v.preload = 'auto'; + try { + const shown = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(v.readyState >= 2), 6000); + v.onloadeddata = () => { clearTimeout(timer); resolve(true); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = src; + }); + if (!shown || !v.videoWidth) return null; + const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = hh; + cv.getContext('2d').drawImage(v, 0, 0, w, hh); + const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9)); + if (!out) return null; + const p = URL.createObjectURL(out); + pagerPosters.set(url, p); + while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); } + // the tile's frame too, from the same decode, when it has none + if (!videoThumbs.has(url)) { + await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } }); + await keepVideoThumb(v, url).catch(() => {}); + } + return p; + } finally { + v.onloadeddata = v.onseeked = v.onerror = null; + try { v.removeAttribute('src'); v.load(); } catch {} + URL.revokeObjectURL(src); + } + } function pumpFrames() { while (frameGrabbing < 2 && frameQueue.length) { const { url, resolve } = frameQueue.shift(); @@ -11833,11 +13194,42 @@ export function messagesFeature(ctx) { // stand taller than they're wide. A clip of unknown shape is measured // (the header probe, or a player when the index sits at the end of a // file a server serves in ranges) and joins once it's known to qualify. - function verticalClip(url) { + // ---- which clips fit the screen ----------------------------------------- + // The Videos view and the pager show the clips the screen's way round: tall + // ones on a phone held upright, wide ones held sideways (or on a desktop + // window), a square either way — unless the reader asks for all shapes + // (remembered on this device). (Adam, 2026-10-16: "only show vertical + // videos by default on a phone that's in vertical orientation and only + // horizontal videos if the phone is in landscape but have some kind of + // control to toggle the ability to see all videos".) + const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round + const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } }; + const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} }; + const screenTall = () => window.innerHeight >= window.innerWidth; + const clipShape = (url) => { const d = videoInfo.get(url) || {}; const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url); const w = k && (k.w || k.width), hh = k && (k.h || k.height); - if (w && hh) return hh > w; + return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null; + }; + // true/false, or null while the clip's shape isn't known + const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); }; + const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide'); + // the toggle: in the pager's top bar only (over the grid it sat oddly + // apart from the chips); the grid follows the same setting + function clipsToggle(cls, after) { + return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => { + e.stopPropagation(); + setClipsAll(!clipsAll()); + for (const b of document.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + if (after) after(); + if (mediaMode === 'videos') scheduleRepaint(); + } }, clipsLabel()); + } + function fitClip(url) { + const f = clipFits(url); + if (f !== null) return f; + const d = videoInfo.get(url) || {}; if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url); return false; } @@ -11860,7 +13252,7 @@ export function messagesFeature(ctx) { // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); for (const url of urls) { const it = { ev, url }; @@ -11973,12 +13365,22 @@ export function messagesFeature(ctx) { // (what the player's own full screen did). The screen isn't turned for a // wide clip — Adam would rather it play with bars and turn the phone // himself — so it follows the phone throughout. - function openClip(v, url) { + // The pager's item for a clip on the page, if the pager has it: a clip in + // the post a reply answers (its "Replying to" card) isn't one of the + // feed's own, and gets the browser's full screen instead. + function clipItem(v, url) { try { const on = pageRowOf(v); - if (!on) return false; + if (!on) return null; const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); - if (!it) return false; + return it ? { on, it } : null; + } catch { return null; } + } + function openClip(v, url) { + try { + const found = clipItem(v, url); + if (!found) return false; + const { on, it } = found; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); @@ -12013,15 +13415,38 @@ export function messagesFeature(ctx) { if (fsElement() === document.documentElement) exitFs(); } // The words of the post, without the links to the media it carries. - function mediaCaption(ev) { + function mediaCaption(ev, raw = false) { let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || ''); s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m)); for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join(''); - return plainExcerpt(s); + return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s); + } + // The pager's caption: the post's words with their names, tags and links + // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A + // tap on a name, a tag or a note reference leaves the pager, and the full + // screen with it, before going where it goes; a link to the web opens in + // its own tab and the pager stays. A tap anywhere else on the caption + // leaves the pager and opens the post ("clicking anywhere on the caption + // that's not a link should take me out of fullscreen and take me to the + // post"), where the whole of a long caption reads. + function captionNode(ev, text) { + const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev))); + el.addEventListener('click', (e) => { + const a = e.target && e.target.closest ? e.target.closest('a') : null; + if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; } + leavePager(); + if (a) return; // the link's own handler opens the name, the tag, the note + e.stopPropagation(); e.preventDefault(); + openNoteThread(ev); + }, true); + return el; } function setMediaMode(m) { mediaMode = m; try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {} + // Notes ↔ Conversations: another feed state (the view's), entered like + // a switch of feed — its watch, its fresh counter — with no change of chip + if (listView() && feedDef(curFeedId)) selectFeed(curFeedId); render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -12031,7 +13456,7 @@ export function messagesFeature(ctx) { onClick: () => setMediaMode(m), }, label); return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -12275,9 +13700,21 @@ export function messagesFeature(ctx) { // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as // they scroll"). A copy that fails falls back to the original. const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url; + // A clip that will play by itself has no poster: every poster there is + // (the kept frame, a post's imeta picture, the server's) is 480px wide + // and taken at some moment of the clip, so it showed for a beat, soft + // and not quite the first frame, and the clip's own first frame + // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're + // auto playing let's not show the thumbnail let's just start on the + // first frame of the video"). The slide is the pager's black until the + // player, whose opening is in the cache by then (headStart), has its + // first frame. (Giving the next clip's player its file ahead of time + // 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 ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', - poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) }) + 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', onError: (e) => { const m = e.currentTarget; @@ -12288,7 +13725,7 @@ export function messagesFeature(ctx) { 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); + const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, media, @@ -12296,7 +13733,7 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-left' }, h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) }, avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), - caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), + caption ? captionNode(ev, caption) : null), h('div', { class: 'mp-counts' }, // as in the feed: a tap picks how you feel about it (the emoji // sheet, over the pager), a tap again takes it back @@ -12383,7 +13820,13 @@ export function messagesFeature(ctx) { // post), each a row of its pictures or clips — its quotes' too — side by // side (left and right go within it). Its slides, in reading order: const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); - const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // the posts in the carousel: those on offer (a clip's post waits out of it + // until it is ready to play, and a clip the wrong way round for the screen + // is kept out — see pagerReveal, pagerFit), or every one built (all) + const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; + const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); + // the posts found: the ready ones and those still warming, not those kept out for their shape + const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12397,13 +13840,17 @@ export function messagesFeature(ctx) { // along (the next in this post), and the post after next function pagerAhead(slide) { const out = []; - const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + // (a post kept out for its shape is passed over: nothing to ready there) + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; }; const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); const down = g1 && rowSlide(g1); if (down) out.push(down); if (slide.nextElementSibling) out.push(slide.nextElementSibling); const down2 = g2 && rowSlide(g2); if (down2) out.push(down2); + 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) return out; } function pagerAttach(slide) { @@ -12451,6 +13898,8 @@ export function messagesFeature(ctx) { slides.forEach((s, j) => { const k = ahead.indexOf(s); if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); + // the slides around the reader (and the one landed on) hold their clip's first frame + if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { @@ -12469,6 +13918,56 @@ export function messagesFeature(ctx) { }); for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src')); } + // A slide near the reader holds its clip's first frame as its poster, so + // a flick glides onto the very picture the player shows first and the + // player takes over with no visible change — and a slide left behind + // (its player let go) keeps it for the swipe back. Some slides came up + // black until the player had its frame: the frame drawn here needed the + // next clip's cached opening, a file led by its index and a host that + // allows the read, and only the next clip had one at all — not the one + // before, not a flight's landing (Adam, 2026-10-16: "i'm still seeing + // mostly black between some videos and then they suddenly appear when + // it starts playing"). The server's first frame (vmedia first.jpg: frame + // 0 at full size, any host, any layout, cached at the CDN) is the first + // choice, fetched and decoded aside and put on the slide when it is + // ready; the frame drawn here is the fallback for the next clip. A clip + // with neither gets no picture at all — and so stays out of the carousel + // (pagerReveal): the post's own still, put up meanwhile, was often not + // the clip's first frame, and the swap from one to the other jarred + // (Adam, 2026-10-17: "i don't want to show posters and then switch to + // the first frame … could we just always wait until we have a non-black + // frame before including the video in the pager?"). + function slideFrame(s, k) { + const v = s.querySelector('video.mp-media'); + if (!v || v._frame === 'first' || v._frameBusy) return; + const url = v.getAttribute('data-src'); + const put = (p, kind) => { + if (!p || !pager || !s.isConnected || v._frame === 'first') return false; + v.setAttribute('poster', p); v._frame = kind; + pagerReveal(); + return true; + }; + const first = serverFirst(url); + if (first && !serverFirstFailed.has(url)) { + v._frameBusy = true; + const im = new Image(); + im.decoding = 'async'; + const ok = () => { + v._frameBusy = false; + if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame + put(first, 'first'); + }; + im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok()); + im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); }; + im.src = first; + return; + } + if (k === 0 && !v._grabbed) { // the next clip: its opening is in the cache, the frame comes from there + v._grabbed = true; + v._frameBusy = true; + pagerPoster(playSrc(url)).then((p) => { v._frameBusy = false; put(p, 'first'); }); + } + } // The opening of a clip about to be reached, fetched into the browser's // HTTP cache with a range request: the player's own request for the file // is answered from there as far as it goes, and streams on from the host. @@ -12501,14 +14000,19 @@ export function messagesFeature(ctx) { if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size; return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity)); } - const headStarted = new Set(); + const headStarted = new Map(); // url -> its promise + const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read) function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return; - headStarted.add(url); - if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); + if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); + if (headStarted.has(url)) return headStarted.get(url); + if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's + const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + return task; + } const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12520,7 +14024,20 @@ export function messagesFeature(ctx) { } const secs = (performance.now() - t0) / 1000; if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; } - }).catch(() => {}); + }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } + return task; + } + async function hlsHead(url) { + const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; }; + const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; }; + const master = await get(url), text = await master.text(); + // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself + let list = text, at = master.url || url; + if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); } + const seg = uri(list, at); + if (seg) await (await get(seg)).arrayBuffer(); } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); @@ -12535,11 +14052,11 @@ export function messagesFeature(ctx) { // first frame itself (only the start of the file, when it leads with // its index or is served in ranges) — again on every return, since // a slide left behind lets go of its file - slidePoster(slide); + slidePoster(slide, true); if (videoThumbs.has(url)) pagerRelease(slide); else grabFrame(url).then((kept) => { if (!pager || !slide.isConnected) return; - if (kept) { slidePoster(slide); return; } + if (kept) { slidePoster(slide, true); return; } // no frame to keep (the host's bytes can't be read, or the index // trails): the player shows its own first frame under the cover const d = videoInfo.get(url) || {}; @@ -12561,8 +14078,10 @@ export function messagesFeature(ctx) { }); } // the kept frame as the poster, looked up as the slide comes near (it may - // have been kept after the slide was built) - function slidePoster(slide) { + // have been kept after the slide was built) — only where a poster is + // wanted: a careful connection, or a heavy clip under its cover (force) + function slidePoster(slide, force = false) { + if (!force && !frugalData()) return; const v = slide.querySelector('video.mp-media'); const thumb = v && videoThumbs.get(v.getAttribute('data-src')); if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb); @@ -12606,7 +14125,7 @@ export function messagesFeature(ctx) { // still after a fast flick; snapping stays off until it lands. // A longer glide (a flight past several posts) says how long it takes; // onDone hears the landing, or the interruption (true) that caught it. - function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) { pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; box.style.scrollSnapType = 'none'; @@ -12619,7 +14138,7 @@ export function messagesFeature(ctx) { let t0 = 0; const step = (now) => { if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still) - const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3); + const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease); if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e; if (k < 1) box._glide = requestAnimationFrame(step); else done(); }; @@ -12648,12 +14167,9 @@ export function messagesFeature(ctx) { if (v) { const groups = pagerGroups(); const from = here && here.closest('.mp-group'); - const index = groups.indexOf(from) + v; - const to = groups[(index + groups.length) % groups.length]; - if (to) { - if (index < 0 || index >= groups.length) pagerWrap(to); - else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); - } + // the ends are ends: no going round to the other (Adam, 2026-10-18) + const to = groups[groups.indexOf(from) + v]; + if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); return !!to; } if (hz) { @@ -12672,6 +14188,15 @@ export function messagesFeature(ctx) { // it is passing and steps back from there; a touch or a key catches it // too (pagerHold → onDone). A notch from rest is the single step it was. let fly = null; // { to: the post flown to, n: notches in this run, at, dir } + // A pager flies only once ten posts are ready (in the carousel: clips + // ready to play, pictures decoded): before that a fling outran the + // warming, landing on whatever was ready and fighting the posts joining + // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at + // least 10 videos fully warmed up"; the picture pager the same: "image + // pager should work similar"). Until then a flick or a spin of the wheel + // is one post at a time. + const PAGER_FLY_READY = 10; + const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY; function pagerFly(dir, now) { if (!pager) return; const stops = pagerGroups(); @@ -12686,10 +14211,10 @@ export function messagesFeature(ctx) { const here = pagerHere(); from = here && here.closest('.mp-group'); } - const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2); - const i = (next % stops.length + stops.length) % stops.length; - if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; } + // a flight stops at the end it reaches + const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2))); const to = stops[i]; + if (to === from && !live) return; fly = { to, n, at: now, dir }; if (live) pager.scroller._onDone = null; // the flight goes on: not a landing pagerFlyTo(to); @@ -12701,12 +14226,30 @@ export function messagesFeature(ctx) { const dest = pagerAt(pager.scroller, to, true); const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); pager.flying = true; + pager.flyDir = Math.sign(dest - pager.scroller.scrollTop); + const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way + if (land) slideFrame(land, -1); + // A flight eases out on a square, not a cube: on a cube a long one + // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a + // nine-post flight), and the picture coming to rest stepped a pixel + // at a time — the stutter "towards the end of the fly as it's + // snapping" (Adam, 2026-10-17); and it set off at three times its + // average speed, faster than the finger that flung it. pagerGlide(pager.scroller, 'y', dest, vel, { + ease: screens > 1.05 ? 2 : 3, ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, - onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); }, + onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); }, }); } const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); }; + // A finger down on a flight stops it there and then, settling on the post + // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the + // flying … and snap to the video that's most on screen"). + function pagerCatch() { + pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen + const here = pagerHere(), g = here && here.closest('.mp-group'); + if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true)); + } // the sideways arrows show only where the post has a picture that way function pagerArrows(slide) { if (!pager || !slide) return; @@ -12714,35 +14257,44 @@ export function messagesFeature(ctx) { pager.el.classList.toggle('has-right', !!slide.nextElementSibling); } - // Crossing the boundary jumps directly to the other end, without flying - // backwards through every loaded post or waiting for a network request. - function pagerWrap(group) { - if (!pager || !group) return; - pager.scroller._onDone = null; - pagerHold(pager.scroller); - pager.scroller.style.scrollSnapType = ''; - pager.flying = false; - const slide = rowSlide(group); - if (slide) { pagerShow(slide); pagerArrive(slide); } - } + // Counted: the posts in the carousel — the ones that can be reached. (All + // the posts found, the waiting ones too, was tried and undone: a count + // that can't be scrolled to confused — Adam, 2026-10-17.) function pagerPosition() { if (!pager) return; const groups = pagerGroups(); const slide = pager.cur || pager.anchor; - const index = groups.indexOf(slide && slide.closest('.mp-group')); - pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length; + let index = groups.indexOf(slide && slide.closest('.mp-group')); + // in a flight, the post passing under the reader: the count runs with + // the slides rather than jumping on landing (Adam, 2026-10-17: "the + // counter should animate as we fly") + if (pager.flying && groups.length) { + const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2; + let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen + while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; } + index = lo; + } + const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position'); + if (el.textContent !== text) el.textContent = text; + // a video pager shows no count until it can fly: ten clips ready (Adam, + // 2026-10-17: "let's also not show the position/total counter until we have 10") + // (one with fewer posts than that, every one of them ready, counts them) + pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)')); } function buildPager(want) { const video = want.kind === 'videos'; const scroller = h('div', { class: 'mp-scroll' }); + scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true }); const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' }, scroller, h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null), + h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }), + video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -12813,10 +14365,16 @@ export function messagesFeature(ctx) { let touch = null, pendingTap = 0, tapDoneAt = 0; el.addEventListener('touchstart', (e) => { const t = e.touches[0]; + // caught in flight: stopped where it is (a two-finger touch, a touch + // on the seek bar too); a swipe back from there is the post before it + // however hard (Adam, 2026-10-17: "reverse swiping should go to the + // previous video"), one on the way it was flying takes it on + const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0; + if (caught) pagerCatch(); const img = e.target.closest && e.target.closest('img.mp-media'); // not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own) touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none') - ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null; + ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null; }, { passive: true }); el.addEventListener('touchmove', (e) => { const d = touch; @@ -12836,24 +14394,33 @@ export function messagesFeature(ctx) { const delta = d.axis === 'y' ? dy : dx; d.far = Math.max(d.far || 0, Math.hypot(dx, dy)); if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta; - d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp) + // Timed when the finger moved (the event's timeStamp), not when the + // move was handled: right after a refresh, with clips warming, moves + // queue behind other work and arrive in bursts — timed on handling, a + // burst after a stall read as a fling (a short flick flew) and a + // stall at the end as a crawl (a long flick took one step) (Adam, + // 2026-10-17: "sometimes a quick long flick only proceeds to the next + // video … sometimes … a short flick does fly"). The handling time is + // kept for moves that share a timeStamp (synthetic ones can). + d.track.push({ t: e.timeStamp, h: performance.now(), v: delta }); while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift(); }, { passive: false }); // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); - const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + // past either end, it stops at the end: going round to the other end + // was tried and undone (Adam, 2026-10-18: "let's not make the media + // pager circle around to the start anymore") const next = from + step * posts; if (y && posts > 1) { const i = Math.min(stops.length - 1, Math.max(0, next)); if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; } } - // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count) - if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; } pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel); } el.addEventListener('touchend', (e) => { @@ -12865,12 +14432,15 @@ export function messagesFeature(ctx) { const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x; const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth; const a = d.track[0], b = d.track[d.track.length - 1]; - const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0; + const dt = a && b ? (b.t - a.t >= 8 ? b.t - a.t : b.h - a.h) : 0; + const vel = dt > 0 ? (b.v - a.v) / dt : 0; const tap = d.far < TAP_SLOP && Date.now() - d.at < TAP_MS; const go = !tap && (Math.abs(delta) > size * (d.axis === 'y' ? SWIPE_Y : SWIPE_X) || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta))); settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0); if (!tap) return; } + // the tap that caught a flight only stopped it (the buttons still work) + if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; } if (Date.now() - d.at > 500) return; // a press, not a tap clearTimeout(pendingTap); pendingTap = setTimeout(() => { @@ -12929,9 +14499,13 @@ export function messagesFeature(ctx) { // pictures are cheap — a post's all at once, the ones along its row // too (out of sight sideways, they'd load only on arrival); clips // are pagerNeighbours' call - if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) { + pagerAttach(x); + const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank + if (im && im.decode) im.decode().catch(() => {}); + } } - }, { root: scroller, rootMargin: '150% 0px' }); + }, { root: scroller, rootMargin: '250% 0px' }); // a slide has come to rest on screen (or a flight has landed on it) pager.arrive = (slide) => { if (!pager) return; @@ -12954,6 +14528,7 @@ export function messagesFeature(ctx) { playSlide(slide); pagerCounts(slide); pagerLoadAhead(); + pagerWarmPics(); }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { @@ -12972,10 +14547,25 @@ export function messagesFeature(ctx) { pager.anchor = first; // the slide meant to be on screen, from the first moment pager.screenH = scroller.clientHeight; pagerLoadAhead(); - // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) - if (video) { + // the clip on screen's seek bar, every frame (timeupdate comes a few + // times a second). While the pager is in motion — under a finger, in a + // glide or a flight, snapping back — no seek bar shows at all: the + // clip's slide is off its mark, and the bar rode along with it, a + // white line sliding up the screen (Adam, 2026-10-16: "i don't want to + // see the seek bar while i'm transitioning to the next video"). It + // fades back in once the clip is square on the screen. (The motion flag + // is a picture pager's too: its counter shows while it moves.) + { const p = pager; - const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); }; + const frame = () => { + if (pager !== p) return; + const bar = p.cur?.querySelector('.mp-seek'); + if (bar) bar._paint(); + const r = p.cur && p.cur.getBoundingClientRect(); + p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2)); + if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); } + p.raf = requestAnimationFrame(frame); + }; p.raf = requestAnimationFrame(frame); } // turned on its side (or back), the slides change height: stay on this one @@ -12991,6 +14581,8 @@ export function messagesFeature(ctx) { if (!pager) return; pager.screenH = pager.scroller.clientHeight; if (pager.anchor) pagerShow(pager.anchor); + for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + pagerFit(); }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move @@ -13020,7 +14612,7 @@ export function messagesFeature(ctx) { async function pagerLoadAhead() { const p = pager; if (!p) return; - const groups = pagerGroups(); + const groups = pagerGroups(true); const slide = p.cur || p.anchor; p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group'))); if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return; @@ -13029,7 +14621,8 @@ export function messagesFeature(ctx) { try { // Bound each run; the timer resumes if sparse media needs more pages. for (let pages = 0; pages < 8 && pager === p; pages++) { - if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + // (the posts found, waiting ones too: ready ones come only as fast as they warm) + if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; const src = pagerLoadSource(p); if (src.done()) break; const before = src.progress(); @@ -13061,16 +14654,114 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); + // out of the carousel until it's ready: a clip to play, a picture decoded + if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait'); pager.groups.set(gk, g); pager.scroller.append(g); } + if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state g._row.append(s); pagerDots(g); pager.near.observe(s); pager.io.observe(s); } + pagerFit(); + pagerPosition(); + pagerWarmPics(); + } + // A picture post is ready once its picture is decoded: only then does it + // join the carousel, so a flight passes over pictures already in hand. + // They were fetched and decoded as the flight came near them, and the + // flight stuttered — dropped frames with no script running, the decoding + // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen + // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a + // time are decoded ahead of the reader (and a few behind, for the way + // back), out of sight; its size learned, a picture the wrong way round + // for the screen stays out like a clip (pagerFit). One that can't be had + // at all — copy and original both gone — stays out. + const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3; + function pagerWarmPics() { + if (!pager || pager.kind !== 'images') return; + const all = pagerFound(); + const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group'))); + let busy = all.filter((g) => g._warming).length; + const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed; + for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) { + if (!all[j].classList.contains('mp-wait')) { ready++; continue; } + if (want(all[j])) { warmPic(all[j]); busy++; } + } + for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; } + } + function warmPic(g) { + const p = pager; + g._warming = true; + for (const x of g._row.children) pagerAttach(x); + const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media'); + // the copy failing falls back to the original (its onError): decoded again + const decode = (n) => { + const src = m.getAttribute('src'); + return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() => + n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false)); + }; + const timeout = new Promise((r) => setTimeout(() => r(false), 20000)); + Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => { + g._warming = false; + if (pager !== p || !g.isConnected) return; + if (!ok) { g._failed = true; pagerWarmPics(); return; } + const url = m && m.getAttribute('data-full'); + if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape + pagerFit(); + pagerToggleGroup(g, 'mp-wait', false); + pagerWarmPics(); + }); + } + // A post's clip is ready to play when its first frame is on the slide and + // its opening is in the cache (the head start done): only then does the + // post join the carousel, so the reader never lands on a black slide + // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather + // just not have a video in carousel yet if it's not ready to play"). A + // post joining above the one on screen would push it down: the scroll + // moves by the post's height in the same frame, snapping off meanwhile, + // and not while the pager is in motion (then, once it has settled). + function pagerReveal() { + if (!pager || pager.kind !== 'videos') return; + const eager = !frugalData(); + for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) { + const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media'); + if (!v) continue; + const url = v.getAttribute('data-src'); + // the frame is asked of a clip that can have one (the server's: an https + // clip); a local http clip (a dev setup) waits only for its opening + if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue; + pagerToggleGroup(g, 'mp-wait', false); + } + } + // the posts whose clips are the wrong way round for the screen stay out + // (not the one opened on; a shape not yet known is let in) + function pagerFit() { + if (!pager) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + for (const g of pagerGroups(true)) { + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; }); + pagerToggleGroup(g, 'mp-off', !fits); + } + } + function pagerToggleGroup(g, cls, on) { + if (!pager || g.classList.contains(cls) === on) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING); + if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop) + const box = pager.scroller; + const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count) + const h0 = shown ? g.offsetHeight : 0; + box.style.scrollSnapType = 'none'; + g.classList.toggle(cls, on); + if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0; + requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; }); pagerPosition(); + if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now } + const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving')); // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. function pagerGroup() { @@ -13116,8 +14807,8 @@ export function messagesFeature(ctx) { const visitor = isVisitor(); queueMicrotask(() => { syncMediaPager(); - if (mediaMode === 'all') watchSeen(); - if (mediaMode !== 'all') watchMediaTiles(); + if (listView()) watchSeen(); + else watchMediaTiles(); }); const authors = feedAuthors(def); const hasQuery = feedHasQuery(def); @@ -13144,7 +14835,7 @@ export function messagesFeature(ctx) { const visNotes = c.notes, visShown = c.shown; c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE)); const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id) - ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev))); + ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id) : feedRow(c, ev))); // Posts that went in above you while you were reading. A floating pill // that says how many are up there; the tap takes you up to them. // Keyed, so the morph keeps this very node while the count changes — @@ -13190,6 +14881,7 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), mediaModes(), + liveStrip(def), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -13214,7 +14906,7 @@ export function messagesFeature(ctx) { h('div', { class: 'small muted' }, t('feedNoFollows')), h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')), // your own posts, under the invitation - noFollows && visible.length && mediaMode === 'all' ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null) + noFollows && visible.length && listView() ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null) : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) @@ -13223,16 +14915,16 @@ export function messagesFeature(ctx) { : !visible.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty')) : mediaMode === 'articles' ? articleList(c) - : mediaMode !== 'all' ? mediaGrid(c) - : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows), + : !listView() ? mediaGrid(c) + : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows), // The foot of the feed says what is happening down there: older // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling) + c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) - : mediaMode !== 'all' ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) + : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) : c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null, @@ -13293,7 +14985,7 @@ export function messagesFeature(ctx) { save(s); feedsChanged(); if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id); - feedStates.delete(id); // its query changed: rebuild from scratch + dropFeedState(id); // its query changed: rebuild from scratch ui.feedEdit = null; switchFeed(id); // the list side: a feed of people goes out as a follow set (or, unticked, @@ -13334,7 +15026,7 @@ export function messagesFeature(ctx) { save(s); feedsChanged(); if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state - feedStates.delete(id); + dropFeedState(id); try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {} ui.feedEdit = null; if (curFeedId === id) switchFeed(FOLLOWING); else render(); @@ -14787,7 +16479,8 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); - const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next + const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); const choice = routeChoice(peer); @@ -14835,12 +16528,16 @@ export function messagesFeature(ctx) { h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted'))), - // Only where there is a choice: this person can be reached two ways. - // The pill says which way the next message goes; tapping it pins one. - choice ? h('button', { - class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', - onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, - }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null), + h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' }, + // a call: voice or video, to this one person + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')), + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')), + // Only where there is a choice: this person can be reached two ways. + // The pill says which way the next message goes; tapping it pins one. + choice ? h('button', { + class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', + onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, + }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)), h('div', { class: 'chat-log', onScroll: (e) => { @@ -14849,7 +16546,7 @@ export function messagesFeature(ctx) { }, }, ...(msgs.length - ? msgs.map((m) => + ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) : h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-line' }, h('div', { @@ -14884,6 +16581,28 @@ export function messagesFeature(ctx) { let notif = null; // { status, items } const NOTIF_V = 2; let notifAt = 0, notifUnsub = null; + // Every notification event seen, id and time, for reconciliation (NIP-77) + // with the relays that speak it: a refresh then fetches only what's new, + // not the newest hundred and twenty again — a hundred kilobytes a visit. + // All of them, not the rows kept: a zap receipt folded into its twin, or + // a hidden author's reply, would otherwise come back on every refresh. + const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120 + let notifHeld = null; // id -> created_at, oldest first + function notifHeldItems() { + if (!notifHeld) { + notifHeld = new Map(); + try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {} + for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts); + } + return [...notifHeld].map(([id, ts]) => [ts, id]); + } + function noteNotifHeld(evs) { + notifHeldItems(); + let grew = false; + for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; } + while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value); + return grew; + } const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; // Notifications are about the IDENTITY, not the wallet: the stored list @@ -14943,6 +16662,7 @@ export function messagesFeature(ctx) { for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); const s2 = st(); s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V; + if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); } s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); save(s2); } @@ -15055,9 +16775,11 @@ export function messagesFeature(ctx) { notifAt = Date.now(); const g = session; try { - const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000); + const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 }); if (g !== session) return; // asked for the previous account + const grew = noteNotifHeld(evs); if (mergeNotifs(evs)) scheduleRepaint(); + if (grew) persistNotifs(); } catch {} finally { if (g === session) { if (notif) notif.status = 'ready'; @@ -15329,12 +17051,14 @@ export function messagesFeature(ctx) { bottomNav: appBottomNav, notifySettingsCards() { return [notifyCard()]; }, screenView() { + liveShown = false; + queueMicrotask(liveAfterRender); // A profile deep link mid-resolution holds the frame over EVERY screen // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. // Same for a note deep link while its thread is being found. - if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' }, + if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')))); @@ -15350,6 +17074,8 @@ export function messagesFeature(ctx) { // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { + if (ui.zapSetup && ui.liveStream) return zapSetupScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15359,6 +17085,8 @@ export function messagesFeature(ctx) { return null; } if (ui.zapSetup) return zapSetupScreen(); + if (ui.goLive) return goLiveScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15372,7 +17100,7 @@ export function messagesFeature(ctx) { userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query userSearchQuery(q) { - if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } + if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); return true; }, @@ -15481,7 +17209,7 @@ export function messagesFeature(ctx) { visitorSignIn() { toFrontDoor(); return true; }, visitorSearch() { rememberReturn(); - ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false; + ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false; ui.userSearch = { q: '', rows: null }; warmSearch(); render(); @@ -15494,11 +17222,13 @@ export function messagesFeature(ctx) { return true; }, navPath(snap) { + if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; } if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null; const def = feedDef(snap.feedId || curFeedId); return def && adhocFeeds.has(def.id) ? feedPath(def) : null; }, avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); }, + callOverlay() { return callOverlay(); }, init() { const session = ++feedWarmSession; // this wallet's own feed (the feature outlives a sign-out): a feed @@ -15530,8 +17260,15 @@ export function messagesFeature(ctx) { try { navigator.serviceWorker?.addEventListener('message', (ev) => { if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); + // a call the worker was woken for: rung here too (an app in the + // background may have missed it live), or picked up from the tap + if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {}); + if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall(); }); } catch {} + // a tapped "is calling" opened the app, or brought it back: the offer the worker kept + setTimeout(pendingCall, 0); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); }); if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -15588,6 +17325,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; @@ -15609,7 +17347,7 @@ export function messagesFeature(ctx) { 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 - stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear(); + stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear(); session++; zapWho.clear(); whoOpenIds.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; diff --git a/src/feed-cache.js b/src/feed-cache.js index 2c0b153..a4f3ac9 100644 --- a/src/feed-cache.js +++ b/src/feed-cache.js @@ -6,7 +6,7 @@ export const FEED_SEED_POSTS = 5; const FULL_FEEDS = 4, MAX_FEEDS = 12, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000; export function createFeedCache(storage, prefix, now = Date.now) { const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id; - const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string' + const valid = (e) => (e?.kind === 1 || e?.kind === 1068 || e?.kind === 1111) && typeof e.id === 'string' && typeof e.pubkey === 'string' && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags); function compact(notes, max = FEED_CACHE_POSTS) { const kept = []; let size = 0; diff --git a/src/hls-player.js b/src/hls-player.js new file mode 100644 index 0000000..d0878e2 --- /dev/null +++ b/src/hls-player.js @@ -0,0 +1,17 @@ +// HLS where the browser has none of its own (Firefox): hls.js feeds the +// <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) { + const { default: Hls } = await import('hls.js'); + if (!Hls.isSupported()) return null; + const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 }); + hls.loadSource(src); + hls.attachMedia(video); + hls.on(Hls.Events.ERROR, (_, d) => { + if (!d.fatal) return; + if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; } + video.dispatchEvent(new Event('hlsfatal')); + }); + return hls; +} diff --git a/src/i18n.js b/src/i18n.js index cdbcfeb..b7432b0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -270,6 +270,10 @@ const STR = { close: 'Close', playVideo: 'Play', videoUnmute: 'Unmute', + clipsTall: 'Tall only', + clipsWide: 'Wide only', + clipsAll: 'All shapes', + clipsToggleTitle: 'Which clips to show: the ones that fit the screen, or all', videoTapToPlay: "Tap to play", pagerFetchingVideo: "Fetching the next video…", pagerFetchingPictures: "Fetching more pictures…", @@ -303,6 +307,7 @@ const STR = { currencyLabel: 'Show amounts in', feedTitle: 'Feed', feedFollowingName: 'Following', + noteReplyingTo: 'Replying to', feedNew: 'New feed', feedNewName: 'New feed', feedEdit: 'Edit feed', @@ -339,7 +344,8 @@ const STR = { askSignInCreate: 'Create a new account', askNotNow: 'Not now', feedLangLabel: 'Language', - mediaAll: "All", + mediaAll: "Notes", + mediaConversations: "Conversations", mediaImages: "Images", mediaVideos: "Videos", mediaArticles: "Articles", @@ -447,6 +453,85 @@ const STR = { feedEmpty: 'Nothing posted yet.', feedLoadingMore: 'Loading more posts…', feedEnd: 'You’re all caught up.', + liveBadge: 'LIVE', + livePlanned: 'SOON', + liveEnded: 'ENDED', + liveUntitled: 'Live stream', + liveWatching: '{n} watching', + liveWatch: 'Watch', + liveZap: 'Zap', + liveChat: 'Chat', + liveChatEmpty: 'No messages yet.', + liveChatHint: 'Say something', + liveSend: 'Send', + liveZapped: 'zapped {n} sats', + liveSomeone: 'Someone', + liveOffline: 'The stream isn’t playing right now.', + liveNoPlayer: 'This browser can’t play the stream.', + liveOver: 'This stream has ended.', + liveStartsAt: 'Starts {when}', + liveNotFound: 'Couldn’t find that stream.', + goLive: 'Go live', + goLiveCamera: 'Camera', + goLiveScreen: 'Screen', + goLiveFlip: 'Flip', + goLiveTitlePh: 'What’s your stream about?', + goLiveDefaultTitle: '{name} is live', + goLiveStart: 'Go live', + goLiveStarting: 'Starting…', + goLiveAsking: 'Asking for the camera…', + goLiveNoCamera: 'The camera isn’t available. Check that the browser may use it.', + goLiveNoScreen: 'Screen sharing was cancelled or isn’t available.', + goLiveNeedsName: 'Going live needs a coinos name: claim one in Settings first.', + goLiveRefused: 'The live server didn’t take the stream.', + goLiveHint: 'Your stream is public: anyone can watch it and its recording, kept for a week. Keep this page open while you’re live.', + goLiveEnd: 'End', + goLiveEndAsk: 'End your stream?', + goLiveEnded: 'Your stream has ended.', + goLiveReconnecting: 'Reconnecting…', + goLiveBehind: 'Your connection is falling behind', + goLiveLost: 'Lost the connection to the live server.', + goLiveRestarted: 'The stream had to start over.', + callVoice: 'Voice call', + callVideo: 'Video call', + callIncomingVoice: 'Voice call', + callIncomingVideo: 'Video call', + callIncomingFrom: '{name} is calling', + callCalling: 'Calling…', + callConnecting: 'Connecting…', + callAccept: 'Answer', + callDecline: 'Decline', + callHangUp: 'Hang up', + callMute: 'Mute', + callUnmute: 'Unmute', + callCamOff: 'Turn camera off', + callCamOn: 'Turn camera on', + callFlip: 'Switch camera', + callMinimize: 'Minimize', + callEnded: 'Call ended', + callNoAnswer: 'No answer', + callDeclined: 'Declined', + callBusy: 'They’re on another call', + callFailed: 'Couldn’t connect', + callNoPermission: 'The microphone or camera isn’t allowed', + callMissed: 'Missed call', + callAnsweredElsewhere: 'Answered on another device', + callDeclinedElsewhere: 'Declined on another device', + callUnsupported: 'This browser can’t make calls.', + notifCall: 'Calls', + callLogVoice: 'voice call', + callLogVideo: 'video call', + callLogOut: 'Outgoing {what}', + callLogIn: 'Incoming {what}', + callLogMissed: 'Missed {what}', + callLogDeclined: 'Declined {what}', + callLogTheyDeclined: 'Outgoing {what} · Declined', + callLogNoAnswer: 'Outgoing {what} · No answer', + callLogCancelled: 'Cancelled {what}', + callLogBusy: 'Outgoing {what} · Busy', + callLogFailed: '{what} · Couldn’t connect', + callLogUnrung: 'This didn’t ring: calls ring from people you follow or have written to.', + callLogUnrungWas: 'This didn’t ring: you didn’t follow them then.', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', @@ -786,6 +871,12 @@ const STR = { msgBadInvite: 'Not a valid invite link', msgInviteLoading: 'Fetching invite…', msgInviteNotFound: 'Invite not found on its relays', + mediaServersTitle: 'Media servers', + mediaServersHow: 'Blossom servers for chat attachments and post media, including videos. Enter up to 6 HTTPS server addresses, one per line. Custom servers replace the public upload defaults. Saved on this device; existing uploads stay where they are.', + mediaServersDefault: 'Using public media servers', + mediaServersSaved: 'Media servers saved', + mediaServersInvalid: 'Enter 1–6 HTTPS server addresses without paths, credentials, queries or fragments.', + mediaServersSaveFailed: 'Could not save media servers on this device.', syncStorageTitle: 'Sync storage', syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works. Run your own or use another provider.', syncStorageSave: 'Save servers', diff --git a/src/lang-guess.js b/src/lang-guess.js index 6d62e30..6911f73 100644 --- a/src/lang-guess.js +++ b/src/lang-guess.js @@ -26,7 +26,7 @@ const STOP = { en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '), es: 'el la de que y en los es un una por con para las del se no como más pero'.split(' '), fr: 'le la les de des et est un une pour que qui dans pas sur avec ce il nous vous'.split(' '), - de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir'.split(' '), + de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir du wie man noch schon aber oder wenn hast habe bin bist kann nur mal jetzt hier sind wird'.split(' '), pt: 'o a de que e do da em um uma para com não os as se mais por isso você'.split(' '), id: 'yang dan di ini itu dengan untuk tidak dari ada saya akan ke juga bisa kita sudah atau kalau aku'.split(' '), tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '), @@ -37,6 +37,13 @@ const STOP = { pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '), }; const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)])); +// Letters only some languages write: one counts as one more common word. +// A short line has few of those ("Weißt Du wie man die kriegt?" had only +// "die" before the list grew, and went untranslated as maybe-English). +const MARKS = { + de: /[ßäöü]/i, es: /[ñ¿¡]/i, pt: /[ãõ]/i, fr: /[œèêëîïû]/i, tr: /[ğşı]/i, pl: /[łąęśźżń]/i, + vi: /[ạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹđ]/i, +}; // The text a reader actually reads: no links, no nostr references, no tags. export const proseOf = (text) => String(text || '') @@ -67,6 +74,7 @@ export function guessLang(text) { if (words.length < 4) return null; const score = {}; for (const w of words) for (const [l, set] of Object.entries(STOPSETS)) if (set.has(w)) score[l] = (score[l] || 0) + 1; + for (const [l, re] of Object.entries(MARKS)) if (re.test(prose)) score[l] = (score[l] || 0) + 1; const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]); if (!ranked.length || ranked[0][1] < 2) return null; if (ranked[1] && ranked[0][1] < ranked[1][1] * 1.5) return null; diff --git a/src/live.js b/src/live.js new file mode 100644 index 0000000..da18f19 --- /dev/null +++ b/src/live.js @@ -0,0 +1,82 @@ +// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover, +// where to watch (`streaming`, almost always an HLS playlist), whether it's +// on (`status`), how many watch, and who's in it (p tags with a role). Its +// chat is kind 1311, addressed to the stream with an `a` tag; zaps carry +// that `a` too, which is how zap.stream and the rest count them. +export const LIVE_KIND = 30311; +export const LIVE_CHAT_KIND = 1311; +// Where streams are announced: zap.stream, Shosho and the rest publish to +// the big public relays (a 2026-10-18 survey found every live one on damus, +// nos.lol and ditto, most on primal; relay.zap.stream held none). +export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub']; +// A stream still marked live that hasn't been refreshed in an hour is over: +// plenty of publishers never say `ended` (NIP-53 allows reading it so). +export const LIVE_STALE_S = 3600; + +// https, or a stream on this machine (a test's, a streamer's own preview) +const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/; +const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || ''; +export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd'); +export const liveTitle = (ev) => tag(ev, 'title').trim(); +export const liveSummary = (ev) => tag(ev, 'summary').trim(); +export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; }; +export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; }; +export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at; + +// The person streaming: the host named in its p tags (zap.stream signs the +// event with its own key and names the streamer as Host), else its author. +export function liveHost(ev) { + const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || '')); + return p ? p[1] : ev.pubkey; +} +// Where to watch: an HLS playlist first (what browsers can play), else any https address. +export function liveStream(ev) { + const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]); + return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || ''; +} +export function liveRecording(ev) { + const u = tag(ev, 'recording'); + return WATCHABLE.test(u) ? u : ''; +} +export function liveStatus(ev, now = Date.now() / 1000) { + const s = tag(ev, 'status').toLowerCase(); + if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live'; + return s === 'planned' ? 'planned' : 'ended'; +} +export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev); +// The relays the stream names for its chat, then the ones it's announced on. +export function liveRelays(ev) { + const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r)); + return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])]; +} +// Newest copy of each stream (it's replaceable: the address is the stream). +export function newestByAddr(evs) { + const out = new Map(); + for (const ev of evs) { + if (!ev || ev.kind !== LIVE_KIND) continue; + const a = liveAddr(ev), cur = out.get(a); + if (!cur || cur.created_at < ev.created_at) out.set(a, ev); + } + return out; +} + +// The zap on a chat line: a 9735 receipt's amount from its bolt11, its +// sender and words from the zap request inside it; coinos's own Ark receipt +// (9737) says them in tags. `decode` is the bolt11 decoder (millisats). +export function zapLine(ev, decode) { + if (ev.kind === 9737) { + return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' }; + } + let req = null; + try { req = JSON.parse(tag(ev, 'description')); } catch {} + let sats = 0; + try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {} + if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000); + return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' }; +} + +// Zaps aimed at a stream carry its address: the zap request (kind 9734) and +// coinos's Ark receipt look the event they're zapping up here. +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; diff --git a/src/lnurl.js b/src/lnurl.js index bfebf0d..7b1a798 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -10,6 +10,7 @@ // (bolt11) → hand that to the swap engine. The recipient's server is the one // that broadcasts the receipt; we only sign the request. +import { zapAddrOf } from './live.js'; import { bech32 } from '@scure/base'; import { secp256k1 } from '@noble/curves/secp256k1'; import { hmac } from '@noble/hashes/hmac'; @@ -126,6 +127,9 @@ export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, 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 }); } diff --git a/src/media-servers.js b/src/media-servers.js new file mode 100644 index 0000000..e2220d7 --- /dev/null +++ b/src/media-servers.js @@ -0,0 +1,27 @@ +// Upload destinations are a device preference, separate from wallet sync storage. +const KEY = 'btc-wallet-media-servers'; +export function parseMediaServers(text) { + const entries = String(text).trim().split(/\s+/).filter(Boolean); + if (!entries.length) throw new Error('invalid'); + const servers = [...new Set(entries.map((entry) => { + let url; + try { url = new URL(entry); } catch { throw new Error('invalid'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) throw new Error('invalid'); + return url.origin; + }))]; + if (servers.length > 6) throw new Error('invalid'); + return servers; +} +export function customMediaServers() { + try { + const saved = JSON.parse(localStorage.getItem(KEY)); + return Array.isArray(saved) ? parseMediaServers(saved.join('\n')) : null; + } catch { return null; } +} +export const mediaServers = (defaults) => customMediaServers() || defaults; +export function saveMediaServers(text) { + const servers = parseMediaServers(text); + localStorage.setItem(KEY, JSON.stringify(servers)); + return servers; +} +export function resetMediaServers() { localStorage.removeItem(KEY); } diff --git a/src/media-upload.js b/src/media-upload.js index c06771b..d7a99e1 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -1,3 +1,4 @@ +import { mediaServers } from './media-servers.js'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; @@ -65,7 +66,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. export async function uploadPublicMedia(file, signEvent, { - servers = PUBLIC_MEDIA_SERVERS, + servers = mediaServers(PUBLIC_MEDIA_SERVERS), fetcher = globalThis.fetch, timeoutMs = 5 * 60_000, onProgress = null, // (fraction 0..1) as the bytes go out @@ -115,3 +116,49 @@ export async function uploadPublicMedia(file, signEvent, { if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); throw new MediaUploadError('failed', details); } + +// An HLS stream (streamVideo) onto one host: the media files first, then +// each playlist with its files' addresses written in, then the master +// playlist naming those — its address is the post's. One host for the lot, +// and one that serves a playlist as one (ditto and nostr.download do; +// primal's says text/plain, nostr.build won't take it); a host that refuses +// any piece is passed over for the next. `files` maps each name the +// playlists use to its File. +export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net']; +export function rewritePlaylist(text, urls) { + const to = (p) => urls.get(p) || p; + return text.split('\n').map((line) => { + const l = line.trim(); + if (l && !l.startsWith('#')) return to(l); + return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`); + }).join('\n'); +} +export async function uploadStream({ files, master }, signEvent, { servers = mediaServers(STREAM_SERVERS), fetcher, onProgress = null } = {}) { + const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p)); + const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master); + const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1; + let lastError = null; + for (const server of servers) { + let done = 0; + const put = async (file) => { + const url = await uploadPublicMedia(file, signEvent, { + servers: [server], ...(fetcher ? { fetcher } : {}), + onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))), + }); + done += file.size; + if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in) + return url; + }; + try { + const urls = new Map(); + for (const p of media) urls.set(p, await put(files.get(p))); + for (const p of lists) { + const text = rewritePlaylist(await files.get(p).text(), urls); + urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' }))); + } + const text = rewritePlaylist(await files.get(master).text(), urls); + return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' })); + } catch (error) { lastError = error; } + } + throw lastError || new MediaUploadError('failed'); +} diff --git a/src/negentropy.js b/src/negentropy.js new file mode 100644 index 0000000..83f65ae --- /dev/null +++ b/src/negentropy.js @@ -0,0 +1,197 @@ +// Negentropy (NIP-77): set reconciliation with a relay. The client holds a +// set of (created_at, id) pairs — the events it already has for a filter — +// and the relay holds its own. Instead of the relay re-sending every event +// the filter matches (a DM catch-up re-served 200 gift wraps, half a +// megabyte, on every subscribe; a notification refresh a hundred kilobytes), +// the two sides exchange fingerprints of ranges of their sets, split the +// ranges that differ, and end with the ids each side lacks — a few hundred +// bytes, usually in one round. The events themselves are then fetched by id. +// +// Protocol version 1 (0x61), as strfry speaks it. A port of Doug Hoyte's +// reference client (github.com/hoytech/negentropy, MIT, © 2023), trimmed to +// the initiator's side plus what a test needs to play the relay. +// Hashing is WebCrypto SHA-256, so everything here is async. + +const PROTOCOL_VERSION = 0x61; +const ID_SIZE = 32; +const FINGERPRINT_SIZE = 16; +const MODE = { skip: 0, fingerprint: 1, idList: 2 }; +const MAX_TS = Number.MAX_SAFE_INTEGER; // "infinity": the bound past every item + +// ---- bytes ---------------------------------------------------------------- +const hexOf = (a) => { let s = ''; for (let i = 0; i < a.length; i++) s += (a[i] < 16 ? '0' : '') + a[i].toString(16); return s; }; +const bytesOf = (h) => { if (h instanceof Uint8Array) return h; if (h.length % 2) throw new Error('odd hex'); const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; }; +const cmpBytes = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; }; +const cmpItem = (a, b) => (a.ts === b.ts ? cmpBytes(a.id, b.id) : a.ts < b.ts ? -1 : 1); + +class Out { + constructor() { this.parts = []; this.length = 0; } + push(a) { if (Array.isArray(a)) a = Uint8Array.from(a); this.parts.push(a); this.length += a.length; } + bytes() { const o = new Uint8Array(this.length); let p = 0; for (const a of this.parts) { o.set(a, p); p += a.length; } return o; } +} +class In { + constructor(a) { this.a = a; this.p = 0; } + get left() { return this.a.length - this.p; } + byte() { if (this.p >= this.a.length) throw new Error('negentropy: message ends early'); return this.a[this.p++]; } + take(n) { if (this.p + n > this.a.length) throw new Error('negentropy: message ends early'); const s = this.a.subarray(this.p, this.p + n); this.p += n; return s; } +} +// varints, big-endian 7-bit groups with a continuation bit (the protocol's) +const putVar = (o, n) => { if (n === 0) return o.push([0]); const g = []; while (n > 0) { g.push(n % 128); n = Math.floor(n / 128); } g.reverse(); for (let i = 0; i < g.length - 1; i++) g[i] |= 128; o.push(g); }; +const getVar = (i) => { let n = 0; for (;;) { const b = i.byte(); n = n * 128 + (b & 127); if (!(b & 128)) return n; } }; + +// ---- the fingerprint of a range: the ids summed mod 2^256, hashed with the count +const sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +function addInto(acc, id) { + let carry = 0; + for (let i = 0; i < ID_SIZE; i++) { const s = acc[i] + id[i] + carry; acc[i] = s & 255; carry = s >> 8; } +} +async function fingerprint(items, begin, end) { + const acc = new Uint8Array(ID_SIZE); + for (let i = begin; i < end; i++) addInto(acc, items[i].id); + const o = new Out(); o.push(acc); putVar(o, end - begin); + return (await sha256(o.bytes())).subarray(0, FINGERPRINT_SIZE); +} + +// ---- the set ---------------------------------------------------------------- +// `items`: [[created_at, id hex], ...] in any order; duplicates are dropped. +export class NegSet { + constructor(items = []) { + const seen = new Set(); + this.items = []; + for (const [ts, id] of items) { + if (typeof id !== 'string' || id.length !== 64 || seen.has(id)) continue; + seen.add(id); + this.items.push({ ts: Math.max(0, Math.floor(ts || 0)), id: bytesOf(id) }); + } + this.items.sort(cmpItem); + } + get size() { return this.items.length; } + // the first index in [begin, end) at or past the bound + lowerBound(begin, end, bound) { + let lo = begin, hi = end; + while (lo < hi) { const m = (lo + hi) >> 1; if (cmpItem(this.items[m], bound) < 0) lo = m + 1; else hi = m; } + return lo; + } +} + +// ---- one side of a reconciliation ------------------------------------------ +export class Negentropy { + constructor(set, { frameSizeLimit = 0 } = {}) { + if (frameSizeLimit && frameSizeLimit < 4096) throw new Error('negentropy: frame size limit too small'); + this.set = set; + this.frameSizeLimit = frameSizeLimit; + this.initiator = false; + this.lastTsIn = 0; this.lastTsOut = 0; + } + // the client's opening message (hex) + async initiate() { + this.initiator = true; + const o = new Out(); o.push([PROTOCOL_VERSION]); + await this.splitRange(0, this.set.size, { ts: MAX_TS, id: new Uint8Array(0) }, o); + return hexOf(o.bytes()); + } + // One message in, one out. For the initiator the answer is null once the + // sets are reconciled; `have` are ids we hold that the other side lacks, + // `need` the ones it holds that we lack. The non-initiator (a relay, or + // a test playing one) always answers and collects nothing. + async reconcile(hex) { + const q = new In(bytesOf(hex)); + const have = [], need = []; + this.lastTsIn = this.lastTsOut = 0; + const full = new Out(); full.push([PROTOCOL_VERSION]); + const version = q.byte(); + if (version < 0x60 || version > 0x6f) throw new Error('negentropy: not a negentropy message'); + if (version !== PROTOCOL_VERSION) { + if (this.initiator) throw new Error('negentropy: unsupported protocol version ' + (version - 0x60)); + return { next: hexOf(full.bytes()), have, need }; + } + const items = this.set.items, size = this.set.size; + let prevBound = { ts: 0, id: new Uint8Array(0) }, prevIndex = 0, skip = false; + while (q.left) { + const o = new Out(); + const doSkip = () => { if (skip) { skip = false; this.putBound(o, prevBound); putVar(o, MODE.skip); } }; + const bound = this.getBound(q); + const mode = getVar(q); + const lower = prevIndex; + let upper = this.set.lowerBound(prevIndex, size, bound); + if (mode === MODE.skip) skip = true; + else if (mode === MODE.fingerprint) { + const theirs = q.take(FINGERPRINT_SIZE); + const ours = await fingerprint(items, lower, upper); + if (cmpBytes(theirs, ours) !== 0) { doSkip(); await this.splitRange(lower, upper, bound, o); } + else skip = true; + } else if (mode === MODE.idList) { + const n = getVar(q); + const theirs = new Map(); + for (let i = 0; i < n; i++) { const id = q.take(ID_SIZE); if (this.initiator) theirs.set(hexOf(id), id); } + if (this.initiator) { + skip = true; + for (let i = lower; i < upper; i++) { + const k = hexOf(items[i].id); + if (theirs.has(k)) theirs.delete(k); else have.push(k); + } + for (const k of theirs.keys()) need.push(k); + } else { + doSkip(); + const ids = new Out(); let count = 0, endBound = bound; + for (let i = lower; i < upper; i++) { + if (this.overFrame(full.length + ids.length)) { endBound = items[i]; upper = i; break; } + ids.push(items[i].id); count++; + } + this.putBound(o, endBound); putVar(o, MODE.idList); putVar(o, count); o.push(ids.bytes()); + full.push(o.bytes()); o.parts = []; o.length = 0; + } + } else throw new Error('negentropy: unexpected mode ' + mode); + if (this.overFrame(full.length + o.length)) { + // the frame is full: one fingerprint for everything left + const rest = await fingerprint(items, upper, size); + this.putBound(full, { ts: MAX_TS, id: new Uint8Array(0) }); putVar(full, MODE.fingerprint); full.push(rest); + break; + } + full.push(o.bytes()); + prevIndex = upper; prevBound = bound; + } + return { next: full.length === 1 && this.initiator ? null : hexOf(full.bytes()), have, need }; + } + // a range that differs: sixteen fingerprinted buckets, or the ids outright when small + async splitRange(lower, upper, upperBound, o) { + const items = this.set.items, n = upper - lower, buckets = 16; + if (n < buckets * 2) { + this.putBound(o, upperBound); putVar(o, MODE.idList); putVar(o, n); + for (let i = lower; i < upper; i++) o.push(items[i].id); + return; + } + const per = Math.floor(n / buckets), extra = n % buckets; + let cur = lower; + for (let b = 0; b < buckets; b++) { + const size = per + (b < extra ? 1 : 0); + const fp = await fingerprint(items, cur, cur + size); + cur += size; + const next = cur === upper ? upperBound : minimalBound(items[cur - 1], items[cur]); + this.putBound(o, next); putVar(o, MODE.fingerprint); o.push(fp); + } + } + overFrame(n) { return !!this.frameSizeLimit && n > this.frameSizeLimit - 200; } + // bounds: a timestamp as a delta from the last one (+1; 0 = infinity), then a prefix of an id + getBound(q) { + let ts = getVar(q); + ts = ts === 0 ? MAX_TS : ts - 1; + if (this.lastTsIn === MAX_TS || ts === MAX_TS) { this.lastTsIn = MAX_TS; ts = MAX_TS; } + else { ts += this.lastTsIn; this.lastTsIn = ts; } + const len = getVar(q); + if (len > ID_SIZE) throw new Error('negentropy: bound key too long'); + return { ts, id: q.take(len) }; + } + putBound(o, b) { + if (b.ts === MAX_TS) { this.lastTsOut = MAX_TS; putVar(o, 0); } + else { putVar(o, b.ts - this.lastTsOut + 1); this.lastTsOut = b.ts; } + putVar(o, b.id.length); o.push(b.id); + } +} +// the shortest bound that separates two neighbouring items +function minimalBound(prev, cur) { + if (cur.ts !== prev.ts) return { ts: cur.ts, id: new Uint8Array(0) }; + let shared = 0; + while (shared < ID_SIZE && cur.id[shared] === prev.id[shared]) shared++; + return { ts: cur.ts, id: cur.id.subarray(0, shared + 1) }; +} diff --git a/src/nostr.js b/src/nostr.js index b76e865..8dcab01 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -18,6 +18,7 @@ import { normalizeURL } from 'nostr-tools/utils'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { dlog } from './debug.js'; +import { NegSet, Negentropy } from './negentropy.js'; import { randomBytes, bytesToHex } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; @@ -365,6 +366,99 @@ export async function queryOn(relays, filter, maxWait = 1500) { } // Several filters in ONE REQ per relay (NIP-01 allows a list) — one // subscription against the relay's REQ budget instead of one per filter. +// ---- negentropy (NIP-77): fetch only what we lack --------------------------- +// A relay that speaks it (strfry with it enabled: relay.coinos.io does; +// nos.lol, damus and primal answer "negentropy disabled") compares the ids +// it holds for a filter with the ids we hold and tells us the difference — +// a few hundred bytes — instead of re-sending the newest N events every +// time we ask. The events we lack are then fetched by id. A relay that +// doesn't speak it is remembered for a day, so the plain query goes +// straight out there next time rather than after a wasted socket. +const NEG_OFF_KEY = 'coinos-neg-off'; +const NEG_OFF_MS = 24 * 60 * 60_000; +const negOff = new Map(); // normalized url -> until +try { + for (const [u, until] of Object.entries(JSON.parse(localStorage.getItem(NEG_OFF_KEY) || '{}'))) if (until > Date.now()) negOff.set(u, until); +} catch {} +function negRefused(url) { + negOff.set(relayKey(url), Date.now() + NEG_OFF_MS); + try { localStorage.setItem(NEG_OFF_KEY, JSON.stringify(Object.fromEntries([...negOff].slice(-100)))); } catch {} +} +export const negentropyOff = (url) => (negOff.get(relayKey(url)) || 0) > Date.now(); +// Reconcile `items` ([[created_at, id], ...]: the events we hold) with one +// relay under `filter`. Resolves { need, have } — the ids it holds that we +// don't, and ours it lacks — or null when it can't be done there: the relay +// doesn't speak it (a NOTICE, a CLOSED, a socket shut on the opening +// message, or silence — remembered for a day), refuses this query (NEG-ERR +// blocked/closed — not remembered), is sick, or we're offline. Its own +// socket: the pool's parser has no ear for NEG-* frames. +export async function reconcileOn(url, filter, items, { timeout = 8000 } = {}) { + const n = relayKey(url); + if (sick.has(n) || negentropyOff(url) || offline()) return null; + let ws; + try { ws = new WebSocket(url); } catch { return null; } + const neg = new Negentropy(new NegSet(items)); + const id = 'neg' + Math.random().toString(36).slice(2, 10); + return new Promise((resolve) => { + let done = false, heard = false; + const need = [], have = []; + const finish = (r, refused) => { + if (done) return; + done = true; clearTimeout(t); + if (refused) negRefused(url); + try { if (ws.readyState === 1) ws.send(JSON.stringify(['NEG-CLOSE', id])); } catch {} + try { ws.close(); } catch {} + resolve(r); + }; + const t = setTimeout(() => finish(null, !heard), timeout); + ws.onerror = () => finish(null, false); + ws.onclose = () => finish(null, !heard && !offline() && !hiddenTab()); + ws.onopen = async () => { + try { ws.send(JSON.stringify(['NEG-OPEN', id, filter, await neg.initiate()])); } catch { finish(null, false); } + }; + ws.onmessage = async (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (!Array.isArray(m)) return; + if (m[0] === 'NEG-MSG' && m[1] === id) { + heard = true; + try { + const r = await neg.reconcile(m[2]); + need.push(...r.need); have.push(...r.have); + if (r.next) ws.send(JSON.stringify(['NEG-MSG', id, r.next])); + else finish({ need, have }, false); + } catch (err) { dlog('nostr: negentropy with ' + url + ' failed: ' + (err?.message || err)); finish(null, false); } + } else if (m[0] === 'NEG-ERR' && m[1] === id) { dlog('nostr: negentropy refused by ' + url + ': ' + m[2]); finish(null, false); } + else if (m[0] === 'NOTICE' && /negentropy|NEG-OPEN|bad msg/i.test(String(m[1] || ''))) finish(null, true); + else if (m[0] === 'CLOSED' && m[1] === id) finish(null, true); + }; + }); +} +// What `filter` matches on these relays, each event once, verified: by +// reconciliation where a relay speaks it (only the events we don't hold +// come, by id) and by the plain query where it doesn't. `items` are the +// events held, [[created_at, id], ...]. +export async function syncOn(relays, filter, items, { maxWait = 5000 } = {}) { + probeRelays(relays); + const live = readRelays(relays); + if (!live.length) return []; + const plain = [], byRelay = new Map(), seen = new Set(); + await Promise.all(live.map(async (url) => { + const r = await reconcileOn(url, filter, items).catch(() => null); + if (!r) { plain.push(url); return; } + for (const id of r.need) { + if (seen.has(id)) continue; // one copy is enough: the first relay that has it + seen.add(id); + if (!byRelay.has(url)) byRelay.set(url, []); + byRelay.get(url).push(id); + } + })); + const jobs = []; + if (plain.length) jobs.push(queryOn(plain, filter, maxWait)); + for (const [url, ids] of byRelay) for (let i = 0; i < Math.min(ids.length, 1000); i += 200) jobs.push(queryOn([url], { ids: ids.slice(i, i + 200) }, maxWait)); + const out = new Map(); + for (const evs of await Promise.all(jobs)) for (const e of evs || []) if (e && e.id && !out.has(e.id)) out.set(e.id, e); + return [...out.values()]; +} export async function queryManyOn(relays, filters, maxWait = 1500) { probeRelays(relays); try { diff --git a/src/style.css b/src/style.css index 4a9b191..235cb4b 100644 --- a/src/style.css +++ b/src/style.css @@ -30,6 +30,12 @@ --accent-fg: #ffffff; /* text on accent */ --accent-ink: #000000; --accent-soft: #ececef; + /* a button's fill (primary, chosen chip or tab): the accent in light; in + dark a lifted grey, not a white slab (Adam, 2026-10-18: "Probably + shouldn't have white buttons in dark mode") */ + --fill: var(--accent); + --fill-hover: var(--accent-hover); + --fill-fg: var(--accent-fg); --green: #14935a; --green-soft: #e7f6ee; --green-line: #c4e7d4; @@ -65,6 +71,9 @@ --accent-fg: #0f1216; /* text on accent */ --accent-ink: #e7e9ee; --accent-soft: #262b33; + --fill: #353b45; + --fill-hover: #404753; + --fill-fg: #f3f5f8; --green: #2dbd80; --green-soft: #14271d; --green-line: #235340; @@ -92,6 +101,12 @@ html { one last time" after a switch. Stable gutter = no reflow when content grows. */ scrollbar-gutter: stable; + /* Nothing ever scrolls the page sideways. Anything wider than the screen + (a strip of chips, a stray wide row) would otherwise let an iPhone + pinch the page out and pan to it. (On the root element, not body: a + body that clips becomes the scroll container a sticky rail measures + against, and the rail would stop sticking.) */ + overflow-x: hidden; } html, body { @@ -224,8 +239,8 @@ button, .btn { button:hover { background: var(--surface2); } button:active { transform: translateY(1px); } button:disabled { opacity: .5; cursor: not-allowed; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } -.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn-primary { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); } +.btn-primary:hover { background: var(--fill-hover); border-color: var(--fill-hover); } .btn-ghost { background: transparent; } .btn-sm { padding: 7px 11px; font-size: 13px; border-radius: 9px; } .btn-sm.on { border-color: var(--accent); color: var(--accent); } @@ -285,6 +300,12 @@ textarea.send-dest::placeholder { font-family: var(--sans); } .seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; } .seg button { border: 0; border-radius: 0; padding: 9px 14px; background: var(--paper); } .seg button.active { background: var(--accent-soft); color: var(--accent-ink); } +/* The profile's Posts / Replies tabs sit right on white cards: a soft-grey + active tab beside a white one read as the WHITE one being selected (its + colour matched the posts under it). So these take the feed chips' look: + the chosen tab solid accent, the other one quiet (Adam, 2026-10-16). */ +.prof-tabs button { background: var(--surface2); color: var(--muted); } +.prof-tabs button.active { background: var(--fill); color: var(--fill-fg); } /* ---- address / qr ---- */ .qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); } @@ -714,7 +735,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; } .pos-tips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; } .pos-tip { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; } -.pos-tip.on { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); } +.pos-tip.on { border-color: var(--fill); background: var(--fill); color: var(--fill-fg); } .pos-tip.on .muted { color: inherit; opacity: .8; } .pos-tip-label { font-size: 20px; font-weight: 600; } .pos-total { font-size: 22px; font-weight: 600; margin-top: 4px; } @@ -723,14 +744,12 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .pos-sale.faint { opacity: .55; } .pos-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; } .pos-key { padding: 14px 0; font-size: 22px; font-weight: 600; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } -.pos-key:active { background: var(--accent); color: var(--accent-fg); } +.pos-key:active { background: var(--fill); color: var(--fill-fg); } .pos-key.del { font-weight: 400; } /* the feeds row: pills that scroll sideways, the one on screen filled */ .feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } .feed-chips::-webkit-scrollbar { display: none; } /* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */ -/* grabbing the feed to scroll it (app.js) */ -.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; } @media (hover: hover) and (pointer: fine) { .feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; } .feed-chips::-webkit-scrollbar { display: block; height: 6px; } @@ -739,7 +758,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .feed-chips.dragging .feed-chip { pointer-events: none; } } .feed-chip { flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface2); color: var(--muted); border-radius: 999px; padding: 5px 12px; font-size: 14px; line-height: 1.2; cursor: pointer; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; } -.feed-chip.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } +.feed-chip.on { background: var(--fill); color: var(--fill-fg); border-color: var(--fill); } .feed-chip.add { font-weight: 600; padding: 5px 11px; } .note-act { background: none; border: 0; padding: 4px 6px; margin: 0; line-height: 1; @@ -867,6 +886,51 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); } .addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; } .addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; } +/* Live streams (NIP-53): the row of what's on above a feed, a profile's + "on now", a post's card, and the stream's own page (player over chat). */ +/* a face whose person is streaming now: a red ring, and LIVE under the bigger ones */ +.chat-avatar.live-ring { box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px #e0245e; position: relative; overflow: visible; } +.chat-avatar.mini.live-ring { box-shadow: 0 0 0 1px var(--paper), 0 0 0 2.5px #e0245e; } +.chat-avatar.live-ring:not(.mini)::after { content: 'LIVE'; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%); + padding: 0 4px; border-radius: 3px; font-size: 9px; font-weight: 700; line-height: 13px; letter-spacing: .04em; + color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; } +.live-strip { display: flex; gap: 10px; overflow-x: auto; padding: 2px 0 4px; scrollbar-width: none; } +.live-strip::-webkit-scrollbar { display: none; } +.live-tile { flex: 0 0 168px; display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; + background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; } +.live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; + background: var(--line) center / cover no-repeat; } +.live-badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; + letter-spacing: .04em; color: #fff; background: #555; line-height: 1.5; flex-shrink: 0; } +.live-badge.live { background: #e0245e; } +.live-cover .live-badge { position: absolute; top: 6px; left: 6px; } +.live-viewers { position: absolute; bottom: 6px; left: 6px; padding: 1px 6px; border-radius: 4px; font-size: 11px; + color: #fff; background: rgba(0, 0, 0, .6); } +.live-tile-title { font-weight: 600; font-size: 14px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; } +.live-tile-host { align-items: center; min-width: 0; } +.live-tile-host .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.live-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0245e; + border-radius: 10px; background: none; color: inherit; cursor: pointer; text-align: left; min-width: 0; } +.live-banner-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.live-card { flex-direction: row; align-items: center; gap: 10px; } +.live-card .live-cover { flex: 0 0 120px; width: 120px; } +.live-title { margin: 0; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; } +.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; } +.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; } +.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); } +.go-live-btn { display: inline-flex; align-items: center; gap: 8px; } +.go-live-title { width: 100%; } .article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; } .article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; } .article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; } @@ -942,7 +1006,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px); left: 50%; transform: translateX(-50%); z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px; - background: var(--accent); color: var(--accent-fg); border: none; + background: var(--fill); color: var(--fill-fg); border: none; font-size: 16px; font-weight: 650; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; /* it swipes away */ box-shadow: 0 3px 12px rgba(0, 0, 0, .28); @@ -951,7 +1015,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } /* the generic button hover (a light surface) and press (a 1px drop, which REPLACED the centring transform and threw the pill half its width to the right, out from under the pointer, so the tap never landed) stay off it */ -.feed-new-pill:hover { background: var(--accent-hover); color: var(--accent-fg); } +.feed-new-pill:hover { background: var(--fill-hover); color: var(--fill-fg); } .feed-new-pill:active { transform: translate(-50%, 1px); } /* the count bumps each time it changes: a fresh node per value replays it */ .feed-new-pill .n { display: inline-block; min-width: 1.1em; text-align: center; animation: pill-bump .32s cubic-bezier(.2,.9,.3,1.4); } @@ -1177,7 +1241,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .hat-demo { position: relative; display: inline-flex; width: 34px; height: 34px; flex-shrink: 0; margin-top: 12px; } .hat-demo-head { width: 100%; height: 100%; border-radius: 50%; background: var(--accent-soft); } .hat-btn { white-space: nowrap; flex-shrink: 0; } -.hat-btn.wearing { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } +.hat-btn.wearing { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); } .hat-btn.confirm { border-color: var(--green); color: var(--green); } /* a small centered confirm popup over a dimmed page */ @@ -1455,7 +1519,13 @@ body::after { .social-count.on { color: var(--ink); border-bottom-color: var(--accent); } /* ---- feed media views: All / Images / Videos, the grid, the pager ---- */ -.media-modes { gap: 6px; } +/* The view strip scrolls sideways like the feed chips above it: five + chips no longer fit a phone (Conversations joined them), and a flex row + that neither wraps nor scrolls made the page 42px wider than the screen + — on an iPhone that is a page you can pinch out of and pan, a blank + strip down the right (Adam, 2026-10-16). */ +.media-modes { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } +.media-modes::-webkit-scrollbar { display: none; } .media-modes .feed-chip { padding: 4px 12px; font-size: 13px; } .article-list { gap: 10px; } .article-list > .quote-card { margin-top: 0; } @@ -1533,6 +1603,7 @@ body::after { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; overflow-wrap: anywhere; } .mp-caption.open { -webkit-line-clamp: unset; max-height: 40vh; overflow-y: auto; } +.mp-caption a { color: #fff; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(255, 255, 255, .5); } .mp-counts { display: flex; flex-direction: column; align-items: center; gap: 14px; flex: 0 0 auto; } .mp-counts button { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0; margin: 0; border: 0; @@ -1555,6 +1626,17 @@ body::after { } .mp-sound { font-size: 15px; } .mp-sound .off { display: none; } +.mp-top-r { display: flex; align-items: center; gap: 8px; } +.mp-fit { height: 40px; padding: 0 14px; font-size: 14px; font-weight: 600; border-radius: 999px; border: 0; color: #fff; background: rgba(255, 255, 255, .16); } +.media-pager.quiet .mp-top .mp-fit { pointer-events: auto; } +/* a post out of the carousel: its clip not ready to play yet (mp-wait), or + the wrong way round for the screen (mp-off). Parked far above the screen + (fixed: out of the scroller's flow and its snap points, no height to + count) rather than display:none, so its box isn't a zero rect at the + origin that a "what's at the top left" lookup takes for the slide on + screen; its clip's poster still loads there. */ +.mp-group.mp-wait, .mp-group.mp-off { position: fixed; top: -400vh; left: 0; height: 0; visibility: hidden; pointer-events: none; } +/* over the Videos grid: which shapes */ /* A mouse's way through the pager: arrows, where there is a pointer to hover with (a phone swipes). Faint until the pointer is over the pager; the sideways pair only when the post has a picture that way. */ @@ -1642,9 +1724,12 @@ body::after { .media-pager { --mp-seek-lift: calc(env(safe-area-inset-bottom, 0px) + 32px); } .mp-seek { 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; + display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; opacity: 0; } -.mp-seek.ready { visibility: visible; } +.mp-seek.ready { visibility: visible; opacity: 1; transition: opacity .18s; } +/* 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; } .mp-seek-track { position: relative; width: 100%; height: 3px; border-radius: 3px; background: rgba(255, 255, 255, .28); transition: height .15s; } .mp-seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: #fff; } .mp-seek-fill::after { @@ -1797,6 +1882,11 @@ body::after { .gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .gif-note { display: flex; justify-content: center; padding: 32px 0; } .note-reposted { margin-bottom: 2px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* a reply shown with the post it answers above it (Conversations, a profile's Replies) */ +.note-context { margin-bottom: 6px; } +.note-context-label { margin-bottom: 2px; } +.note-context .quote-card { margin-top: 0; } +.note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; } /* attach + GIF inside the composer's field, bottom right; the field keeps room under its text so a long post never runs beneath them */ .compose-box { position: relative; } @@ -1816,3 +1906,46 @@ body::after { /* Always visible, including when the media controls are tucked away. */ .mp-position { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 16px); left: 50%; transform: translateX(-50%); padding: 5px 10px; border-radius: 20px; background: #0009; color: #fff; font-size: 13px; font-variant-numeric: tabular-nums; pointer-events: none; z-index: 5; } +/* the counter shows while the pager is in motion and fades out a moment + after it settles (the animation restarts each time `moving` comes off) */ +.media-pager .mp-position { animation: mp-position-out .4s 1.2s both; } +.media-pager.moving .mp-position { animation: none; opacity: 1; } +@keyframes mp-position-out { from { opacity: 1; } to { opacity: 0; } } +.media-pager.few .mp-position { visibility: hidden; } + +/* A call (calls.js): over every page, its picture filling it when there is + one; minimized, a bar along the top. */ +.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; } +.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; } +.call-who { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8vh; z-index: 1; } +.call-who .call-avatar { width: 112px; height: 112px; font-size: 32px; } +.call-name { font-size: 22px; font-weight: 650; } +.call-status { font-size: 15px; opacity: .8; font-variant-numeric: tabular-nums; } +.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-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; } +.call-btn:hover { background: rgba(255, 255, 255, .26); } +.call-btn.on { background: #fff; color: #101318; } +.call-btn.call-hang, .call-btn.call-decline { background: #e0245e; } +.call-btn.call-accept { background: #1fa45a; } +.call-overlay.call-min { inset: 0 0 auto 0; padding: 0; background: none; height: auto; justify-content: flex-start; pointer-events: none; } +.call-min-bar { pointer-events: auto; margin: max(8px, env(safe-area-inset-top)) auto 0; display: flex; align-items: center; gap: 10px; + padding: 8px 16px; border-radius: 999px; border: 0; background: #1fa45a; color: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, .3); } +.call-min-status { font-variant-numeric: tabular-nums; opacity: .9; } +.call-min .call-audio { display: none; } +/* a call in its conversation: a row of its own, centred, a tap calls back */ +.chat-row.call-log { justify-content: center; margin: 6px 0; flex-direction: column; align-items: center; gap: 3px; } +.call-log-why { text-align: center; max-width: 320px; } +.call-log-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; + border: 1px solid var(--line); background: var(--surface2); color: inherit; font-size: 13px; cursor: pointer; } +.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)); } diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 3dac246..f9d383e 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -7,15 +7,59 @@ import { respondFromBg, tendRenewals } from './nwc-respond.js'; import { withRequestLock } from './nwc-lock.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; -import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js'; +import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; +const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens +// where a call's offer wrap is fetched from: the notifier, or (the app served +// on this machine, as the tests do) this origin +const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER; self.addEventListener('push', (e) => { let data = {}; try { data = e.data ? e.data.json() : {}; } catch (_) {} // User-facing notifications (payments, DMs, community chat). An open // window already shows everything live — stay quiet then. + // A call ringing in: the push names the offer's wrap, kept at the notifier + // (too big to ride in a push). Opened here with our key; a fresh offer + // from someone we follow or talk to rings, as a notification that stays + // until it's answered or missed. The offer is kept for the app to pick up + // when the tap opens it (CALL_CACHE). An open window rings by itself. + if (data.type === 'notify' && data.reason === 'call') { + e.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.some((c) => c.visibilityState === 'visible')) return; + let ring = null; + try { + const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || '')); + if (r.ok) ring = await classifyCall(await r.json(), await allInboxes()); + } catch (err) { console.warn('[sw-call] could not open:', err && err.message); } + if (!ring) return; + if (ring.quiet) { + // no ring for them; a note for the app, which lists it as a missed call + try { + const cache = await caches.open(CALL_CACHE); + const had = await cache.match('/__quiet-calls'); + const list = had ? await had.json() : []; + list.push(ring.offer); + await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } })); + } catch (_) {} + return; + } + try { + const cache = await caches.open(CALL_CACHE); + await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } })); + } catch (_) {} + for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} } + await self.registration.showNotification((ring.name || 'Someone') + ' is calling', { + body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.', + icon: 'icon-192.png', badge: 'badge-96.png', + tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400], + data: { url: './?open=call', view: 'call' }, + }); + })()); + return; + } if (data.type === 'notify') { e.waitUntil((async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js index 6d34db6..c21289c 100644 --- a/src/thread-prefetch.js +++ b/src/thread-prefetch.js @@ -1,5 +1,9 @@ // Dwell is measured on mounted rows, including posts taller than the viewport. // Sampling also catches fast movement within a large post (IO alone cannot). +// A post read for half a second is a post that may be opened: its thread is +// fetched then (it was 1.2 s, which a quick reader beat to the tap), one at +// a time, a dozen a minute. +const DWELL_MS = 600, PER_MINUTE = 12; export function createThreadPrefetch({ load, key, cached, doc = globalThis.document, nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) { const seeds = new Map(), dwell = new Map(); @@ -23,12 +27,12 @@ export function createThreadPrefetch({ load, key, cached, doc = globalThis.docum if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top }; d.top = r.top; dwell.set(id, d); - if (time - d.since >= 1200) ready.push(seed); + if (time - d.since >= DWELL_MS) ready.push(seed); } for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id); for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id); starts = starts.filter((t) => time - t < 60000); - if (!busy && starts.length < 6 && ready.length) { + if (!busy && starts.length < PER_MINUTE && ready.length) { const seed = ready[0], token = generation; busy = true; starts.push(time); // The loader inserts synchronously, so clicks share its in-flight work. diff --git a/src/video-compress.js b/src/video-compress.js index 53d4eb0..8fe0856 100644 --- a/src/video-compress.js +++ b/src/video-compress.js @@ -52,3 +52,66 @@ export async function compressVideo(file, onProgress = () => {}) { try { input.dispose?.(); } catch {} } } + +// A long video as an HLS stream (Adam, 2026-10-18; jb55: "you should use +// hls (m3u8 ext) for long videos"): a 720p and a 360p rendition, the +// player taking the one the connection carries, so an hour-long clip +// neither stalls on mobile data nor costs the whole file to watch a +// minute of it. Made here, like the shrinking above, in 4-second segments, +// each rendition (and the sound) one file read in ranges — a handful of +// uploads, not hundreds. The files keep the names the playlists give them; +// uploadStream puts them on a host and the playlists on after them. +// Anything short of a stream — a short clip, no WebCodecs, an error — is +// null, and the video goes up as one file as before. +export const STREAM_MIN_S = 90; +export async function streamVideo(file, onProgress = () => {}) { + if (!file || !/^video\//.test(file.type || '')) return null; + if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return null; + let mb; + try { mb = await import('mediabunny'); } catch { return null; } + const { Input, Output, Conversion, BlobSource, BufferTarget, PathedTarget, HlsOutputFormat, CmafOutputFormat, ALL_FORMATS } = mb; + if (!HlsOutputFormat || !PathedTarget) return null; + const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS }); + try { + const video = await input.getPrimaryVideoTrack(); + if (!video) return null; + const seconds = await input.computeDuration(); + if (!(seconds >= STREAM_MIN_S)) return null; + const w = video.displayWidth, h = video.displayHeight, short = Math.min(w, h); + const edge = (n) => (w >= h ? { height: Math.min(n, short) } : { width: Math.min(n, short) }); + const rungs = [{ ...edge(SHORT_EDGE), bitrate: VIDEO_BPS }, { ...edge(360), bitrate: 800_000 }]; + const files = new Map(); // path -> { target, mime } + const target = new PathedTarget('master.m3u8', (req) => { + const t = new BufferTarget(); + files.set(req.path, { target: t, mime: req.mimeType }); + return t; + }); + const output = new Output({ + target, + format: new HlsOutputFormat({ + segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, targetDuration: 4, + getPlaylistPath: ({ n }) => `p${n}.m3u8`, + getSegmentPath: ({ playlist }) => `s${playlist.n}.mp4`, // (a host serves .mp4 to anyone; .m4s it may not take) + }), + }); + const conversion = await Conversion.init({ + input, output, + video: rungs.map((r) => ({ ...r, codec: 'avc', keyFrameInterval: 4, forceTranscode: true })), + }); + if (!conversion.isValid) return null; + conversion.onProgress = (p) => { try { onProgress(p); } catch {} }; + await conversion.execute(); + const out = new Map(); + for (const [path, { target: t, mime }] of files) { + if (!t.buffer) return null; + out.set(path, new File([t.buffer], path, { type: /m3u8$/.test(path) ? 'application/vnd.apple.mpegurl' : mime || 'video/mp4' })); + } + if (!out.has('master.m3u8')) return null; + const dim = w >= h ? `${Math.round(w * Math.min(1, SHORT_EDGE / short))}x${Math.min(SHORT_EDGE, short)}` : `${Math.min(SHORT_EDGE, short)}x${Math.round(h * Math.min(1, SHORT_EDGE / short))}`; + return { files: out, master: 'master.m3u8', seconds, dim }; + } catch { + return null; + } finally { + try { input.dispose?.(); } catch {} + } +} |
