aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js2464
1 files changed, 2145 insertions, 319 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 8f0ed42..4ba4bfb 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,4 @@
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
@@ -14,7 +15,7 @@ import { mediaServers } from '../media-servers.js';
// listens for both identities and decrypts with whichever keys are present.
import {
- subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay,
+ subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays,
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS,
} from '../nostr.js';
@@ -53,8 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
-import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js';
-import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -63,7 +64,15 @@ import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+// coinos's own relay and primal: both take DM gift wraps and pass call
+// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
+// refused or ignored the household IP's publishes, call answers among them.
+// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads;
+// relay.coinos.io stays on while older inbox lists still name it
+const MESSAGES_RELAY = 'wss://messages.coinos.io';
+const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net'];
+// what this app put in a wallet key's kind 10050 before messages.coinos.io
+const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -578,7 +587,10 @@ export function messagesFeature(ctx) {
let repostBy = null; // set while noteRow draws a repost's note
// A profile shows its articles (NIP-23) among the notes, as Amethyst does.
// Their boosts too, under "<name> reposted", as in a feed.
- const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST];
+ // ...and their NIP-22 comments (kind 1111): the Replies tab is where
+ // those go, and a comment scoped to a web address (DaddyTube's, on a
+ // film's IMDb page) was never asked for at all
+ const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST, 1111];
// An edited article is a new event at the same address: keep the newest.
const newestArticles = (evs) => {
const best = new Map();
@@ -748,14 +760,38 @@ export function messagesFeature(ctx) {
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
+ // Is the page under the reader's finger, or still coasting from a flick?
+ // Background work (a repaint for a face that arrived, writing the profile
+ // cache) waits for it to settle: on Adam's Pixel a flick through the feed
+ // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued
+ // behind it (2026-11-07, "flick to scroll … not getting up to speed …
+ // clicking around feels sluggish"). Never longer than `most` ms, though:
+ // a slow, steady read must still see what arrived.
+ let pageMovedAt = 0, fingerDown = false;
+ if (typeof window !== 'undefined') {
+ window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true });
+ window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true });
+ for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true });
+ }
+ const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200;
+ const whenSettled = (fn, most = 1500, step = 150) => {
+ const until = Date.now() + most;
+ const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); };
+ go();
+ };
+
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
+ const since = Date.now();
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; }
+ // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph
+ // in the middle of a fling is a dropped frame and a late tap
+ if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
@@ -814,11 +850,14 @@ export function messagesFeature(ctx) {
// memory and is written back once per burst: a batch of thirty profiles
// landing during a scroll used to parse and re-serialize the whole blob
// thirty times over, on the main thread, mid-scroll.
- let profBlob = null, profFlush = null;
+ let profBlob = null, profFlush = null, profFlushSince = 0;
const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room
const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {}));
- function flushProfiles() {
- profFlush = null;
+ function flushProfiles(force) {
+ // up to 5 s for the page to settle: stringifying this blob (thumbnails
+ // and all, up to ~2 MB) is a long main-thread task
+ if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; }
+ profFlush = null; profFlushSince = 0;
const s = profBlob;
if (!s) return;
// Who stays when space runs out: your own faces, the people you follow,
@@ -836,7 +875,10 @@ export function messagesFeature(ctx) {
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen strangers give theirs up first. The row itself
// stays — that face just paints the way it used to.
- let size = JSON.stringify(s).length;
+ // (an estimate, not a JSON.stringify of the lot just to measure it:
+ // a row is ~430 chars plus its thumbnail)
+ let size = 0;
+ for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length;
if (size > THUMB_BUDGET) {
// never our own: the header face is the one that must paint offline
for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) {
@@ -863,7 +905,7 @@ export function messagesFeature(ctx) {
? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
- if (!profFlush) profFlush = setTimeout(flushProfiles, 500);
+ if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); }
}
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
@@ -1007,6 +1049,101 @@ export function messagesFeature(ctx) {
}
}
}
+ // ---- a Blossom blob gone from its host, found on another ---------------
+ // A picture named by its sha256 (…/<64 hex>.jpg) is a Blossom blob: any
+ // server holding the same bytes serves them under the same name. When the
+ // host in the URL no longer has it — Karnage's face and banner on
+ // media.21media.to answered "blob not found" (2026-11-02) while
+ // blossom.primal.net, the server he publishes in his kind 10063, still had
+ // both — the picture is looked for on the servers the person published
+ // and then on the public ones we know, and the first that answers stands
+ // in for it: the thumbnail is made from it, the big face and the banner
+ // paint from it. Remembered on the device, so the search runs once.
+ const BLOB_RE = /^(https?:\/\/[^/?#]+)(?:\/[^?#]*)?\/([0-9a-f]{64})(\.[a-z0-9]{2,5})?(?:[?#].*)?$/i;
+ const BLOB_ALT_HOSTS = ['https://blossom.primal.net', 'https://blossom.ditto.pub', 'https://nostr.download',
+ 'https://blossom.nostr.build', 'https://blossom.band', 'https://cdn.satellite.earth'];
+ const BLOB_ALT_KEY = 'btc-wallet-blob-alts';
+ const blobAlts = new Map(); // url -> the copy that answered, or null when none did
+ (() => { try { for (const [k, v] of Object.entries(JSON.parse(localStorage.getItem(BLOB_ALT_KEY) || '{}'))) if (typeof v === 'string') blobAlts.set(k, v); } catch {} })();
+ const saveBlobAlts = () => {
+ try {
+ const found = [...blobAlts].filter(([, v]) => v).slice(-200);
+ localStorage.setItem(BLOB_ALT_KEY, JSON.stringify(Object.fromEntries(found)));
+ } catch {}
+ };
+ const blobProbing = new Map();
+ const blobAltOf = (url) => (url && blobAlts.get(url)) || null;
+ // the servers a person publishes for their files (BUD-03), asked only when
+ // one of their pictures has gone missing
+ const blobServersOf = async (pk) => {
+ try {
+ const evs = await queryOn(PROFILE_RELAYS, { kinds: [10063], authors: [pk], limit: 1 }, 4000);
+ const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ return ev ? ev.tags.filter((x) => x[0] === 'server' && /^(https:\/\/|http:\/\/localhost(:\d+)?\/)/i.test(x[1] || ''))
+ .map((x) => x[1].replace(/\/+$/, '')) : [];
+ } catch { return []; }
+ };
+ // does an <img> load from here? (no CORS needed to find out)
+ const imageAnswers = (url, ms = 8000) => new Promise((resolve) => {
+ const im = new Image();
+ const timer = setTimeout(() => { im.onload = im.onerror = null; im.src = ''; resolve(false); }, ms);
+ im.onload = () => { clearTimeout(timer); resolve(true); };
+ im.onerror = () => { clearTimeout(timer); resolve(false); };
+ im.src = url;
+ });
+ function findBlobAlt(url, pk) {
+ if (blobAlts.has(url)) return Promise.resolve(blobAlts.get(url));
+ if (blobProbing.has(url)) return blobProbing.get(url);
+ const m = BLOB_RE.exec(url || '');
+ if (!m || typeof Image === 'undefined') return Promise.resolve(null);
+ const task = (async () => {
+ const origin = m[1].toLowerCase(), name = m[2] + (m[3] || '');
+ const hosts = [...new Set([...(pk ? await blobServersOf(pk) : []), ...BLOB_ALT_HOSTS])]
+ .filter((h) => h.toLowerCase() !== origin);
+ // asked together (a host that hangs mustn't hold the rest), the
+ // person's own servers preferred among those that answer
+ const answers = await Promise.all(hosts.map((h) => imageAnswers(h + '/' + name).then((ok) => (ok ? h + '/' + name : null))));
+ const alt = answers.find(Boolean) || null;
+ blobAlts.set(url, alt);
+ if (alt) saveBlobAlts();
+ return alt;
+ })().finally(() => blobProbing.delete(url));
+ blobProbing.set(url, task);
+ return task;
+ }
+ // Centre-crop to a square (the way background-size:cover paints it),
+ // shrink, and encode — off the main thread where the browser allows: the
+ // crop and resize through createImageBitmap, the encode through an
+ // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it
+ // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07).
+ async function encodeThumb(bmp) {
+ const side = Math.min(bmp.width, bmp.height);
+ if (!side) throw new Error('empty');
+ const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2;
+ if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) {
+ let small = null;
+ try {
+ small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' });
+ const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX);
+ oc.getContext('2d').drawImage(small, 0, 0);
+ const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); });
+ let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 });
+ if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 });
+ if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder
+ const data = await asUrl(blob);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ } finally { try { small && small.close(); } catch {} }
+ }
+ const c = document.createElement('canvas');
+ c.width = c.height = THUMB_PX;
+ c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX);
+ let data = c.toDataURL('image/webp', 0.75);
+ if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
+ if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ }
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway
@@ -1063,21 +1200,23 @@ export function messagesFeature(ctx) {
// the browser's cache is welcome: the face was just painted from this
// very URL, and 'reload' re-downloaded every original (38 MB in one
// scroll) — a stale copy still makes a fine thumbnail
- const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW));
- if (!res.ok) throw new Error('http ' + res.status);
+ let res = null;
+ try { res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); } catch (e) {
+ if (e && e.corsRefused) noteNoCors(url);
+ if (!BLOB_RE.test(url)) throw e;
+ }
+ // the host no longer has it (or won't answer): the same blob on
+ // another server is as good — and the face it paints is this one
+ if (!res || !res.ok) {
+ const alt = BLOB_RE.test(url) ? await findBlobAlt(url, pk) : null;
+ if (!alt) throw new Error('http ' + (res ? res.status : 'none'));
+ res = await corsFetch(alt, AbortSignal.timeout(THUMB_SLOW));
+ if (!res.ok) throw new Error('http ' + res.status);
+ scheduleRepaint(); // the big face, where one is up, can paint from the copy now
+ }
bmp = await createImageBitmap(await res.blob());
- // centre-crop to a square, the way background-size:cover paints it
- const side = Math.min(bmp.width, bmp.height);
- if (!side) throw new Error('empty');
- const c = document.createElement('canvas');
- c.width = c.height = THUMB_PX;
- c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2,
- side, side, 0, 0, THUMB_PX, THUMB_PX);
- let data = c.toDataURL('image/webp', 0.75);
- if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
- if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder
- if (data.length > THUMB_MAX) throw new Error('too big');
- done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX });
+ const data = await encodeThumb(bmp);
+ whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }));
} catch (e) {
// a quick TypeError is the CORS refusal (a dead host takes longer)
// only a refusal corsFetch confirmed (a body cut off mid-download
@@ -3310,12 +3449,19 @@ export function messagesFeature(ctx) {
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
+ // A list this app wrote with its old defaults (nothing else in it) is
+ // brought up to today's, so senders find messages.coinos.io first.
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
- if (existing.length) return;
+ if (existing.length) {
+ const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a));
+ const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, ''));
+ const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u));
+ if (!ours || urls.includes(DM_RELAYS[0])) return;
+ }
const evt = finalizeEvent({
kind: 10050,
content: '',
@@ -3838,8 +3984,9 @@ export function messagesFeature(ctx) {
const punk = localPunk(p.picture, big);
if (punk) return `url(${JSON.stringify(punk)})`;
const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null;
- if (!local) return `url(${JSON.stringify(p.picture)})`;
- return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
+ const pic = blobAltOf(p.picture) || p.picture; // the same blob elsewhere, when the host lost it
+ if (!local) return `url(${JSON.stringify(pic)})`;
+ return (big ? `url(${JSON.stringify(pic)}),` : '') + `url(${JSON.stringify(local)})`;
}
const FACE_WAIT_MS = 1000;
@@ -3922,7 +4069,8 @@ export function messagesFeature(ctx) {
try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
if (on) {
node.classList.add('live-ring');
- if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); };
+ // (on that stream's own page, the face is a way out: to their profile)
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); };
}
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -4044,7 +4192,17 @@ export function messagesFeature(ctx) {
};
function warmBanner(pk) {
const b = bannerOf(pk);
- if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint());
+ if (!b) return;
+ const shown = blobAltOf(b) || b;
+ if (mediaReady.has(shown)) return;
+ return warmMedia(shown, { thumb: true }).then(async () => {
+ // the host lost it: the same blob on another server paints instead
+ if (!mediaReady.has(shown) && shown === b && BLOB_RE.test(b)) {
+ const alt = await findBlobAlt(b, pk);
+ if (alt) await warmMedia(alt, { thumb: true });
+ }
+ scheduleRepaint();
+ });
}
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
@@ -4118,7 +4276,8 @@ export function messagesFeature(ctx) {
// ---- profile notes: latest public posts & replies -----------------------
- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
+ // (not messages.coinos.io: it takes messages, not posts)
+ const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
@@ -4216,6 +4375,8 @@ export function messagesFeature(ctx) {
if (persist) persistThread(c);
}
const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
+ const APP_KIND = 32267; // a Zapstore app listing (NIP-82): zaps on one are zaps on your app
+ const APP_RELAYS = ['wss://relay.zapstore.dev'];
function receiptSats(ev) {
if (ev.kind === 9737) {
const net = tagOf(ev, 'network');
@@ -4236,6 +4397,16 @@ export function messagesFeature(ctx) {
if (ev.kind === 9737) return String(ev.content || '').slice(0, 200);
try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; }
}
+ // The address (kind:pubkey:d) a zap was for, when it wasn't a note: a
+ // stream (30311) or a Zapstore app listing (32267). LNURL servers often
+ // leave the a tag off the receipt (Zapstore's did, 2026-11-07), so the zap
+ // request inside it is asked too.
+ function zapAddrOf(ev) {
+ const a = (e) => ((e && e.tags) || []).find((x) => x[0] === 'a' && x[1]);
+ const own = a(ev);
+ if (own) return own[1];
+ try { const r = a(JSON.parse(tagOf(ev, 'description') || 'null')); return r ? r[1] : null; } catch { return null; }
+ }
function zapperOf(ev) {
if (ev.kind === 9737) {
// Signed by the wallet key; the sender tag names the person behind it.
@@ -4503,7 +4674,81 @@ export function messagesFeature(ctx) {
// Where an event about a note goes. A zap receipt is money and has its own
// bookkeeping (see below); a like and a boost are just tallies of who.
+ const timedActivity = new Map();
+ function rememberMoment(ev) {
+ const moment = readMoment(ev);
+ if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return;
+ const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1];
+ if (!target) return;
+ if (!timedActivity.has(target)) timedActivity.set(target, new Map());
+ const items = timedActivity.get(target);
+ if (items.has(ev.id)) return;
+ items.set(ev.id, { id: ev.id, ev, moment,
+ icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩',
+ text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content });
+ if (items.size > 300) items.delete(items.keys().next().value);
+ if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value);
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); }));
+ }
+ // a post's timed activity on one of its clips, in order, each with who and what
+ const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
+ .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
+ .sort((a, b) => a.moment.seconds - b.moment.seconds)
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) }));
+ // how a moment shows on a bar: a reaction as its emoji (a :code: as its
+ // picture, from the reaction's own tag), a zap ⚡, a reply as its
+ // author's face
+ function momentGlyph(x) {
+ if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false);
+ if (x.ev.kind !== 7) return x.icon;
+ const code = shortcodeOf(x.ev.content);
+ const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
+ return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content);
+ }
+ // Where a post's clip is up to, if it has been played: a reply, reaction or
+ // zap made then is about that moment. (No buttons of their own for it: the
+ // usual ones carry the time.)
+ function momentOf(ev) {
+ if (!ev) return null;
+ const cur = pager && pager.cur;
+ const players = cur && cur._ev && cur._ev.id === ev.id
+ ? [cur.querySelector('.mp-media')]
+ : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')];
+ for (const v of players) {
+ if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime);
+ }
+ return null;
+ }
+ // A comment made at a moment of a clip opens with its time as a link that
+ // plays the clip from there; the "[0:03] " other clients read as text is
+ // left out of the words beside it.
+ function momentLink(ev) {
+ const m = readMoment(ev);
+ if (!m) return null;
+ return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(m.seconds));
+ }
+ const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text);
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
+ function attachMediaTimeline(player, url, explicitEvent) {
+ if (player._timeline) return;
+ player._timeline = true;
+ player._momentUrl = url;
+ const event = () => {
+ const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post'));
+ return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null;
+ };
+ const box = player.parentElement;
+ box._skipMorph = true;
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph,
+ onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null }));
+ player.dispatchEvent(new Event('timeupdate'));
+ }
function noteEvent(ev) {
+ rememberMoment(ev);
if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; }
if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; }
if (ev.kind === 7 || ev.kind === 6) {
@@ -4541,6 +4786,7 @@ export function messagesFeature(ctx) {
// root and reply (or the bare positional e tags of older clients), NIP-22's
// E and e. A 'mention' e tag is a quote, not a reply.
function noteReply(ev) {
+ rememberMoment(ev);
let changed = false;
for (const x of ev.tags || []) {
if (!x[1] || x[1] === ev.id) continue;
@@ -4561,6 +4807,7 @@ export function messagesFeature(ctx) {
}
function noteReceipt(ev) {
+ rememberMoment(ev);
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
@@ -4829,25 +5076,38 @@ export function messagesFeature(ctx) {
const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] }
// NIP-65: where this author actually writes. Their notes and threads live
// there first — our default relays are just the common ground.
+ // Asked one at a time — a quoted note's author, a face tapped, a reply's
+ // target — these went out as one REQ per author per relay: a feed with
+ // six quotes on screen sent eighteen. Asks now wait a beat and go out
+ // together, one REQ with every author wanted, through the feed's own
+ // batched fetch, which also remembers who has no list so nobody is asked
+ // twice.
const relayListCache = new Map(); // pk -> Promise<string[]>
+ const RELAY_ASK_MS = 80;
+ let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve]
function relaysOf(pk) {
let p = relayListCache.get(pk);
if (p) return p;
// the feed's batched fetch has probably already asked for this one
const known = relayListsNow().get(pk);
if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; }
- p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500)
- .then((evs) => {
- const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
- if (!newest) return [];
- return [...new Set(newest.tags
- .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1]))
- .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4);
- })
- .catch(() => []);
+ p = new Promise((resolve) => {
+ if (!relayAskQ.has(pk)) relayAskQ.set(pk, []);
+ relayAskQ.get(pk).push(resolve);
+ if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS);
+ });
relayListCache.set(pk, p);
return p;
}
+ async function flushRelayAsks() {
+ relayAskTimer = null;
+ const batch = relayAskQ; relayAskQ = new Map();
+ try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {}
+ for (const [pk, waiters] of batch) {
+ const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4);
+ for (const w of waiters) w(r);
+ }
+ }
// Plus relays that keep history: the big ones prune within days, and an
// author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows
// only whatever primal still holds — 2 posts of 22.
@@ -4895,12 +5155,31 @@ export function messagesFeature(ctx) {
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();
+ // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at
+ // [4] only, every comment's parent lost its author, and its outbox went
+ // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the
+ // people the reply is addressed to: their outboxes are where it was written.
+ const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null);
+ ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]);
+ if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3);
return ref;
}
+ // The web page a NIP-22 comment is on (its I tag, an http(s) URL), with
+ // the label its alt tag gives it — DaddyTube: "Comment on Sheep
+ // Detectives (2025) in DaddyTube" — or else the address, host and path.
+ function webScopeOf(ev) {
+ const url = ((ev.tags || []).find((x) => x[0] === 'I' && /^https?:\/\/\S+$/i.test(x[1] || '')) || [])[1];
+ if (!url) return null;
+ let where = url;
+ try { const u = new URL(url); where = (u.host + u.pathname).replace(/^www\./, '').replace(/\/$/, ''); } catch {}
+ const alt = ((ev.tags || []).find((x) => x[0] === 'alt' && String(x[1] || '').trim()) || [])[1];
+ return { url, label: alt ? String(alt).trim().slice(0, 140) : t('noteCommentOn', { where }) };
+ }
// 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.
+ // a reply whose parent every lookup came back without
+ const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; };
function noteInHand(id) {
for (const c of feedStates.values()) for (const e of c.notes) {
if (e.id === id) return e;
@@ -4911,6 +5190,24 @@ export function messagesFeature(ctx) {
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 noteOnDisk(id);
+ }
+ // ...or on disk with the feed it was read in: a Conversations view opened
+ // straight after a reload has the posts its replies answer in Following's
+ // cache, not in any feed state yet (and without them, those replies were
+ // taken for replies to nothing and left out)
+ const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session
+ function noteOnDisk(id) {
+ for (const f of new Set([FOLLOWING, curFeedId])) {
+ let m = diskHeld.get(f);
+ if (!m) {
+ let notes = null;
+ try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {}
+ m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e]));
+ diskHeld.set(f, m);
+ }
+ if (m.has(id)) return m.get(id);
+ }
return null;
}
function notesFor(pk) {
@@ -5065,7 +5362,8 @@ export function messagesFeature(ctx) {
]);
const all = [...(relayEvs || []), ...primalEvs].filter((e) => e.pubkey === pk);
const list = all.filter((e) => e.kind === 3).sort((a, b) => b.created_at - a.created_at)[0] || null;
- return { list, existing: all.length > 0 };
+ const profile = all.filter((e) => e.kind === 0).sort((a, b) => b.created_at - a.created_at)[0] || null;
+ return { list, profile, existing: all.length > 0 };
}
// Fetch the newest list from the relays. Older than what we hold is
@@ -5264,12 +5562,12 @@ export function messagesFeature(ctx) {
// small, widely mirrored, and worth having before anything else is asked.
// Authors with no list at all are remembered as such, so we don't ask again
// every time the feed refreshes.
- async function fetchRelayLists(pks) {
+ async function fetchRelayLists(pks, relays = NOTE_RELAYS) {
const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk));
if (!want.length) return;
for (let i = 0; i < want.length; i += 100) {
const batch = want.slice(i, i + 100);
- const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []);
+ const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []);
const newest = new Map();
for (const ev of evs || []) {
const cur = newest.get(ev.pubkey);
@@ -6106,13 +6404,17 @@ export function messagesFeature(ctx) {
}
try {
const orT = orTopicsOf(def);
+ // the feed's own kinds: a Conversations view asks for replies and
+ // NIP-22 comments (asked for posts, it painted no comment at all
+ // until a refresh half a minute on; the pass below holds for them)
+ const kinds = feedKinds(def);
await Promise.all([
// people's reposts with their posts (a topic or firehose ask: posts only);
// every author, a chunk of them per ask
...(authors.length ? authorChunks(authors) : [null]).map((chunk) =>
- queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length ? [...FEED_KINDS, REPOST] : FEED_KINDS, limit: FEED_PAGE,
+ queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length && !def.replies ? [...kinds, REPOST] : kinds, limit: FEED_PAGE,
...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000)),
- orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]);
+ orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]);
} catch {} finally {
if (!c.stopped) {
c.status = 'ready';
@@ -6186,6 +6488,13 @@ export function messagesFeature(ctx) {
// ---- feeds anyone can open --------------------------------------------
// Every kind of feed has a URL, and a visitor with no wallet can read it.
const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv;
+ // Whose page is painted: a visitor's (no wallet on this device), or the
+ // wallet's that boot is about to restore (ctx.restorePending). The feed,
+ // reached by URL, paints before that restore lands; painted as a visitor's
+ // it had no post button, chips or nav until the wallet opened a beat later
+ // (Adam, 2026-10-31). Taps go by isVisitor(): by the time one lands the
+ // restore, synchronous, has run.
+ const visitorView = () => isVisitor() && !(ctx.restorePending && ctx.restorePending());
const showFeed = (id) => {
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';
@@ -6625,7 +6934,8 @@ export function messagesFeature(ctx) {
const d = {};
if (!have.size && +f.size > 0) d.size = +f.size;
// no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough
- else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8);
+ // (a guess: never taken for the file's identity, see mediaItems)
+ else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; }
if (!have.dur && +f.duration > 0) d.dur = +f.duration;
const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || '');
if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; }
@@ -6677,6 +6987,7 @@ export function messagesFeature(ctx) {
const videoProbes = new Map();
let videoProbing = 0;
const videoProbeQueue = [];
+ const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; };
const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000;
function probeVideo(url, layout = false) {
const have = videoInfo.get(url);
@@ -6710,7 +7021,7 @@ export function messagesFeature(ctx) {
// player's plain load answers this request without them, and it fails.
// Any clip that had played in the feed was "unmeasurable" that way.
const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal });
- if (!r.ok) throw new Error('HTTP ' + r.status);
+ if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status });
const ranges = r.status === 206;
const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
// never more than the first bytes, even from a server that ignored the range
@@ -6727,22 +7038,27 @@ 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 } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
- if (total > 0) d.size = total;
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: 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; d.sizeGuess = undefined; }
else {
// a server that hides Content-Range from scripts (Primal's) still
// says Content-Length to a HEAD, which any page may read
try {
const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) });
const n = +hr.headers.get('content-length');
- if (hr.ok && n > 0) d.size = n;
+ if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; }
} catch {}
}
if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
if (head.w) rememberVideo(url, head.w, head.h);
noteVideoInfo(url, d);
- } catch {
- noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour
+ } catch (e) {
+ // a host that won't say (no CORS, down, slow): asked again in an hour.
+ // One that says the file is gone (deleted: a 404 or 410) is believed
+ // for a day, and the grid leaves the clip out (clipGone).
+ const gone = e && (e.status === 404 || e.status === 410);
+ noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) });
+ if (gone && mediaMode === 'videos') scheduleRepaint();
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
@@ -6812,7 +7128,7 @@ export function messagesFeature(ctx) {
if (!url || mediaReady.has(url)) return Promise.resolve();
const known = videoDims.get(url) || dim;
if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); }
- if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve();
+ if (typeof document === 'undefined' || videoUnknown.has(url) || clipGone(url)) return Promise.resolve(); // (gone: nothing to measure)
if (mediaWarming.has(url)) return mediaWarming.get(url);
// the 64 KB probe first: it reads the shape from the file's own header
// and costs nothing like a player's metadata load of an index-last file
@@ -6934,7 +7250,7 @@ export function messagesFeature(ctx) {
return urls;
}
const avatarUrl = (p, pk) => p?.picture
- ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
+ ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || blobAltOf(p.picture) || p.picture
: punkSmallUrl(pk);
async function warmAvatar(pk, deadline) {
let p = liveProfileOf(pk);
@@ -7157,6 +7473,7 @@ export function messagesFeature(ctx) {
await noteReady(e, undefined, 0, { parent: feedReplies(c) });
unstage(e);
if (c.stopped) return false;
+ if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find
// Cached rows can still be warming. This new row is already prepared
// and can take its stable presentation without waiting for that page.
if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); }
@@ -7634,22 +7951,33 @@ export function messagesFeature(ctx) {
}
// While the feed is what's on screen, new posts arrive by themselves — on
// the same relays the pass above reads, one subscription each.
+ // The live subscriptions stay up across refreshes. Every refresh pass
+ // used to end by closing and reopening all of them — seventeen REQs over
+ // eight relays, twice per opening of Following — for subscriptions whose
+ // relays and authors hadn't changed; only the head they count fresh posts
+ // against moves, and that is read at each event.
+ let feedWatchKey = '', feedWatchHead = { t: Infinity };
function watchFeed() {
- stopFeedWatch();
const c = feedNow();
const def = feedDef(c.id); // the Conversations view has its own
- if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
+ if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); 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);
+ const orT = orTopicsOf(def);
+ const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
+ const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(),
+ ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n');
+ if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; }
+ stopFeedWatch();
+ feedWatchKey = key;
+ const head = feedWatchHead = { t: newerThan };
+ const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
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, ...tag, since }, on)); return; }
- const orT = orTopicsOf(def);
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 || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
@@ -7657,6 +7985,7 @@ export function messagesFeature(ctx) {
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
feedUnsubs = [];
+ feedWatchKey = '';
}
// Prepare older pages several screens ahead of the reader. A scroll while
// preparation is underway shares the same work instead of exposing it early.
@@ -7788,7 +8117,7 @@ export function messagesFeature(ctx) {
// A bare npub/nprofile — with or without an @ in front, no nostr: prefix —
// is how Vector (and people) write a mention; it resolves to the name too.
const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])';
- const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
@@ -7850,13 +8179,13 @@ export function messagesFeature(ctx) {
// known, never taller than the screen; only a clip taller than that sits
// in a black frame, rather than in a strip at the left.
const videoBoxStyle = (d) => d && d.width && d.height
- ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
+ ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
// Over a clip that waits for a tap: what it costs, and the tap that plays
// it (with sound — the reader asked for this one). The box is left alone
// by the morph from here, or a repaint would take the cover off.
function heavyCover(box, url, onPlay) {
- if (box.querySelector('.vid-heavy')) return;
+ if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return;
box._skipMorph = true;
const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'),
onClick: (e) => {
@@ -7868,7 +8197,7 @@ export function messagesFeature(ctx) {
if (v) {
if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
v.preload = 'auto';
- v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; });
+ v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
@@ -7888,6 +8217,7 @@ export function messagesFeature(ctx) {
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)
+ const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out
// A picture from a host that won't hand over its bytes (no CORS: primal's
// blossom, nostrcheck, most personal sites) can't have its column copy made
// here, so the server makes it (pic.webp: 1100px wide, or a 360px square
@@ -7959,13 +8289,34 @@ export function messagesFeature(ctx) {
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 clip whose file its host no longer has: the player is the first to
+ // know (Primal's blossom answers a 404 with no CORS headers, so the
+ // probe couldn't read it and the box sat as a dead "Tap to play" — a
+ // post whose video "won't play", Adam, 2026-11-01). It says so where the
+ // player stood, with the file's link, and the grid leaves the clip out
+ // for a day, as it does a 404 the probe could read. The copy is tried
+ // first: a file the browser can't decode fails the same way, and the
+ // copy plays that one.
+ function clipDead(v, url) {
+ noteVideoInfo(url, { goneAt: Date.now() });
+ v._gone = true;
+ v.controls = false;
+ const box = v.parentElement;
+ if (!box) return;
+ box._skipMorph = true;
+ box.classList.add('gone');
+ for (const el of box.querySelectorAll('.vid-heavy, .vid-pp, .vid-sound')) el.remove();
+ if (!box.querySelector('.vid-nopic')) box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoGone')),
+ h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile'))));
+ }
// 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.
+ // One whose original won't load at all tries the copy too; the copy
+ // failing as well means the file is gone (clipDead).
function guardPicture(v, url) {
- if (!vmediaOk(url)) return;
const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA);
- v.addEventListener('loadedmetadata', () => {
+ if (vmediaOk(url)) v.addEventListener('loadedmetadata', () => {
if (v.videoWidth || onCopy() || h264Failed.has(url)) return;
noPicture.add(url);
const want = !v.paused;
@@ -7973,7 +8324,18 @@ export function messagesFeature(ctx) {
if (want) v.play().catch(() => {});
});
v.addEventListener('error', () => {
- if (!onCopy()) return;
+ if (!v.getAttribute('src')) return; // let go of (scrolled off), not failed
+ if (!onCopy()) {
+ if (vmediaOk(url) && !h264Failed.has(url) && !v._origFailed) {
+ v._origFailed = true;
+ const want = !v.paused;
+ v.setAttribute('src', h264Of(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
+ clipDead(v, url);
+ return;
+ }
// the stream failed: the copy or the original, as it would have been
if (isHls(v.getAttribute('src'))) {
hlsFailed.add(url);
@@ -7983,6 +8345,7 @@ export function messagesFeature(ctx) {
return;
}
h264Failed.add(url);
+ if (v._origFailed) { clipDead(v, url); return; } // neither the file nor a copy of it: gone
const want = !v.paused;
v.setAttribute('src', url);
if (want) v.play().catch(() => {});
@@ -7995,10 +8358,38 @@ export function messagesFeature(ctx) {
});
}
// A tap on the picture itself (not the player's own play button or its
- // control bar): inline, it goes full screen — the corner button is a
+ // control bar): inline, a mouse click pauses a playing clip; a paused one
+ // (or any touch) goes full screen — the corner button is a
// reach on a phone. Full screen, it pauses a playing clip, and a tap on a
// paused one comes back out. The player's controls are inside the video
// element, so where they sit is judged from the tap's position.
+ // The big play/pause over a feed clip is for a mouse: <html> carries
+ // .has-mouse while the last pointer was one (a touch laptop switches with
+ // the hand), and CSS shows the button on :hover (a repaint can't drop it).
+ if (typeof document !== 'undefined') {
+ const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on);
+ document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true });
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true });
+ }
+ // A clip whose post has timed reactions seeks on the moments bar under it
+ // (the reactions sit on it at their moments), so the player's own controls
+ // — a second seek bar — stay off; the middle button and the sound button
+ // are its controls then (Adam: "hide the main seek bar … and only show a
+ // moments seek bar"). Otherwise the player's controls, once it has woken.
+ const nativeControls = (v) => { v.controls = !v._ownBar && !v._gone; };
+ function ownBar(player, on) {
+ const box = player.closest('.note-video-box');
+ if (!box || player._ownBar === on) return;
+ player._ownBar = on;
+ box.classList.toggle('own-bar', on);
+ if (!box.classList.contains('idle')) nativeControls(player);
+ // with no player controls, the sound button stays to turn it back down
+ if (on && !box.querySelector('.vid-sound')) {
+ const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); });
+ b.setSound(!player.muted);
+ box.append(b);
+ }
+ }
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened
function videoTap(e, url) {
@@ -8020,12 +8411,24 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return true;
}
+ // a mouse click on a playing clip just pauses it (Adam: "clicking on a
+ // video that's playing should pause the video, not full screen it"); it
+ // keeps its controls and the middle button resumes it, and a click on it
+ // paused opens the pager. A touch has no middle button: its tap still opens.
+ if (!v.paused && e.pointerType === 'mouse') {
+ v.pause();
+ wokeClips.add(url);
+ const box = v.closest('.note-video-box');
+ if (box) { box.classList.remove('idle'); box._skipMorph = true; }
+ nativeControls(v);
+ return true;
+ }
if (openClip(v, url)) return true;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
if (box) { box.classList.remove('idle'); box._skipMorph = true; }
- v.controls = true;
+ nativeControls(v);
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return true;
// (the screen isn't turned for it: a wide clip plays with bars, and the
@@ -8065,10 +8468,9 @@ export function messagesFeature(ctx) {
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
- onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
+ onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); },
// a touch's tap was taken at pointerup (below): its click, if one comes, is spent
- onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); },
- onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
+ onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); } });
guardPicture(v, url);
// With its own controls up, Chrome on Android keeps a tap on the clip for
// them (it shows or hides the bar) and the page never gets the click — so
@@ -8087,13 +8489,38 @@ export function messagesFeature(ctx) {
if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now();
});
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
- const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v);
+ // a tall clip is held to the cap's height and the box narrows to its
+ // shape, instead of a full-width box of black bars (Adam, on a 1920 screen)
+ const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined;
+ const known = feedMedia(url);
+ const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v);
+ v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; });
if (loud) box._skipMorph = true;
+ // With a mouse: a big play/pause button in the middle while the pointer is
+ // over the clip. It plays or pauses in place, never full screen (a click
+ // elsewhere on a paused clip opens the pager). Touch keeps its taps.
+ const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const player = e.currentTarget.closest('.note-video-box')?.querySelector('video');
+ if (!player) return;
+ if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause();
+ } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' }));
+ const ppState = () => {
+ const live = v.closest('.note-video-box');
+ const b = live && live.querySelector('.vid-pp');
+ if (!b) return;
+ b.classList.toggle('playing', !v.paused);
+ b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause'));
+ };
+ v.addEventListener('play', ppState);
+ v.addEventListener('pause', ppState);
+ box.append(pp);
+
// remembered by clip, so a repaint draws the box as it now stands
const wake = () => {
wokeClips.add(url);
box.classList.remove('idle');
- v.controls = true;
+ nativeControls(v);
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
@@ -8102,14 +8529,19 @@ export function messagesFeature(ctx) {
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) return;
+ if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; }
player.muted = false;
unmutedClips.add(url);
liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted
if (player.paused) player.play().catch(() => {});
- button.remove(); // the player's own controls carry mute from here
+ // the player's own controls carry mute from here — unless they're off
+ // for the moments bar: then this button turns it down again
+ if (player._ownBar) button.setSound(true); else button.remove();
});
v.onvolumechange = () => {
- if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same
+ const b = v.closest('.note-video-box')?.querySelector('.vid-sound');
+ if (v._ownBar && b && b.setSound) b.setSound(!v.muted);
+ if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same
else unmutedClips.delete(url); // muted again from the control: the next box starts quiet
};
if (btn) box.append(btn);
@@ -8121,6 +8553,7 @@ export function messagesFeature(ctx) {
() => {
box._inView = true;
clearTimeout(box._release);
+ if (v._gone) return; // nothing to load, and nothing to ask
if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) {
v.setAttribute('src', playSrc(url));
v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
@@ -8149,6 +8582,9 @@ export function messagesFeature(ctx) {
try { v.removeAttribute('src'); v.load(); } catch {}
}, 2500);
});
+ // known gone already (this player, or one before it, found the file
+ // missing): said at once, no request made
+ if (clipGone(url)) clipDead(v, url);
return box;
}
@@ -8263,8 +8699,10 @@ export function messagesFeature(ctx) {
// dozen relays a fresh REQ for each quote it met).
const findQ = new Map(); // relay -> Map(id -> waiters)
let findTimer = null;
- function askNote(relays, id) {
- relays = readRelayList(relays);
+ function askNote(relays, id, hints = []) {
+ // (a relay the reference itself names is asked even when this device has
+ // marked it sick: it's the strongest clue there is to where the note lives)
+ relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])];
if (!relays.length) return Promise.resolve(null);
return new Promise((resolve) => {
const w = { left: relays.length, resolve, done: false };
@@ -8296,19 +8734,35 @@ export function messagesFeature(ctx) {
}
}
}
- // All at once, first answer wins: asked in turn, a note on a far relay
- // took longer than the feed waits for a row.
+ // The near relays first — the reference's hints, ours, the author's own
+ // — and the wide net only once those have come back empty, or after a
+ // moment if they are slow about it (asked in turn, a note on a far relay
+ // took longer than the feed waits for a row; asked all at once, every
+ // quoted note and every reply's parent went to a dozen relays, and the
+ // Conversations view spent half its REQs — three hundred in twenty
+ // seconds — on notes our own relays answered in the first beat).
+ const WIDE_AFTER_MS = 1200;
function findNote(ref) {
const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
- const asks = [
- ask(near),
- ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
- ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
- ];
+ const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))];
return new Promise((resolve) => {
- let left = asks.length;
- for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); });
+ let done = false;
+ const found = (ev) => { if (!done && ev) { done = true; resolve(ev); } };
+ const nearAsks = [
+ askNote(near, ref.id, ref.relays || []),
+ ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))),
+ ];
+ for (const a of nearAsks) a.then(found);
+ const nearDone = Promise.all(nearAsks);
+ let wide = null;
+ const goWide = () => {
+ if (done || wide) return;
+ wide = ask(WIDE_RELAYS.filter((r) => !near.includes(r)));
+ wide.then(found);
+ };
+ const timer = setTimeout(goWide, WIDE_AFTER_MS);
+ nearDone.then(() => { clearTimeout(timer); if (done) return; goWide(); wide.then((ev) => { if (!done) { done = true; resolve(ev || null); } }); });
});
}
function quotedNote(ref) {
@@ -8514,6 +8968,121 @@ export function messagesFeature(ctx) {
// who is on now (host -> their stream), for the rings round faces: worked
// out again when a stream changes and every half minute (one goes stale)
let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ // taken down by an admin, or deleted by its streamer: nothing to play
+ const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; };
+ const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev);
+ const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; };
+ const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev);
+ // Your own stream announced live that this tab isn't sending (the app was
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]);
+ const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats'));
+ if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]);
+ const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ liveHostsAt = 0;
+ render();
+ await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt);
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // ---- moderation: a viewer reports a stream we carry; an admin takes it
+ // down (the server ends it and keeps it from everyone, for reporting)
+ const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
+ const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; };
+ async function livePost(path, body) {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] });
+ const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status);
+ return j;
+ }
+ const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other'];
+ async function sendLiveReport(ev) {
+ const r = ui.liveReport;
+ if (!r || !r.reason) return;
+ r.busy = true; render();
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() });
+ ui.liveReport = null;
+ toast(t('liveReported'));
+ } catch (e) { r.busy = false; toast(e.message || String(e)); }
+ render();
+ }
+ function liveReportPanel(ev) {
+ const r = ui.liveReport;
+ return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' },
+ h('strong', {}, t('liveReportTitle')),
+ h('div', { class: 'small muted' }, t('liveReportDesc')),
+ ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }),
+ h('span', {}, t('liveReason_' + x)))),
+ h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel'))));
+ }
+ async function takeDown(ev) {
+ if (!confirm(t('liveTakeDownAsk'))) return;
+ const ban = confirm(t('liveBanAsk'));
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/remove', { ban });
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'removed'; k.at = Date.now(); }
+ toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown'));
+ render();
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // Your own stream's recording, deleted: the server lets its files go, and the
+ // announcement is said again without it (other apps drop the link too).
+ async function deleteRecording(ev) {
+ if (!confirm(t('liveDeleteAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ await livePost('/live/' + ourStreamId(ev) + '/delete', {});
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'deleted'; k.at = Date.now(); }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], CLIENT_TAG]);
+ if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]);
+ const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags });
+ takeLive([evt]);
+ liveHostsAt = 0;
+ publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {});
+ toast(t('liveDeleted'));
+ render();
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }
+ }
function liveHostNow(pk) {
if (!liveSeen.size) return null;
if (Date.now() - liveHostsAt > 30_000) {
@@ -8544,11 +9113,45 @@ export function messagesFeature(ctx) {
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),
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000),
]);
}).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
}
+ // Past streams: ended ones that left a recording, the last month of them.
+ // A feed asks for its people's (all of them for Everything), a profile for
+ // its person's; opening one plays the recording (livePlayable).
+ const PAST_DAYS = 30;
+ const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400;
+ const pastAsked = new Map(); // what was asked -> when
+ let pastAsking = 0;
+ function askPast(key, filters) {
+ if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return;
+ pastAsked.set(key, Date.now());
+ pastAsking++;
+ Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => [])))
+ .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); })
+ .finally(() => { pastAsking--; scheduleRepaint(); });
+ }
+ const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev);
+ const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at;
+ const pastPeople = (def) => {
+ const fol = followsNow().set;
+ const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])];
+ return people && people.slice(0, 400);
+ };
+ const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]);
+ function pastForFeed(def) {
+ const ps = pastPeople(def);
+ if (ps && !ps.length) return [];
+ askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300));
+ const set = ps && new Set(ps);
+ return [...liveSeen.values()]
+ .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey)))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ }
+ const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
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) {
@@ -8570,26 +9173,82 @@ export function messagesFeature(ctx) {
const s = liveStatus(ev);
return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
}
+ // Past streams scroll on: more tiles as the grid's foot comes near, and
+ // once those we hold run out, older ones from the relays (before the oldest
+ // asked so far) until a page brings nothing back.
+ const PAST_STEP = 24;
+ const pastPages = new Map(); // feed id -> { shown, until, end, busy }
+ const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; };
+ function morePast() {
+ if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return;
+ const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]');
+ if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return;
+ const def = feedDef(), p = pastPage(def), all = pastForFeed(def);
+ if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; }
+ if (p.end || p.busy || pastAsking) return;
+ const ps = pastPeople(def);
+ if (ps && !ps.length) { p.end = true; return; }
+ // older than the oldest we hold (the first ask's page may stop short of its month)
+ if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at));
+ p.busy = true;
+ scheduleRepaint();
+ Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => [])))
+ .then((rs) => {
+ const evs = rs.flat();
+ if (!evs.length) { p.end = true; return; }
+ p.until = Math.min(...evs.map((e) => e.created_at));
+ takeLive(evs);
+ p.shown += PAST_STEP;
+ })
+ .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on
+ }
+ let pastScrollQueued = false;
+ if (typeof document !== 'undefined') document.addEventListener('scroll', () => {
+ if (pastScrollQueued || mediaMode !== 'live') return;
+ pastScrollQueued = true;
+ requestAnimationFrame(() => { pastScrollQueued = false; morePast(); });
+ }, { capture: true, passive: true });
// the feed's Live chip: its streams on now, as a grid of tiles
function liveGrid(def) {
const evs = liveForFeed(def).slice(0, 60);
- if (!evs.length) return liveAsking
- ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
- : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive'));
- return h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...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))));
- }));
+ const pp = pastPage(def);
+ const past = pastForFeed(def).slice(0, pp.shown);
+ setTimeout(morePast, 0); // a short grid fills the screen without a scroll
+ return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' },
+ evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile))
+ : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
+ : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')),
+ past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null,
+ past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null,
+ pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null);
+ }
+ // a stream's tile: on now (how many watch) or past (when it ended)
+ function liveTile(ev) {
+ const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(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) },
+ now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')),
+ now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null)
+ : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))),
+ 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 Streams tab: their past streams, as the Live chip's tiles
+ function pastStreams(pk) {
+ return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile));
}
// a profile's "on now" line
function liveBanner(pk) {
askLiveOf(pk);
const ev = liveNowOf(pk);
- if (!ev) return null;
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
h('span', { class: 'live-badge live' }, t('liveBadge')),
h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
@@ -8632,34 +9291,201 @@ export function messagesFeature(ctx) {
const [, pk, ...rest] = s.a.split(':');
const d = rest.join(':');
takeLive([s.seed]);
- const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' };
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
let mine = []; try { mine = wallet.nostrRelays(); } catch {}
- const relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
const add = (e) => {
if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
scheduleRepaint();
};
w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ // who's here: NIP-53 presence (kind 10312, the stream in its a tag) —
+ // Shosho, zap.stream and others say it, and so do we while it's open
+ w.here = new Map(); // pubkey -> when they last said so
+ w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => {
+ if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return;
+ w.here.set(e.pubkey, e.created_at);
+ scheduleRepaint();
+ }));
+ sayHere(w);
return w;
}
+ // Presence counts for half an hour (some clients say it once, on joining);
+ // ours is said again every five minutes while the stream is open, and
+ // taken back (an empty one) on leaving. Only with a key held here: a
+ // signer app would be asked to sign every five minutes.
+ const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800;
+ async function presence(w, here) {
+ const id = await identity().catch(() => null);
+ if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return;
+ const ev = liveSeen.get(w.a);
+ if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over
+ const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer);
+ if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; }
+ publishOn(w.relays, evt).catch(() => {});
+ }
+ function sayHere(w) {
+ presence(w, true);
+ w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000);
+ }
+ // the people known to be watching (fresh presence), newest first
+ const watchersOf = (w) => {
+ const since = Date.now() / 1000 - PRESENCE_FRESH;
+ return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : [];
+ };
+ // "4 watching": hover names who (as many as are known), a tap shows their faces
+ function watchingLine(w, n, cls = 'small muted') {
+ const who = watchersOf(w);
+ const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : '');
+ return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined,
+ role: who.length ? 'button' : undefined,
+ onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined },
+ t('liveWatching', { n: Math.max(n || 0, who.length) }));
+ }
+ function watchersRow(w) {
+ const who = watchersOf(w);
+ if (!w || !w.showWho || !who.length) return null;
+ return h('div', { class: 'live-who', 'data-key': 'live-who' },
+ ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) },
+ avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk)))));
+ }
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
function stopLivePlayer(w = liveWatch) {
if (!w) return;
if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; }
+ clearInterval(w.rtcBeat);
const v = w.video;
w.video = null;
- if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} }
+ if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer
+ // the sender's tracks, we answer. Resolves once connected (rejects after
+ // 8 s, or if there's no WebRTC for it); after that, a lost connection or a
+ // new publish (the sender reconnected) mounts the player again.
+ async function rtcWatch(w, el, src, go) {
+ const base = src.replace(/live\.m3u8$/, '');
+ const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const media = new MediaStream();
+ pc.ontrack = (e) => {
+ media.addTrack(e.track);
+ if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); }
+ };
+ const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) });
+ if (!r.ok) throw new Error('watch ' + r.status);
+ const j = await r.json();
+ if (w.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp });
+ await pc.setLocalDescription(await pc.createAnswer());
+ const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) });
+ if (!r2.ok) throw new Error('answer ' + r2.status);
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('rtc timeout')), 8000);
+ const on = () => {
+ if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); }
+ else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); }
+ };
+ pc.addEventListener('connectionstatechange', on);
+ on();
+ });
+ if (w.pc !== pc) return;
+ w.rtcFails = 0;
+ const again = (giveUp) => {
+ if (w.pc !== pc || w.video !== el) return;
+ if (giveUp || ++w.rtcFails > 3) w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint();
+ };
+ pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); });
+ // counted as watching (no playlist is asked for), and told of a new publish
+ w.rtcBeat = setInterval(() => {
+ fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json())
+ .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {});
+ }, 10_000);
}
function stopLiveWatch() {
const w = liveWatch;
if (!w) return;
liveWatch = null;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
for (const stop of w.stops) { try { stop(); } catch {} }
+ clearInterval(w.hereTimer);
+ if (w.said) presence(w, false); // gone
stopLivePlayer(w);
}
// What to play: the stream while it's on, its recording once it's over.
- const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : '');
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // that gets a link out — a player can't play a page.
+ const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || '');
+ const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; };
+ const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : '');
// The <video> is the page's own once it plays: Chrome takes the playlist as
// its src; anywhere without HLS of its own, hls.js feeds it. The morph
// leaves it alone from then on (it would strip the src hls.js set).
@@ -8686,8 +9512,27 @@ export function messagesFeature(ctx) {
el.addEventListener('hlsfatal', fail);
el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
- if (!isHls(src) || canHls()) { el.src = src; go(); return; }
- import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => {
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ // Ours, on: WebRTC first (a fraction of a second behind), HLS if it
+ // can't be had — no WebRTC for the stream, or no connection in 8 s
+ if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) {
+ rtcWatch(w, el, src, go).catch(() => {
+ if (w.video !== el) return;
+ w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint(); // mounted again, as HLS
+ });
+ return;
+ }
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
w.hls = hls; go();
@@ -8708,31 +9553,70 @@ export function messagesFeature(ctx) {
if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
}
- async function sendLiveChat(w, text) {
+ async function sendLiveChat(w, text, reply) {
const id = await requireIdentity();
const ev = liveSeen.get(w.a);
const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
- tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] };
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
render();
if (!(await publishOn(relays, evt))) {
w.lines.delete(evt.id);
- w.draft = text;
- toast(t('msgSendFailed'));
- render();
+ throw new Error(t('msgSendFailed'));
}
}
- function liveLine(ev) {
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
if (ev.kind === LIVE_CHAT_KIND) {
if (hiddenPk(ev.pubkey)) return null;
- return keyed(h('div', { class: 'live-line' },
- avatar(ev.pubkey, 'chat-avatar mini'),
- h('div', { class: 'live-line-body' },
- h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ',
- ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id);
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
}
const z = zapLine(ev, decodeBolt11);
if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
@@ -8754,31 +9638,46 @@ export function messagesFeature(ctx) {
const ev = liveSeen.get(s.a) || s.seed;
const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
const src = livePlayable(ev);
- const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean);
- const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ const reactions = liveReactions([...w.actions.values()]);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean);
+ const draftKey = 'live:' + w.a;
+ const reply = w.lines.get(w.reply);
+ const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
: !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
const send = () => {
- const text = (w.draft || '').trim();
- if (!text) return;
- w.draft = '';
- const inp = document.querySelector('.live-input');
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
- sendLiveChat(w, text).catch(() => { w.draft = text; render(); });
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
};
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
- h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'card col live-main', 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')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
liveBadge(ev)),
mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
- h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ watchingLine(w, bc.viewers || 0),
bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
: bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
- h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
@@ -8788,31 +9687,44 @@ export function messagesFeature(ctx) {
src && !w.err
? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
- : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))),
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
avatar(host, 'chat-avatar mini'),
h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
- n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ n != null && status === 'live' ? watchingLine(w, n) : null,
h('span', { style: 'margin-left:auto' }),
canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
- h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => {
+ // ours to answer for: anyone signed in can report it, an admin can take it down
+ ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null,
+ ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null,
+ // your own, over: its recording is yours to delete
+ ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) },
+ h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => {
const url = location.origin + '/' + packNaddr(s.a);
try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
- } }, '↗')),
- liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null),
- h('div', { class: 'card col', style: 'gap:8px' },
+ } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))),
+ watchersRow(w),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
+ ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
+ h('div', { class: 'card col live-chat-card', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('liveChat')),
h('div', {
class: 'live-chat', 'data-key': 'live-chat:' + s.a,
onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
}, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
- isVisitor() ? null : h('div', { class: 'row gap6' },
- h('input', {
- type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500',
- onInput: (e) => { w.draft = e.target.value; },
- onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } },
- }),
- h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
...noteOverlays());
}
@@ -8850,7 +9762,10 @@ export function messagesFeature(ctx) {
async function bcPreview() {
const b = bc;
if (!b) return;
- bcStopMedia();
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
b.err = null;
try {
let media;
@@ -8858,8 +9773,8 @@ export function messagesFeature(ctx) {
media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
- // the browser's own "Stop sharing" ends the stream
- media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); });
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
} else {
media = await navigator.mediaDevices.getUserMedia({
video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
@@ -8867,11 +9782,15 @@ export function messagesFeature(ctx) {
});
}
if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
b.media = media;
- // a switch mid-stream: the recorder carries on with the new picture
- if (b.phase === 'live') bcRecord(b, true);
+ // a switch mid-stream: the recorder carries on with the new picture,
+ // and the WebRTC copy swaps it in where it is
+ if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); }
} catch (e) {
- if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
}
render();
}
@@ -8929,7 +9848,21 @@ export function messagesFeature(ctx) {
b.ws = ws;
bcRecord(b);
b.reconnecting = false;
- if (info.id !== b.id) toast(t('goLiveRestarted'));
+ // The server couldn't take us back (it restarted, or we were gone
+ // too long): this is a new stream at a new address. The old one is
+ // over — said so, with no recording (there's next to nothing in
+ // it) — and the new one is announced as soon as it plays.
+ if (info.id !== b.id) {
+ toast(t('goLiveRestarted'));
+ const old = { ...b, recording: null };
+ Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id });
+ bcRtc(b).catch(() => {});
+ bcAnnounce(old, 'ended').catch(() => {});
+ bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => {
+ // the streamer's own view of it follows to the new address
+ if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); }
+ }).catch(() => {});
+ }
render();
return;
} catch {}
@@ -8938,6 +9871,48 @@ export function messagesFeature(ctx) {
b.reconnecting = false;
if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
}
+ // The stream as WebRTC, for the app's own viewers: the picture also goes to
+ // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from
+ // there a fraction of a second behind — HLS is seconds behind. Best effort:
+ // the HLS above is the stream either way, and the recording.
+ async function bcRtc(b, tries = 0) {
+ if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return;
+ bcRtcStop(b);
+ const id = b.id;
+ const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const again = () => {
+ if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return;
+ bcRtcStop(b);
+ setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1));
+ };
+ pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; };
+ try {
+ const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0];
+ const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] });
+ const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' });
+ await pc.setLocalDescription(await pc.createOffer());
+ const u = liveBase() + '/live/' + id + '/rtc/publish';
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] });
+ const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' },
+ body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) });
+ if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server
+ if (!r.ok) throw new Error('publish ' + r.status);
+ const j = await r.json();
+ if (b.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp });
+ } catch { again(); }
+ }
+ function bcRtcSwap(b) {
+ if (!b.pc || !b.media) return;
+ for (const tr of b.pc.getTransceivers()) {
+ const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind;
+ const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null;
+ if (kind) tr.sender.replaceTrack(next || null).catch(() => {});
+ }
+ }
+ function bcRtcStop(b) {
+ if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; }
+ }
// the announcement: your 30311, and its copies as the stream goes on
async function bcAnnounce(b, status) {
const id = await requireIdentity();
@@ -8948,14 +9923,27 @@ export function messagesFeature(ctx) {
['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
if (me.picture) tags.push(['image', me.picture]);
if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
- if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))], ['recording', b.recording]);
- const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags };
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
+ if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
takeLive([evt]);
- b.ev = evt;
+ if (bc === b) b.ev = evt;
scheduleRepaint();
return publishOn(relays, evt);
}
+ async function bcPlaying(b) {
+ const url = b.liveUrl;
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ }
async function startBroadcast() {
const b = bc;
if (!b || b.phase !== 'setup' || !b.media) return;
@@ -8968,16 +9956,13 @@ export function messagesFeature(ctx) {
b.phase = 'live';
b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
bcRecord(b);
+ bcRtc(b).catch(() => {});
try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
// the stream is a few seconds behind the camera: announced once it has
// three segments — Chrome's own player won't start a live playlist with
// fewer (it fails to parse it), so a viewer who comes at once would get
// an error, not the stream
- for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) {
- await new Promise((r) => setTimeout(r, 500));
- const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
- if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
- }
+ await bcPlaying(b);
if (bc !== b || b.phase !== 'live') return;
await bcAnnounce(b, 'live');
b.tick = setInterval(() => bcTick(b), 60_000);
@@ -9007,6 +9992,7 @@ export function messagesFeature(ctx) {
b.phase = 'ended';
clearInterval(b.tick);
try { b.rec && b.rec.stop(); } catch {}
+ bcRtcStop(b);
try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
bcStopMedia();
@@ -9030,6 +10016,15 @@ export function messagesFeature(ctx) {
if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
});
const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ // the server's say first (a name a day old, not barred), so Start doesn't just fail
+ if (name && b.allowed === undefined) {
+ b.allowed = null;
+ signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] })
+ .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }))
+ .then((r) => r.json()).then((j) => { b.allowed = j; render(); })
+ .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start
+ }
+ const refused = b.allowed && b.allowed.ok === false ? b.allowed : null;
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(true),
h('div', { class: 'card col', style: 'gap:12px' },
@@ -9037,6 +10032,8 @@ export function messagesFeature(ctx) {
backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
h('h3', { style: 'margin:0' }, t('goLive'))),
!name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until
+ ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null,
h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
b.media
? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
@@ -9048,7 +10045,7 @@ export function messagesFeature(ctx) {
h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
onInput: (e) => { b.title = e.target.value; } }),
b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
- h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup', onClick: startBroadcast },
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast },
b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
h('div', { class: 'small faint' }, t('goLiveHint'))));
}
@@ -9064,6 +10061,21 @@ export function messagesFeature(ctx) {
};
let calls = null;
const callInbox = new Map(); // peer -> their inbox relays, for this session
+ // Relays that turned a call message away are left out of the next ones: a
+ // relay that won't take the kind (nip17.com: "only kind 1059") for a day,
+ // one rate-limiting or banning us for ten minutes. Kept on this device.
+ const CALL_SHUN_KEY = 'coinos-call-shun';
+ let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until
+ function callRefused(url, msg) {
+ const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg);
+ callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000);
+ try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {}
+ }
+ function callRelaysOf(relays) {
+ const now = Date.now();
+ const ok = relays.filter((r) => !(callShun[r] > now));
+ return ok.length ? ok : relays; // all of them shunned: try them anyway
+ }
async function inboxFor(pk) {
if (callInbox.has(pk)) return callInbox.get(pk);
const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
@@ -9092,6 +10104,7 @@ export function messagesFeature(ctx) {
const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
}
// who may ring: someone you follow, or someone you've written to
@@ -9112,9 +10125,18 @@ export function messagesFeature(ctx) {
calls = createCalls({
me: () => dmSubKeys()[0],
sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
send: async (pk, signed) => {
- const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth });
- if (!ok) throw new Error('not delivered');
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ const relays = callRelaysOf(await inboxFor(pk));
+ if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return;
+ }
+ throw new Error('not delivered');
},
iceServers: callIce,
media: callMedia,
@@ -9326,6 +10348,7 @@ export function messagesFeature(ctx) {
micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
};
@@ -9338,7 +10361,9 @@ export function messagesFeature(ctx) {
queueMicrotask(mountCallMedia);
if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
const video = c.type === 'video';
- const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0;
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
: c.state === 'starting' || c.state === 'offering' ? t('callCalling')
@@ -9358,7 +10383,7 @@ export function messagesFeature(ctx) {
avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
h('div', { class: 'call-name' }, displayName(c.peer)),
h('div', { class: 'call-status' }, status)),
- video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
c.state === 'incoming'
? h('div', { class: 'call-bar' },
btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
@@ -9366,8 +10391,14 @@ export function messagesFeature(ctx) {
: c.state === 'ended' ? null
: h('div', { class: 'call-bar' },
btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
- video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
- video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
}
@@ -9472,6 +10503,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)),
h('audio', {
+ onLoadedmetadata: e => attachMediaTimeline(e.target, url),
controls: true, preload: 'none', src: url,
onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; },
}));
@@ -9795,6 +10827,14 @@ export function messagesFeature(ctx) {
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
+ } else if (/^wss?:\/\//i.test(part)) {
+ // a relay named in a post opens what's on it (the raw firehose)
+ const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part);
+ const relay = normRelay(addr);
+ out.push(relay ? h('a', {
+ href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'),
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); },
+ }, addr) : addr, tail);
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
@@ -9868,15 +10908,15 @@ export function messagesFeature(ctx) {
ui.screen = 'accounts';
render();
}
- function zapNote(pk, ev, origin) {
+ function zapNote(pk, ev, origin, moment = null) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
- ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return;
+ ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return;
}
markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally
- if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
+ if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) {
settleZap(ev.id, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -9918,7 +10958,7 @@ export function messagesFeature(ctx) {
const { pk, npub, eventId } = s;
closeZapSetup();
markZapPending(eventId, n, s.origin);
- if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
+ if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) {
settleZap(eventId, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -9972,13 +11012,17 @@ export function messagesFeature(ctx) {
// A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
- function replyToNote(ev) {
+ function replyToNote(ev, moment = null) {
+ // the post opened from a clip as it played: a reply to it is about that moment
+ const offered = ui.noteThread && ui.noteThread.momentFor;
+ if (!moment && offered && offered.id === ev.id) moment = offered.moment;
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
+ s.mediaMoment = moment;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
@@ -10122,7 +11166,7 @@ export function messagesFeature(ctx) {
const id = await requireIdentity();
const partial = {
kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000),
- tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG],
+ tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
myReactEv.set(ev.id, evt.id);
@@ -10384,6 +11428,10 @@ export function messagesFeature(ctx) {
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
+ // A list row with nothing left to show: a post its author deleted, or a
+ // repost of one (a deletion that lands after the row did, too). Threads
+ // keep their deleted posts' places; lists just drop them.
+ const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev)));
const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
@@ -10413,10 +11461,14 @@ export function messagesFeature(ctx) {
function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); }
function indexReport(r, ev, mine) {
const hex = (v) => /^[0-9a-f]{64}$/.test(v || '');
+ // every post named counts toward es, the 'other' ones too: a report
+ // of a post as 'other' with its author's p tag beside it used to have
+ // es = 0 and land on the AUTHOR as a person report
let es = 0;
for (const x of ev.tags || []) {
- if (x[0] !== 'e' || !hex(x[1]) || x[2] === 'other') continue;
+ if (x[0] !== 'e' || !hex(x[1])) continue;
es++;
+ if (x[2] === 'other') continue;
(r.ev.get(x[1]) || r.ev.set(x[1], new Set()).get(x[1])).add(ev.pubkey);
if (mine) r.mine.add(x[1]);
}
@@ -10430,10 +11482,16 @@ export function messagesFeature(ctx) {
}
}
const REPORTS_MS = 30 * 60_000;
+ // The half hour counts from the last sync the device SAVED, so a reload
+ // (every open of the installed app is one) doesn't ask the outbox relays
+ // again — some forty REQs with every follow's reports — and a sync after
+ // one asks only for what came since. (The index is kept.)
async function syncReports({ force = false } = {}) {
if (!modOn('reports') || !myPubkeys().length) return;
- if (!force && Date.now() - reportsAt < REPORTS_MS) return;
+ const last = Math.max(reportsAt, reportsNow().at || 0);
+ if (!force && Date.now() - last < REPORTS_MS) return;
reportsAt = Date.now();
+ const since = last ? { since: Math.floor(last / 1000) - 3600 } : {};
const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX);
const from = new Set(authors);
try {
@@ -10442,7 +11500,7 @@ export function messagesFeature(ctx) {
const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => {
const chunks = [];
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
- return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => []));
+ return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => []));
}));
for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev);
reportsNow().at = Date.now();
@@ -11082,8 +12140,16 @@ export function messagesFeature(ctx) {
const rm = reacts.get(ev.id);
const cached = ui.noteThread
? threadCache.get(ui.noteThread.rootId)?.counts?.[ev.id] : null;
- const likeN = cached ? cached.likes : rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0;
- const boostN = cached ? cached.boosts : visiblePks(boosts.get(ev.id) || new Set()).length;
+ // A thread remembers its counts between visits, so a short relay answer
+ // can't erase them — but what the relays HAVE brought since (a heart
+ // the feed's own count fetch tallied before this visit) outranks the
+ // memory: the number says what the who-panel below it lists. "Why does
+ // the count beside the heart say 1 if I have 2 heart reactions?"
+ // (Adam, 2026-11-02): the memory said 1, the tally in hand 2.
+ const liveLikes = rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0;
+ const liveBoosts = visiblePks(boosts.get(ev.id) || new Set()).length;
+ const likeN = cached ? Math.max(cached.likes || 0, liveLikes) : liveLikes;
+ const boostN = cached ? Math.max(cached.boosts || 0, liveBoosts) : liveBoosts;
const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', {
class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label,
'aria-disabled': onClick ? undefined : 'true',
@@ -11099,20 +12165,20 @@ export function messagesFeature(ctx) {
const z = zapTotals.get(ev.id) || zapSeeds().get(ev.id);
const zp = pendingOf(ev.id);
const optimistic = zp ? zp.sats : 0;
- const zapSats = (cached ? cached.sats : z ? z.sats : 0) + optimistic;
+ const zapSats = Math.max(cached ? cached.sats || 0 : 0, z ? z.sats : 0) + optimistic;
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
- (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
- btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }),
+ btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }),
btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})),
btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }),
// tap to choose how you feel about it; tap again to take it back
btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact,
- () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }),
+ () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }),
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
@@ -11231,6 +12297,11 @@ export function messagesFeature(ctx) {
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;
+ // A NIP-22 comment on something that isn't an event — a web page (I
+ // tag: DaddyTube comments on a film's IMDb page) — has no post to show
+ // above it; the page it is on goes there instead, named by the
+ // comment's own alt tag when it carries one, else by the address.
+ const scope = ev.kind === COMMENT && !parent && !(ev.tags || []).some((x) => (x[0] === 'e' || x[0] === 'E') && x[1]) ? webScopeOf(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
@@ -11273,6 +12344,9 @@ export function messagesFeature(ctx) {
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,
+ scope ? h('div', { class: 'note-context note-scope' },
+ h('a', { class: 'small faint note-context-label', href: scope.url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() },
+ '\u{1F4AC} ' + scope.label)) : 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', {
@@ -11282,7 +12356,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
- (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (isReply && !parent && !scope ? '↩ ' + 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
@@ -11297,7 +12371,9 @@ export function messagesFeature(ctx) {
zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null)
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
- : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
+ : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
+ momentLink(ev),
+ ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))),
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -11558,12 +12634,24 @@ export function messagesFeature(ctx) {
// at the top of the feed before any signing or relay round-trip (a remote
// signer alone can take seconds), swapped for the signed event on success
// and withdrawn on failure.
+ // the hashtags in the text as t tags (NIP-24), so topic feeds and other
+ // clients' searches find the post — up to the count past which a post
+ // reads as hashtag spam
+ const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ function postTags(text, media, me) {
+ // NIP-92: what we uploaded, described, so clients needn't sniff the URL
+ const imeta = media.filter((m) => m && m.url).map(mediaImeta);
+ // NIP-18 quote: the reference is already in the text; the tags let a
+ // client render the quoted post inline and tell its author
+ const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
+ ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
+ // the people it mentions are told (NIP-27 p tags)
+ const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
+ return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG];
+ }
async function publishPost(text, media = []) {
const id = await requireIdentity();
- // the hashtags in the text as t tags (NIP-24), so topic feeds and other
- // clients' searches find the post — up to the count past which a post
- // reads as hashtag spam
- const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ const hashtags = postHashtags(text);
const temp = {
id: 'pending:' + Math.random().toString(36).slice(2),
pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
@@ -11582,15 +12670,7 @@ export function messagesFeature(ctx) {
for (const c of feeds) c.notes = [temp, ...c.notes];
render();
try {
- // NIP-92: what we uploaded, described, so clients needn't sniff the URL
- const imeta = media.filter((m) => m && m.url).map(mediaImeta);
- // NIP-18 quote: the reference is already in the text; the tags let a
- // client render the quoted post inline and tell its author
- const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
- ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
- // the people it mentions are told (NIP-27 p tags)
- const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] };
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -11606,6 +12686,93 @@ export function messagesFeature(ctx) {
}
}
+ // ---- scheduled posts -----------------------------------------------------
+ // Signed now, with created_at = when it should appear, and handed to the
+ // notifier, which holds it and publishes it then (relays refuse notes from
+ // the future). Signing now means a remote signer is asked while you're
+ // here, and the note that goes out is exactly the one you wrote. This
+ // device remembers what it scheduled, to show and cancel; the post itself
+ // turns up in the feeds once it's out.
+ const SCHED_MIN_AHEAD = 120;
+ async function schedulePost(text, media, at) {
+ const id = await requireIdentity();
+ if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon'));
+ const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
+ const res = await fetch(`${NOTIFIER}/schedule`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, relays }),
+ }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') }));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ ui.quoteOf = null;
+ const s = st();
+ s.scheduled ||= {};
+ s.scheduled[evt.id] = { at, text, pubkey: id.pubkey };
+ save(s);
+ render();
+ return evt;
+ }
+ // what this account has waiting, soonest first; anything past its time
+ // has gone out (or the notifier gave up) and leaves the list
+ function scheduledPosts() {
+ const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000);
+ let dropped = false;
+ for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; }
+ if (dropped) save(s);
+ return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me)
+ .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at);
+ }
+ // once a session, ask which are still held: one cancelled on another
+ // device is gone from the notifier, so it goes from here too
+ let schedChecked = false;
+ function checkScheduled(list) {
+ if (schedChecked || !list.length) return;
+ schedChecked = true;
+ fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => {
+ const s = st();
+ let n = 0;
+ for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; }
+ if (n) { save(s); render(); }
+ }).catch(() => {});
+ }
+ async function cancelScheduled(item) {
+ const id = await requireIdentity();
+ const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] };
+ const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const res = await fetch(`${NOTIFIER}/schedule/cancel`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }),
+ }).then((r) => r.json()).catch(() => ({}));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ const s = st();
+ delete s.scheduled?.[item.id];
+ save(s);
+ render();
+ }
+ const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ // <input type=datetime-local> speaks local wall-clock time
+ const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
+ function scheduledList() {
+ const list = scheduledPosts();
+ checkScheduled(list);
+ if (!list.length) return null;
+ return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })),
+ ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' },
+ h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
+ h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)),
+ h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)),
+ h('button', {
+ class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id,
+ onClick: async () => {
+ ui.schedCancelling = x.id; render();
+ try { await cancelScheduled(x); toast(t('schedCancelled')); }
+ catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.schedCancelling = null; render();
+ },
+ }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel')))));
+ }
+
async function publishReply(c, s, text) {
const id = await requireIdentity();
const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
@@ -11651,6 +12818,8 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
+ note.tags.push(...momentTags(s.mediaMoment));
+ if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content;
const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -11717,6 +12886,11 @@ export function messagesFeature(ctx) {
class: 'col thread-reply' + (joined ? ' joined' : ''),
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
+ // the moment of the clip this reply is about, and a way to make it about the whole post
+ s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' },
+ t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'),
+ style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -11741,7 +12915,9 @@ export function messagesFeature(ctx) {
onKeydown: (e) => {
mentionKey(e);
if (e.defaultPrevented) return;
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
+ // a phone's Return is a new line (Reply is the button); a
+ // keyboard's Enter sends
+ if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
const el = e.target;
@@ -11782,7 +12958,19 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; render(); },
+ // Cancel discards: the typed text, its attachments and the box.
+ // (It only folded the `replying` flag before, and the box stays
+ // up while a draft has text — so with anything typed it did
+ // nothing visible.) The field may still be focused, and the
+ // morph leaves a focused field's value alone: clear it by hand.
+ onClick: () => {
+ const text = s.draft || '';
+ s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null;
+ ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url));
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ render();
+ },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -11798,7 +12986,7 @@ export function messagesFeature(ctx) {
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
- s.replying = false; s.preview = false; // sent: the box folds away
+ s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
@@ -11881,12 +13069,47 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
- async function publishProfileFields(fields, opts = {}) {
+ // Every kind-0 publish merges over the newest copy on the relays, so two
+ // publishes in flight at once clobber each other: both read the same base,
+ // the later one lands without the earlier one's fields. That exact race
+ // put a migrated user's released placeholder name back into their lud16
+ // (the rename repointed it, the legacy-avatar import republished the old
+ // base nine seconds later). One at a time, and the base is never older
+ // than what this session last wrote.
+ let kind0Chain = Promise.resolve();
+ let kind0Last = null; // { at, content } — the newest kind 0 we published
+ const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; };
+ // A kind 0 is replaceable too: one built on a profile we failed to find
+ // wipes the real one wherever it lands. 2026-10-30, a long-time nostr
+ // user signed in with his key for the first time; our relays had never
+ // seen his kind 0 (it lived on ditto, snort and oxtr), the lookup below
+ // came back empty, and the payment-address publish went out as
+ // {lud16, nip05} alone — name, picture and banner gone everywhere. So an
+ // empty quick lookup widens to the same search the follow list gets
+ // (indexes, archives, his own write relays, Primal's signed copy) before
+ // anything is written. Once per key per session: a key with no profile
+ // anywhere is new, and its first publish shouldn't wait twice.
+ const kind0DeepMiss = new Set();
+ async function newestKind0(pk) {
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000);
+ let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null;
+ if (!newest && !(kind0Last && kind0Last.pk === pk) && !kind0DeepMiss.has(pk)) {
+ try { newest = (await deepContactList(pk)).profile || null; } catch {}
+ if (!newest) kind0DeepMiss.add(pk);
+ }
+ if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last;
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ return base;
+ }
+ // Strictly later than the last one we sent: relays keep the LOWEST id of
+ // two replaceable events with the same second, so a second publish in
+ // the same second as the first could lose to it.
+ const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; };
+ function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); }
+ async function publishProfileFieldsNow(fields, opts = {}) {
const id = await requireIdentity();
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
@@ -11902,10 +13125,11 @@ export function messagesFeature(ctx) {
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
@@ -11919,11 +13143,20 @@ export function messagesFeature(ctx) {
ui.profSaving = true;
render();
try {
+ await withKind0Lock(() => saveProfileNow(id, e));
+ ui.profEdit = null; ui.profEditFilled = false;
+ toast(t('profSaved'));
+ } catch (err) {
+ if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
+ toast(err.message || String(err));
+ } finally {
+ ui.profSaving = false;
+ render();
+ }
+ }
+ async function saveProfileNow(id, e) {
// merge over the newest published kind 0 so unknown fields round-trip
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
@@ -11943,26 +13176,134 @@ export function messagesFeature(ctx) {
// when empty; move them when they pointed at our (old) address. A
// deliberately foreign lud16/nip05 is the user's business.
if (addr) {
- for (const k of ['lud16', 'nip05'])
- if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
+ const domain = addr.split('@')[1];
+ for (const k of ['lud16', 'nip05']) {
+ const cur = merged[k];
+ if (!cur || cur === oldAddr) { merged[k] = addr; continue; }
+ // a name at our own domain that is not ours and no longer answers
+ // (the placeholder a migration released) is ours now — Save is the
+ // heal an interactive signer can't do in the background
+ if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr;
+ }
}
if (merged.name) merged.display_name = merged.name;
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
- ui.profEdit = null; ui.profEditFilled = false;
- toast(t('profSaved'));
- } catch (err) {
- if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
- toast(err.message || String(err));
- } finally {
- ui.profSaving = false;
+ }
+ // ---- profile history: undo a kind 0 that replaced the real one ---------
+ // Relays holding the newest copy dropped the older ones; the archives
+ // often haven't. An older copy fills the editor's EMPTY fields (name,
+ // bio, picture, banner) — nothing typed or already set is touched, and
+ // nothing is published until Save.
+ const PROFILE_HISTORY_FIELDS = ['name', 'about', 'picture', 'banner'];
+ async function findProfileHistory() {
+ const me = mePk();
+ if (!me) return [];
+ const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS,
+ ...(await relaysOf(me).catch(() => []))])];
+ const evs = await queryOn(relays, { kinds: [0], authors: [me], limit: 50 }, 8000).catch(() => []);
+ const byId = new Map();
+ for (const e of evs || []) if (e.pubkey === me) byId.set(e.id, e);
+ return [...byId.values()].sort((a, b) => b.created_at - a.created_at);
+ }
+ const profileFieldsOf = (e) => {
+ let c = {};
+ try { c = JSON.parse(e.content) || {}; } catch {}
+ const out = {};
+ for (const k of PROFILE_HISTORY_FIELDS) {
+ const v = k === 'name' ? (c.display_name || c.name) : c[k];
+ if (typeof v === 'string' && v.trim()) out[k] = v.trim();
+ }
+ return out;
+ };
+ // what an older copy has that the draft is missing
+ const profileHistoryExtra = (e, draft) => Object.keys(profileFieldsOf(e)).filter((k) => !(draft && String(draft[k] || '').trim()));
+ function fillProfileFrom(e) {
+ const draft = ui.profEdit;
+ if (!draft) return 0;
+ const old = profileFieldsOf(e);
+ let n = 0;
+ for (const k of profileHistoryExtra(e, draft)) { draft[k] = old[k]; (draft.touched ||= {})[k] = true; n++; }
+ if (n) {
+ if (old.picture && draft.picture === old.picture) preloadPicture({ picture: old.picture });
+ toast(t('profHistFilled', { n }));
+ } else toast(t('profHistNothing'));
+ if (ui.profHist) ui.profHist.done = true;
+ render();
+ return n;
+ }
+ function profileHistoryCard() {
+ const draft = ui.profEdit;
+ if (!draft || !mePk()) return null;
+ const ph = ui.profHist;
+ const look = () => {
+ ui.profHist = { loading: true };
render();
+ findProfileHistory().then((vs) => { ui.profHist = { versions: vs }; render(); })
+ .catch(() => { ui.profHist = { versions: [] }; render(); });
+ };
+ const versions = (ph && ph.versions) || [];
+ const offers = versions.map((e) => ({ e, extra: profileHistoryExtra(e, draft) })).filter((v) => v.extra.length);
+ return h('div', { class: 'col prof-hist-card', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('profHistDesc')),
+ !ph || ph.done
+ ? h('button', { class: 'btn-sm', type: 'button', style: 'align-self:flex-start', onClick: look }, t('profHistLook'))
+ : ph.loading
+ ? h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' }))
+ : offers.length
+ ? h('div', { class: 'col', style: 'gap:8px' }, ...offers.slice(0, 6).map(({ e, extra }) => {
+ const f = profileFieldsOf(e);
+ return h('div', { class: 'row between gap6', style: 'align-items:center' },
+ h('div', { class: 'col', style: 'gap:1px;min-width:0' },
+ h('div', { class: 'break' }, f.name || t('profHistNoName')),
+ h('div', { class: 'small faint' }, new Date(e.created_at * 1000).toLocaleDateString()
+ + ' · ' + extra.map((k) => t('profHist_' + k)).join(', '))),
+ h('button', { class: 'btn-sm', type: 'button', onClick: () => fillProfileFrom(e) }, t('profHistUse')));
+ }))
+ : h('div', { class: 'small faint' }, t('profHistNone')));
+ }
+ // A payment-address rename released the old name: repoint the kind 0's
+ // lud16 and nip05 — but only where they pointed at the released address
+ // (or were empty). Deliberately different values are not ours to touch.
+ function repointAddress(oldAddr, newAddr) {
+ const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
+ return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
+ onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
+ fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
+ });
+ }
+ // Our kind 0 still names an address at our own domain that is not the one
+ // we hold — a placeholder name released by a migration whose repoint lost
+ // the race above. Repointed once the domain confirms the old name is dead;
+ // a same-domain name that still answers (a second account, say) is left
+ // alone. Once per address per session.
+ const addrHealed = new Set();
+ async function healStaleAddress(olds, myAddr) {
+ // Amber by intent needs a tap per signature: a background publish would
+ // sit in the kind-0 queue forever and block every later one. Save in the
+ // profile editor repoints the address for these users instead.
+ const id = await identity().catch(() => null);
+ if (!id || (id.signer && id.signer.interactive)) return;
+ for (const old of olds) {
+ if (addrHealed.has(old)) continue;
+ addrHealed.add(old);
+ try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {}
}
}
+ async function addressDead(addr) {
+ const [name, domain] = addr.split('@');
+ try {
+ const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' });
+ if (!r.ok) return true;
+ const j = await r.json().catch(() => null);
+ return !j || j.status === 'ERROR' || !j.callback;
+ } catch { return false; } // unreachable is not dead
+ }
function profileScreen() {
const pk = ui.profilePk;
@@ -12102,9 +13443,17 @@ export function messagesFeature(ctx) {
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
- const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
+ // Our own page never shows a name at our domain that the registrar says
+ // is no longer ours (see healStaleAddress): the claimed address is the
+ // truth here, and the kind 0 is brought in line in the background.
+ const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null;
+ if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
+ const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
- const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ // the lightning line too: a stale same-domain lud16 (jar@ after the
+ // rename to jarryd@) showed under the right NIP-05 as a second address
+ const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
// an about of "~" or a lone character is noise, not a bio.
// Until the full kind 0 lands, the light profile cache answers — it was
@@ -12130,7 +13479,8 @@ export function messagesFeature(ctx) {
// doesn't drop by the height of a cover photo when it arrives
: urlish(lp.banner);
const draftPic = draft && urlish(draft.picture);
- if (bannerUrl && !draft && !mediaReady.has(bannerUrl)) warmBanner(pk);
+ const bannerShown = bannerUrl && !draft ? blobAltOf(bannerUrl) || bannerUrl : bannerUrl;
+ if (bannerUrl && !draft && !mediaReady.has(bannerShown)) warmBanner(pk);
return h('div', { class: 'col', style: 'gap:16px' },
// full header on profiles too — losing the search button here made
// finding the NEXT person a trek back home
@@ -12139,7 +13489,7 @@ export function messagesFeature(ctx) {
t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'),
h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null,
h('div', { class: 'card col', style: 'gap:12px' },
- bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : null,
+ bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerShown, mediaReady.get(bannerShown)))})` }) : null,
h('div', { class: 'row gap6', style: 'align-items:center' },
draftPic
? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` })
@@ -12247,6 +13597,7 @@ export function messagesFeature(ctx) {
ui.profUploading = false; render();
},
})),
+ profileHistoryCard(),
h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile },
ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel')))
@@ -12260,6 +13611,7 @@ export function messagesFeature(ctx) {
// the half-written post back, composer open. Posting clears
// it; Cancel just closes the composer and keeps the text.
postComposer(),
+ ui.profCompose == null ? scheduledList() : null,
// the non-destructive way out: add another identity (Nostr,
// passkey, Google) next to this one and switch between them
// on the Accounts screen — logout stays for actually leaving
@@ -12345,23 +13697,27 @@ export function messagesFeature(ctx) {
const c = notesFor(pk);
// Posts | Replies: the replies are their side of other people's
// conversations, apart from what they posted themselves
- const tab = profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // Streams joins the tabs once they've streamed
+ const streamed = pastOf(pk).length > 0;
+ const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
+ // a post its author deleted leaves the list (no "was deleted" row)
+ const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
// too short to scroll means the scroll that loads more never comes
// (five pages at most per tab: someone who never replies must not
// have their whole history paged through looking for one)
const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 });
- if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
+ if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
fill.n++;
queueMicrotask(() => loadOlderNotes(pk).catch(() => {}));
}
const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' },
- ...['posts', 'replies'].map((k) => h('button', {
+ ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', {
type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k,
onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); },
- }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies'))));
+ }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams'))));
+ if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk));
// while loading with nothing to show: an invisible copy of the
// empty-state line holds the height, so the page doesn't jump when
// the answer lands (no spinner). With posts already in hand — from
@@ -12378,7 +13734,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, { context: profTab(pk) === 'replies' }), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(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' }))
@@ -12436,6 +13792,7 @@ export function messagesFeature(ctx) {
ui.profCompose = null;
ui.postMedia = [];
ui.postPreview = false;
+ ui.postSchedule = null;
discardDraft(POST_DRAFT);
render();
}
@@ -12531,11 +13888,34 @@ export function messagesFeature(ctx) {
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
+ ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' },
+ h('span', { class: 'small muted' }, t('schedAt')),
+ h('input', {
+ type: 'datetime-local', class: 'grow', value: ui.postSchedule,
+ min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000),
+ onInput: (ev) => { ui.postSchedule = ev.target.value; },
+ })) : null,
h('div', { class: 'row gap6 wrap post-actions' },
- h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
+ h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => {
const body = composeText().trim();
if (!body) return;
const media = (ui.postMedia || []).filter((m) => body.includes(m.url));
+ if (ui.postSchedule != null) {
+ // nothing goes on screen yet, so the composer stays open until
+ // the notifier has it (a signer may ask first)
+ const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000);
+ ui.postScheduling = true; render();
+ try {
+ await schedulePost(body, media, at);
+ ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null;
+ setDraft(POST_DRAFT, '');
+ const inp = document.querySelector('.post-input');
+ if (inp) inp.value = '';
+ toast(t('schedDone', { when: schedWhen(at) }));
+ } catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.postScheduling = false; render();
+ return;
+ }
// the post is on screen instantly (publishPost is optimistic) —
// close the composer now; a failure reopens it with the text intact
ui.profCompose = null;
@@ -12549,14 +13929,21 @@ export function messagesFeature(ctx) {
if (!e.silent) toast(e.message || String(e));
render();
}
- } }, t('profPostBtn')),
+ } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')),
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
h('div', { class: 'row gap6 composer-options' },
previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
+ h('button', {
+ type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'),
+ 'aria-pressed': String(ui.postSchedule != null),
+ // an hour from now, on the next quarter hour
+ onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); },
+ }, '\u{1F552}'),
h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))),
+ scheduledList(),
postCancelWarning(),
gifs.sheet('post', (g) => {
const cur = composeText().replace(/\s+$/, '');
@@ -12588,10 +13975,13 @@ export function messagesFeature(ctx) {
function feedChips() {
const items = feedList();
const cur = feedDef();
- if (cur && adhocFeeds.has(cur.id)) items.push(cur);
+ // (a wallet restoring behind /feed is on the visitor's copy of Popular
+ // for a beat: the built-in chip stands for it, not a second Popular)
+ const onVisitorCopy = curFeedId === 'all:visitor';
+ if (cur && adhocFeeds.has(cur.id) && !onVisitorCopy) items.push(cur);
return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' },
...items.map((f) => h('button', {
- class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
+ class: 'feed-chip' + (f.id === curFeedId || (onVisitorCopy && f.id === EVERYTHING) ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
onClick: () => switchFeed(f.id),
// someone's timeline carries the person, not a name — an empty
// label left a blank black pill
@@ -12670,14 +14060,17 @@ export function messagesFeature(ctx) {
}, 0);
}
function appBottomNav() {
- const visitor = isVisitor();
+ const visitor = visitorView();
const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
- const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ // a stream (or Go live) over the shell is a place of its own: no button
+ // is "here", so the one tapped goes there (Feed under a stream thought it
+ // was home and only scrolled the hidden feed)
+ const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet';
const leavePage = () => {
stopFeedWatch(); stopNotifWatch();
// Top-level destinations must leave any feature page covering the shell.
- for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch',
+ for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch',
'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
@@ -12786,7 +14179,9 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- 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 };
+ const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 });
+ 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,
+ order: { images: order(), videos: order() } };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -13013,12 +14408,17 @@ export function messagesFeature(ctx) {
// <img> of the original (or the 1100px column copy) to fetch and decode,
// and the browser's lazy margin is a screen or two. These are local and a
// few KB, so every tile loads at once and decodes in no time.
- const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200;
+ const TILE_CACHE = 'coinos-tile-thumbs-v2', TILE_PX = 360, TILE_MAX = 1200;
const tileThumbs = new Map(); // url -> object URL
const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url);
const tileQueue = [], tileTried = new Set(), tileLooked = new Set();
let tileBusy = 0, tileRepaint = null;
- const tileStore = () => caches.open(TILE_CACHE);
+ let tileOldGone = false;
+ const tileStore = () => {
+ // the square tiles of before (v1) go: the grid's rows want each shape
+ if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); }
+ return caches.open(TILE_CACHE);
+ };
async function warmTileThumbs(urls) {
if (typeof caches === 'undefined') return;
let found = 0;
@@ -13038,7 +14438,7 @@ export function messagesFeature(ctx) {
const kept = mediaReady.get(url)?.thumb;
if (!kept && (noCorsHost(url) || corsRefused.has(url))) {
// the host keeps its bytes: the server cuts the tile
- const tile = await serverPicBlob(url, 'tile');
+ const tile = await serverPicBlob(url, 'row');
if (tile) await saveTileThumb(url, tile);
return;
}
@@ -13048,7 +14448,7 @@ export function messagesFeature(ctx) {
if (!res.ok) return;
blob = await res.blob();
} catch (e) {
- if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); }
+ if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); }
return;
}
if (/gif/i.test(blob.type)) return; // animated: the tile shows the original
@@ -13203,16 +14603,51 @@ export function messagesFeature(ctx) {
// 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 {} };
+ // On a desktop-sized window every shape is the default (Adam, 2026-10-22:
+ // the justified rows hold both); a phone keeps to its way round.
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default
+ const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } };
+ const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } };
+ const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } catch {} };
const screenTall = () => window.innerHeight >= window.innerWidth;
- const clipShape = (url) => {
+ const clipSize = (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);
- return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null;
+ return w && hh ? { w, h: hh } : null;
+ };
+ // (no size yet: the still's proportions, which a grid of every shape has
+ // loaded without measuring anything — so going back to one shape finds
+ // the clips' shapes known)
+ const clipShape = (url) => {
+ const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url);
+ return r ? (r < 0.99 ? 'tall' : r > 1.01 ? 'wide' : 'square') : null;
};
+ // width over height, kept between a phone's upright 9:16 and a cinema 2:1
+ // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square
+ // (until the clip's size is known, the still's own proportions: the
+ // server's and a frame grab keep the clip's shape)
+ const posterAr = new Map();
+ const clampAr = (r) => Math.min(2, Math.max(0.5, r));
+ const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; };
+ // a grid item's: the file's measured size, the post's word for it (NIP-92
+ // dim), or its still once that has loaded
+ function itemRatio(it) {
+ if (clipSize(it.url)) return clipRatio(it.url);
+ for (const tag of it.ev.tags || []) {
+ if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue;
+ const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || '');
+ if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]);
+ }
+ return posterAr.get(it.url) || 1;
+ }
+ function arFromStill(img, url) {
+ if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return;
+ const r = clampAr(img.naturalWidth / img.naturalHeight);
+ posterAr.set(url, r);
+ const tile = img.closest('.media-grid.justified .media-tile');
+ if (tile) tile.style.setProperty('--ar', r.toFixed(3));
+ }
// 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');
@@ -13242,8 +14677,17 @@ export function messagesFeature(ctx) {
if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url);
}).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); });
}
- function mediaItems(c, kind) {
- const out = [], seen = new Set();
+ // One picture or clip shows once in the grid, however many posts carry it
+ // (Adam, 2026-10-22: a conference promo filled the Videos view with
+ // copies): the same file (a Blossom hash names it wherever it's hosted),
+ // or the same words from the same person posted again with another upload.
+ const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); };
+ const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; };
+ // (anyShape: the pager's list — every clip, the ones the wrong way round
+ // for the screen held out of sight by pagerFit, so "All shapes" there has
+ // them to show)
+ function mediaItems(c, kind, anyShape = false) {
+ const out = [], seen = new Set(), said = new Map();
const r = mediaIndex(c.id);
indexMedia(r, c.notes);
const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at);
@@ -13252,17 +14696,51 @@ export function messagesFeature(ctx) {
if (hidden(ev)) continue;
// 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;
+ const again = saidAgain(ev);
+ if (again && said.has(again) && said.get(again) !== ev.id) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip)
+ // (a clip whose host says it's deleted has nothing to show: a blank tile)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u)))
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
+ // (the words are taken by a post that shows: one whose clip doesn't fit
+ // the screen leaves them to the next)
+ if (again && urls.length) said.set(again, ev.id);
for (const url of urls) {
- const it = { ev, url };
- if (seen.has(mediaKey(it))) continue;
- seen.add(mediaKey(it));
- out.push(it);
+ if (deadTiles.has(url)) continue;
+ const f = sameFile(url);
+ if (seen.has(f)) continue;
+ seen.add(f);
+ // the same clip uploaded twice by one person (a note and a video
+ // event a few minutes apart) gets two hashes but keeps its size to
+ // the byte
+ const d = kind === 'videos' ? videoInfo.get(url) || {} : {};
+ const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null;
+ if (twin && seen.has(twin)) continue;
+ if (twin) seen.add(twin);
+ out.push({ ev, url });
}
}
- return out;
+ return settledOrder(r, kind, out);
+ }
+ // The grid's order holds for the session (Adam, 2026-10-23: clips the
+ // crawl found landed in the middle of the grid, above an older tail the
+ // index had from before, and the tiles already there jumped): what it
+ // shows first is newest first, and whatever turns up after goes under what
+ // is already there, in its own date order. Only a post newer than the top
+ // tile goes above it. Nothing is settled until the kept index is in, so
+ // the first paint is one date order, not two batches.
+ function settledOrder(r, kind, out) {
+ if (!r.loaded) return out;
+ const o = r.order[kind];
+ const key = (it) => it.ev.id + '|' + it.url;
+ const fresh = out.filter((it) => !o.seq.has(key(it)));
+ if (fresh.length) {
+ const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : [];
+ for (const it of above.reverse()) o.seq.set(key(it), --o.top);
+ for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom);
+ for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at;
+ }
+ return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b)));
}
// The feed's pictures (or clips) in reading order — every one of a post
// before the next post's — for the pager opened from one in the feed: the
@@ -13479,7 +14957,16 @@ export function messagesFeature(ctx) {
for (const u of missing) tileLooked.add(u);
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
- return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
+ if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
+ // The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a
+ // wide screen) go one to a row, their posters whole, not slivers cut to
+ // portrait tiles; every shape ("All shapes") is laid out in justified
+ // rows, each tile its clip's own proportions and each row as tall as it
+ // takes to fill the width; tall ones only (a phone held upright) keep
+ // the tall tiles three across.
+ // Pictures go in justified rows too, in their own shapes.
+ const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide';
+ return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
// a video tile shows a still at once: kept, given, or the server's
@@ -13492,18 +14979,28 @@ export function messagesFeature(ctx) {
const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url);
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
+ style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
+ onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
mediaMode === 'images' || poster
? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false',
loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async',
+ onLoad: (e) => arFromStill(e.currentTarget, it.url),
onError: (e) => {
if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn
+ // remembered, not only a class on the tile: the next
+ // repaint rewrites the class list, and the broken-picture
+ // glyph came back (a deleted nostr.media file in #artstr)
+ deadTiles.add(it.url);
const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken');
+ scheduleRepaint();
} })
// no still to show: the clip's first frame, fetched once the tile is near
: h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }),
- mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
+ // (drawn here whatever the tile shows, so a repaint keeps the one
+ // the frame-grab added)
+ mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null);
}));
}
@@ -13517,8 +15014,14 @@ export function messagesFeature(ctx) {
if (!e.isIntersecting) continue;
const v = e.target;
tileObs.unobserve(v);
- const tile = v.closest('.media-tile');
- if (tile) tile._skipMorph = true;
+ // the player (and whatever stands in for it) is left alone by
+ // repaints, which would strip its src; the tile around it keeps up
+ // with them (its shape when the grid's layout changes, its class).
+ // Freezing the whole tile kept a tile built for one-to-a-row out of
+ // the justified rows: no --ar, no width.
+ v._skipMorph = true;
+ const standIn = (el) => { el._skipMorph = true; return el; };
+ const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); };
// A first frame costs the start of the file when the index leads or
// the server serves ranges — whatever the clip's size. Only an
// index-last file without ranges would have to come down whole for
@@ -13532,9 +15035,11 @@ export function messagesFeature(ctx) {
if (await grabFrame(url)) {
if (!v.isConnected) return;
const img = document.createElement('img');
- img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url);
+ img.alt = ''; img.draggable = false;
+ img.addEventListener('load', () => arFromStill(img, url), { once: true });
+ img.src = videoThumbs.get(url);
v.replaceWith(img);
- if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(img);
return;
}
if (!v.isConnected) return;
@@ -13546,7 +15051,7 @@ export function messagesFeature(ctx) {
const cap = setTimeout(() => {
if (v.readyState >= 2 || !v.isConnected) return;
try { v.removeAttribute('src'); v.load(); } catch {}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, '')));
}, 8000);
v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true });
}
@@ -13554,11 +15059,23 @@ export function messagesFeature(ctx) {
// (a plain load: shown, not kept — a player's pixels can only be
// read from a CORS load, and those fail on a cached plain copy)
v.muted = true;
+ // a clip the player can't load either is gone as far as the grid
+ // goes: a deleted file whose 404 came without CORS headers
+ // (Primal's blossom) only looked like a quiet host to the probe,
+ // and its tile sat blank. Left out for a day, like a 404 the
+ // probe could read.
+ v.addEventListener('error', () => {
+ if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap
+ noteVideoInfo(url, { goneAt: Date.now() });
+ const b = v.closest('.media-tile');
+ if (b) b.classList.add('broken');
+ scheduleRepaint();
+ }, { once: true });
v.src = v.getAttribute('data-src');
- if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(v);
return;
}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')));
});
}
}, { rootMargin: '400px 0px' });
@@ -13568,6 +15085,51 @@ export function messagesFeature(ctx) {
}
}
+ // Opening a clip from the grid: on Adam's phone a player made at the tap
+ // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting
+ // its sound ~0.3 — the clip's opening already in the cache saved nothing),
+ // and 1.8 s when the host's connection had to be made too. So the grid's
+ // clip hosts are connected to ahead (a preconnect: cheap), and the
+ // player starts loading when the finger comes down on the tile — the
+ // pager adopts that same element (pagerSlide), so the load overlaps the
+ // tap and the pager's opening. One the tap doesn't follow (a scroll) is
+ // let go.
+ const preconnected = new Set();
+ function preconnect(url) {
+ let origin; try { origin = new URL(url, location.href).origin; } catch { return; }
+ if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return;
+ preconnected.add(origin);
+ const l = document.createElement('link');
+ l.rel = 'preconnect'; l.href = origin;
+ document.head.append(l);
+ }
+ let early = null; // { url, v, timer }
+ function earlyPlayer(url) {
+ if (frugalData() || (early && early.url === url)) return;
+ dropEarly();
+ const v = document.createElement('video');
+ v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true;
+ v.setAttribute('src', playSrc(url));
+ v.dataset.early = '1'; // (the pager's adoption of it, for tests)
+ // on the page (out of sight) while it waits: a detached player isn't loaded everywhere
+ v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none';
+ document.body.append(v);
+ early = { url, v, timer: setTimeout(dropEarly, 2500) };
+ }
+ function dropEarly() {
+ if (!early) return;
+ clearTimeout(early.timer);
+ try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {}
+ early = null;
+ }
+ function takeEarly(url) {
+ if (!early || early.url !== url) return null;
+ const { v } = early;
+ clearTimeout(early.timer);
+ early = null;
+ return v;
+ }
+
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
@@ -13718,7 +15280,10 @@ export function messagesFeature(ctx) {
// was tried: it pulled the whole file down.) On a connection to be
// careful with the poster stays: the clip may never load (a heavy one
// waits for a tap), and the still is what there is.
- const media = video
+ // (the clip a grid tile was pressed on: the player already loading since the finger came down)
+ const adopted = video && !frugalData() ? takeEarly(url) : null;
+ if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); }
+ const media = adopted || (video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined })
: h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async',
@@ -13730,7 +15295,8 @@ export function messagesFeature(ctx) {
let host = ''; try { host = new URL(url).hostname; } catch {}
m.closest('.mp-slide').classList.add('mp-gone');
m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
- } });
+ } }));
+ if (video) capUpscale(media);
const caption = mediaCaption(ev, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
@@ -13746,18 +15312,22 @@ export function messagesFeature(ctx) {
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
- ui.reactPick = ev; render();
+ ui.reactPick = withMoment(ev); render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
+ // a reply written there is about where the clip was
+ const moment = momentOf(ev);
leavePager(); openNoteThread(ev);
+ if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment };
}) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })),
h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => {
if (signinAsk()) { leavePager(); render(); return; }
// the first zap ever asks for an amount on a page of its own
const at = e.currentTarget.getBoundingClientRect();
+ const moment = momentOf(ev);
if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, at);
+ zapNote(ev.pubkey, ev, at, moment);
recheckZap(ev.id);
setTimeout(() => pagerCounts(slide), 50);
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
@@ -13769,7 +15339,8 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
- slide.append(seekBar(media));
+ media._momentUrl = url;
+ slide.append(seekBar(media, () => momentsOn(ev.id, url)));
}
return slide;
}
@@ -13778,14 +15349,75 @@ export function messagesFeature(ctx) {
// (the clip holds still meanwhile, and the time shows); the drag is the
// bar's alone, never the pager's swipe or its tap.
const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
- function seekBar(v) {
+ // A big screen blew a small clip up into blocks (Adam, 2026-10-22: "videos
+ // that are low res … so pixellated on my large 1920 monitor"). In a big
+ // window (a phone on its side is too short) a clip is drawn at most 1.5×
+ // its own pixels,
+ // smaller ones sitting in the middle on the pager's black. A phone fills
+ // its screen as before.
+ const MAX_UPSCALE = 1.5;
+ function capUpscale(v) {
+ const fit = () => {
+ const big = v.videoWidth && matchMedia('(min-width: 1000px) and (min-height: 600px)').matches;
+ const k = MAX_UPSCALE / (window.devicePixelRatio || 1);
+ v.style.maxWidth = big ? Math.round(v.videoWidth * k) + 'px' : '';
+ v.style.maxHeight = big ? Math.round(v.videoHeight * k) + 'px' : '';
+ };
+ v.addEventListener('loadedmetadata', fit);
+ if (v.readyState >= 1) fit();
+ }
+ // up for a few seconds, and on while a finger is on it
+ // (a mouse brings it up by moving, so it goes sooner once the mouse is
+ // still, and stays while the pointer rests on the bar itself)
+ function revealSeek(bar, ms = 3500) {
+ bar.classList.add('reveal');
+ clearTimeout(bar._hide);
+ bar._hide = setTimeout(() => { if (bar._drag || bar._hover) revealSeek(bar, ms); else bar.classList.remove('reveal'); }, ms);
+ }
+ // What was said, reacted or zapped at a moment of the clip sits over the
+ // bar at that moment (a tap goes there and says who and what), and floats
+ // up off it as the clip plays past.
+ function seekBar(v, moments = () => []) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
- const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const marks = h('div', { class: 'mp-marks' });
+ const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ bar.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') bar._hover = true; });
+ bar.addEventListener('pointerleave', () => { bar._hover = false; });
+ let items = [], sig = '', checked = 0, last = 0;
+ const say = (x) => {
+ time.textContent = clock(x.moment.seconds) + ' ' + x.text;
+ bar.classList.add('say');
+ clearTimeout(bar._say);
+ bar._say = setTimeout(() => bar.classList.remove('say'), 3000);
+ revealSeek(bar);
+ };
+ const paintMarks = () => {
+ checked = performance.now();
+ items = moments();
+ const next = items.map((x) => x.id).join(',') + '@' + v.duration;
+ if (next === sig) return;
+ sig = next;
+ marks.replaceChildren(...items.map((x) => h('button', {
+ type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, v.duration) + '%',
+ onPointerdown: (e) => e.stopPropagation(),
+ onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); },
+ }, momentGlyph(x))));
+ };
+ v.addEventListener('mediaactivity', () => { checked = 0; });
+ // a moment the clip just played through floats up from its place
+ const pop = (x) => {
+ const slide = bar.closest('.mp-slide');
+ if (!slide || !pager || pager.cur !== slide) return;
+ const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x));
+ slide.append(el);
+ setTimeout(() => el.remove(), 1700);
+ };
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
const show = (f) => {
fill.style.width = (f * 100).toFixed(2) + '%';
- time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment)
};
const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
bar.addEventListener('pointerdown', (e) => {
@@ -13794,6 +15426,7 @@ export function messagesFeature(ctx) {
try { bar.setPointerCapture(e.pointerId); } catch {}
bar._drag = { playing: !v.paused, id: e.pointerId };
bar.classList.add('drag');
+ bar.classList.remove('say');
v.pause();
const f = frac(e); show(f); to(f);
});
@@ -13808,6 +15441,7 @@ export function messagesFeature(ctx) {
if (!d || d.id !== e.pointerId) return;
bar._drag = null;
bar.classList.remove('drag');
+ if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag
if (e.type === 'pointerup') to(frac(e));
if (d.playing) v.play().catch(() => {});
};
@@ -13818,7 +15452,12 @@ export function messagesFeature(ctx) {
if (bar._drag) return;
const ok = v.duration > 0 && isFinite(v.duration);
bar.classList.toggle('ready', ok);
- if (ok) show(v.currentTime / v.duration);
+ if (!ok) return;
+ show(v.currentTime / v.duration);
+ if (performance.now() - checked > 1000) paintMarks();
+ const now = v.currentTime;
+ if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x);
+ last = now;
};
return bar;
}
@@ -13857,6 +15496,7 @@ export function messagesFeature(ctx) {
const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; };
const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up);
if (back) out.push(back); // (the one before: ready again for the swipe back)
+ out.back = back || null;
return out;
}
function pagerAttach(slide) {
@@ -13879,9 +15519,41 @@ export function messagesFeature(ctx) {
im.src = full;
}, 300);
}
+ // A player's first play with sound waits on the phone's audio output to
+ // start: on Adam's Android ~250 ms of a still picture, every clip, warm
+ // or not (muted, 60 ms; a muted start then unmuted freezes the picture
+ // for those 250 ms instead). A player that has played with sound resumes
+ // in 20-80 ms, even many seconds later. So a warm player plays at a
+ // volume nobody hears until its picture moves (a frame or two in: the
+ // audio's start is the wait) and pauses there — not back to 0: a seek
+ // undoes it. Only while the pager has sound on, and the browser lets it.
+ // Having played, the player no longer stops reading at its first frame —
+ // it fetches the rest of the file — so only a clip known to be small
+ // enough to be worth that is primed (most are 3-25 MB).
+ const PRIME_MAX = 25e6;
+ function primeSound(s, v) {
+ if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return;
+ const d = videoInfo.get(v.getAttribute('data-src')) || {};
+ if (!(d.size > 0 && d.size <= PRIME_MAX)) return;
+ const go = () => {
+ if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return;
+ v._primed = true;
+ v.muted = false; v.volume = 0.001;
+ const done = () => {
+ clearTimeout(v._primeTimer);
+ if (pager && pager.cur !== s) { try { v.pause(); } catch {} }
+ v.volume = 1;
+ if (pager && pager.cur !== s) v.muted = true;
+ };
+ v._primeTimer = setTimeout(done, 2000);
+ const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); };
+ v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); });
+ };
+ if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true });
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
- if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
+ if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} }
}
// Flipping through clips is asking to watch them: whatever they cost, the
// one on screen plays at once (streaming the rest as it goes), and the
@@ -13909,8 +15581,25 @@ export function messagesFeature(ctx) {
if (j === i) return;
const v = s.querySelector('video.mp-media');
if (eager && k >= 0) {
+ // the next clip and the one before: a player of its own, warm — once
+ // its opening is in the cache, given the file to read up to its
+ // first frame (preload 'metadata': Chrome reads ~2 s of it and
+ // stops) and paused there, so the swipe onto it is only play and
+ // sound (TikTok's trick). The one just left keeps the player it
+ // played in (a swipe back to it took 0.7 s on a phone, built anew).
+ // The others ahead get their opening only.
+ const warm = k === 0 || s === ahead.back;
+ if (warm && v.getAttribute('src')) return; // warm already
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ if (warm) head.then(() => {
+ const now = pager && pager.cur && pagerAhead(pager.cur);
+ if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return;
+ v.preload = 'metadata';
+ v.muted = true;
+ pagerAttach(s);
+ primeSound(s, v);
+ });
return;
}
if (k === 0) {
@@ -14109,6 +15798,9 @@ export function messagesFeature(ctx) {
else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
}
v.muted = !pagerLoud;
+ v.volume = 1;
+ clearTimeout(v._primeTimer);
+ clockStart(slide, v);
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
if (err && err.name === 'NotAllowedError' && !v.muted) {
@@ -14118,6 +15810,37 @@ export function messagesFeature(ctx) {
}
});
}
+ // How long from landing on a clip to its picture moving, kept for the
+ // last hundred (coinosPagerStarts() in the console, and dlog), with what
+ // shape the player was in when the swipe landed (warm: it had its frame).
+ const pagerStarts = [];
+ function clockStart(slide, v) {
+ const t0 = slide._arriveAt;
+ slide._arriveAt = 0;
+ if (!t0) return;
+ const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState;
+ const primed = !!v._primed;
+ const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0;
+ const d = videoInfo.get(url) || {};
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ const done = () => {
+ const ms = Math.round(performance.now() - t0);
+ pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url });
+ if (pagerStarts.length > 100) pagerStarts.shift();
+ dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host);
+ };
+ const tick = () => {
+ if (!v.isConnected || performance.now() - t0 > 20000) return;
+ if (v.currentTime > 0 && !v.paused) { done(); return; }
+ if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
+ };
+ tick();
+ }
+ if (typeof window !== 'undefined') window.coinosPagerStarts = () => {
+ const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b);
+ const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null;
+ return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() };
+ };
function pagerSound(on) {
pagerLoud = on;
if (!pager) return;
@@ -14452,7 +16175,7 @@ export function messagesFeature(ctx) {
pendingTap = setTimeout(() => {
pendingTap = 0;
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed)
tapDoneAt = Date.now();
}, 120);
}, { passive: true });
@@ -14467,11 +16190,23 @@ export function messagesFeature(ctx) {
if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
}, true);
- el.addEventListener('click', (e) => pagerTap(e.target));
+ el.addEventListener('click', (e) => pagerTap(e.target, e.clientY));
+ // On a desktop the bar comes up while the mouse moves over a playing
+ // clip and fades once it's been still a moment (Adam, 2026-10-22).
+ el.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse' || !pager || !video) return;
+ const bar = pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar) revealSeek(bar, 2000);
+ });
// a tap shows (or hides) who posted it and what they said
- function pagerTap(target) {
+ function pagerTap(target, y) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
+ // The seek bar is out of sight while a clip plays (Adam, 2026-10-19:
+ // only when someone goes for it, or pauses): a tap near the foot of
+ // the screen, where it sits, brings it up without pausing.
+ const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; }
if (pager && video) {
// A clip (from the feed or the media grid): a tap anywhere but the
// seek bar and the buttons pauses it and brings up who posted it,
@@ -14526,6 +16261,7 @@ export function messagesFeature(ctx) {
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ slide._arriveAt = performance.now();
pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();
@@ -14651,7 +16387,7 @@ export function messagesFeature(ctx) {
const c = feedNow();
pager.groups ||= new Map();
const src = pagerSource();
- for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -14747,8 +16483,13 @@ export function messagesFeature(ctx) {
function pagerFit() {
if (!pager) return;
const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ // the grid's pager goes by the grid's rule: a clip of a shape not yet
+ // known stays out until it's measured (fitClip measures it); a page's
+ // pager shows one until it's known not to fit
+ const strict = pager.kind === 'videos' && !pagerSource();
+ const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false);
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; });
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); });
pagerToggleGroup(g, 'mp-off', !fits);
}
}
@@ -14801,6 +16542,9 @@ export function messagesFeature(ctx) {
window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0));
}
+ // what the feed floats over the page (post button, new-posts pill): the
+ // shell takes them each render, see pageFloaters in the hooks
+ let pageFloaters = [];
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
@@ -14810,7 +16554,7 @@ export function messagesFeature(ctx) {
const c = feedNow();
c.opened = true;
const def = feedDef();
- const visitor = isVisitor();
+ const visitor = visitorView();
queueMicrotask(() => {
syncMediaPager();
if (listView()) watchSeen();
@@ -14827,7 +16571,10 @@ export function messagesFeature(ctx) {
// their settled presentation once the warm-up lands. The spinner is
// for a feed with nothing cached at all.
const curatedFeed = !!(feedDef(c.id) || {}).curated;
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev));
+ // (a reply whose post every lookup came back without isn't shown: a
+ // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
+ const repliesHere = feedReplies(c);
+ const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -14862,9 +16609,28 @@ export function messagesFeature(ctx) {
}, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ',
waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord'))
: null;
+ // The post button floats bottom right wherever the reader is in the
+ // feed; the composer opens at the top and takes the focus. It and the
+ // pill are position: fixed — and a page sliding in has a transform on
+ // it, which makes fixed descendants position within the page instead:
+ // the button rode at the foot of the feed card, below the screen, until
+ // the entrance animation ended ("not seeing the Post button right away",
+ // Adam, 2026-11-02; measured on his phone: 4109 px down at 87 ms, in
+ // place at 562 ms). So they're handed to the shell, which paints them
+ // beside the page, outside anything that animates.
+ const fab = visitor || ui.profCompose != null ? null : h('button', {
+ class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
+ onClick: () => {
+ ui.profCompose = draftFor(POST_DRAFT) || '';
+ render();
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
+ setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
+ },
+ }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html:
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab')));
+ pageFloaters = [pill, fab].filter(Boolean);
// the chat shell draws the brand header; this is just the page under it
return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' },
- h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill),
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => {
stopFeedWatch();
@@ -14887,18 +16653,6 @@ export function messagesFeature(ctx) {
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
(refreshLive(), mediaModes()),
- // 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', {
- class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
- onClick: () => {
- ui.profCompose = draftFor(POST_DRAFT) || '';
- render();
- try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
- setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
- },
- }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html:
- '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))),
visitor ? null : postComposer(),
def.of && !ofLoaded(def)
? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' },
@@ -15917,6 +17671,13 @@ export function messagesFeature(ctx) {
render();
}
+ const chatBubbleClick = (open) => (e) => {
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ open();
+ };
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -15965,13 +17726,10 @@ export function messagesFeature(ctx) {
// ends in a click too, and must not pop the sheet over the copy.
// A custom emoji is an <img> too, but tapping it should open the
// sheet like tapping any other word.
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
replyQuote(room, chId, m),
...noteBody(text, 0, em),
@@ -16362,13 +18120,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''),
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
src ? h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
@@ -16559,13 +18314,10 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-line' }, h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
}, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
@@ -16715,7 +18467,11 @@ export function messagesFeature(ctx) {
if (ZAP_KINDS.includes(ev.kind)) {
const actor = zapperOf(ev), sats = receiptSats(ev);
if (!actor || my.includes(actor) || !sats) return null;
- return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at };
+ // a zap on a stream or an app names it by address, not by an e tag
+ const addr = !lastE ? zapAddrOf(ev) : null;
+ const stream = addr && addr.startsWith(LIVE_KIND + ':') ? addr : null;
+ const app = addr && addr.startsWith(APP_KIND + ':') ? addr : null;
+ return { id: ev.id, what: 'zap', actor, target: lastE, stream, ...(app ? { app } : {}), hint, sats, text: zapText(ev), ts: ev.created_at };
}
if (my.includes(ev.pubkey)) return null;
if (ev.kind === 7) {
@@ -16752,6 +18508,12 @@ export function messagesFeature(ctx) {
const old = c.items.find((y) => y.id === x.id);
if (old && !old.emojiUrl) { old.emojiUrl = x.emojiUrl; old.emoji = x.emoji; healed = true; }
}
+ // ...and a zap on an app saved before rows knew one
+ for (const x of items) {
+ if (!x || !x.app || !known.has(x.id)) continue;
+ const old = c.items.find((y) => y.id === x.id);
+ if (old && !old.app) { old.app = x.app; healed = true; }
+ }
const add = items.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id));
if (!add.length) { if (healed) persistNotifs(); return healed; }
warmNotifFaces(add);
@@ -16869,7 +18631,32 @@ export function messagesFeature(ctx) {
return ref && ref.type === 'pubkey' ? '@' + displayName(ref.pk) : m.slice(6, 18) + '\u2026';
}).replace(/\s+/g, ' ').trim();
}
+ // Zapstore app listings (NIP-82 kind 32267): a zap on one is a zap on your
+ // app. Its name is asked of Zapstore's relay once per session.
+ const appIdOf = (addr) => String(addr || '').split(':').slice(2).join(':');
+ const appNames = new Map(); // app id -> name | null (asking / none)
+ function appName(addr) {
+ const id = appIdOf(addr);
+ if (!id) return null;
+ if (!appNames.has(id)) {
+ appNames.set(id, null);
+ queryOn(APP_RELAYS, { kinds: [APP_KIND], '#d': [id], limit: 5 }, 5000).then((evs) => {
+ const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const name = ev && tagOf(ev, 'name');
+ if (name) { appNames.set(id, String(name).slice(0, 60)); scheduleRepaint(); }
+ }).catch(() => {});
+ }
+ return appNames.get(id);
+ }
+ const openApp = (addr) => {
+ const url = 'https://zapstore.dev/apps/' + encodeURIComponent(appIdOf(addr));
+ if (ctx.openExternal) ctx.openExternal(url); else window.open(url, '_blank', 'noopener');
+ };
function notifLabel(x) {
+ if (x.what === 'zap' && x.app) {
+ const name = appName(x.app);
+ return name ? t('alertZapApp', { name, sats: fmtSats(x.sats) + ' sats' }) : t('alertZapAppAnon', { sats: fmtSats(x.sats) + ' sats' });
+ }
if (x.what === 'zap') return t('alertZap', { sats: fmtSats(x.sats) + ' sats' });
if (x.what === 'react') {
const code = shortcodeOf(x.emoji);
@@ -16904,6 +18691,24 @@ export function messagesFeature(ctx) {
if (target) openNoteThread(target);
// a note we couldn't find is asked for again on tap, hints and all
else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {});
+ // a zap on a stream opens the stream; one on your profile (no post
+ // at all) opens the zapper's — the tap used to do nothing
+ else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); }
+ else if (x.app) openApp(x.app);
+ // a row saved before rows read the zap request's address: look at
+ // the receipt once, then go where it says
+ else if (x.what === 'zap' && !x.addrChecked) {
+ x.addrChecked = true;
+ queryOn(zapRelays(), { ids: [x.id] }, 4500).catch(() => []).then((evs) => {
+ const addr = evs && evs[0] ? zapAddrOf(evs[0]) : null;
+ if (addr && addr.startsWith(APP_KIND + ':')) x.app = addr;
+ else if (addr && addr.startsWith(LIVE_KIND + ':')) x.stream = addr;
+ persistNotifs();
+ render();
+ open();
+ });
+ }
+ else { stopNotifWatch(); openProfile(x.actor); }
}
};
return h('div', {
@@ -16998,10 +18803,14 @@ export function messagesFeature(ctx) {
resumed(awayMs) {
// a blink between apps didn't kill anything
if (awayMs && awayMs < 5_000) return;
- resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
- if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true;
- if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
- else refreshFeed({ force: true }).catch(() => {});
+ // the sockets first: after a freeze they read open and are dead, and
+ // a refresh sent on them finds nothing (wakeRelays)
+ wakeRelays().catch(() => {}).then(() => {
+ resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
+ if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return;
+ if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
+ else refreshFeed({ force: true }).catch(() => {});
+ });
return true;
},
// A zap flow reporting how its payment went, so the chip that appeared
@@ -17056,6 +18865,8 @@ export function messagesFeature(ctx) {
return unreadCount();
},
bottomNav: appBottomNav,
+ // taken once per render: a page that isn't the feed leaves none
+ pageFloaters() { const f = pageFloaters; pageFloaters = []; return f; },
notifySettingsCards() { return [notifyCard()]; },
screenView() {
liveShown = false;
@@ -17188,13 +18999,13 @@ export function messagesFeature(ctx) {
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
- addressRenamed(oldAddr, newAddr) {
- const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
- return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
- onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
- fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
- }).catch(() => {});
- },
+ addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); },
+ // older copies of our kind 0 on history-keeping relays, and filling the
+ // open editor's empty fields from one (tests)
+ profileHistory() { return findProfileHistory(); },
+ fillProfileFrom(e) { return fillProfileFrom(e); },
+ // the profile editor's Save, without the editor (tests)
+ async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); },
// opens the profile, unless the caller has a step to take first (the
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
@@ -17206,6 +19017,21 @@ export function messagesFeature(ctx) {
}, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
+ // a gift link names its sender: sign its template as us (gifts.js)
+ async signGiftFrom(tmpl) {
+ const id = await identity();
+ if (!id) return null;
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ },
+ // A claimed gift's sender: follow them, once any follow publish in
+ // flight (a fresh wallet's starter follows) has landed — followMany
+ // drops a call that arrives mid-publish.
+ async followGiftSender(pk) {
+ for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500));
+ if (!(await identity())) return false;
+ await followMany([pk]).catch(() => {});
+ return true;
+ },
// A sign-in (Google, passkey, key) that found no wallet on its identity:
// the same start, but only the follows of an identity that follows
// nobody yet — an established nostr user keeps their own list.
@@ -17348,9 +19174,9 @@ export function messagesFeature(ctx) {
follows = null; followsAt = 0; feed = null; relayLists = null;
mutes = null; mutesAt = 0;
stCache = null; stAt = 0; // the next account's state, not this one's
- if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
+ if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped
profBlob = null;
- reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key