diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 929 |
1 files changed, 811 insertions, 118 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index cdb9aea..0573def 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,14 +15,14 @@ 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'; import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, - foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, + foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, banlistHead, makeBanlist, communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js'; @@ -488,8 +488,9 @@ export function messagesFeature(ctx) { function roomUnread(room) { const s = st(); const my = myPubkeys(); + const banned = room.folded && room.folded.banned; for (const [id, msgs] of room.byChannel) - if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true; + if (newestFrom(msgs.values(), (m) => !my.includes(m.author) && !(banned && banned.has(m.author))) > (s.read[chRead(id)] || 0)) return true; return false; } @@ -587,7 +588,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(); @@ -757,14 +761,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(); @@ -823,11 +851,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, @@ -845,7 +876,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)))) { @@ -872,7 +906,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 @@ -1016,6 +1050,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 @@ -1072,21 +1201,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 @@ -1476,6 +1607,7 @@ export function messagesFeature(ctx) { for (const room of wanted) subscribeRoom(room); if (wn) wn.resubscribe(); if (cd) cd.resubscribe(); + if (ng) ng.resubscribe(); } // ---- subscription watchdog ------------------------------------------------ @@ -1887,9 +2019,12 @@ export function messagesFeature(ctx) { const mine = my.includes(m.author); const reacts = room.reactions.get(m.rumor.id); const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); - const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); }; - const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, + const close = () => { ui.msgSheet = null; ui.emojiPick = null; ui.msgBanArm = null; render(); }; + const item = (icon, label, onClick, cls = '') => h('button', { class: 'msg-sheet-item' + cls, onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + // the community's admin can take a person out, with everything they said + const canBan = !mine && canModerate(room) && m.author !== room.jm.owner; + const armed = ui.msgBanArm === m.rumor.id; return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); }, @@ -1906,7 +2041,54 @@ export function messagesFeature(ctx) { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); }), - !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null)); + !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null, + // two taps: the first asks, the second does it + !ui.emojiPick && canBan ? item('🚫', t(armed ? 'msgBanConfirm' : 'msgBan', { name: displayName(m.author) }), () => { + if (!armed) { ui.msgBanArm = m.rumor.id; render(); return; } + close(); setBanned(room, m.author, true); + }, ' danger' + (armed ? ' armed' : '')) : null)); + } + + // Removing someone from a community is the owner's ban list (CORD-04 vsk 4): + // one owner-signed edition naming every removed key. Every client that + // folds the control plane drops those authors' messages, old and new, and + // takes them off the member list. Only where we hold the control stream's + // signing key (a community founded here, or the built-in one). + const canModerate = (room) => !!room.control.sk && myPubkeys().includes(room.jm.owner); + let banBusy = false; + async function setBanned(room, pk, banned) { + if (banBusy) return false; + const id = await identity(); + if (!id || id.pubkey !== room.jm.owner || !room.control.sk) { toast(t('msgOwnerOnly')); return false; } + banBusy = true; + try { + // The new edition replaces the list whole, so it must be built on the + // newest one: ask the relays now rather than trust what has streamed + // in. A relay that hands back nothing at all (the founding editions + // are always there) is not an answer, and we stop. + const wraps = await queryOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, 5000).catch(() => []); + if (!wraps.length) { toast(t('msgSendFailed')); return false; } + const have = new Set(room.controlEntries.map((e) => e.rumor.id)); + for (const w of wraps) { + const o = openWrap(w, room.control); + if (o && o.rumor.kind === 3308 && !have.has(o.rumor.id)) { have.add(o.rumor.id); room.controlEntries.push(o); } + } + const head = banlistHead(room.controlEntries, { ownerHex: room.jm.owner, cid: room.jm.community_id }); + const list = head.list.filter((x) => x !== pk); + if (banned) list.push(pk); + if (list.length === head.list.length) { room.scheduleFold(); return true; } // already so + const edition = { ...makeBanlist(head, list, Date.now()), pubkey: id.pubkey }; + const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true }); + if (!(await publishOn(room.relays, wrap))) { toast(t('msgSendFailed')); return false; } + room.controlEntries.push({ rumor: rumorWithId(edition), author: id.pubkey }); + room.scheduleFold(); + bumpMsgRev(); + toast(t(banned ? 'msgBanned' : 'msgUnbanned', { name: displayName(pk) })); + return true; + } catch (e) { + toast(t('msgSendFailed')); + return false; + } finally { banBusy = false; } } // The composer's "replying to" context bar, with its way out. @@ -2202,6 +2384,13 @@ export function messagesFeature(ctx) { // An invite link opened in the browser lands here before the wallet exists. const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // /?chat: the public chat page's button on a device that has an account. + // It opens the coinos community's room itself once the wallet is open + // (init below), where "Open coinos" used to leave a signed-in reader on + // the wallet to find the chat (Andy, 2026-11-10). + let urlChat = typeof location !== 'undefined' && !urlInvite && location.pathname === '/' + && /(?:^|&)chat(?:=|&|$)/.test((location.search || '').slice(1)); + if (urlChat) { try { history.replaceState(null, '', '/'); } catch {} } // coinos's own pack share link, /emojis/pack/<naddr> — the Android app // owns every v3.coinos.io path, so one tapped on a phone lands here, not // in a browser; added once a wallet is open (init below). @@ -3547,8 +3736,43 @@ export function messagesFeature(ctx) { bumpMsgRev(); scheduleRepaint(); } - const gClient = (g) => (g && g.cordn ? cd : wn); - const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null; + // NIP-29 groups (nip29-client.js): a group on one relay that keeps its + // members and signs its metadata; messages are plain signed events with + // an `h` tag. Not encrypted. Joined by address (host'id, a client's link), + // and the list of them is the NIP-51 kind 10009 other clients keep too. + let ng = null, ngLoading = null; + function nip29Chat() { + const pk = identityPk(); + if (!pk || !wallet._cacheKey) return Promise.resolve(null); + if (ng && ng.pubkey === pk) return Promise.resolve(ng); + if (ngLoading && ngLoading.pk === pk) return ngLoading.p; + if (ng) { try { ng.stop(); } catch {} ng = null; } + const p = import('../nip29-client.js').then(async (m) => { + if (identityPk() !== pk) return null; + const c = m.nip29Client({ + scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg, + listRelays: async (deep) => deep + ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])] + : zapRelays(), + }); + ng = c; + ngLoading = null; + await c.start(); + return c; + }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; }); + ngLoading = { pk, p }; + return p; + } + function onNg(type, g, data) { + if (!ng) return; + if (type === 'message') { try { noteEmoji(data); } catch {} } + if (g) wnFolds.delete(g.id); + bumpMsgRev(); + scheduleRepaint(); + } + const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn); + const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn); + const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null; // One delivered event of a direct group, into that person's thread. function wnIntoThread(g, peer, m) { @@ -3698,14 +3922,14 @@ export function messagesFeature(ctx) { const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]); const ok = await wn.send(g, inner).catch(() => false); if (!ok) { toast(t('msgSendFailed')); return; } - onWn('message', g, inner); + gOn(g)('message', g, inner); } // ---- groups ---- const wnRead = (id) => 'wn:' + id; // groups with a screen of their own (a direct group lives in its DM thread) - const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])]; + const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])]; function wnTitle(g) { const c = gClient(g); @@ -3820,7 +4044,7 @@ export function messagesFeature(ctx) { const sent = wn.send(g, inner).catch(() => false); wnFolds.delete(g.id); render(); - if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); } + if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); } } async function wnAct(fn) { @@ -3854,8 +4078,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; @@ -4061,7 +4286,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; @@ -4234,6 +4469,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'); @@ -4254,6 +4491,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. @@ -4923,25 +5170,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. @@ -4976,8 +5236,10 @@ export function messagesFeature(ctx) { // Threads"): the replies these people write, each under the post it // answers, instead of their posts. Asked for as notes and NIP-22 comments; // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) - const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); - const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // A word search (def.search) asks NIP-50 relays for notes and replies + // whose text holds it: kind 1 only, replies kept as rows of their own. + const feedKinds = (def) => (def && def.search ? [1] : def && def.replies ? REPLY_KINDS : FEED_KINDS); + const feedKeeps = (def, e) => (def && def.search ? e.kind === 1 : def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' // (a reply straight to the root), else — the deprecated positional form — // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. @@ -4998,6 +5260,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. @@ -5185,7 +5458,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 @@ -5384,12 +5658,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); @@ -5689,13 +5963,16 @@ export function messagesFeature(ctx) { // posts from anyone on these tags, as well as the feed's people (Everything) const orTopicsOf = (def) => [...new Set(((def && def.orTopics) || []).map(normTopic).filter(Boolean))]; const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20); - const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || feedAuthors(def).length > 0 || feedTopics(def).length > 0); + const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || !!def.search || feedAuthors(def).length > 0 || feedTopics(def).length > 0); // A feed's own relay list, when it has one, replaces the usual choice // (the authors' outboxes, or the wide relays for a topic) everywhere the // feed reads. Independent of what the feed asks for. // Popular relays that answer a bare "everything" query (probed 2026-09-27; // paid/search relays like nostr.wine and nostr.band answer nothing and are // left out). The firehose reads a few of them by default. + // NIP-50 full-text search over notes (probed 2026-11-11: these three + // answer `search` on kind 1; nostr.band and primal answered nothing). + const TEXT_SEARCH_RELAYS = ['wss://relay.ditto.pub', 'wss://search.nos.today', 'wss://nostr.wine']; const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.ditto.pub', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev', 'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net']; @@ -6215,9 +6492,15 @@ export function messagesFeature(ctx) { async function prefetchFeed(c) { const def = feedDef(c.id); const authors = feedAuthors(def), topics = feedTopics(def); - if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; } + if (!authors.length && !topics.length && !(def && (def.all || def.search))) { c.status = 'ready'; return; } c.at = Date.now(); const merge = { newerThan: c.notes.length ? c.notes[0].created_at : Infinity }; + if (def.search) { + try { await queryFeed(feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit: FEED_PAGE }, merge, c, 5000); } catch {} finally { + if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') scheduleRepaint(); } + } + return; + } if (def && def.curated) { try { await popularPass({}, merge, c, def); } catch {} finally { if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); } } @@ -6226,13 +6509,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'; @@ -6306,6 +6593,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'; @@ -6355,6 +6649,8 @@ export function messagesFeature(ctx) { const topics = [...new Set((q.get('t') || '').split(',').map(normTopic).filter(Boolean))]; const packs = (q.get('pack') || '').split(',').map((x) => parsePackLink(x.trim())).filter(Boolean); const relays = urlRelays(q); + const words = (q.get('q') || '').trim(); + if (words) { openTextSearch(words, relays); return true; } if (!authors.length && !topics.length && !packs.length) { openFirehose(relays); return true; } const sig = JSON.stringify([authors, topics, packs.map(packKey), relays]); let hsh = 5381; for (let i = 0; i < sig.length; i++) hsh = ((hsh * 33) ^ sig.charCodeAt(i)) >>> 0; @@ -6385,6 +6681,7 @@ export function messagesFeature(ctx) { if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; + if (def.search) return '/feed?q=' + encodeURIComponent(def.search) + (r ? '&' + r.slice(1) : ''); const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; if (!authors.length && !topics.length && packs.length === 1) { @@ -6512,6 +6809,15 @@ export function messagesFeature(ctx) { ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } + // Words typed into search and submitted without picking a suggestion: + // the notes and replies that say them, as a feed for this session. + function openTextSearch(q, relays = []) { + q = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200); + if (!q) return; + const id = 'search:' + q.toLowerCase() + (relays.length ? '@' + relays.join(',') : ''); + if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '\u201c' + q + '\u201d', follows: false, authors: [], packs: [], topics: [], search: q, relays }); + showFeed(id); + } // Something above the posts is in use — a post being written, the relay // panel, a focused field — so new posts wait behind the pill rather than // push it off the screen. @@ -6939,7 +7245,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 @@ -7061,7 +7367,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); @@ -7234,6 +7540,35 @@ export function messagesFeature(ctx) { // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new WeakMap(); + // A word search turns up bot fleets posting one newsletter dozens of times + // from a dozen keys, each copy reworded a little (an emoji, "BREAKING", the + // picture moved) so it isn't the same text. Words and links as a set: a + // post sharing half of them with one already in the search feed is that + // post again, and only the first is shown. + const textSigs = new WeakMap(); // event -> Set of words + function textSig(e) { + let sig = textSigs.get(e); + if (!sig) { + const words = String(e.content || '').toLowerCase().match(/https?:\/\/\S+|[\p{L}\p{N}]{3,}/gu) || []; + textSigs.set(e, sig = new Set(words)); + } + return sig; + } + const NEAR_COPY = 0.5; // measured 2026-11-11: copies 0.53+, distinct posts at most 0.45 + const nearCopies = new WeakMap(); // feed state -> sigs admitted + function nearCopy(c, held, e) { + const sig = textSig(e); + if (sig.size < 8) return false; // short posts ("gm fiatjaf") are allowed to agree + let seen = nearCopies.get(c); + if (!seen) nearCopies.set(c, seen = held.map(textSig).filter((x) => x.size >= 8)); + for (const o of seen) { + let both = 0; + for (const w of sig) if (o.has(w)) both++; + if (both / (sig.size + o.size - both) >= NEAR_COPY) return true; + } + seen.push(sig); + return false; + } async function mergeFeed(evs, opts = {}, c = feedNow()) { let staged = feedStaged.get(c); if (!staged) feedStaged.set(c, staged = new Set()); @@ -7255,6 +7590,7 @@ export function messagesFeature(ctx) { const def = feedDef(c.id); let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); + if (def && def.search) add = add.filter((e) => !nearCopy(c, held, e)); if (!add.length) return false; if (def && def.curated) { const kept = curateFeed(add); @@ -7542,6 +7878,7 @@ export function messagesFeature(ctx) { const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); + if (def.search) return queryFeed(relays || feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit, ...extra }, merge, c); const orT = orTopicsOf(def); const orPass = orT.length ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) @@ -7762,22 +8099,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) || def.search || !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)); @@ -7785,6 +8133,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. @@ -7984,7 +8333,7 @@ export function messagesFeature(ctx) { // 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) => { @@ -8088,13 +8437,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; @@ -8102,7 +8472,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); @@ -8112,6 +8493,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(() => {}); @@ -8142,7 +8524,7 @@ export function messagesFeature(ctx) { // — 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; }; + 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; @@ -8202,8 +8584,21 @@ export function messagesFeature(ctx) { Promise.resolve(go.call(v)).catch(() => {}); return true; } + // A YouTube box we took full screen still shows YouTube's own full-screen + // button, and a press on it asks for full screen again from inside the + // frame — nothing to see, and no way out ("worked to make the video + // fullscreen but not toggle it off"). The page does see that happen: the + // frame becomes the full-screen element in place of its box. The press + // meant "out", so out it goes, both steps. + let ytFs = null, ytLeaving = false; if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => { const el = fsElement(); + const leave = () => Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}); + if (!el) { ytFs = null; ytLeaving = false; } + else if (el.classList && el.classList.contains('yt-embed')) { + if (ytLeaving) { ytLeaving = false; ytFs = null; leave(); return; } + ytFs = el; + } else if (el.tagName === 'IFRAME' && ytFs && ytFs.contains(el)) { ytLeaving = true; leave(); return; } // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. @@ -8236,8 +8631,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 @@ -8320,6 +8714,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 }); @@ -8348,6 +8743,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; } @@ -8406,15 +8804,26 @@ export function messagesFeature(ctx) { if (!go) return; Promise.resolve(go.call(box, { navigationUI: 'hide' })).catch(() => {}); }; + // With a mouse the player's own buttons are all in reach, so its clicks + // are its own: the shield is off while a mouse is over the box (by-mouse; + // a finger on the same screen gets it back) and the middle button pauses, the + // corner one goes full screen and back (Adam, 2026-11-10: the pause button + // "didn't pause, it fullscreened. it should just pause"). A click on the + // still plays it where it is. const tapped = (e) => { if (e.target.closest('.yt-open, .vid-sound, iframe')) return; e.stopPropagation(); e.preventDefault(); const box = e.currentTarget; if (!box.dataset.playing) load(box, false); // a tap means sound else { post(box, 'unMute'); post(box, 'playVideo'); if (box._soundOn) box._soundOn(); } - full(box); + if (e.pointerType !== 'mouse') full(box); }; - const frame = h('div', { class: 'yt-embed', onClick: tapped }, + const byMouse = (e) => { e.currentTarget.classList.toggle('by-mouse', e.pointerType === 'mouse'); }; + const frame = h('div', { + class: 'yt-embed', onClick: tapped, + onPointerEnter: byMouse, onPointerMove: byMouse, onPointerDown: byMouse, + onPointerLeave: (e) => { e.currentTarget.classList.remove('by-mouse'); }, + }, h('img', { class: 'yt-poster', loading: 'lazy', alt: '', src: !feedPaint || feedMedia('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg') @@ -8497,20 +8906,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) { @@ -11209,10 +11633,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]); } @@ -11226,10 +11654,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 { @@ -11238,7 +11672,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(); @@ -11878,8 +12312,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', @@ -11895,7 +12337,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()), @@ -12027,6 +12469,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 @@ -12069,6 +12516,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', { @@ -12078,7 +12528,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 @@ -12801,9 +13251,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 {} @@ -12830,6 +13295,8 @@ export function messagesFeature(ctx) { if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } + // fields to take out altogether (an address that no longer exists) + for (const k of opts.remove || []) if (!opts.fieldWhen || !opts.fieldWhen[k] || opts.fieldWhen[k](base)) delete merged[k]; 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: kind0Stamp(id.pubkey) }; @@ -12902,6 +13369,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. @@ -13114,7 +13653,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 @@ -13123,7 +13663,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)})` }) @@ -13231,6 +13771,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'))) @@ -13608,10 +14149,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 @@ -13690,7 +14234,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 @@ -16172,6 +16716,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 @@ -16181,7 +16728,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(); @@ -16236,9 +16783,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(); @@ -16261,18 +16827,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' }, @@ -16552,7 +17106,16 @@ export function messagesFeature(ctx) { type: 'text', class: 'user-search-input user-search-main', placeholder: t('searchUsersHint'), value: s.q, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && s.q.trim()) { e.preventDefault(); openTextSearch(s.q); render(); } }, }), + // the words themselves: every note and reply that says them + s.q.trim().length >= 2 && !/^(npub1|nprofile1|nevent1|note1|naddr1|nostr:)/i.test(s.q.trim()) + ? h('div', { class: 'list' }, h('div', { class: 'item chat-thread-row', onClick: () => { openTextSearch(s.q); render(); } }, + h('div', { class: 'chat-avatar fallback' }, '\u{1F50D}'), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('span', { class: 'chat-name' }, '\u201c' + s.q.trim() + '\u201d'), + h('div', { class: 'muted small' }, t('searchPostsRow'))))) + : null, // a word is a topic too: posts tagged with it, as a feed (() => { const m = /^#?([A-Za-z0-9_\u00C0-\uFFFF]{2,64})$/.exec(s.q.trim()); @@ -16961,6 +17524,7 @@ export function messagesFeature(ctx) { // login is asked when it first opens a Cordn group or invites someone). if (ui.msgView !== 'grp' && cd) cd.focus(null); cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } }); + nip29Chat(); // Group chats ride along: the client loads with the wallet, and standing // on this screen is what makes us invitable (a KeyPackage on the relays). whiteNoise().then(async (c) => { @@ -17087,7 +17651,10 @@ export function messagesFeature(ctx) { // one list, newest first: a group chat is a conversation like any other const chatRows = [ ...dmRows.map((x) => ({ at: x.last.rumor.created_at, dm: x })), - ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || g.at, g })), + // a relay group with nothing said yet sorts by when it was joined + // HERE: one that only arrived on the 10009 list (joined elsewhere, + // maybe years ago, maybe on a relay long gone) waits at the bottom + ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || (g.nip29 ? g.joinedAt || 0 : g.at), g })), ].sort((a, b) => b.at - a.at); const shownDms = ui.msgAllDms ? chatRows : chatRows.slice(0, LIST_PREVIEW); kids.push( @@ -17478,7 +18045,14 @@ export function messagesFeature(ctx) { h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, h('span', { class: 'chat-name' }, displayName(r.pk)), r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), - h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk)))))); + h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))), + ...(canModerate(room) && room.folded && room.folded.banned.size ? [ + h('div', { class: 'small muted chat-removed-head' }, t('msgBannedHead')), + ...[...room.folded.banned].map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, + h('span', { class: 'ava-wrap' }, avatar(pk)), + h('span', { class: 'chat-name grow', style: 'min-width:0' }, displayName(pk)), + h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); setBanned(room, pk, false); } }, t('msgUnban')))), + ] : [])); } function invitePanel(room) { @@ -17547,6 +18121,7 @@ export function messagesFeature(ctx) { h('div', { class: 'muted small chat-preview' }, g.removed ? t('msgGroupGone') : last ? (last.mine ? t('msgYouPrefix') : displayName(last.rumor.pubkey) + ':') + ' ' + (last.rumor.content || '📎 ' + t('msgGroupAttachment')) + : g.nip29 && !wn.accounts(g).length ? t(g.unreachable ? 'msgNip29Unreachable' : 'msgNip29Waiting', { host: g.relay.replace(/^wss?:\/\//, '').replace(/\/$/, '') }) : t('msgMembers', { n: wn.accounts(g).length }))), unread ? h('i', { class: 'thread-dot' }) : null); } @@ -17602,7 +18177,42 @@ export function messagesFeature(ctx) { // which protocol the group speaks: White Noise (Marmot) or Cordn h('div', { class: 'seg' }, h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')), - h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))), + h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')), + h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))), + proto === 'n29' ? nip29JoinPanel() : null, + proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready)); + } + // Join a NIP-29 group by its address (they're made in other clients). + async function nip29Join() { + const raw = (ui.msgN29Addr || '').trim(); + if (!raw || ui.msgBusy) return; + const m = await import('../nip29-client.js'); + const a = m.parseGroupAddress(raw); + if (!a) { toast(t('msgNip29BadAddress')); return; } + ui.msgBusy = true; render(); + try { + const c = await nip29Chat(); + if (!c) throw new Error('no signer'); + const g = await c.join(a); + ui.msgN29Addr = ''; ui.msgHomePanel = null; + ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; + if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused })); + } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + function nip29JoinPanel() { + return h('div', { class: 'col gap6' }, + h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')), + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false', + value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); }, + }), + h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join }, + ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin')))); + } + function wnNewGroupForm(proto, picks, ready) { + return h('div', { class: 'col gap6' }, proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null, h('input', { type: 'text', placeholder: t('msgGroupName'), maxlength: '64', @@ -17621,7 +18231,11 @@ export function messagesFeature(ctx) { const accounts = wn.accounts(g); const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []); return h('div', { class: 'col chat-members' }, - h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null, + g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {} + } }, t('msgNip29CopyAddress'))) : null, ...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, avatar(pk), h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' }, @@ -17681,15 +18295,20 @@ export function messagesFeature(ctx) { !ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => { close(); const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]); - if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del); + if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del); }) : null)); } + // Outside wnView: its own `const wn = gClient(g)` shadows the module's wn + // for the whole function, so a `!wn` in there read the inner one before + // it was set — a ReferenceError the moment a group was left from its own + // screen (Adam's phone, 2026-11-13). + const groupClientsLoading = () => !wn || wnLoading || !cd || cdLoading || !ng || ngLoading; function wnView() { const g = findGroup(ui.msgPeer); if (!g) { // the groups are still loading (a reload straight into this screen) - if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } + if (groupClientsLoading()) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } ui.msgView = 'home'; return homeView(); } @@ -17798,7 +18417,7 @@ export function messagesFeature(ctx) { h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null, live - ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f)) + ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f)) : h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')), ui.msgSheet ? wnSheet(g) : null); } @@ -18087,9 +18706,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; - // a zap on a stream names it by address (a 30311), not by an e tag - const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; - return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; + // 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) { @@ -18126,6 +18747,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); @@ -18243,7 +18870,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); @@ -18281,6 +18933,20 @@ export function messagesFeature(ctx) { // 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); } } }; @@ -18376,10 +19042,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 @@ -18434,6 +19104,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; @@ -18484,6 +19156,7 @@ export function messagesFeature(ctx) { // The header magnifier: search anyone on nostr, results open profiles. userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query + userSearchSubmit(q) { if (String(q || '').trim()) { openTextSearch(q); render(); } }, userSearchQuery(q) { if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); @@ -18567,6 +19240,19 @@ 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(() => {}); }, + // the address was given up with nothing in its place (account deleted): + // a profile still naming it would send zaps to nobody + addressGone(oldAddr) { + const is = (k) => (base) => base[k] === oldAddr; + return publishProfileFields({}, { + onlyWhen: (base) => is('lud16')(base) || is('nip05')(base), + remove: ['lud16', 'nip05'], fieldWhen: { lud16: is('lud16'), nip05: is('nip05') }, + }).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 @@ -18669,6 +19355,13 @@ export function messagesFeature(ctx) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } + if (urlChat) { + urlChat = false; // once: init runs again for every wallet born in this tab + setTimeout(() => { + ui.chatOpen = true; ui.msgView = 'room'; ui.msgCommunity = COMMUNITY.community_id; + ui.msgChannel = null; ui.msgStick = true; render(); + }, 0); + } if (urlPack) { addPack(urlPack).catch(() => {}); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -18737,7 +19430,7 @@ 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; timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); |
