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.js828
1 files changed, 711 insertions, 117 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index c82bd0e..90bfaaf 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -15,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';
@@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js';
// coinos's own relay and primal: both take DM gift wraps and pass call
// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
// refused or ignored the household IP's publishes, call answers among them.
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
+// 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
@@ -582,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();
@@ -1011,6 +1019,68 @@ 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;
+ }
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
@@ -1067,8 +1137,20 @@ 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);
@@ -3314,12 +3396,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: '',
@@ -3842,8 +3931,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;
@@ -4049,7 +4139,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;
@@ -4123,7 +4223,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
@@ -4528,10 +4629,12 @@ export function messagesFeature(ctx) {
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(x.text || x.icon).slice(0, 240) }));
+ .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 ↩
+ // 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]);
@@ -4551,6 +4654,19 @@ export function messagesFeature(ctx) {
}
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;
@@ -4562,7 +4678,8 @@ export function messagesFeature(ctx) {
};
const box = player.parentElement;
box._skipMorph = true;
- box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
+ 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) {
@@ -4894,25 +5011,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.
@@ -4969,6 +5099,17 @@ export function messagesFeature(ctx) {
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.
@@ -5156,7 +5297,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
@@ -5355,12 +5497,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);
@@ -6197,13 +6339,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';
@@ -6277,6 +6423,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';
@@ -6910,7 +7063,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
@@ -7032,7 +7185,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);
@@ -7733,22 +7886,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));
@@ -7756,6 +7920,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.
@@ -7949,13 +8114,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) => {
@@ -7967,7 +8132,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);
@@ -8059,13 +8224,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;
@@ -8073,7 +8259,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);
@@ -8083,6 +8280,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(() => {});
@@ -8095,10 +8293,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) {
@@ -8120,12 +8346,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
@@ -8167,8 +8405,7 @@ export function messagesFeature(ctx) {
// learned now, used by the next paint of any row with this clip
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
@@ -8187,13 +8424,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) => {
@@ -8202,14 +8464,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);
@@ -8221,6 +8488,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 });
@@ -8249,6 +8517,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;
}
@@ -8398,20 +8669,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 authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))];
- const asks = [
- askNote(near, ref.id, ref.relays || []),
- ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))),
- ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
- ];
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) {
@@ -11077,6 +11363,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));
@@ -11106,10 +11396,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]);
}
@@ -11123,10 +11417,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 {
@@ -11135,7 +11435,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();
@@ -11775,8 +12075,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',
@@ -11792,7 +12100,7 @@ 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()),
@@ -11924,6 +12232,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
@@ -11966,6 +12279,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', {
@@ -11975,7 +12291,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
@@ -11990,13 +12306,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)))),
- readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
- e.stopPropagation();
- const m = readMoment(ev);
- const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
- if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
- } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null,
+ : 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 }),
@@ -12257,12 +12569,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),
@@ -12281,15 +12605,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);
@@ -12305,6 +12621,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;
@@ -12447,7 +12850,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;
@@ -12488,7 +12893,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; s.mediaMoment = null; 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,
@@ -12597,9 +13014,24 @@ export function messagesFeature(ctx) {
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 {}
@@ -12679,8 +13111,15 @@ 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: kind0Stamp(id.pubkey) };
@@ -12691,6 +13130,78 @@ export function messagesFeature(ctx) {
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
}
+ // ---- 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.
@@ -12708,6 +13219,11 @@ export function messagesFeature(ctx) {
// 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);
@@ -12869,7 +13385,10 @@ export function messagesFeature(ctx) {
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
@@ -12895,7 +13414,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
@@ -12904,7 +13424,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)})` })
@@ -13012,6 +13532,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')))
@@ -13025,6 +13546,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
@@ -13113,7 +13635,8 @@ export function messagesFeature(ctx) {
// Streams joins the tabs once they've streamed
const streamed = pastOf(pk).length > 0;
const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // a 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
@@ -13146,7 +13669,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' }))
@@ -13204,6 +13727,7 @@ export function messagesFeature(ctx) {
ui.profCompose = null;
ui.postMedia = [];
ui.postPreview = false;
+ ui.postSchedule = null;
discardDraft(POST_DRAFT);
render();
}
@@ -13299,11 +13823,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;
@@ -13317,14 +13864,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+$/, '');
@@ -13356,10 +13910,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
@@ -13438,7 +13995,7 @@ export function messagesFeature(ctx) {
}, 0);
}
function appBottomNav() {
- const visitor = isVisitor();
+ const visitor = visitorView();
const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
// 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
@@ -15920,6 +16477,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
@@ -15929,7 +16489,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();
@@ -15949,7 +16509,7 @@ export function messagesFeature(ctx) {
// (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) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
+ 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
@@ -15984,9 +16544,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();
@@ -16009,18 +16588,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' },
@@ -18124,10 +18691,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
@@ -18182,6 +18753,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;
@@ -18315,6 +18888,12 @@ export function messagesFeature(ctx) {
// 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) { 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) {
@@ -18326,6 +18905,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.