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.js1273
1 files changed, 1113 insertions, 160 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index c82bd0e..85b44f3 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';
@@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js';
// coinos's own relay and primal: both take DM gift wraps and pass call
// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
// refused or ignored the household IP's publishes, call answers among them.
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
+// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads;
+// relay.coinos.io stays on while older inbox lists still name it
+const MESSAGES_RELAY = 'wss://messages.coinos.io';
+const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net'];
+// what this app put in a wallet key's kind 10050 before messages.coinos.io
+const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -483,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;
}
@@ -582,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();
@@ -752,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();
@@ -818,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,
@@ -840,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)))) {
@@ -867,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
@@ -1011,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
@@ -1067,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
@@ -1471,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 ------------------------------------------------
@@ -1882,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(); },
@@ -1901,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.
@@ -2197,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).
@@ -3314,12 +3508,19 @@ export function messagesFeature(ctx) {
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
+ // A list this app wrote with its old defaults (nothing else in it) is
+ // brought up to today's, so senders find messages.coinos.io first.
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
- if (existing.length) return;
+ if (existing.length) {
+ const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a));
+ const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, ''));
+ const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u));
+ if (!ours || urls.includes(DM_RELAYS[0])) return;
+ }
const evt = finalizeEvent({
kind: 10050,
content: '',
@@ -3535,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) {
@@ -3686,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);
@@ -3808,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) {
@@ -3842,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;
@@ -4049,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;
@@ -4123,7 +4370,8 @@ export function messagesFeature(ctx) {
// ---- profile notes: latest public posts & replies -----------------------
- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
+ // (not messages.coinos.io: it takes messages, not posts)
+ const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
@@ -4221,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');
@@ -4241,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.
@@ -4528,10 +4788,12 @@ export function messagesFeature(ctx) {
const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
.filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
.sort((a, b) => a.moment.seconds - b.moment.seconds)
- .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) }));
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) }));
// how a moment shows on a bar: a reaction as its emoji (a :code: as its
- // picture, from the reaction's own tag), a zap ⚡, a reply ↩
+ // picture, from the reaction's own tag), a zap ⚡, a reply as its
+ // author's face
function momentGlyph(x) {
+ if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false);
if (x.ev.kind !== 7) return x.icon;
const code = shortcodeOf(x.ev.content);
const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
@@ -4551,6 +4813,19 @@ export function messagesFeature(ctx) {
}
return null;
}
+ // A comment made at a moment of a clip opens with its time as a link that
+ // plays the clip from there; the "[0:03] " other clients read as text is
+ // left out of the words beside it.
+ function momentLink(ev) {
+ const m = readMoment(ev);
+ if (!m) return null;
+ return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(m.seconds));
+ }
+ const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text);
const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
function attachMediaTimeline(player, url, explicitEvent) {
if (player._timeline) return;
@@ -4562,7 +4837,8 @@ export function messagesFeature(ctx) {
};
const box = player.parentElement;
box._skipMorph = true;
- box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph,
+ onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null }));
player.dispatchEvent(new Event('timeupdate'));
}
function noteEvent(ev) {
@@ -4894,25 +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.
@@ -4947,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.
@@ -4969,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.
@@ -5156,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
@@ -5355,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);
@@ -5660,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'];
@@ -6186,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(); } }
@@ -6197,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';
@@ -6277,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';
@@ -6326,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;
@@ -6356,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) {
@@ -6483,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.
@@ -6910,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
@@ -7032,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);
@@ -7205,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());
@@ -7226,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);
@@ -7513,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; })
@@ -7733,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));
@@ -7756,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.
@@ -7949,13 +8327,13 @@ export function messagesFeature(ctx) {
// known, never taller than the screen; only a clip taller than that sits
// in a black frame, rather than in a strip at the left.
const videoBoxStyle = (d) => d && d.width && d.height
- ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
+ ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
// Over a clip that waits for a tap: what it costs, and the tap that plays
// it (with sound — the reader asked for this one). The box is left alone
// by the morph from here, or a repaint would take the cover off.
function heavyCover(box, url, onPlay) {
- if (box.querySelector('.vid-heavy')) return;
+ if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return;
box._skipMorph = true;
const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'),
onClick: (e) => {
@@ -7967,7 +8345,7 @@ export function messagesFeature(ctx) {
if (v) {
if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
v.preload = 'auto';
- v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; });
+ v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
@@ -8059,13 +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;
@@ -8073,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);
@@ -8083,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(() => {});
@@ -8095,10 +8506,38 @@ export function messagesFeature(ctx) {
});
}
// A tap on the picture itself (not the player's own play button or its
- // control bar): inline, it goes full screen — the corner button is a
+ // control bar): inline, a mouse click pauses a playing clip; a paused one
+ // (or any touch) goes full screen — the corner button is a
// reach on a phone. Full screen, it pauses a playing clip, and a tap on a
// paused one comes back out. The player's controls are inside the video
// element, so where they sit is judged from the tap's position.
+ // The big play/pause over a feed clip is for a mouse: <html> carries
+ // .has-mouse while the last pointer was one (a touch laptop switches with
+ // the hand), and CSS shows the button on :hover (a repaint can't drop it).
+ if (typeof document !== 'undefined') {
+ const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on);
+ document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true });
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true });
+ }
+ // A clip whose post has timed reactions seeks on the moments bar under it
+ // (the reactions sit on it at their moments), so the player's own controls
+ // — a second seek bar — stay off; the middle button and the sound button
+ // are its controls then (Adam: "hide the main seek bar … and only show a
+ // moments seek bar"). Otherwise the player's controls, once it has woken.
+ const nativeControls = (v) => { v.controls = !v._ownBar && !v._gone; };
+ function ownBar(player, on) {
+ const box = player.closest('.note-video-box');
+ if (!box || player._ownBar === on) return;
+ player._ownBar = on;
+ box.classList.toggle('own-bar', on);
+ if (!box.classList.contains('idle')) nativeControls(player);
+ // with no player controls, the sound button stays to turn it back down
+ if (on && !box.querySelector('.vid-sound')) {
+ const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); });
+ b.setSound(!player.muted);
+ box.append(b);
+ }
+ }
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened
function videoTap(e, url) {
@@ -8120,12 +8559,24 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return true;
}
+ // a mouse click on a playing clip just pauses it (Adam: "clicking on a
+ // video that's playing should pause the video, not full screen it"); it
+ // keeps its controls and the middle button resumes it, and a click on it
+ // paused opens the pager. A touch has no middle button: its tap still opens.
+ if (!v.paused && e.pointerType === 'mouse') {
+ v.pause();
+ wokeClips.add(url);
+ const box = v.closest('.note-video-box');
+ if (box) { box.classList.remove('idle'); box._skipMorph = true; }
+ nativeControls(v);
+ return true;
+ }
if (openClip(v, url)) return true;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
if (box) { box.classList.remove('idle'); box._skipMorph = true; }
- v.controls = true;
+ nativeControls(v);
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return true;
// (the screen isn't turned for it: a wide clip plays with bars, and the
@@ -8133,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.
@@ -8167,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
@@ -8187,13 +8650,38 @@ export function messagesFeature(ctx) {
if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now();
});
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
- const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v);
+ // a tall clip is held to the cap's height and the box narrows to its
+ // shape, instead of a full-width box of black bars (Adam, on a 1920 screen)
+ const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined;
+ const known = feedMedia(url);
+ const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v);
+ v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; });
if (loud) box._skipMorph = true;
+ // With a mouse: a big play/pause button in the middle while the pointer is
+ // over the clip. It plays or pauses in place, never full screen (a click
+ // elsewhere on a paused clip opens the pager). Touch keeps its taps.
+ const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const player = e.currentTarget.closest('.note-video-box')?.querySelector('video');
+ if (!player) return;
+ if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause();
+ } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' }));
+ const ppState = () => {
+ const live = v.closest('.note-video-box');
+ const b = live && live.querySelector('.vid-pp');
+ if (!b) return;
+ b.classList.toggle('playing', !v.paused);
+ b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause'));
+ };
+ v.addEventListener('play', ppState);
+ v.addEventListener('pause', ppState);
+ box.append(pp);
+
// remembered by clip, so a repaint draws the box as it now stands
const wake = () => {
wokeClips.add(url);
box.classList.remove('idle');
- v.controls = true;
+ nativeControls(v);
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
@@ -8202,14 +8690,19 @@ export function messagesFeature(ctx) {
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) return;
+ if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; }
player.muted = false;
unmutedClips.add(url);
liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted
if (player.paused) player.play().catch(() => {});
- button.remove(); // the player's own controls carry mute from here
+ // the player's own controls carry mute from here — unless they're off
+ // for the moments bar: then this button turns it down again
+ if (player._ownBar) button.setSound(true); else button.remove();
});
v.onvolumechange = () => {
- if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same
+ const b = v.closest('.note-video-box')?.querySelector('.vid-sound');
+ if (v._ownBar && b && b.setSound) b.setSound(!v.muted);
+ if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same
else unmutedClips.delete(url); // muted again from the control: the next box starts quiet
};
if (btn) box.append(btn);
@@ -8221,6 +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 });
@@ -8249,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;
}
@@ -8307,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')
@@ -8398,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) {
@@ -11077,6 +11600,10 @@ export function messagesFeature(ctx) {
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
+ // A list row with nothing left to show: a post its author deleted, or a
+ // repost of one (a deletion that lands after the row did, too). Threads
+ // keep their deleted posts' places; lists just drop them.
+ const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev)));
const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
@@ -11106,10 +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]);
}
@@ -11123,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 {
@@ -11135,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();
@@ -11775,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',
@@ -11792,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()),
@@ -11924,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
@@ -11966,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', {
@@ -11975,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
@@ -11990,13 +12543,9 @@ export function messagesFeature(ctx) {
zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null)
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
- : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
- readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
- e.stopPropagation();
- const m = readMoment(ev);
- const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
- if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
- } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null,
+ : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
+ momentLink(ev),
+ ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))),
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -12257,12 +12806,24 @@ export function messagesFeature(ctx) {
// at the top of the feed before any signing or relay round-trip (a remote
// signer alone can take seconds), swapped for the signed event on success
// and withdrawn on failure.
+ // the hashtags in the text as t tags (NIP-24), so topic feeds and other
+ // clients' searches find the post — up to the count past which a post
+ // reads as hashtag spam
+ const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ function postTags(text, media, me) {
+ // NIP-92: what we uploaded, described, so clients needn't sniff the URL
+ const imeta = media.filter((m) => m && m.url).map(mediaImeta);
+ // NIP-18 quote: the reference is already in the text; the tags let a
+ // client render the quoted post inline and tell its author
+ const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
+ ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
+ // the people it mentions are told (NIP-27 p tags)
+ const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
+ return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG];
+ }
async function publishPost(text, media = []) {
const id = await requireIdentity();
- // the hashtags in the text as t tags (NIP-24), so topic feeds and other
- // clients' searches find the post — up to the count past which a post
- // reads as hashtag spam
- const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ const hashtags = postHashtags(text);
const temp = {
id: 'pending:' + Math.random().toString(36).slice(2),
pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
@@ -12281,15 +12842,7 @@ export function messagesFeature(ctx) {
for (const c of feeds) c.notes = [temp, ...c.notes];
render();
try {
- // NIP-92: what we uploaded, described, so clients needn't sniff the URL
- const imeta = media.filter((m) => m && m.url).map(mediaImeta);
- // NIP-18 quote: the reference is already in the text; the tags let a
- // client render the quoted post inline and tell its author
- const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
- ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
- // the people it mentions are told (NIP-27 p tags)
- const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] };
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -12305,6 +12858,93 @@ export function messagesFeature(ctx) {
}
}
+ // ---- scheduled posts -----------------------------------------------------
+ // Signed now, with created_at = when it should appear, and handed to the
+ // notifier, which holds it and publishes it then (relays refuse notes from
+ // the future). Signing now means a remote signer is asked while you're
+ // here, and the note that goes out is exactly the one you wrote. This
+ // device remembers what it scheduled, to show and cancel; the post itself
+ // turns up in the feeds once it's out.
+ const SCHED_MIN_AHEAD = 120;
+ async function schedulePost(text, media, at) {
+ const id = await requireIdentity();
+ if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon'));
+ const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
+ const res = await fetch(`${NOTIFIER}/schedule`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, relays }),
+ }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') }));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ ui.quoteOf = null;
+ const s = st();
+ s.scheduled ||= {};
+ s.scheduled[evt.id] = { at, text, pubkey: id.pubkey };
+ save(s);
+ render();
+ return evt;
+ }
+ // what this account has waiting, soonest first; anything past its time
+ // has gone out (or the notifier gave up) and leaves the list
+ function scheduledPosts() {
+ const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000);
+ let dropped = false;
+ for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; }
+ if (dropped) save(s);
+ return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me)
+ .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at);
+ }
+ // once a session, ask which are still held: one cancelled on another
+ // device is gone from the notifier, so it goes from here too
+ let schedChecked = false;
+ function checkScheduled(list) {
+ if (schedChecked || !list.length) return;
+ schedChecked = true;
+ fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => {
+ const s = st();
+ let n = 0;
+ for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; }
+ if (n) { save(s); render(); }
+ }).catch(() => {});
+ }
+ async function cancelScheduled(item) {
+ const id = await requireIdentity();
+ const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] };
+ const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const res = await fetch(`${NOTIFIER}/schedule/cancel`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }),
+ }).then((r) => r.json()).catch(() => ({}));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ const s = st();
+ delete s.scheduled?.[item.id];
+ save(s);
+ render();
+ }
+ const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ // <input type=datetime-local> speaks local wall-clock time
+ const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
+ function scheduledList() {
+ const list = scheduledPosts();
+ checkScheduled(list);
+ if (!list.length) return null;
+ return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })),
+ ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' },
+ h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
+ h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)),
+ h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)),
+ h('button', {
+ class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id,
+ onClick: async () => {
+ ui.schedCancelling = x.id; render();
+ try { await cancelScheduled(x); toast(t('schedCancelled')); }
+ catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.schedCancelling = null; render();
+ },
+ }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel')))));
+ }
+
async function publishReply(c, s, text) {
const id = await requireIdentity();
const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
@@ -12447,7 +13087,9 @@ export function messagesFeature(ctx) {
onKeydown: (e) => {
mentionKey(e);
if (e.defaultPrevented) return;
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
+ // a phone's Return is a new line (Reply is the button); a
+ // keyboard's Enter sends
+ if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
const el = e.target;
@@ -12488,7 +13130,19 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); },
+ // Cancel discards: the typed text, its attachments and the box.
+ // (It only folded the `replying` flag before, and the box stays
+ // up while a draft has text — so with anything typed it did
+ // nothing visible.) The field may still be focused, and the
+ // morph leaves a focused field's value alone: clear it by hand.
+ onClick: () => {
+ const text = s.draft || '';
+ s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null;
+ ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url));
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ render();
+ },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -12597,9 +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 {}
@@ -12626,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) };
@@ -12679,8 +13350,15 @@ export function messagesFeature(ctx) {
// when empty; move them when they pointed at our (old) address. A
// deliberately foreign lud16/nip05 is the user's business.
if (addr) {
- for (const k of ['lud16', 'nip05'])
- if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
+ const domain = addr.split('@')[1];
+ for (const k of ['lud16', 'nip05']) {
+ const cur = merged[k];
+ if (!cur || cur === oldAddr) { merged[k] = addr; continue; }
+ // a name at our own domain that is not ours and no longer answers
+ // (the placeholder a migration released) is ours now — Save is the
+ // heal an interactive signer can't do in the background
+ if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr;
+ }
}
if (merged.name) merged.display_name = merged.name;
const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
@@ -12691,6 +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.
@@ -12708,6 +13458,11 @@ export function messagesFeature(ctx) {
// alone. Once per address per session.
const addrHealed = new Set();
async function healStaleAddress(olds, myAddr) {
+ // Amber by intent needs a tap per signature: a background publish would
+ // sit in the kind-0 queue forever and block every later one. Save in the
+ // profile editor repoints the address for these users instead.
+ const id = await identity().catch(() => null);
+ if (!id || (id.signer && id.signer.interactive)) return;
for (const old of olds) {
if (addrHealed.has(old)) continue;
addrHealed.add(old);
@@ -12869,7 +13624,10 @@ export function messagesFeature(ctx) {
if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
- const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ // the lightning line too: a stale same-domain lud16 (jar@ after the
+ // rename to jarryd@) showed under the right NIP-05 as a second address
+ const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
// an about of "~" or a lone character is noise, not a bio.
// Until the full kind 0 lands, the light profile cache answers — it was
@@ -12895,7 +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
@@ -12904,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)})` })
@@ -13012,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')))
@@ -13025,6 +13785,7 @@ export function messagesFeature(ctx) {
// the half-written post back, composer open. Posting clears
// it; Cancel just closes the composer and keeps the text.
postComposer(),
+ ui.profCompose == null ? scheduledList() : null,
// the non-destructive way out: add another identity (Nostr,
// passkey, Google) next to this one and switch between them
// on the Accounts screen — logout stays for actually leaving
@@ -13113,7 +13874,8 @@ export function messagesFeature(ctx) {
// Streams joins the tabs once they've streamed
const streamed = pastOf(pk).length > 0;
const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // a post its author deleted leaves the list (no "was deleted" row)
+ const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
// too short to scroll means the scroll that loads more never comes
@@ -13146,7 +13908,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -13204,6 +13966,7 @@ export function messagesFeature(ctx) {
ui.profCompose = null;
ui.postMedia = [];
ui.postPreview = false;
+ ui.postSchedule = null;
discardDraft(POST_DRAFT);
render();
}
@@ -13299,11 +14062,34 @@ export function messagesFeature(ctx) {
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
+ ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' },
+ h('span', { class: 'small muted' }, t('schedAt')),
+ h('input', {
+ type: 'datetime-local', class: 'grow', value: ui.postSchedule,
+ min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000),
+ onInput: (ev) => { ui.postSchedule = ev.target.value; },
+ })) : null,
h('div', { class: 'row gap6 wrap post-actions' },
- h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
+ h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => {
const body = composeText().trim();
if (!body) return;
const media = (ui.postMedia || []).filter((m) => body.includes(m.url));
+ if (ui.postSchedule != null) {
+ // nothing goes on screen yet, so the composer stays open until
+ // the notifier has it (a signer may ask first)
+ const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000);
+ ui.postScheduling = true; render();
+ try {
+ await schedulePost(body, media, at);
+ ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null;
+ setDraft(POST_DRAFT, '');
+ const inp = document.querySelector('.post-input');
+ if (inp) inp.value = '';
+ toast(t('schedDone', { when: schedWhen(at) }));
+ } catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.postScheduling = false; render();
+ return;
+ }
// the post is on screen instantly (publishPost is optimistic) —
// close the composer now; a failure reopens it with the text intact
ui.profCompose = null;
@@ -13317,14 +14103,21 @@ export function messagesFeature(ctx) {
if (!e.silent) toast(e.message || String(e));
render();
}
- } }, t('profPostBtn')),
+ } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')),
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
h('div', { class: 'row gap6 composer-options' },
previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
+ h('button', {
+ type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'),
+ 'aria-pressed': String(ui.postSchedule != null),
+ // an hour from now, on the next quarter hour
+ onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); },
+ }, '\u{1F552}'),
h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))),
+ scheduledList(),
postCancelWarning(),
gifs.sheet('post', (g) => {
const cur = composeText().replace(/\s+$/, '');
@@ -13356,10 +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
@@ -13438,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
@@ -15920,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
@@ -15929,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();
@@ -15949,7 +16748,7 @@ export function messagesFeature(ctx) {
// (a reply whose post every lookup came back without isn't shown: a
// "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
const repliesHere = feedReplies(c);
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
+ const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -15984,9 +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();
@@ -16009,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' },
@@ -16300,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());
@@ -16709,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) => {
@@ -17226,7 +18042,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) {
@@ -17350,7 +18173,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',
@@ -17369,7 +18227,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' },
@@ -17429,7 +18291,7 @@ 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));
}
@@ -17437,7 +18299,7 @@ export function messagesFeature(ctx) {
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 (!wn || wnLoading || !cd || cdLoading || !ng || ngLoading) { 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();
}
@@ -17546,7 +18408,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);
}
@@ -17835,9 +18697,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) {
@@ -17874,6 +18738,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);
@@ -17991,7 +18861,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);
@@ -18029,6 +18924,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); }
}
};
@@ -18124,10 +19033,14 @@ export function messagesFeature(ctx) {
resumed(awayMs) {
// a blink between apps didn't kill anything
if (awayMs && awayMs < 5_000) return;
- resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
- if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true;
- if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
- else refreshFeed({ force: true }).catch(() => {});
+ // the sockets first: after a freeze they read open and are dead, and
+ // a refresh sent on them finds nothing (wakeRelays)
+ wakeRelays().catch(() => {}).then(() => {
+ resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
+ if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return;
+ if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
+ else refreshFeed({ force: true }).catch(() => {});
+ });
return true;
},
// A zap flow reporting how its payment went, so the chip that appeared
@@ -18182,6 +19095,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;
@@ -18232,6 +19147,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();
@@ -18315,6 +19231,21 @@ 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
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
@@ -18326,6 +19257,21 @@ export function messagesFeature(ctx) {
}, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
+ // a gift link names its sender: sign its template as us (gifts.js)
+ async signGiftFrom(tmpl) {
+ const id = await identity();
+ if (!id) return null;
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ },
+ // A claimed gift's sender: follow them, once any follow publish in
+ // flight (a fresh wallet's starter follows) has landed — followMany
+ // drops a call that arrives mid-publish.
+ async followGiftSender(pk) {
+ for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500));
+ if (!(await identity())) return false;
+ await followMany([pk]).catch(() => {});
+ return true;
+ },
// A sign-in (Google, passkey, key) that found no wallet on its identity:
// the same start, but only the follows of an identity that follows
// nobody yet — an established nostr user keeps their own list.
@@ -18400,6 +19346,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);
@@ -18468,7 +19421,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();