aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js929
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();