// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
// Concord, membership travels by invite. coinos ships with one built-in
// community (below) so every user starts somewhere; beyond it users join by
// invite link, by direct invite (giftwrapped to their npub), or by founding
// their own community in-app.
//
// Identity: the session's nostr login when a signer is live, else the
// wallet's NIP-06 key. Community traffic encrypts under stream keys the app
// holds (signers only sign seals); DMs seal to the peer, which is what the
// widened signer adapters (encryptTo/decryptFrom) exist for. The DM inbox
// listens for both identities and decrypts with whichever keys are present.
import {
subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay,
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS,
} from '../nostr.js';
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js';
import {
EMOJI_SET_KIND, EMOJI_LIST_KIND, EMOJI_PARTIAL_RE, PACK_LINK_RE,
emojiTagMap, splitEmoji, emojiOnlyCount, outboundEmojiTags, shortcodeOf,
packAddr, packNaddr, parsePackRef, parsePackAddr, parseEmojiSet,
} from '../emoji.js';
import { saveInbox } from '../dm-inbox.js';
import { loadMediaIndex, saveMediaIndex, wipeMediaIndex } from '../media-cache.js';
import { mergeFeedWindow } from '../feed-window.js';
import { createThreadStore } from '../thread-cache.js';
import { createThreadPrefetch } from '../thread-prefetch.js';
import { makeImageThumbnails } from '../image-thumbnails.js';
import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js';
import { POPULAR_RELAYS, FIREHOSE_RELAYS, popularCandidates } from '../popular-feed.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { sha256 } from '@noble/hashes/sha256';
import { base64urlnopad } from '@scure/base';
import { t, getLang } from '../i18n.js';
import { inLanguage, isMachinePost, guessLang } from '../lang-guess.js';
import { translatePieces, translatable as transParts } from '../translate-split.js';
import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
// with the public read-only chat page so the two can never drift.
import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
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
// NIP-89 client attribution on PUBLIC notes (posts + thread replies) — other
// clients render it as "via coinos". Name-only: the fuller form appends a
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos'];
// A view the URL asks to open (a notification tap): read once, then taken
// off the address so a reload doesn't repeat it.
const OPEN_VIEW = (() => {
try {
const v = new URLSearchParams(location.search).get('open');
if (!v) return null;
const u = new URL(location.href); u.searchParams.delete('open');
history.replaceState(null, '', u.pathname + (u.search || ''));
return v;
} catch { return null; }
})();
export function messagesFeature(ctx) {
const { h, ui, render, wallet, toast, hook } = ctx;
// ---- persisted state ----------------------------------------------------
// The messages state is a large blob (chat caches, DMs, feeds) and reading
// it means parsing all of it. A repaint asks for it dozens of times (every
// row's mute and spam checks go through it), so within a tick every caller
// shares one parse; a write refreshes the copy. Another tab's write shows
// up on the next tick.
let stCache = null, stAt = 0;
const ST_MEMO_MS = 50;
const st = () => {
if (stCache && Date.now() - stAt < ST_MEMO_MS) return stCache;
const s = wallet.loadFeatureState('messages', {});
stCache = s; stAt = Date.now();
s.joined ||= {}; // { [cid]: { [pubkey]: true } }
s.cache ||= {}; // { [channelId]: [msgs] }
s.communities ||= []; // join material beyond the built-in
s.invites ||= {}; // { [cid]: { sk, token, url } } — minted links
s.dms ||= {}; // { [peerPk]: [{ id, from, text, t }] }
s.declined ||= {}; // direct-invite rumor ids dismissed
s.tombstones ||= {}; // { [cid]: removed_at ms } — left communities (CORD-02 §8)
s.read ||= {}; // { ['dm:'+pk | 'ch:'+id]: created_at } — last message we've seen
s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in)
s.drafts ||= {}; // { ['dm:'+pk | 'ch:'+id]: text } — half-typed messages
s.zaps ||= {}; // { [eventId]: { s: sats, m: 1 } } — last known zap tallies
s.feeds ||= []; // custom feeds: { id, name, follows, authors: [pk], topics: [tag] }
for (const c of s.communities) c.added_at ||= Date.now();
// pre-multi-community shape: joined was { [pubkey]: true } for coinos
for (const k of Object.keys(s.joined))
if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; }
return s;
};
const save = (s) => { stCache = s; stAt = Date.now(); wallet.saveFeatureState('messages', s); };
const communities = () => [COMMUNITY, ...st().communities];
// A [key, value] list cache folded into its Map. What this session already
// learned stays (and stays newest); stored rows fill in around it — the
// same call serves the first read and the re-read once IndexedDB opens.
function mergeStored(map, key) {
let rows;
try { rows = JSON.parse(cacheStorage.getItem(key) || '[]'); } catch { return; }
if (!Array.isArray(rows) || !rows.length) return;
const mine = [...map];
map.clear();
for (const [k, v] of rows) map.set(k, v);
for (const [k, v] of mine) { map.delete(k); map.set(k, v); }
}
// ---- custom emoji (NIP-30 tags, NIP-51 emoji sets) ----------------------
// A message that says :pika_wave: carries an ["emoji", code, url] tag for
// it, so RENDERING never needs anything held locally. Two sources feed
// what you can type and pick: packs you hold (kind 30030 sets, listed in
// your kind 10030 — the list Vector reads and writes too, so a pack added
// in either app shows in both) and shortcodes LEARNED from messages seen,
// so anyone can answer a :pika_wave: with one.
const EMOJI_STATE = 'emoji';
const LEARNED_MAX = 400;
let emojiState = null;
const emojiSt = () => {
if (!emojiState) {
const s = wallet.loadFeatureState(EMOJI_STATE, {}) || {};
s.packs ||= {}; // { [addr]: { title, image, emojis: [[code, url]], at } }
s.order ||= []; // pack addrs in picker order
s.learned ||= {}; // { [code]: url }, insertion-ordered, oldest first
s.list ||= { at: 0, tags: [] }; // our kind 10030 as last seen or published
s.unfetched ||= []; // listed packs no relay answered for — kept on the list
emojiState = s;
}
return emojiState;
};
// Learned shortcodes arrive in bursts (a backfill), so those writes are
// coalesced; a pack change is one deliberate act and lands at once — a
// tap-then-navigate must never lose it.
let emojiSaveTimer = 0;
const saveEmoji = (now = false) => {
clearTimeout(emojiSaveTimer);
const write = () => { try { wallet.saveFeatureState(EMOJI_STATE, emojiSt()); } catch {} };
if (now) write(); else emojiSaveTimer = setTimeout(write, 500);
};
// code → image url: held packs first (in order), then what's been seen
const emojiUrl = (code) => {
const s = emojiSt();
for (const addr of s.order) {
const p = s.packs[addr];
if (!p) continue;
for (const [c, u] of p.emojis) if (c === code) return u;
}
return s.learned[code] || null;
};
// everything typeable: [{ code, url, pack }], packs first, newest-seen next
function customEmojis() {
const s = emojiSt();
const out = [];
const seen = new Set();
for (const addr of s.order) {
const p = s.packs[addr];
if (!p) continue;
for (const [code, url] of p.emojis) {
if (seen.has(code)) continue;
seen.add(code);
out.push({ code, url, pack: p.title });
}
}
for (const [code, url] of Object.entries(s.learned).reverse()) {
if (seen.has(code)) continue;
seen.add(code);
out.push({ code, url, pack: '' });
}
return out;
}
// Remember the emoji tags a message (or reaction) arrived with. Bounded:
// the oldest fall off, and re-seeing one moves it back to the front.
function noteEmoji(rumor) {
const m = emojiTagMap(rumor && rumor.tags);
if (!m.size) return;
const s = emojiSt();
let changed = false;
for (const [code, url] of m) {
if (s.learned[code] === url) continue;
delete s.learned[code];
s.learned[code] = url;
changed = true;
}
if (!changed) return;
const keys = Object.keys(s.learned);
for (const k of keys.slice(0, Math.max(0, keys.length - LEARNED_MAX))) delete s.learned[k];
saveEmoji();
}
const emojiRelays = (extra = []) => [...new Set([...extra, ...zapRelays(), ...DM_RELAYS, ...STOCK_RELAYS])];
async function fetchEmojiPack(ref) { // (not fetchPack: that name is the follow packs' further down, and a second declaration wins)
const evs = await queryOn(emojiRelays(ref.relays), { kinds: [EMOJI_SET_KIND], authors: [ref.pubkey], '#d': [ref.identifier] }, 6000).catch(() => []);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
return newest ? parseEmojiSet(newest) : null;
}
function holdPack(pack) {
const s = emojiSt();
s.packs[pack.addr] = { title: pack.title, image: pack.image, emojis: pack.emojis, at: pack.at };
if (!s.order.includes(pack.addr)) s.order.push(pack.addr);
s.unfetched = s.unfetched.filter((a) => a !== pack.addr);
saveEmoji(true);
}
const hasPack = (addr) => !!emojiSt().packs[addr];
// A pasted naddr / Vector share link, or the Add button on a pack link in
// chat: fetch the set, hold it, and put it on our list.
async function addPack(input) {
const ref = parsePackRef(input);
if (!ref) { toast(t('msgEmojiPackBad')); return false; }
const addr = packAddr(ref.pubkey, ref.identifier);
if (hasPack(addr)) { toast(t('msgEmojiPackHave')); return true; }
ui.emojiBusy = true; render();
const pack = await fetchEmojiPack(ref);
ui.emojiBusy = false;
if (!pack) { toast(t('msgEmojiPackBad')); render(); return false; }
holdPack(pack);
toast(t('msgEmojiPackAdded', { name: pack.title }));
render();
publishEmojiList().catch(() => {});
return true;
}
function removePack(addr) {
const s = emojiSt();
delete s.packs[addr];
s.order = s.order.filter((a) => a !== addr);
s.unfetched = s.unfetched.filter((a) => a !== addr);
saveEmoji(true);
render();
publishEmojiList().catch(() => {});
}
// a plain (unwrapped) event signed by the chat identity
const signPlain = (id, evt) => (id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : id.signer.signEvent(evt));
// Our kind 10030: the packs held as `a` tags. Every other tag of the list
// we last saw rides along (inline emoji, whatever another client keeps
// there), so publishing from here never strips what Vector wrote.
async function publishEmojiList() {
const id = await identity();
if (!id) return;
const s = emojiSt();
const kept = (s.list.tags || []).filter((x) => x[0] !== 'a');
const created_at = Math.max(Math.floor(Date.now() / 1000), (s.list.at || 0) + 1);
const tags = [...kept, ...[...new Set([...s.order, ...s.unfetched])].map((a) => ['a', a])];
const evt = await signPlain(id, { kind: EMOJI_LIST_KIND, content: '', tags, created_at });
s.list = { at: created_at, tags };
saveEmoji(true);
publishOn(emojiRelays(), evt);
}
// Adopt the newest kind 10030 on the relays when it's newer than what we
// hold: packs it lists arrive, packs it dropped go. Ten-minute throttle.
let emojiSyncAt = 0;
async function syncEmoji({ force = false } = {}) {
if (!force && Date.now() - emojiSyncAt < 10 * 60_000) return;
emojiSyncAt = Date.now();
const pks = myPubkeys();
if (!pks.length) return;
try {
const evs = await queryOn(emojiRelays(), { kinds: [EMOJI_LIST_KIND], authors: pks }, 5000);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
const s = emojiSt();
if (!newest || newest.created_at <= (s.list.at || 0)) return;
const want = (newest.tags || []).filter((x) => x[0] === 'a' && parsePackAddr(x[1])).map((x) => x[1]);
s.list = { at: newest.created_at, tags: newest.tags || [] };
s.order = s.order.filter((a) => want.includes(a));
for (const a of Object.keys(s.packs)) if (!s.order.includes(a)) delete s.packs[a];
s.unfetched = [];
saveEmoji();
let changed = false;
for (const addr of want) {
if (s.packs[addr]) continue;
const pack = await fetchEmojiPack(parsePackAddr(addr));
if (pack) { holdPack(pack); changed = true; } else s.unfetched.push(addr);
}
// the list's own order wins over arrival order
s.order = want.filter((a) => s.packs[a]);
saveEmoji(true);
if (changed) scheduleRepaint();
} catch { emojiSyncAt = 0; }
}
const emojiImg = (code, url, cls = 'cemoji') =>
feedPaint && !feedMedia(url) ? ':' + code + ':' : h('img', { class: cls, src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
// A reaction's emoji always draws as its picture. The feed's freeze
// (emojiImg above) keeps a post's BODY from reflowing as pictures load, but
// a reaction sits in the action bar at a fixed size — and usually lands
// after its row was frozen, which left :kek_lord: as text for good.
const reactImg = (code, url) => h('img', { class: 'cemoji', src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
// A reaction's content as shown on its chip: the picture for a :code: we
// know (its own tag taught it to us), the text otherwise.
function reactNode(emoji) {
const code = shortcodeOf(emoji);
const url = code && emojiUrl(code);
return url ? reactImg(code, url) : emoji;
}
// the NIP-30 tag a :code: reaction travels with
const reactEmojiTags = (emoji) => {
const code = shortcodeOf(emoji);
const url = code && emojiUrl(code);
return url ? [['emoji', code, url]] : [];
};
// a bubble that is nothing but one to three custom emoji shows them big
const emojiJumbo = (text, em) => {
if (!em || !em.size) return false;
const n = emojiOnlyCount(splitEmoji(text, (c) => em.get(c)));
return n > 0 && n <= 3;
};
// A pack share link (Vector's, or ours) rendered as what it is: one tap
// adds the pack right here. Never an outbound link — on a phone the
// vectorapp.io address belongs to the Vector app, and a tap on it walked
// out of coinos into Vector.
function packLinkNode(url, naddr) {
const ref = parsePackRef(naddr);
const addr = ref && packAddr(ref.pubkey, ref.identifier);
const have = !!addr && hasPack(addr);
return h('button', {
class: 'pack-link', type: 'button', title: url, disabled: have || !!ui.emojiBusy,
onClick: (e) => { e.stopPropagation(); addPack(naddr); },
}, '🎨 ' + t('msgEmojiPackLink'), ' · ', h('b', {}, have ? t('msgEmojiPackHave') : t('msgEmojiPackAdd')));
}
// our share link for a pack — opens in the coinos app on a phone
const packShareLink = (addr) => APP_BASE + '/emojis/pack/' + packNaddr(addr);
// ---- unread -------------------------------------------------------------
// A conversation is unread when its newest message from someone else is
// newer than the last one we looked at. The watermark is the rumor's own
// created_at, not a local clock, so it means the same thing on every device
// the state syncs to. Our own messages never count — sending isn't reading,
// but you've obviously seen what you wrote.
const dmRead = (pk) => 'dm:' + pk;
const chRead = (id) => 'ch:' + id;
// The list screen has a watermark of its own: standing on it answers the
// header dot ("someone's waiting" — you've now been shown who), while each
// row keeps its own dot until that conversation is actually opened.
const HOME_READ = 'home';
const newestFrom = (entries, theirs) => {
let ts = 0;
for (const m of entries) if (theirs(m)) ts = Math.max(ts, m.rumor.created_at || 0);
return ts;
};
function markRead(key, ts) {
const s = st();
if (!ts || (s.read[key] || 0) >= ts) return;
s.read[key] = ts;
bumpMsgRev();
save(s);
// read markers ride their own sync domain (registered below) — nudging
// the cache is what gets the watermark onto the relays for other devices
try { wallet.saveCache(); } catch {}
}
// One tap, every dot: advance each conversation's watermark to its newest
// foreign message — DMs and community channels alike.
function markAllRead() {
const s = st();
const my = myPubkeys();
let changed = false;
const bump = (key, ts) => { if (ts && (s.read[key] || 0) < ts) { s.read[key] = ts; changed = true; } };
for (const [pk, msgs] of threads) bump(dmRead(pk), newestFrom(msgs.values(), (m) => !m.mine));
for (const room of rooms.values())
for (const [id, msgs] of room.byChannel)
bump(chRead(id), newestFrom(msgs.values(), (m) => !my.includes(m.author)));
for (const g of wnGroups()) bump(wnRead(g.id), wnNewest(g));
if (changed) { bumpMsgRev(); save(s); try { wallet.saveCache(); } catch {} }
render();
}
const anyUnread = () => {
for (const [pk, msgs] of threads) if (dmUnread(pk, msgs)) return true;
for (const room of rooms.values()) if (roomUnread(room)) return true;
for (const g of wnGroups()) if (wnUnread(g)) return true;
return false;
};
// Read state syncs across devices: each watermark only ever grows, so a
// per-key max is a clean commutative merge — a thread read anywhere is
// read everywhere.
if (wallet.registerCacheExtension && !wallet._msgReadSync) {
wallet._msgReadSync = true;
wallet.registerCacheExtension({
domain: 'msgread',
mergeAlways: true,
save: () => {
const read = wallet.loadFeatureState('messages', {}).read || {};
return Object.keys(read).length ? { msgRead: read } : {};
},
load: (d) => {
if (!d.msgRead) return;
const s = wallet.loadFeatureState('messages', {});
s.read ||= {};
let changed = false;
for (const [k, v] of Object.entries(d.msgRead)) {
if (typeof v === 'number' && (s.read[k] || 0) < v) { s.read[k] = v; changed = true; }
}
if (changed) { bumpMsgRev(); wallet.saveFeatureState('messages', s); }
},
});
}
// Custom feeds sync across devices too (mergeFeeds: newest edit wins,
// deletions remembered).
if (wallet.registerCacheExtension && !wallet._feedsSync) {
wallet._feedsSync = true;
wallet.registerCacheExtension({
domain: 'feeds',
mergeAlways: true,
save: () => {
const m = wallet.loadFeatureState('messages', {});
const feeds = m.feeds || [], gone = m.feedsGone || {};
return feeds.length || Object.keys(gone).length ? { feeds, feedsGone: gone } : {};
},
load: (d) => {
if (!Array.isArray(d.feeds) && !d.feedsGone) return;
const m = wallet.loadFeatureState('messages', {});
const got = mergeFeeds({ feeds: m.feeds, gone: m.feedsGone }, { feeds: d.feeds, gone: d.feedsGone });
if (!got) return;
m.feeds = got.feeds; m.feedsGone = got.gone;
wallet.saveFeatureState('messages', m);
stCache = null;
try { for (const id of got.touched) feedStates.delete(id); } catch {} // its query may have changed
scheduleRepaint();
},
});
}
// A feed added, edited or deleted here goes out on the next sync.
let feedsPush = null;
const feedsChanged = () => {
// a feed that takes in your lists is built from the other feeds: a
// changed list rebuilds it next time it's opened
for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); }
clearTimeout(feedsPush);
feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000);
};
// Per-conversation dots for the list screen: newer than when we last had
// that thread (or any channel of that room) open.
const dmUnread = (pk, msgs) =>
newestFrom(msgs.values(), (m) => !m.mine) > (st().read[dmRead(pk)] || 0);
function roomUnread(room) {
const s = st();
const my = myPubkeys();
for (const [id, msgs] of room.byChannel)
if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true;
return false;
}
// How many conversations are waiting on us — the header only draws a dot, but
// a count keeps the door open for a number later. Floored by the list-screen
// watermark: once you've seen the list, the header stops repeating it.
//
// Memoized on a revision counter: the header asks on EVERY render, and the
// walk touches every cached message in every thread and room — a phone
// paid that for each toast and repaint. Message arrivals and read-marks
// bump the revision; everything else reuses the answer.
let msgRev = 0;
const bumpMsgRev = () => { msgRev++; };
let _unreadMemo = { rev: -1, val: 0 };
function unreadCount() {
if (_unreadMemo.rev === msgRev) return _unreadMemo.val;
_unreadMemo = { rev: msgRev, val: unreadCountNow() };
return _unreadMemo.val;
}
function unreadCountNow() {
const s = st();
const my = myPubkeys();
const seen = s.read[HOME_READ] || 0;
let n = 0;
for (const [pk, msgs] of threads)
if (newestFrom(msgs.values(), (m) => !m.mine) > Math.max(seen, s.read[dmRead(pk)] || 0)) n++;
for (const room of rooms.values())
for (const [id, msgs] of room.byChannel)
if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > Math.max(seen, s.read[chRead(id)] || 0)) n++;
for (const g of wnGroups())
if (!g.fresh && wnNewest(g) > Math.max(seen, s.read[wnRead(g.id)] || 0)) n++;
return n;
}
const communityById = (cid) => communities().find((c) => c.community_id === cid);
// ---- identity -----------------------------------------------------------
// Who we speak as. If the user logged in with a nostr account we speak as
// that account or not at all — falling back to the wallet's own key would
// sign under a pubkey that isn't the one their avatar and profile show,
// which reads to everyone else as a different person entirely.
async function identity() {
const id = hook('nostrLoginIdentity');
if (id) {
const signer = id.signer || (await hook('nostrLoginResume'));
return signer ? { pubkey: id.pubkey, signer } : null;
}
if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk };
return null;
}
// NIP-42: sign a relay's AUTH challenge as the DM identity — an inbox
// relay (auth.nostr1.com, a personal relay) shows gift wraps only to the
// pubkey they're for, and some take writes only from authenticated users.
// Never Amber by intent: an app trip per relay connection, for a read
// nobody asked for.
async function relayAuth(tmpl) {
const id = await identity();
if (!id) throw new Error('no identity');
if (id.signer instanceof Uint8Array) return finalizeEvent(tmpl, id.signer);
if (id.signer.interactive) throw new Error('signer is interactive');
return id.signer.signEvent(tmpl);
}
// Our own kind-10050 inbox beyond DM_RELAYS (read at startDMs): sent
// copies go there too, so other clients reading that list see them.
let ownInbox = [];
const toOwnInbox = (wrap) => { if (ownInbox.length) publishOn(ownInbox, wrap, { onauth: relayAuth }); };
// what a feed or a profile lists: notes, and polls (NIP-88)
const FEED_KINDS = [1, 1068];
// A profile shows its articles (NIP-23) among the notes, as Amethyst does.
const PROFILE_KINDS = [...FEED_KINDS, 30023];
// An edited article is a new event at the same address: keep the newest.
const newestArticles = (evs) => {
const best = new Map();
for (const e of evs) {
if (e.kind !== 30023) continue;
const k = e.pubkey + ':' + ((e.tags.find((x) => x[0] === 'd') || [])[1] || '');
if (!best.has(k) || best.get(k).created_at < e.created_at) best.set(k, e);
}
return evs.filter((e) => e.kind !== 30023 || best.get(e.pubkey + ':' + ((e.tags.find((x) => x[0] === 'd') || [])[1] || '')) === e);
};
const myPubkeys = () => {
const pks = [];
const id = hook('nostrLoginIdentity');
if (id) pks.push(id.pubkey);
if (wallet.nostr && wallet.nostr.pk && !pks.includes(wallet.nostr.pk)) pks.push(wallet.nostr.pk);
return pks;
};
const isMe = (pk) => myPubkeys().includes(pk);
// The ONE nostr identity this wallet uses for DMs: the login identity when
// signed in (extension/bunker/passkey/Google), else the seed-derived key. A
// wallet has two keys (seed + login), but only this one should receive and
// advertise DMs — advertising/polling the seed key as a SECOND inbox is what
// produced duplicate welcome DMs and split a person's messages across two
// identities. isMe/dmDecryptors stay tolerant of both so already-received
// messages still thread and align; we just stop treating the seed key as a
// live second inbox. (One key here, but kept as a list for the '#p' filters.)
const dmSubKeys = () => {
const id = hook('nostrLoginIdentity');
const pk = (id && id.pubkey) || (wallet.nostr && wallet.nostr.pk) || null;
return pk ? [pk] : [];
};
// "No identity" while soft-locked means the keys left with the lock — say
// that, not "signer disconnected" (which reads as a nostr-login problem).
const noIdToast = () => {
if (signinAsk()) return; // no wallet at all: the way in, not a toast
// A missing signer gets the reconnect screen, not a dead-end toast —
// logging out and back in was the workaround nobody should need.
if (!wallet.watchOnly && hook('nostrReconnectPrompt')) return;
toast(t(wallet.watchOnly ? 'msgLockedChat' : 'msgNoIdentity'));
};
// Thrown by the publish paths after noIdToast already did the talking
// (opened the reconnect screen, or toasted): callers must not toast it
// again — a pasted-nsec login that lost its signer on reload used to get
// the toast INSTEAD of the screen with the nsec field this way.
class NoIdentity extends Error { constructor() { super(t('msgNoIdentity')); this.silent = true; } }
const requireIdentity = async () => {
const id = await identity();
if (!id) { noIdToast(); throw new NoIdentity(); }
return id;
};
// ---- shared runtime -----------------------------------------------------
// Bumped on every stop(): an account switch. Async work started under the
// previous identity (a relay query, a wrap mid-decrypt) checks it after
// each await and drops what it got — it used to land in the NEXT account's
// state and be saved there, so a throwaway account showed adam's DMs and
// notifications for good.
let session = 0;
const rooms = new Map(); // cid -> room runtime
const threads = new Map(); // peerPk -> Map(rumorId -> { rumor, mine })
// DM reactions, keyed by the reacted message's id — no thread needed, so a
// reaction that arrives before its message still lands. Ephemeral like the
// community ones: the wrap backlog rebuilds them on reload.
const dmReacts = new Map(); // rumorId -> Map(authorPk -> emoji)
const dmReactAt = new Map(); // `${rumorId}|${authorPk}` -> ms of the reaction that stands
const pendingDirect = new Map(); // rumor id -> { bundle, from }
const profiles = new Map(); // pubkey -> profile | null while loading
const seenWraps = new Set();
// Wraps this device already opened (or gave up on), remembered ACROSS
// boots: relays re-serve their last ~400 wraps on every subscribe, and a
// remote-signer wallet paid two NIP-46 round trips (sign + verify each)
// per wrap to rediscover DMs it already had — ~4s of a phone's boot.
// Keyed by an id prefix (64 bits is plenty for "skip this one").
const WRAPS_MAX = 1500;
const wrapKey = (id) => String(id).slice(0, 16);
let wrapLog = null, wrapsTimer = 0;
// One pass, once per wallet, over the last two weeks of wraps even though
// the log calls them opened: caches written before rows kept file fields,
// or clobbered by another tab, are missing messages no relay would ever be
// asked for again. Only messages and reactions are re-applied.
// v2 (2026-10-09): every wrap the relays still hold. Until then a login
// signer that hadn't resumed yet counted as "complete", so a boot's whole
// backfill could be given up on the wallet key alone and logged as opened.
// The pass is only marked done once nothing it re-opened is still waiting
// for the signer — a session closed before the bunker answered runs it again.
const HEAL_VERSION = 2;
let healSince = 0, healAt = 0, healTimer = 0;
const healed = new Set();
function loadSeenWraps() {
if (wrapLog) return;
const s = st();
wrapLog = (s.wraps || []).slice(-WRAPS_MAX);
for (const k of wrapLog) seenWraps.add(k);
healSince = 0;
if ((s.dmHeal || 0) < HEAL_VERSION) {
if (wrapLog.length) {
healSince = 1; healAt = Date.now();
// wraps that open at once never reach the drain: check on a timer too
clearTimeout(healTimer);
const tick = () => { healDone(); if (healSince) healTimer = setTimeout(tick, 60_000); };
healTimer = setTimeout(tick, 90_000);
} else { s.dmHeal = HEAL_VERSION; save(s); }
}
}
function healDone() {
if (!healSince || Date.now() - healAt < 60_000 || !decryptorsComplete()) return;
for (const p of pendingWraps.values()) if (p.heal) return;
const s = st();
s.dmHeal = HEAL_VERSION;
save(s);
healSince = 0;
}
const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id);
function rememberWrap(id) {
const k = wrapKey(id);
if (!wrapLog) wrapLog = [];
if (!seenWraps.has(k)) wrapLog.push(k);
seenWraps.add(k);
clearTimeout(wrapsTimer);
wrapsTimer = setTimeout(() => {
try {
// another tab may have logged wraps since this one loaded: keep theirs
const s = st();
const mine = new Set(wrapLog);
s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX);
wrapLog = s.wraps;
save(s);
} catch {}
}, 1500);
}
let dmStarted = false;
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
repaintTimer = setTimeout(() => {
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
}, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
// away instead of flashing the punk fallback; entries refresh in the
// background once a day.
const PROFILE_TTL = 24 * 3600_000;
let profilesWarmed = false;
function warmProfiles() {
if (profilesWarmed) return;
// The cache lives under the WALLET's namespace. The header avatar can
// ask for a face before the wallet has its keys (a silent sign-in still
// resuming at boot), and warming then read an empty slot under a bogus
// key and counted itself done — online the relays papered over it, but
// offline every cached face stayed a punk. Wait for the keys; init()
// warms again once they're there.
if (!wallet.mnemonic && !wallet.xprv && !wallet.xpub) return;
profilesWarmed = true;
const cached = profilesDisk();
// (a row asked for before the cache opened — still loading, or a miss
// while offline — gives way to the remembered face; a kind 0 that
// really named nothing keeps its say)
for (const [pk, p] of Object.entries(cached)) {
const cur = profiles.get(pk);
if (!cur || (!cur.name && !cur.picture && !cur.eventAt)) profiles.set(pk, p);
}
// someone known to have no face paints their punk from the first frame,
// instead of a blank circle that turns into one when the relays answer
for (const [pk, t] of Object.entries(facelessDisk())) {
if (!profiles.has(pk)) profiles.set(pk, { name: null, picture: null, t, miss: 1 });
}
}
// People with no face: no kind 0 on two asks running, or one naming
// nothing. Kept apart from the profile blob (whose rows are for real
// faces) as a small pubkey -> time map; one found with a face leaves it.
const FACELESS_MAX = 1000;
let facelessBlob = null, facelessFlush = null;
const facelessDisk = () => (facelessBlob ||= wallet.loadFeatureState('profilesFaceless', {}) || {});
function markFaceless(pk, on) {
const s = facelessDisk();
if (on ? s[pk] : !s[pk]) return;
if (on) s[pk] = Date.now(); else delete s[pk];
if (facelessFlush) return;
facelessFlush = setTimeout(() => {
facelessFlush = null;
const keys = Object.keys(s);
if (keys.length > FACELESS_MAX) for (const k of keys.sort((a, b) => s[a] - s[b]).slice(0, keys.length - FACELESS_MAX)) delete s[k];
wallet.saveFeatureState('profilesFaceless', s);
}, 500);
}
// The persisted profile blob (names, faces, thumbnails) lives parsed in
// 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;
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;
const s = profBlob;
if (!s) return;
// Who stays when space runs out: your own faces, the people you follow,
// talk to and hear from in Notifications. A feed scroll through a
// hundred strangers used to evict them all within minutes, and a reload
// then painted a punk and an npub while their kind 0 was asked for again.
const mine = new Set(myPubkeys());
const keep = new Set([...mine, (hook('nostrLoginIdentity') || {}).pubkey,
wallet.nostrPubkey && wallet.nostrPubkey()].filter(Boolean));
const known = new Set(keep);
try { for (const pk of followsNow().set) known.add(pk); } catch {}
try { for (const pk of threads.keys()) known.add(pk); } catch {}
try { for (const x of (notif ? notif.items : st().notifs) || []) if (x && x.actor) known.add(x.actor); } catch {}
const evictFirst = (keys) => keys.sort((a, b) => (known.has(a) - known.has(b)) || ((s[a].t || 0) - (s[b].t || 0)));
// 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;
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)))) {
size -= (s[k].thumb || '').length;
delete s[k].thumb; delete s[k].thumbFor;
if (size <= THUMB_BUDGET) break;
}
}
const keys = Object.keys(s);
if (keys.length > PROFILE_ROWS)
for (const k of evictFirst(keys.filter((k) => !keep.has(k))).slice(0, keys.length - PROFILE_ROWS)) delete s[k];
wallet.saveFeatureState('profiles', s);
}
function persistProfile(pk, p) {
// A faceless answer is not a fact worth writing down here — persisting
// {name:null, picture:null} rows only evicts real faces from the cap.
// (It goes on the faceless list instead.)
if (p && p.eventAt) markFaceless(pk, !p.name && !p.picture);
if (!p || (!p.name && !p.picture)) return;
const s = profilesDisk();
s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(),
...(p.thumbFor === p.picture && p.thumb
? { 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);
}
// 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
// the legacy coinos.io copies 502 for a third of the set — so painting
// those from the local files means no network, no broken faces, and the
// right size for the circle being drawn.
// Absolute (what the wizard publishes) or the bare relative path an older
// build wrote into a few profiles.
const PUNK_PIC_RE = /^(?:https?:\/\/(?:[a-z0-9-]+\.)*coinos\.io\/)?punks\/(\d{1,2})\.webp$/i;
const localPunk = (url, big) => {
const m = PUNK_PIC_RE.exec(url || '');
return m ? punkImageUrl(m[1], !big) : null;
};
// A refreshed profile whose picture hasn't changed keeps the thumbnail we
// already made of it — otherwise every relay refresh throws the local copy
// away and the original gets downloaded all over again.
const keepThumb = (pk, entry) => {
const prev = profiles.get(pk);
if (!prev || !entry || !entry.picture) return entry;
if (prev.thumb && prev.thumbFor === entry.picture) {
// thumbPx rides along: without it a refreshed profile looks like a
// thumbnail of unknown size and gets remade on every relay refresh
entry.thumb = prev.thumb; entry.thumbFor = prev.thumbFor; entry.thumbPx = prev.thumbPx || 0;
}
if (prev.thumbFail === entry.picture) {
entry.thumbFail = prev.thumbFail;
entry.thumbFailAt = prev.thumbFailAt || 0;
entry.thumbFails = prev.thumbFails || 1;
entry.thumbFailVersion = prev.thumbFailVersion || 0;
}
return entry;
};
// Pull the picture bytes into the HTTP cache the moment we learn the URL —
// an avatar div then paints instantly instead of holding its quiet circle
// while the image downloads.
const preloadPicture = (p, big = false) => {
try {
if (!p?.picture) return;
const image = new Image();
image.src = big ? localPunk(p.picture, true) || p.picture
: localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture;
if (big) image.decode().catch(() => {});
} catch {}
};
// A profile picture is whatever its owner uploaded, and that is very often
// the full-size original: among the faces this wallet had cached, one was a
// 5MB JPEG and another 3.9MB — megabytes fetched and decoded to paint a
// 30px circle, which is the second of blank circles you see after a
// refresh. So the first time we see a picture we downscale it ONCE and keep
// the thumbnail beside the profile. Every later boot paints the face from
// localStorage in the first frame, with no network at all.
//
// Reading the pixels needs CORS (a tainted canvas can't be exported). Most
// avatar hosts allow it; the ones that don't are remembered as failures and
// keep today's behaviour rather than being retried every boot.
const THUMB_PX = 144; // the 44px feed circle at 3x, with room to spare;
// the 64px profile avatar layers the original
// over it anyway
// Chars of data URL for one face. Scaled with THUMB_PX: the busiest
// picture on hand fit 9000 at 96px and needs 12827 at 144px — measured in
// the browser, not estimated, because its own webp encoder is nothing
// like as kind to noise as an offline one. A face over the cap keeps
// painting from its original, which is the old behaviour, so the cap only
// decides who gets the fast path.
const THUMB_MAX = 16_000;
// ...and for all of them together. Raised with THUMB_PX: a 144px face
// costs about 5.4KB against 96px's 3.2KB, and the budget is what decides
// how many faces paint instantly rather than fetching their original —
// this holds about fifty, which covers a feed and an inbox at once. The
// blob carries each profile's bio and banner as well now — a couple of
// hundred bytes each, and the reason a profile page paints on the first
// tap — so the ceiling is raised to keep the same number of faces.
// Since the blob moved to IndexedDB (cache-store.js) the ceiling is no
// longer localStorage's five million characters shared with everything:
// 320K held thirty-odd faces on a phone that follows hundreds, and the
// rest churned — thumbnail made, evicted, the original fetched again.
const THUMB_BUDGET = 2_000_000;
const THUMB_SLOW = 20_000; // a host that won't answer must not hold a slot
const THUMB_RETRY = 6 * 3600_000; // ...and must not be written off for good
const THUMB_RETRY_MAX = 14 * 24 * 3600_000; // a host that never works, backed off
// Older crossOrigin attempts could fail on a cached no-CORS response.
// Those failures must not suppress the corrected fetch/blob path.
const THUMB_VERSION = 1;
const thumbing = new Set();
const thumbQueue = new Map();
// Hosts that serve pictures without CORS headers (cdn.nostrcheck.me, most
// personal sites): the browser refuses the bytes to
// a fetch and prints a red line per face while the paints fine. No
// thumbnail is to be had there, so after a second quick refusal the host
// is left alone for a week rather than asked about every new face.
// v2: the first list was mostly wrong (primal's CDN, coinos.io) — see corsFetch
// v3: v2 also caught dropped connections (primal again, on a phone)
// v5: v4's reachability probe was answered from the HTTP cache
const NOCORS_KEY = 'coinos-thumb-nocors-v5';
try { for (const v of ['', '-v2', '-v3', '-v4']) localStorage.removeItem('coinos-thumb-nocors' + v); } catch {}
const NOCORS_MS = 7 * 24 * 3600_000;
let noCors = null; // host -> { n, until }
const noCorsNow = () => { if (!noCors) { try { noCors = JSON.parse(localStorage.getItem(NOCORS_KEY) || '{}'); } catch { noCors = {}; } } return noCors; };
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
const noCorsHost = (u) => { const h = noCorsNow()[hostOf(u)]; return !!h && h.n >= 2 && Date.now() < h.until; };
const noteNoCors = (u) => {
const host = hostOf(u); if (!host) return;
const m = noCorsNow(); const cur = m[host] || { n: 0 };
m[host] = { n: cur.n + 1, until: Date.now() + NOCORS_MS };
if (Object.keys(m).length > 200) delete m[Object.keys(m)[0]];
try { localStorage.setItem(NOCORS_KEY, JSON.stringify(m)); } catch {}
};
// The bytes of a picture, readable by script. The HTTP cache comes first:
// the face was usually just painted from this URL. But a CDN that sends
// its CORS header only to requests carrying an Origin, and caches the
// plain answer without `Vary: Origin` (primal's r2a.primal.net does), hands
// the cached copy — header-less — to our CORS fetch, which then fails as if
// the host refused. That wrote primal and coinos.io off as "no CORS" and
// left every picture hosted there painting from the full-size original.
// So a refusal is retried once past the cache before anyone is blamed.
// And a refusal looks exactly like a dropped connection (a phone between
// networks, a DNS hiccup): both are a quick TypeError. Two of those wrote
// blossom.primal.net off for a week on a phone. So the host is only
// blamed (TypeError rethrown) when a plain no-cors ask of the same URL
// goes through; otherwise it's the network, and the error says so.
async function corsFetch(url, signal) {
try {
return await fetch(url, { mode: 'cors', signal });
} catch (e) {
if (!e || e.name !== 'TypeError') throw e;
try {
return await fetch(url, { mode: 'cors', cache: 'reload', signal });
} catch (e2) {
if (!e2 || e2.name !== 'TypeError') throw e2;
// past the cache: the face was just painted from this URL, so a
// cached copy would answer even with the network gone — and a dropped
// connection got primal, nostr.build and coinos.io itself blamed
let reachable = false;
try { await fetch(url, { mode: 'no-cors', cache: 'reload', signal }); reachable = true; } catch {}
if (reachable) { e2.corsRefused = true; throw e2; }
const net = new Error('network'); net.name = 'NetworkError';
throw net;
}
}
}
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 shows it anyway
// thumbPx: a thumbnail made when the circles were smaller is too soft for
// today's, so it's remade once. The old one keeps painting until then.
if (p.thumbFor === p.picture && p.thumbPx === THUMB_PX) return;
if (localPunk(p.picture)) return; // our own art, already the right size on disk
// A failed attempt is usually the network, not the host: these images sit
// on CDNs that answer in 700ms one minute and time out the next. So every
// failure is tried again — backing off each time, so a host that truly
// won't have us is asked about twice a month rather than every boot.
if (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === p.picture
&& Date.now() - (p.thumbFailAt || 0) < Math.min(THUMB_RETRY * 2 ** ((p.thumbFails || 1) - 1), THUMB_RETRY_MAX)) return;
if (thumbing.has(pk)) return;
if (thumbing.size >= 3 && !isMe(pk)) { thumbQueue.set(pk, p); return; }
// Making the thumbnail costs one more fetch of the original today to
// save every fetch after it — but not on a connection someone is
// nursing. Data Saver keeps today's behaviour.
try { if (navigator.connection && navigator.connection.saveData) return; } catch {}
thumbing.add(pk);
const url = p.picture;
const done = (patch) => {
thumbing.delete(pk);
const entry = { ...(profiles.get(pk) || p), ...patch };
if (patch.thumb) {
delete entry.thumbFail; delete entry.thumbFailAt;
delete entry.thumbFails; delete entry.thumbFailVersion;
}
profiles.set(pk, entry);
persistProfile(pk, entry);
persistThreadProfile(pk);
if (patch.thumb) scheduleRepaint();
// Every warmed face gets a turn; the old concurrency guard silently
// discarded everyone after the first three in a batch.
for (const [nextPk, next] of thumbQueue) {
if (thumbing.size >= 3) break;
thumbQueue.delete(nextPk);
makeThumb(nextPk, profiles.get(nextPk) || next);
}
};
const failed = () => done({ thumbFail: url, thumbFailAt: Date.now(), thumbFailVersion: THUMB_VERSION,
thumbFails: (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === url ? p.thumbFails || 0 : 0) + 1 });
// Fetch the bytes rather than pointing a crossOrigin at the URL.
// The avatar itself is painted as a plain background-image, so the
// picture is already in the HTTP cache as a no-CORS entry — and a
// crossOrigin for the same URL reuses that entry and fails the CORS
// check, which is how a host that allows us perfectly well (verified: the
// same fetch succeeds) came to be written off. Drawing from a blob also
// keeps the canvas untainted, so nothing here depends on the element's
// CORS bookkeeping at all.
(async () => {
let bmp = null;
try {
// 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);
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 });
} 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
// is a TypeError too)
if (e && e.corsRefused) noteNoCors(url);
failed();
} finally { try { bmp && bmp.close(); } catch {} }
})();
}
// A profile that came back EMPTY is usually not proof there's no kind 0.
// A feed holds a couple of dozen relay subscriptions open, relays cap what
// one connection may ask at once, and a batch of a dozen pubkeys routinely
// comes back with seven — the rest aren't missing, they're crowded out.
// Trusting that silence for ten minutes is what left real accounts wearing
// a punk and an npub. So a miss is retried soon and backs off only if it
// keeps missing: fifteen seconds, then half a minute, then a minute, up to
// the ten minutes that's right for someone who genuinely has no profile.
const EMPTY_RETRY = 10 * 60_000;
const emptyRetry = (p) => Math.min(15_000 * Math.pow(2, Math.max(0, (p && p.miss) || 1) - 1), EMPTY_RETRY);
// A feed row keeps the presentation it had when admitted. Background
// profile/quote refreshes must not rewrite text or replace faces mid-read.
let feedPaint = null;
function profileOf(pk) {
if (!feedPaint) return liveProfileOf(pk);
if (!feedPaint.profiles.has(pk)) {
const p = { ...(liveProfileOf(pk) || {}) };
let url = avatarUrl(p, pk);
if (!feedMedia(url) && feedMedia(p.picture)) url = p.picture;
if (!feedMedia(url)) p.picture = null;
else p.picture = url;
delete p.thumb;
delete p.loading;
feedPaint.profiles.set(pk, p);
}
return feedPaint.profiles.get(pk);
}
function liveProfileOf(pk) {
warmProfiles();
const cur = profiles.get(pk);
if (cur !== undefined && (cur === null
|| Date.now() - (cur.t || 0) < (!cur.name && !cur.picture ? emptyRetry(cur) : PROFILE_TTL))) return cur;
profiles.set(pk, cur || null); // null = loading, no fallback art yet
wantProfile(pk);
return cur || null;
}
// Profiles are asked for in BATCHES.
//
// One REQ per pubkey was the old shape, and it looked fine until a feed
// painted eighty rows at once: eighty REQs in the same breath, against
// relays that cap how many a connection may have open. Most were refused,
// the five-second timeout expired, and an empty answer was remembered for
// ten minutes — which is the punk-and-a-shortened-npub row. Measured
// against this wallet's own feed, 131 of the 133 people on screen had a
// kind 0 sitting on relays we were already connected to.
const PROF_BATCH = 200;
// Relays that exist to index profiles rather than to carry conversation.
// We don't read notes from these, so they never appear in the batch above —
// but they are the ones most likely to hold the kind 0 of someone whose
// own relays we can't reach.
const PROFILE_INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es', 'wss://relay.nostr.band'];
let profQueue = new Set(), profTimer = null, profInFlight = new Set();
function wantProfile(pk) {
if (!pk || profQueue.has(pk) || profInFlight.has(pk)) return;
profQueue.add(pk);
if (profTimer) return;
// a short beat, so one paint's worth of rows becomes one request
profTimer = setTimeout(() => { profTimer = null; const b = [...profQueue]; profQueue = new Set(); fetchProfiles(b).catch(() => {}); }, 250);
}
function applyProfile(pk, ev) {
let m = null;
try { m = JSON.parse(ev.content); } catch { m = null; }
if (!m || typeof m !== 'object' || Array.isArray(m)) return false;
const prev = profiles.get(pk);
if ((prev?.eventAt || 0) > ev.created_at) return true;
const p = {
name: m.display_name || m.name || null,
picture: m.picture || null,
nip05: m.nip05 || null,
lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null,
// The batch downloaded this person's WHOLE kind 0 to get their name.
// Keeping the bio and the banner costs one more field each and is the
// difference between a profile page that paints and one that jumps
// when the fetch lands a second later. 1000 chars is what the page
// renders anyway.
about: typeof m.about === 'string' ? m.about.slice(0, 1000) : null,
banner: typeof m.banner === 'string' ? m.banner.slice(0, 400) : null,
};
const entry = keepThumb(pk, { ...p, eventAt: ev.created_at, t: Date.now(), miss: 0 });
profiles.set(pk, entry);
// A page and the batched avatar lookup must share their answer. A slow
// page miss must never erase a name/photo the batch already found.
if (fullInFlight.has(pk) || fullProfiles.has(pk)) {
fullProfiles.set(pk, m);
fullFetched.add(pk);
fullMisses.delete(pk);
persistPage('full', pk, m);
}
persistProfile(pk, entry);
persistThreadProfile(pk);
preloadPicture(entry);
return true;
}
async function fetchProfiles(pks) {
for (const pk of pks) profInFlight.add(pk);
try {
const found = new Set();
// Our relays and the profile indexes at once, each answer applied as
// it lands. Asking them one after the other, each waiting out its
// slowest relay, put a five-second floor under anyone whose kind 0
// only an index held: a visitor opening a shared post saw a punk and
// an npub for seven and a half seconds.
let painted = 0;
const asked = new Set(pks);
const take = (ev) => {
if (ev && ev.kind === 0 && asked.has(ev.pubkey) && applyProfile(ev.pubkey, ev)) {
found.add(ev.pubkey);
if (!painted) painted = setTimeout(() => { painted = 0; scheduleRepaint(); }, 30);
}
};
const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS])];
const asks = [];
for (let i = 0; i < pks.length; i += PROF_BATCH) {
asks.push(queryStreamOn(relays, { kinds: [0], authors: pks.slice(i, i + PROF_BATCH) }, take, 5000).catch(() => {}));
}
await Promise.all(asks);
const stillLeft = pks.filter((pk) => !found.has(pk));
if (stillLeft.length) {
const left = stillLeft;
await fetchRelayLists(left);
await Promise.all(outboxPlan(left).map(async ({ relays, authors }) => {
const evs = await queryOn(relays, { kinds: [0], authors }, 5000).catch(() => []);
const newest = new Map();
for (const ev of evs || []) {
const c = newest.get(ev.pubkey);
if (!c || ev.created_at > c.created_at) newest.set(ev.pubkey, ev);
}
for (const [pk, ev] of newest) { if (applyProfile(pk, ev)) found.add(pk); }
}));
}
// nobody has one: mark the clock so it's retried in minutes, not asked
// again on every paint
for (const pk of pks) {
if (found.has(pk)) continue;
const prev = profiles.get(pk);
const miss = ((prev && prev.miss) || 0) + 1;
profiles.set(pk, { ...(prev || {}), t: Date.now(), miss });
// remembered, so the next load paints their punk at once (never
// someone whose face we do know; a face found later replaces it)
if (!(prev && (prev.name || prev.picture))) markFaceless(pk, true);
}
scheduleRepaint();
} finally {
for (const pk of pks) profInFlight.delete(pk);
}
}
const displayName = (pk) => {
const p = profileOf(pk);
if (p && p.name) return p.name;
const npub = npubOf(pk);
return npub ? npub.slice(0, 12) : pk.slice(0, 12);
};
// Community wraps decrypt with real secp256k1 work (NIP-44), and a relay
// backlog delivers hundreds at once. Decrypting them inline in the
// subscription callbacks blocked the main thread in one burst on boot — the
// stutter felt when dragging the balance carousel right after a refresh.
// Queue the decrypt work and run it time-sliced: ~8ms of work, then yield so
// input and paint get a turn. Chat catches up a few frames later; the wallet
// stays responsive. (Same pattern the DM inbox drain already uses.)
// Open one community wrap off the main thread when the crypto worker is
// up (the decrypt + seal verify per wrap is the remaining boot secp256k1
// the profiler pinned); the time-sliced queue below stays as the fallback,
// so a dead worker only changes where the crypto runs, never whether chat
// catches up. The stream conversation key is group material every member
// derives — no secret crosses the thread boundary.
function openWrapBg(wrap, stream, cb) {
const job = openWrapsOffthread([wrap], stream.convKey);
if (!job) { enqueueRoomWork(() => cb(openWrap(wrap, stream))); return; }
job.then((r) => {
if (r) cb(r[0]);
else enqueueRoomWork(() => cb(openWrap(wrap, stream))); // worker died mid-job
});
}
const roomWork = [];
let roomWorking = false;
function enqueueRoomWork(fn) {
roomWork.push(fn);
if (roomWorking) return;
roomWorking = true;
(async () => {
try {
let t0 = performance.now();
while (roomWork.length) {
try { roomWork.shift()(); } catch {}
if (performance.now() - t0 > 8) { await new Promise((r) => setTimeout(r)); t0 = performance.now(); }
}
} finally { roomWorking = false; }
})();
}
// ---- community rooms ----------------------------------------------------
// subscribe:false builds the room from LOCAL CACHE only — no relay
// subscription, so none of the gift-wrap verify/decrypt (real secp256k1)
// runs. That's the boot default: the home screen and its unread dot read
// the last-known cached messages, and the heavy live backfill waits until
// the user actually opens Chat (homeView/room view pass subscribe:true).
function ensureRoom(jm, { subscribe = true } = {}) {
let room = rooms.get(jm.community_id);
if (room) { if (subscribe) subscribeRoom(room); return room; }
const root = hexToBytes(jm.community_root);
// CORD-02 §5: a Community past the control_root split hands members the
// Control Plane's signer pubkey in the invite — the address to read at —
// while the wraps still decrypt under the community_root-derived key. A
// legacy Community (the built-in coinos one) has no control_pk: the one
// derivation is both address and key, and its sk still signs our writes.
const controlRead = controlKey(root, jm.community_id, jm.root_epoch || EPOCH);
const control = /^[0-9a-f]{64}$/.test(jm.control_pk || '') && jm.control_pk !== controlRead.pk
? { pk: jm.control_pk, sk: null, convKey: controlRead.convKey }
: controlRead;
// A channel's stream follows its own key and epoch (CORD-03): public ones
// rotate with the base epoch, private ones carry theirs in the join
// material. A channel only known from the Control fold is public.
const chEntry = (id) => (jm.channels || []).find((c) => c.id === id) || { id };
room = {
jm,
control,
guestbook: guestbookKey(root, jm.community_id, jm.root_epoch || EPOCH),
chStream: (id) => channelStream(jm, chEntry(id)),
chEpoch: (id) => channelEpoch(jm, chEntry(id)),
folded: null,
controlEntries: [],
guestEntries: [],
// the wraps this room opened: a room rebuilt with fresher keys hands
// them back to seenWraps, or its new subscription would skip the
// replay and fold without them
wrapIds: new Set(),
members: new Map(),
byChannel: new Map(),
edits: new Map(),
deletes: new Set(),
reactions: new Map(),
reactAt: new Map(), // `${target}|${author}` -> ms of the reaction that stands
presence: new Map(), // pubkey -> ms of their last beat (this session only)
typing: new Map(), // pubkey -> { ch, t }
subbed: new Set(),
subscribed: false,
relays: jm.relays && jm.relays.length ? jm.relays : DM_RELAYS,
};
rooms.set(jm.community_id, room);
const refold = () => { room.folded = foldControl(room.controlEntries, { ownerHex: jm.owner, cid: jm.community_id }); };
const refoldGuestbook = () => {
room.members = foldGuestbook(room.guestEntries, { nowMs: Date.now(), banned: room.folded?.banned, ownerHex: jm.owner });
for (const [, msgs] of room.byChannel)
for (const { rumor, author } of msgs.values()) {
const tms = eventMs(rumor);
if (tms) observeAuthor(room.members, author, tms);
}
};
// The replay of stored wraps arrives one event at a time, and folding
// after every wrap paints each intermediate state: a superseded ban-list
// version blinks members' messages out until the newest version lands,
// and the member count creeps upward as joins stream in. Fold once per
// burst instead — a short quiet gap ends the burst — so the page goes
// from cache straight to the settled state.
let foldTimer = 0;
room.scheduleFold = () => {
clearTimeout(foldTimer);
foldTimer = setTimeout(() => {
refold();
refoldGuestbook();
// An invite may grant no channels at all (a Vector link carries only
// the private ones): the public channels are learned from the
// Control fold, so a room open on the relays follows them as they
// fold in — otherwise the room stays "No messages yet" forever.
if (room.subscribed) for (const c of roomChannels(room)) subChannel(room, c.id);
// remember the settled count — next session's list paints it
// immediately instead of "encrypted" flipping to a number
const s = st();
(s.memberCounts ||= {})[jm.community_id] =
[...room.members.values()].filter((m) => m.state === 'join').length;
save(s);
scheduleRepaint();
}, 250);
};
// warm from local cache so the page (and the unread dot) paint before —
// and without needing — any relay subscription
const cached = st().cache;
for (const c of jm.channels || [])
for (const m of cached[c.id] || []) {
const msgs = room.byChannel.get(c.id) || room.byChannel.set(c.id, new Map()).get(c.id);
if (!msgs.has(m.rumor.id)) msgs.set(m.rumor.id, m);
}
bumpMsgRev();
if (subscribe) subscribeRoom(room);
return room;
}
// Open the relay subscriptions for a room — the heavy path (gift-wrap
// verify + decrypt). Deferred until Chat is opened; idempotent.
function subscribeRoom(room) {
if (room.subscribed) return;
room.subscribed = true;
const scheduleFold = room.scheduleFold;
room.unsubs = room.unsubs || [];
const keep = (...us) => { for (const u of us) { allUnsubs.push(u); room.unsubs.push(u); } };
keep(
subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
room.wrapIds.add(wrap.id);
openWrapBg(wrap, room.control, (opened) => {
if (!opened || opened.rumor.kind !== 3308) return;
room.controlEntries.push(opened);
scheduleFold();
});
}, { onclose: noteSubClosed }),
subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
room.wrapIds.add(wrap.id);
openWrapBg(wrap, room.guestbook, (opened) => {
if (!opened) return;
room.guestEntries.push(opened);
scheduleFold();
});
}, { onclose: noteSubClosed })
);
for (const c of room.jm.channels || []) subChannel(room, c.id);
}
function subChannel(room, id) {
if (room.subbed.has(id)) return;
room.subbed.add(id);
// a fold-discovered channel warms from its cache here, the way the
// invite's own channels do when the room is built
if (!room.byChannel.has(id)) {
const msgs = new Map();
for (const m of st().cache[id] || []) msgs.set(m.rumor.id, m);
if (msgs.size) { room.byChannel.set(id, msgs); bumpMsgRev(); }
}
// derive the stream key once per channel, not once per wrap — groupKey
// does an hkdf + ECDH each call, real curve work on a 200-wrap backfill
const stream = room.chStream(id);
const u = subscribeOn(room.relays, { kinds: [1059, 21059], authors: [stream.pk], limit: 200 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
room.wrapIds.add(wrap.id);
openWrapBg(wrap, stream, (opened) => {
if (!opened) return;
onChat(room, id, opened);
});
}, { onclose: noteSubClosed });
allUnsubs.push(u); (room.unsubs = room.unsubs || []).push(u);
}
// The relay pool reconnects a dropped socket, but one that ERRORED is
// given up on — and a phone freezing the tab does exactly that to every
// subscription we hold. Messages sent while the app was away then never
// arrived, and nothing after them did either, until a reload. So coming
// back (and coming online) rebuilds every live wrap subscription: DMs and
// rooms. The windows they ask for overlap what we already hold, and
// seenWraps drops the repeats before any decryption, so this costs a few
// round trips and nothing else.
function resubscribeStreams() {
const wasDm = dmStarted;
const wanted = [...rooms.values()].filter((r) => r.subscribed);
tearingDown = true; // our own closes are not a relay giving up on us
try {
if (wasDm) {
for (const u of dmUnsubs) { try { u(); } catch {} }
dmUnsubs = []; dmStarted = false;
}
for (const room of wanted) {
for (const u of room.unsubs || []) { try { u(); } catch {} }
room.unsubs = []; room.subscribed = false; room.subbed.clear();
}
} finally { tearingDown = false; }
if (wasDm) startDMs();
for (const room of wanted) subscribeRoom(room);
if (wn) wn.resubscribe();
if (cd) cd.resubscribe();
}
// ---- subscription watchdog ------------------------------------------------
// The resume hook above rebuilds after an absence it was TOLD about. It
// isn't told when a socket errors while the page stays on screen (a wifi
// hop, a laptop lid, a phone freezing the tab without the away timer
// noticing, another tab clearing that timer first) — and an errored
// socket is dropped from the pool with every subscription it carried.
// The room then sits quietly stale until a reload, while another client
// is ringing about new messages. So whenever the app is on screen — on
// focus, on becoming visible, and every ten seconds in between — the pool
// is asked whether each relay the inbox and the rooms listen on still
// holds a live socket; a relay that closed a subscription on us counts
// too. Anything missing rebuilds the lot. A rebuild is a few round trips
// (seenWraps drops the replay before any decryption), but a relay that
// keeps refusing is retried on a growing backoff, not every tick.
let tearingDown = false;
let subsDead = false;
let lastRebuild = 0;
let rebuildBackoff = 15_000;
let watchdogArmed = false;
function noteSubClosed() {
if (tearingDown || !allUnsubs.length) return;
subsDead = true;
setTimeout(ensureLive, 500);
}
function ensureLive() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
if (!allUnsubs.length) return; // nothing is meant to be live yet
const urls = new Set();
if (dmStarted) for (const u of liveRelayList(DM_RELAYS)) urls.add(u);
for (const room of rooms.values()) if (room.subscribed) for (const u of liveRelayList(room.relays)) urls.add(u);
if (wn) for (const u of liveRelayList(wn.relaysInUse())) urls.add(u);
if (wn && wn.starved()) subsDead = true; // a group's relay came back after being skipped
const dead = [...urls].filter((u) => !relayAlive(u));
if (!dead.length && !subsDead) { rebuildBackoff = 15_000; return; }
const now = Date.now();
if (now - lastRebuild < rebuildBackoff) return;
lastRebuild = now;
rebuildBackoff = Math.min(rebuildBackoff * 2, 5 * 60_000);
subsDead = false;
console.warn('chat: relay socket gone (' + (dead.join(', ') || 'subscription closed') + ') — resubscribing');
// a relay the pool is stuck dialing would just hand the rebuild the same
// hung promise — drop it so the rebuild dials afresh
tearingDown = true;
try { for (const u of dead) resetRelay(u); } finally { tearingDown = false; }
resubscribeStreams();
}
function armWatchdog() {
if (watchdogArmed || typeof window === 'undefined') return;
watchdogArmed = true;
const soon = () => setTimeout(ensureLive, 300);
window.addEventListener('focus', soon);
window.addEventListener('pageshow', soon);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') soon(); });
setInterval(ensureLive, 10_000);
}
function onChat(room, channelId, { rumor, author }) {
const tag = (k) => rumor.tags?.find((x) => x[0] === k);
// CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it
if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(room.chEpoch(channelId))) return;
if (room.folded && room.folded.banned.has(author)) return;
if (rumor.kind === PRESENCE || rumor.kind === TYPING) {
// Dated by the beat itself, never by arrival. Relays hand back stored
// beats when we subscribe, and treating those as "just now" would light
// up the whole member list as online. Clamped to now so a fast clock
// can't hold someone online forever.
const ts = Math.min(Date.now(), eventMs(rumor) || rumor.created_at * 1000);
if (ts > (room.presence.get(author) || 0)) room.presence.set(author, ts);
if (rumor.kind === TYPING && Date.now() - ts < TYPING_MS) {
room.typing.set(author, { ch: channelId, t: ts });
setTimeout(scheduleRepaint, TYPING_MS + 100); // clear itself when it lapses
}
scheduleRepaint();
return;
}
noteEmoji(rumor); // custom emoji seen here become custom emoji you can send
if (rumor.kind === 9) {
const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId);
msgs.set(rumor.id, { rumor, author });
bumpMsgRev();
room.typing.delete(author); // the message itself ends the "typing…"
schedulePersist(room);
} else if (rumor.kind === 5) {
for (const e of rumor.tags.filter((x) => x[0] === 'e')) {
const m = room.byChannel.get(channelId)?.get(e[1]);
if (!m || m.author === author) room.deletes.add(e[1]);
}
schedulePersist(room);
} else if (rumor.kind === 3302) {
const target = tag('e')?.[1];
if (target) {
const cur = room.edits.get(target);
if (!cur || eventMs(rumor) > eventMs(cur.rumor)) room.edits.set(target, { rumor, author });
}
} else if (rumor.kind === 7) {
const target = tag('e')?.[1];
if (target) {
// One reaction per author, the NEWEST by the rumor's own clock — not
// the last to arrive. A reload's backfill hands them back in relay
// order, and the flame swapped for confetti came back as the flame.
const key = target + '|' + author;
const at = eventMs(rumor) || rumor.created_at * 1000;
if (at >= (room.reactAt.get(key) || 0)) {
room.reactAt.set(key, at);
const r = room.reactions.get(target) || room.reactions.set(target, new Map()).get(target);
r.set(author, rumor.content);
}
}
}
const tms = eventMs(rumor);
if (tms) observeAuthor(room.members, author, tms);
scheduleRepaint();
}
const roomChannels = (room) => {
if (room.folded && room.folded.channels.size) {
// a private channel is only ours if the invite handed us its key
const held = (id) => (room.jm.channels || []).some((c) => c.id === id && channelIsPrivate(room.jm, c));
return [...room.folded.channels.entries()]
.filter(([id, c]) => !c.private || held(id))
.map(([id, c]) => ({ id, name: c.name }));
}
return room.jm.channels || [];
};
// Messages that ARRIVE get cached too, not only ones we send: the cache
// used to be written on our own actions alone, so a member who only reads
// had nothing on an offline boot — the room said "No messages yet" with a
// hundred messages read the day before. A backfill lands in a burst, so
// the write waits for it to settle.
const persistTimers = new Map(); // room -> timer
function schedulePersist(room) {
clearTimeout(persistTimers.get(room));
persistTimers.set(room, setTimeout(() => { persistTimers.delete(room); try { persistCache(room); } catch {} }, 2500));
}
function persistCache(room) {
const s = st();
for (const [chId, msgs] of room.byChannel) {
// Pending entries stay out of the cache: unconfirmed means the relay
// never took it, and a reload would resurrect it dimmed forever.
s.cache[chId] = [...msgs.values()]
.filter((m) => !room.deletes.has(m.rumor.id) && !m.pending)
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor))
.slice(-CACHE_MAX);
}
save(s);
}
async function ensureJoined(room, id) {
const s = st();
const j = (s.joined[room.jm.community_id] ||= {});
if (j[id.pubkey]) return;
const { created_at, ms } = msTags(Date.now());
const tags = [ms];
if (room.jm.invitedBy) tags.push(['invite', room.jm.invitedBy, room.jm.inviteLabel || '']);
const wrap = await wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'join', tags, created_at }, id.signer, room.guestbook);
publishOn(room.relays, wrap);
j[id.pubkey] = true;
save(s);
}
// ---- drafts -------------------------------------------------------------
// Half-typed text survives navigation and reloads: one draft per
// conversation ('dm:' / 'ch:'), kept in feature state.
// The session map is authoritative while the app runs; persistence rides a
// debounce so typing doesn't hammer storage.
const sessionDrafts = new Map();
let draftPersist = 0;
const POST_DRAFT = 'post'; // the profile page's new-post composer
const draftFor = (key) =>
(sessionDrafts.has(key) ? sessionDrafts.get(key) : (st().drafts || {})[key] || '');
function setDraft(key, text) {
sessionDrafts.set(key, text);
clearTimeout(draftPersist);
draftPersist = setTimeout(() => {
const s = st();
s.drafts ||= {};
for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
save(s);
}, 800);
}
// Cancel means discard, including across an immediate reload. Typing can
// use the debounce above; an explicit destructive action is persisted now.
function discardDraft(key) {
sessionDrafts.set(key, '');
clearTimeout(draftPersist); draftPersist = 0;
const s = st();
s.drafts ||= {};
for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
save(s);
}
// The morph never rewrites a focused field's value (it would fight the user
// mid-keystroke), and the composer is focused at the moment you send — so
// clearing the draft alone leaves the sent text sitting in the input.
// Clear the live element too.
function clearDraft(key) {
setDraft(key, '');
ui.emojiAc = null;
const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
}
async function sendMessage(room, chId) {
const text = draftFor('ch:' + chId).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
// The rumor id is a plain hash, so the message can be on screen before
// any signing, encryption or network runs. The pending flag never shows —
// it only keeps unconfirmed messages out of the cache and marks what to
// withdraw if signing fails.
const { created_at, ms } = msTags(Date.now());
// an armed reply context e-tags the quoted message (rendered as a quote
// by replyQuote on every device that has the original)
const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
ui.emojiAc = null;
// every :code: we can resolve travels with its picture (NIP-30), so the
// room renders it whether or not anyone else holds the pack
const rumor = rumorWithId({
kind: 9, pubkey: id.pubkey, content: text,
tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl), ms], created_at,
});
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
msgs.set(rumor.id, entry);
clearDraft('ch:' + chId);
ui.msgStick = true;
render();
try {
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
seenWraps.add(wrap.id); // our own echo has nothing to add
const ok = await publishOn(room.relays, wrap);
if (!ok) { toast(t('msgSendFailed')); return; }
delete entry.pending;
ensureJoined(room, id).catch(() => {});
persistCache(room);
} catch (e) {
// Signing or wrapping failed — the message never existed on the wire,
// so it leaves the screen rather than sit there looking sent.
msgs.delete(rumor.id);
toast(e.message || String(e));
render();
}
}
// A reaction is a kind-7 rumor on the channel stream — same envelope as a
// message, content = the emoji. One reaction per author per message (the
// fold keeps the latest), so picking a different emoji replaces yours;
// tapping an existing chip joins that vote. Optimistic like sendMessage:
// the chip appears immediately, the wire catches up.
const REACT_EMOJIS = ['👍', '❤️', '😂', '🔥', '🎉', '🙏'];
// The full picker behind the quick row: a searchable grid. Names are the
// search terms; the list is curated rather than the whole Unicode table,
// which is plenty for a reaction and keeps the bundle small.
const EMOJI_LIB = `👀 eyes seen look|👍 thumbs up like yes|👎 thumbs down no|❤️ heart love red|🧡 orange heart|💛 yellow heart|💚 green heart|💙 blue heart|💜 purple heart|🖤 black heart|🤍 white heart|💔 broken heart|❤️🔥 heart fire|💯 100 hundred|😂 laugh joy tears|🤣 rofl laughing|😀 grin smile|😄 smile happy|😁 beaming grin|😅 sweat smile|😊 blush smile|🙂 slight smile|😉 wink|😍 heart eyes love|🥰 smiling hearts love|😘 kiss blow|😗 kiss|🤩 star struck wow|🥳 party celebrate|😎 cool sunglasses|🤓 nerd glasses|🧐 monocle thinking|🤔 thinking hmm|🤨 raised eyebrow skeptical|😐 neutral meh|😑 expressionless|😶 no mouth silent|🙄 eye roll|😏 smirk|😒 unamused|😞 disappointed sad|😔 pensive sad|😟 worried|😕 confused|🙁 frown sad|☹️ frowning sad|😢 cry tear sad|😭 sob crying|😤 huff steam angry|😠 angry|😡 rage furious|🤬 swearing cursing|🤯 mind blown exploding|😳 flushed embarrassed|🥺 pleading puppy eyes|😱 scream fear|😨 fearful|😰 anxious sweat|😥 sad relieved|😓 downcast sweat|🤗 hug|🤭 hand over mouth giggle|🤫 shush quiet|🤥 lying|😬 grimace awkward|😴 sleeping zzz|🤤 drooling|😪 sleepy|😷 mask sick|🤒 thermometer sick|🤕 bandage hurt|🤢 nauseated|🤮 vomit|🤧 sneeze|🥵 hot|🥶 cold freezing|🥴 woozy|😵 dizzy|😵💫 spiral dizzy|🤠 cowboy|🥸 disguise|😇 halo angel innocent|🤡 clown|👻 ghost|💀 skull dead|☠️ skull crossbones|👽 alien|🤖 robot|💩 poop|🙈 see no evil monkey|🙉 hear no evil|🙊 speak no evil|👋 wave hello bye|🤚 raised back hand|✋ raised hand stop|🖐️ hand fingers|🖖 vulcan spock|👌 ok perfect|🤌 pinched fingers italian|🤏 pinch small|✌️ peace victory|🤞 fingers crossed luck|🤟 love you gesture|🤘 rock horns|🤙 call me shaka|👈 point left|👉 point right|👆 point up|👇 point down|☝️ index up|👊 fist punch|✊ raised fist|🤛 left fist|🤜 right fist|👏 clap applause|🙌 raised hands hooray|🤲 palms up|🤝 handshake deal|🙏 pray thanks please|💪 muscle strong flex|🫡 salute|🫶 heart hands|🧠 brain smart|👑 crown king|🎩 top hat|🔥 fire lit hot|✨ sparkles|⭐ star|🌟 glowing star|💫 dizzy star|⚡ lightning zap bolt|💥 boom collision|💢 anger|💦 sweat drops|💨 dash wind|🎉 party popper tada|🎊 confetti|🎈 balloon|🎁 gift present|🏆 trophy win|🥇 gold medal first|🎯 bullseye target|🚀 rocket launch moon|🛸 ufo|🌙 moon|☀️ sun|🌈 rainbow|☕ coffee|🍺 beer|🍻 cheers beers|🥂 clink champagne|🍾 champagne bottle|🍕 pizza|🍔 burger|🍿 popcorn|🎂 cake birthday|🍰 cake slice|🍩 donut|🍪 cookie|🍎 apple|🥑 avocado|🌶️ hot pepper spicy|🧂 salt|🐐 goat|🐶 dog|🐱 cat|🐸 frog pepe|🐵 monkey|🦄 unicorn|🐝 bee|🦋 butterfly|🐢 turtle slow|🐍 snake|🦀 crab|🐋 whale|🦈 shark|🦅 eagle|🐔 chicken|🐷 pig|🐮 cow|🐻 bear|🐼 panda|🐨 koala|🦁 lion|🐯 tiger|🦊 fox|🐺 wolf|🐉 dragon|🌹 rose|🌻 sunflower|🌱 seedling grow|🌲 tree|🍀 clover luck|🍄 mushroom|💎 gem diamond|💰 money bag|💸 money wings|💵 dollar cash|🪙 coin|₿ bitcoin btc|🏦 bank|📈 chart up stonks|📉 chart down|🔑 key|🔒 lock|🔓 unlock|🛡️ shield|⚔️ swords|🗡️ dagger|🔨 hammer|🔧 wrench|⚙️ gear settings|🧲 magnet|💡 idea bulb|🔦 flashlight|🔋 battery|📱 phone|💻 laptop|🖥️ desktop|⌨️ keyboard|🖨️ printer|📷 camera|📸 flash camera|🎥 movie camera|🎬 clapper|🎵 music note|🎶 notes music|🎤 microphone|🎧 headphones|🎸 guitar|🎮 game controller|🎲 dice|🧩 puzzle|♟️ chess|🏀 basketball|⚽ soccer football|🏈 football|⚾ baseball|🎾 tennis|🏐 volleyball|🏓 ping pong|🥊 boxing|🏄 surf|🚴 bike|🏃 run|🧘 yoga meditate|🛌 bed sleep|🚗 car|🚕 taxi|🚌 bus|🚂 train|✈️ plane travel|⛵ sailboat|🚢 ship|🏠 house home|🏢 office|🏗️ construction crane|🗽 statue liberty|🗼 tower|🏔️ mountain|🏖️ beach|🌍 earth globe world|🗺️ map|🧭 compass|⏰ alarm clock|⏳ hourglass|⌛ hourglass done|📅 calendar|📌 pin|📎 paperclip|✂️ scissors|📝 memo note|📚 books|📖 book|📰 newspaper|✉️ envelope mail|📬 mailbox|📦 package box|🏷️ label tag|🔔 bell notification|🔕 bell off mute|📣 megaphone|📢 loudspeaker|💬 speech bubble|💭 thought bubble|🗯️ anger bubble|✅ check yes done|❌ cross no wrong|❓ question|❗ exclamation|‼️ double exclamation|⁉️ interrobang|⚠️ warning|🚫 prohibited|♻️ recycle|✔️ check mark|➕ plus|➖ minus|➗ divide|✖️ multiply|🔁 repeat|🔀 shuffle|▶️ play|⏸️ pause|⏹️ stop|⏩ fast forward|⏪ rewind|🔝 top|🆕 new|🆒 cool|🆓 free|🆗 ok|🔞 18 adult|🅰️ a|🅱️ b|🆎 ab|🅾️ o|🔴 red circle|🟠 orange circle|🟡 yellow circle|🟢 green circle|🔵 blue circle|🟣 purple circle|⚫ black circle|⚪ white circle|🟥 red square|🟧 orange square|🟨 yellow square|🟩 green square|🟦 blue square|🟪 purple square|⬛ black square|⬜ white square|🏁 checkered flag finish|🚩 red flag|🏳️ white flag|🏴☠️ pirate flag|🇨🇦 canada|🇺🇸 usa america|🇬🇧 uk britain|🇪🇺 eu europe|🇯🇵 japan|🇧🇷 brazil|🇩🇪 germany|🇫🇷 france|🇮🇹 italy|🇪🇸 spain|🇲🇽 mexico|🇦🇺 australia|🇸🇻 el salvador|🇦🇷 argentina|🇳🇬 nigeria|🇮🇳 india|🇨🇳 china|🇰🇷 korea|🇷🇺 russia|🇺🇦 ukraine|🇵🇹 portugal|🇳🇱 netherlands|🇸🇪 sweden|🇨🇭 switzerland`
.split('|').map((x) => { const i = x.indexOf(' '); return { e: x.slice(0, i), k: x.slice(i + 1) }; });
const RECENT_KEY = 'btc-wallet-recent-emoji';
const recentEmojis = () => { try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { return []; } };
const noteRecent = (e) => { try { localStorage.setItem(RECENT_KEY, JSON.stringify([e, ...recentEmojis().filter((x) => x !== e)].slice(0, 24))); } catch {} };
// The reaction row for a sheet: recents lead the quick row, and the last
// button opens the full picker in place — a search box and the grid.
// A picker entry: `e` is what gets sent (a character, or `:code:` for a
// custom emoji, which then carries `url`), `k` what search matches.
const emojiEntry = (e) => {
const code = shortcodeOf(e);
if (!code) return { e, k: '' };
const url = emojiUrl(code);
return url ? { e, k: code.toLowerCase(), url } : null; // a recent whose pack is gone
};
const emojiFace = (x) => (x.url ? emojiImg(shortcodeOf(x.e), x.url) : x.e);
function reactRow(myReact, onPick) {
const pick = (e) => { ui.emojiPick = null; noteRecent(e); onPick(e); };
if (ui.emojiPick) {
const q = (ui.emojiPick.q || '').trim().toLowerCase();
// custom emoji lead — packs you hold, then ones you've seen — and the
// standard set follows; a search matches shortcodes and keywords alike
const custom = customEmojis().map((x) => ({ e: ':' + x.code + ':', k: x.code.toLowerCase(), url: x.url }));
const std = EMOJI_LIB;
let hits;
if (q) hits = [...custom.filter((x) => x.k.includes(q)), ...std.filter((x) => x.k.includes(q) || x.e === q)];
else hits = [...recentEmojis().map(emojiEntry).filter(Boolean), ...custom, ...std];
const seen = new Set();
hits = hits.filter((x) => !seen.has(x.e) && seen.add(x.e));
const s = emojiSt();
const packs = s.order.map((addr) => ({ addr, ...s.packs[addr] })).filter((p) => p.title !== undefined);
return h('div', { class: 'col', style: 'gap:8px' },
h('input', {
type: 'text', placeholder: t('msgEmojiSearch'), value: ui.emojiPick.q || '', autofocus: true,
onInput: (e) => { ui.emojiPick.q = e.target.value; render(); },
onKeydown: (e) => { if (e.key === 'Enter' && hits.length) pick(hits[0].e); if (e.key === 'Escape') { ui.emojiPick = null; render(); } },
}),
h('div', { class: 'emoji-grid' },
hits.slice(0, 200).map((x) => h('button', { class: x.e === myReact ? 'on' : '', title: x.url ? x.e : x.k, onClick: () => pick(x.e) }, emojiFace(x))),
!hits.length ? h('div', { class: 'small muted', style: 'padding:8px' }, t('msgEmojiNone')) : null),
// the packs behind the custom ones, and the way to add another —
// Vector's share link or a bare naddr, both land the same set
h('div', { class: 'emoji-packs' },
packs.map((p) => h('span', { class: 'chat-react emoji-pack', title: p.addr },
p.image ? h('img', { class: 'cemoji', src: p.image, alt: '' }) : null, ' ', p.title || '…',
// its share link, ours: a tap on a phone opens the coinos app
h('button', {
type: 'button', class: 'linklike', title: t('msgEmojiPackShare'),
onClick: async () => { try { await navigator.clipboard.writeText(packShareLink(p.addr)); toast(t('copied')); } catch {} },
}, '⧉'),
h('button', { type: 'button', class: 'linklike', title: t('msgEmojiPackRemove'), onClick: () => removePack(p.addr) }, '×'))),
ui.emojiPick.addPack
? h('form', {
class: 'row gap6', style: 'width:100%',
onSubmit: async (e) => {
e.preventDefault();
const inp = e.target.querySelector('input');
if (await addPack(inp.value)) { ui.emojiPick.addPack = false; render(); }
},
},
h('input', { type: 'text', class: 'grow', placeholder: t('msgEmojiPackHint'), autofocus: true, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false' }),
h('button', { class: 'btn-sm', type: 'submit', disabled: !!ui.emojiBusy }, ui.emojiBusy ? h('span', { class: 'spinner sm' }) : t('msgEmojiPackAdd')))
: h('button', { type: 'button', class: 'linklike small', onClick: () => { ui.emojiPick.addPack = true; render(); } }, '+ ' + t('msgEmojiAddPack'))));
}
const quick = [...new Set([...recentEmojis(), ...REACT_EMOJIS])].map(emojiEntry).filter(Boolean).slice(0, 6);
return h('div', { class: 'msg-sheet-emojis' },
quick.map((x) => h('button', { class: x.e === myReact ? 'on' : '', onClick: () => pick(x.e) }, emojiFace(x))),
h('button', { class: 'more', title: t('msgEmojiMore'), onClick: () => { ui.emojiPick = { q: '' }; render(); } }, '+'));
}
async function sendReaction(room, chId, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
ui.msgSheet = null;
const { created_at, ms } = msTags(Date.now());
const rumor = rumorWithId({
kind: 7, pubkey: id.pubkey, content: emoji,
// NIP-25 shape per CORD examples §2.3: e = the message, p = ITS AUTHOR,
// k = its kind. Vector refuses a reaction without the p tag, so ours
// never showed there until it was added.
tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ['e', m.rumor.id], ['p', m.author], ['k', '9'], ...reactEmojiTags(emoji), ms], created_at,
});
const r = room.reactions.get(m.rumor.id) || room.reactions.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
r.set(id.pubkey, emoji);
room.reactAt.set(m.rumor.id + '|' + id.pubkey, eventMs(rumor)); // older ones arriving later stay out
render();
try {
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
seenWraps.add(wrap.id); // our own echo has nothing to add
const ok = await publishOn(room.relays, wrap);
if (!ok) toast(t('msgSendFailed'));
} catch (e) {
// never went out — put back whatever stood before
if (prev) r.set(id.pubkey, prev); else r.delete(id.pubkey);
toast(e.message || String(e));
render();
}
}
// The displayed text of a message after its edit fold, trimmed to one line.
const foldedRumor = (room, m) => {
const edit = room.edits.get(m.rumor.id);
return edit && edit.author === m.author ? edit.rumor : m.rumor;
};
const oneLine = (text, max = 90) => {
const one = String(text || '').replace(/\s+/g, ' ').trim();
return one.length > max ? one.slice(0, max - 1) + '…' : one;
};
function msgSnippet(room, m, max = 90) {
return oneLine(foldedRumor(room, m).content, max);
}
// The snippet as nodes: its custom emoji (NIP-30 tags on the rumor) show
// as small pictures, as they do in the bubble being quoted. The cut lands
// on the text first, so a shortcode is never sliced in half.
function snippetNodes(rumor, max = 90) {
const em = emojiTagMap(rumor && rumor.tags);
const text = oneLine(rumor && rumor.content, max + 40);
if (!em.size) return [oneLine(text, max)];
const out = [];
let left = max;
for (const p of splitEmoji(text, (c) => em.get(c))) {
if (left <= 0) { out.push('…'); break; }
if (typeof p === 'string') {
if (p.length > left) { out.push(p.slice(0, Math.max(0, left - 1)) + '…'); break; }
out.push(p); left -= p.length;
} else { out.push(emojiImg(p.code, p.url)); left -= 2; }
}
return out;
}
const msgSnippetNodes = (room, m, max = 90) => snippetNodes(foldedRumor(room, m), max);
// A kind-9 that e-tags another message is a REPLY — render the quoted
// context above its text, Telegram-style. Quotes of deleted (or not-yet-
// loaded) messages stay silent rather than resurrect them.
function replyQuote(room, chId, m) {
const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
if (!replyId || room.deletes.has(replyId)) return null;
const src = room.byChannel.get(chId)?.get(replyId);
if (!src) return null;
return h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.author)),
h('span', { class: 'chat-quote-text' }, ...msgSnippetNodes(room, src)));
}
// Telegram-style message action sheet: quick reactions on top, then the
// actions this message supports. Opened by a tap on the bubble.
function messageSheet(room, chId) {
const m = room.byChannel.get(chId)?.get(ui.msgSheet);
if (!m) { ui.msgSheet = null; return null; }
const my = myPubkeys();
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 },
h('span', { class: 'msg-sheet-ico' }, icon), label);
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
reactRow(myReact, (e2) => { close(); sendReaction(room, chId, m, e2); }),
ui.emojiPick ? null : item('↩', t('msgReply'), () => {
ui.msgReplyTo = m.rumor.id;
close();
setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
}),
!ui.emojiPick && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null,
ui.emojiPick ? null : item('⧉', t('copy'), async () => {
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));
}
// The composer's "replying to" context bar, with its way out.
function replyBar(room, chId) {
const m = ui.msgReplyTo && room.byChannel.get(chId)?.get(ui.msgReplyTo);
if (!m) { ui.msgReplyTo = null; return null; }
return h('div', { class: 'reply-bar' },
h('div', { class: 'col grow', style: 'gap:1px;min-width:0' },
h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.author)),
h('span', { class: 'small muted chat-quote-text' }, ...msgSnippetNodes(room, m))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×'));
}
async function deleteMessage(room, chId, m) {
const id = await identity();
if (!id || id.pubkey !== m.author) return;
const { created_at, ms } = msTags(Date.now());
const rumor = {
kind: 5, pubkey: id.pubkey, content: '',
tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ['e', m.rumor.id], ['k', '9'], ms], created_at,
};
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
room.deletes.add(m.rumor.id);
render();
publishOn(room.relays, wrap);
persistCache(room);
}
// ---- founding a community (CORD-02 genesis) -----------------------------
async function createCommunity(name) {
const id = await identity();
if (!id) { noIdToast(); return; }
name = name.trim().slice(0, 64);
if (!name) return;
const ownerSalt = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const rootBytes = crypto.getRandomValues(new Uint8Array(32));
const cid = communityId(id.pubkey, ownerSalt);
const generalId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const control = controlKey(rootBytes, cid, 0);
const guestbook = guestbookKey(rootBytes, cid, 0);
const now = Date.now();
const relays = DM_RELAYS;
const meta = { ...makeEdition({ vsk: 0, eid: cid, version: 1, content: JSON.stringify({ name, relays }) }, now), pubkey: id.pubkey };
const general = { ...makeEdition({ vsk: 2, eid: generalId, version: 1, content: JSON.stringify({ name: 'general', private: false }) }, now + 1), pubkey: id.pubkey };
const { created_at, ms } = msTags(now + 2);
const join = { kind: 3306, pubkey: id.pubkey, content: 'join', tags: [ms], created_at };
const events = [
await wrapRumor(meta, id.signer, control, { plaintext: true }),
await wrapRumor(general, id.signer, control, { plaintext: true }),
await wrapRumor(join, id.signer, guestbook),
];
for (const e of events) await publishOn(relays, e);
const jm = {
community_id: cid, owner: id.pubkey, owner_salt: ownerSalt,
community_root: bytesToHex(rootBytes), root_epoch: 0,
channels: [{ id: generalId, name: 'general' }], relays, name,
added_at: Date.now(),
};
const s = st();
s.communities.push(jm);
(s.joined[cid] ||= {})[id.pubkey] = true;
save(s);
publishLists();
registerPush().catch(() => {});
ensureRoom(jm);
ui.msgView = 'room';
ui.msgCommunity = cid;
ui.msgChannel = null;
ui.msgNewName = '';
ui.msgHomePanel = null;
render();
}
// A new channel is one owner-signed ChannelMetadata edition (CORD-03 §2);
// the control-plane fold picks it up and every member's switcher grows.
async function createChannel(room, name) {
name = (name || '').trim().slice(0, 64);
if (!name) return;
const id = await identity();
if (!id || id.pubkey !== room.jm.owner) { toast(t('msgOwnerOnly')); return; }
const chId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const edition = {
...makeEdition({ vsk: 2, eid: chId, version: 1, content: JSON.stringify({ name: name.replace(/^#/, ''), private: false }) }, Date.now()),
pubkey: id.pubkey,
};
const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true });
const ok = await publishOn(room.relays, wrap);
if (!ok) { toast(t('msgSendFailed')); return; }
ui.msgNewChannel = '';
ui.msgChannelPanel = false; // the field has done its job — put it away
toast('#' + name.replace(/^#/, ''));
}
// ---- invites ------------------------------------------------------------
function bundleFor(room, creatorPk) {
return {
community_id: room.jm.community_id, owner: room.jm.owner, owner_salt: room.jm.owner_salt,
community_root: room.jm.community_root, root_epoch: room.jm.root_epoch || 0,
...(room.jm.control_pk ? { control_pk: room.jm.control_pk } : {}),
channels: channelList({ ...room.jm, channels: roomChannels(room).map((c) => (room.jm.channels || []).find((h) => h.id === c.id) || c) }),
relays: room.relays, name: (room.folded?.metadata?.name) || room.jm.name,
creator_npub: creatorPk,
};
}
// Bundles minted before channel entries carried key + epoch never opened in
// Vector. The link itself is fine (signer + token), so re-sign the bundle at
// the same coordinate with the current shape; the relay replaces it. Once
// per invite per device, and again whenever the shape changes.
const BUNDLE_REV = 2;
async function healInviteBundles() {
const s = st();
for (const [cid, inv] of Object.entries(s.invites || {})) {
if (inv.rev === BUNDLE_REV || !inv.sk || !inv.token) continue;
const jm = communityById(cid);
if (!jm) continue;
try {
const room = ensureRoom(jm, { subscribe: false });
const creator = inv.creator || (await identity())?.pubkey;
const evt = makeInviteBundleEvent(hexToBytes(inv.sk), bundleFor(room, creator), hexToBytes(inv.token));
if (await publishOn(room.relays, evt)) { inv.rev = BUNDLE_REV; save(s); }
} catch {}
}
}
async function mintInviteLink(room) {
const id = await identity();
if (!id) { noIdToast(); return null; }
const s = st();
const existing = s.invites[room.jm.community_id];
if (existing) { if (existing.rev !== BUNDLE_REV) healInviteBundles().catch(() => {}); return existing.url; }
const linkSk = generateSecretKey();
const token = crypto.getRandomValues(new Uint8Array(16));
const evt = makeInviteBundleEvent(linkSk, bundleFor(room, id.pubkey), token);
const ok = await publishOn(room.relays, evt);
if (!ok) { toast(t('msgSendFailed')); return null; }
const url = makeInviteLink(APP_BASE, getPublicKey(linkSk), room.relays, token);
s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url, created_at: Math.floor(Date.now() / 1000), rev: BUNDLE_REV, creator: id.pubkey };
save(s);
publishLists();
return url;
}
async function sendDirectInvite(room, input) {
const peer = parseNostrPubkey(input);
if (!peer) { toast(t('msgBadNpub')); return; }
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
const rumor = {
kind: 3313, pubkey: id.pubkey,
content: JSON.stringify(bundleFor(room, id.pubkey)),
tags: [], created_at: Math.floor(Date.now() / 1000),
};
const wrap = await wrapDM(id.signer, peer, rumor, [['k', '3313']]);
const inbox = (await fetchInboxRelays(peer)).slice(0, 4);
const ok = await publishOn([...new Set([...inbox, ...DM_RELAYS])], wrap);
toast(ok ? t('msgInviteSent') : t('msgSendFailed'));
}
function acceptBundle(b, { invitedBy, inviteLabel } = {}) {
const s = st();
if (!communityById(b.community_id)) {
const jm = {
community_id: b.community_id, owner: b.owner, owner_salt: b.owner_salt,
community_root: b.community_root, root_epoch: b.root_epoch || 0,
control_pk: b.control_pk, channels: channelList(b),
relays: (b.relays || []).slice(0, 5), name: b.name || 'community',
invitedBy, inviteLabel, added_at: Date.now(),
};
s.communities.push(jm);
save(s);
publishLists();
}
const room = ensureRoom(communityById(b.community_id));
identity().then((id) => id && ensureJoined(room, id)).catch(() => {});
registerPush().catch(() => {});
ui.msgView = 'room';
ui.msgCommunity = b.community_id;
ui.msgChannel = null;
ui.msgHomePanel = null;
pendingLink = null;
render();
}
// A link invite being previewed (pasted or arrived via /invite/#…).
let pendingLink = null; // { parsed, state: 'loading'|'ready'|'error', bundle?, error? }
async function loadLinkInvite(parsed, { where = 'top' } = {}) {
pendingLink = { parsed, state: 'loading', where };
scheduleRepaint();
const relays = [...new Set([...(parsed.relays || []), ...DM_RELAYS])];
const evts = await queryOn(relays, { kinds: [33301], authors: [parsed.signerPk] }, 4000);
const newest = evts.sort((a, b) => b.created_at - a.created_at)[0];
const b = newest && openInviteBundle(newest, parsed.token);
if (!b) pendingLink = { parsed, state: 'error', error: t('msgInviteNotFound'), where };
else if (b.revoked) pendingLink = { parsed, state: 'error', error: t('msgInviteRevoked'), where };
else if (b.expired) pendingLink = { parsed, state: 'error', error: t('msgInviteExpired'), where };
else pendingLink = { parsed, state: 'ready', bundle: b, where };
scheduleRepaint();
}
// Public suggestions render immediately, including on a first/offline visit.
// The bundled snapshot is a fallback; the device cache refreshes once a day.
// Invite previews still check revocation and expiration on the relays.
const HUB_URL = 'https://vectorapp.io/api/hub';
const HUB_CACHE_KEY = 'coinos-community-directory-v1';
const HUB_REFRESH_MS = 86400_000;
function normalizeHub(list) {
return list.filter((c) => c && c.name && parseInviteLink(c.invite_url || ''))
.map((c) => ({
name: String(c.name).slice(0, 64), description: String(c.description || '').slice(0, 200),
category: String(c.category || '').slice(0, 64), invite: c.invite_url,
members: Math.max(0, Number(c.member_count) || 0),
icon: /^data:image\//.test(c.icon_url || '') && c.icon_url.length <= 32_768 ? c.icon_url : null,
}))
.sort((a, b) => b.members - a.members);
}
const hub = { list: normalizeHub(COMMUNITY_DIRECTORY), at: 0, attempted: false, loading: false };
function loadHubCache() {
try {
const cached = JSON.parse(cacheStorage.getItem(HUB_CACHE_KEY) || 'null');
if (cached && Array.isArray(cached.list) && Number.isFinite(cached.at) && cached.at > hub.at) {
const list = normalizeHub(cached.list);
if (list.length || !cached.list.length) { hub.list = list; hub.at = cached.at; }
}
} catch {}
}
loadHubCache();
async function loadHub() {
if (hub.loading || hub.attempted || (hub.at > 0 && Date.now() - hub.at < HUB_REFRESH_MS)) return;
hub.attempted = true; hub.loading = true;
try {
const res = await fetch(HUB_URL, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error('http ' + res.status);
const raw = await res.json();
if (!Array.isArray(raw)) throw new Error('invalid directory');
const list = normalizeHub(raw);
if (raw.length && !list.length) throw new Error('invalid invites');
hub.list = list; hub.at = Date.now();
try {
// Bound icon sizes so public suggestions do not crowd out wallet data.
const cached = list.map((c) => ({ name: c.name, description: c.description, category: c.category,
invite_url: c.invite, member_count: c.members, icon_url: c.icon }));
cacheStorage.setItem(HUB_CACHE_KEY, JSON.stringify({ at: hub.at, list: cached }));
} catch {}
scheduleRepaint();
} catch { /* Keep the usable directory if refresh fails or we're offline. */ }
finally { hub.loading = false; }
}
function hubRows({ preview = false } = {}) {
const q = preview ? '' : (ui.msgJoinText || '').trim().toLowerCase();
const have = new Set(communities().map((jm) => (rooms.get(jm.community_id)?.folded?.metadata?.name || jm.name || '').toLowerCase()));
let rows = hub.list.filter((c) => !q || [c.name, c.description, c.category].some((x) => x.toLowerCase().includes(q)));
if (preview) rows = rows.filter((c) => !have.has(c.name.toLowerCase())).slice(0, 6);
if (!rows.length) return [h('div', { class: 'small muted' }, t(preview ? 'msgHubNoSuggestions' : 'msgNoMatches'))];
return rows.map((c) => h('div', {
class: 'item chat-thread-row clickable', role: 'button', tabindex: '0',
onClick: (e) => { e.stopPropagation(); joinFromText(c.invite); },
onKeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); joinFromText(c.invite); } },
},
c.icon ? h('img', { class: 'chat-avatar hub-icon', src: c.icon, alt: '' }) : h('div', { class: 'chat-avatar fallback' }, c.name.slice(0, 2)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { class: 'chat-name', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, c.name),
have.has(c.name.toLowerCase()) ? h('span', { class: 'tag conf' }, t('msgJoined')) : null),
h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
(c.members ? t('msgMembers', { n: c.members }) + ' · ' : '') + c.category + (c.description ? ' · ' + c.description : '')))));
}
function hubPanel({ preview = false } = {}) {
loadHub().catch(() => {});
return h('div', { class: 'col community-discovery', style: 'gap:8px' },
h('div', { class: 'row between', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t(preview ? 'msgPopularCommunities' : 'msgDiscoverTitle')),
preview ? h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); ui.msgJoinText = ''; ui.msgHomePanel = 'join'; render(); } }, t('msgBrowseCommunities')) : null),
h('div', { class: 'list' + (preview ? '' : ' hub-list') }, hubRows({ preview })));
}
function joinFromText(text) {
const parsed = parseInviteLink(text);
if (!parsed) { toast(t('msgBadInvite')); return; }
ui.msgJoinText = '';
ui.msgHomePanel = null;
// the answer belongs where the question was asked: the preview replaces
// the paste box under Communities, not a card at the top of a page the
// user may have scrolled past
loadLinkInvite(parsed, { where: 'communities' });
}
// 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 {} }
// coinos's own pack share link, /emojis/pack/ — 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).
const urlPack = (() => {
if (typeof location === 'undefined') return null;
const m = location.pathname.match(/^\/emojis\/pack\/(naddr1[a-z0-9]+)\/?$/i);
return m && parsePackRef(m[1]) ? m[1] : null;
})();
if (urlPack) { try { history.replaceState(null, '', '/'); } catch {} }
// coinos.io/ parity: a claimed name (or a raw npub / hex key) as
// the whole URL path deep-links to that profile. Real files never reach the
// app — the server tries them first — and gift/invite links carry their own
// multi-segment paths, which this single-segment shape can't match. The
// path is consumed immediately so a reload lands on the wallet as usual.
const urlProfile = (() => {
if (typeof location === 'undefined' || urlInvite) return null;
const m = location.pathname.match(/^\/([A-Za-z0-9._-]{1,64})\/?$/);
// reserved app routes are never usernames — /chat is the public
// community page (app.js routes it), and eating it here rewrote the URL
// to / before that route ever saw it; a note reference is its own link
if (m && (['chat', 'pos', 'feed'].includes(m[1]) || /^(note|nevent|naddr)1/i.test(m[1]))) return null;
return m ? m[1] : null;
})();
if (urlProfile) { try { history.replaceState(null, '', '/'); } catch {} }
// /feed/ is the timeline that person sees (their follows), /feed/
// a follow pack or list, /feed/t/ a topic, /feed?p=…&t=…&pack=… any mix.
// Public content: a visitor with no wallet reads it over the front door.
const urlFeed = (() => {
if (typeof location === 'undefined' || urlInvite) return null;
const m = location.pathname.match(/^\/feed(?:\/(.+?))?\/?$/i);
return m ? { path: m[1] || '', search: location.search || '' } : null;
})();
if (urlFeed) { try { history.replaceState(null, '', '/'); } catch {} }
// /note1… and /nevent1… open the thread the same way — the NIP-89 handler
// event for coinos points njump and friends here for notes, / for
// people. Public content: shown over the front door without a wallet.
const urlNote = (() => {
if (typeof location === 'undefined' || urlInvite || urlProfile) return null;
const m = location.pathname.match(/^\/((?:note|nevent)1[a-z0-9]+)\/?$/i);
const ref = m ? parseNostrRef(m[1].toLowerCase()) : null;
return ref && ref.type === 'event' ? ref : null;
})();
// The app's history already holds this exact thread on a reload. Leave its
// state and URL intact so wallet restore can paint the header and thread
// together; the public deep-link path is only for a NEW visit.
const restoringUrlThread = (() => {
try { return !!urlNote && history.state?.nav?.noteThread?.focusId === urlNote.id; } catch { return false; }
})();
// The server renders /nevent1… and /note1… with the thread already in the
// page (feed/note-page.js): the post, its root and replies, and their
// authors' kind 0s, in a JSON script tag. Into the thread cache it goes,
// in the shape a reload leaves there, so the thread opens from it with
// names and faces on the first paint instead of after the relays answer.
// No signature check: this data arrives in the same document as the code
// that reads it (the server verified it off the relays).
if (urlNote && !restoringUrlThread && typeof document !== 'undefined') {
try {
const el = document.getElementById('boot-thread');
const d = el && JSON.parse(el.textContent);
if (d && d.v === 1 && d.root && Array.isArray(d.replies)
&& [d.root, ...d.replies].some((e) => e && e.id === urlNote.id)) {
const faces = {};
for (const ev of d.profiles || []) {
let m = null;
try { m = JSON.parse(ev.content); } catch {}
if (m && typeof m === 'object' && !Array.isArray(m) && ev.pubkey) {
faces[ev.pubkey] = { name: m.display_name || m.name || null, picture: m.picture || null, eventAt: ev.created_at, t: Date.now() };
}
}
createThreadStore().save({ root: d.root, replies: d.replies }, urlNote.id, faces, {}, d.quotes || []);
}
} catch {}
}
if (urlNote && !restoringUrlThread) {
try { history.replaceState(null, '', '/'); } catch {}
ui.pubProf = true;
// holds the frame (screenView) until the thread opens, so the front door
// never flashes up while the note is being found
ui.pubNotePending = urlNote.id;
// a reload of a thread the history restores by itself needs no fetch
setTimeout(() => {
if (ui.noteThread && ui.noteThread.focusId === urlNote.id) { ui.pubNotePending = null; return; }
openNoteRef(urlNote).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); });
}, 0);
}
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
// the unlock screen (ui.pubProf gates that surface) — nostr profiles are
// public data, and Back lands on the app's own front door.
if (urlFeed) {
ui.pubProf = true; ui.chatOpen = true; ui.msgView = 'feed';
// hold the frame until the URL's feed is chosen: painted a tick early,
// the feed view would open (and fetch) the default feed first
ui.pubFeedPending = true;
setTimeout(() => {
ui.pubFeedPending = null;
if (openFeedUrl(urlFeed.path, urlFeed.search)) return;
ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; // not a feed we know: the front door
render();
}, 0);
}
// (runs after construction: the caches it fills are declared further down)
function seedBootProfile(path) {
try {
const el = typeof document !== 'undefined' && document.getElementById('boot-profile');
const d = el && JSON.parse(el.textContent);
if (!d || d.v !== 1 || !/^[0-9a-f]{64}$/.test(d.pk || '') || String(d.path || '').toLowerCase() !== path.toLowerCase()) return null;
const pk = d.pk;
for (const ev of [...(d.profiles || []), d.profile]) {
if (ev && ev.kind === 0 && /^[0-9a-f]{64}$/.test(ev.pubkey || '')) applyProfile(ev.pubkey, ev);
}
// the page's own copy (not marked fetched: the relays still freshen it)
if (d.profile && d.profile.pubkey === pk && !fullProfiles.has(pk)) {
try { const m = JSON.parse(d.profile.content); if (m && typeof m === 'object' && !Array.isArray(m)) fullProfiles.set(pk, m); } catch {}
}
const notes = (d.notes || []).filter((e) => e && e.pubkey === pk && PROFILE_KINDS.includes(e.kind));
if (notes.length && !notesCache.has(pk)) notesCache.set(pk, { status: 'ready', notes, fetchedAt: 0 });
if (d.counts && !socialCounts.get(pk)?.at) {
const num = (n) => (Number.isFinite(n) && n >= 0 ? n : null);
socialCounts.set(pk, { follows: num(d.counts.follows), followers: num(d.counts.followers), at: Date.now() });
}
return pk;
} catch { return null; }
}
if (urlProfile) {
// Claim the first paint synchronously: these are set before the boot
// render, so the visitor sees a profile shell from the first frame —
// never a flash of the front door while the registrar lookup runs.
ui.pubProf = true;
ui.pubProfPending = urlProfile;
(async () => {
await new Promise((r) => setTimeout(r, 0)); // never render mid-boot
// The server renders profile links with the page already in them
// (feed/profile-page.js): kind 0, latest posts, follow counts and the
// faces the posts mention. Into the caches the page reads they go, so
// it paints whole on its first frame; the usual fetches still freshen
// it behind. A coinos name arrives already resolved.
let pk = seedBootProfile(urlProfile) || parseNostrPubkey(urlProfile);
if (!pk && /^[a-z0-9._-]{1,30}$/i.test(urlProfile)) {
// same registrar + domain rule the names feature uses
const domain = getNetwork() === 'mutinynet' ? 'staging.coinos.io' : 'coinos.io';
const name = urlProfile.toLowerCase();
try {
const j = await fetch(`https://names.coinos.io/.well-known/nostr.json?name=${encodeURIComponent(name)}&domain=${domain}`)
.then((r) => r.json());
pk = (j.names || {})[name] || null;
} catch {}
// The username is real display material: seed the light cache (stale,
// t:0, so the kind 0 still gets fetched) and the page paints the name
// immediately instead of an npub prefix. `loading` keeps the avatar a
// quiet circle meanwhile — punk art must mean "has no picture", never
// "picture not here yet", or the wrong face flashes before the real
// one loads.
if (pk && /^[0-9a-f]{64}$/.test(pk)) {
warmProfiles();
if (!profiles.has(pk)) profiles.set(pk, { name, addr: `${name}@${domain}`, t: 0, loading: true });
}
}
if (pk && /^[0-9a-f]{64}$/.test(pk)) {
// hold the pinned nav key across this render: the profile replaces
// the shell in place, with no page-slide re-animation
ui.pubProfHold = true;
ui.pubProfPending = null;
if (ui.screen === 'wallet') ui.pubProf = null; // wallet chrome owns it
openProfile(pk);
ui.pubProfHold = null;
} else {
ui.pubProfPending = null;
ui.pubProf = null; // unknown name: fall through to the front door
render();
}
})();
}
// ---- push notifications -------------------------------------------------
// The nwcpush notifier watches relays for what it can see without keys:
// kind-1059 wraps p-tagged at us (DMs, direct invites), zap receipts
// (payments), and wraps authored by our communities' channel keys (chat).
// It pushes a typed nudge; the service worker shows a generic notification
// unless a window is visible. Payment pushes also arrive server-to-server
// from the names registrar when a lightning-address receive settles.
const b64ToBytes = (b64) => {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
};
// What the notifier is allowed to wake us for. Communities are opt-in and
// off by default: a busy room would otherwise buzz a phone all day for
// conversations that aren't addressed to anyone in particular.
function pushWatch() {
const s = st();
const on = s.notify;
const authors = [];
for (const jm of communities()) {
if (!on[jm.community_id]) continue;
const root = hexToBytes(jm.community_root);
for (const c of (jm.channels || []).slice(0, 8)) authors.push(channelStream(jm, c).pk);
}
// per-category opt-outs travel with the registration so the notifier
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const want = (k) => s.reasons?.[k] !== false;
const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
// staging. The notifier now delivers a payment only to registrations on
// the payment's own network.
return { ptags: myPubkeys(), authors, reasons, net: getNetwork() };
}
const roomNotify = (cid) => !!st().notify[cid];
async function toggleRoomNotify(cid) {
const s = st();
if (s.notify[cid]) delete s.notify[cid]; else s.notify[cid] = true;
save(s);
render();
// The server keeps the watch list, so the change only lands once we
// re-register. Asking for permission is fair here — they just opted in.
const ok = await registerPush({ interactive: !!s.notify[cid] });
if (!ok && s.notify[cid]) {
const s2 = st();
delete s2.notify[cid];
save(s2);
toast(t('msgPushFailed'));
render();
}
}
// Settings → Notifications: the master switch for this device plus the
// per-category choices the registration carries to the notifier.
function notifyCard() {
const supported = typeof Notification !== 'undefined'
&& typeof navigator !== 'undefined' && 'serviceWorker' in navigator
&& typeof window !== 'undefined' && 'PushManager' in window;
const perm = supported ? Notification.permission : 'unsupported';
const s = st();
const on = (k) => s.reasons?.[k] !== false;
const flip = async (k) => {
const s2 = st();
s2.reasons = { ...(s2.reasons || {}), [k]: !on(k) };
save(s2); render();
const ok = await registerPush({ interactive: true });
if (!ok) toast(t('msgPushFailed'));
};
const rowT = (label, k) => h('div', { class: 'row between' },
h('span', { class: 'small' }, label),
h('button', { class: 'btn-sm', onClick: () => flip(k) }, on(k) ? t('nwcOn') : t('nwcOff')));
return h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('notifTitle')),
!supported
? h('div', { class: 'small faint' }, t('nwcNoPush'))
: perm === 'denied'
? h('div', { class: 'notice err' }, t('nwcNotifBlocked'))
: perm !== 'granted' || !s.push
? h('button', { class: 'btn-primary btn-block', onClick: async () => {
const ok = await registerPush({ interactive: true });
toast(ok ? t('notifEnabled') : t('msgPushFailed'));
render();
} }, t('notifEnable'))
: h('div', { class: 'small faint' }, t('notifOnDevice')),
rowT(t('notifPayRecv'), 'payment'),
rowT(t('notifZap'), 'zap'),
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
rowT(t('notifReaction'), 'reaction'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
// Hand the service worker what it needs to tell a friend's DM from a
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
let inboxAt = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
if (!id) return;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
const follows = newest
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
if (p && p.name) names[pk] = p.name;
}
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
follows, known, names, hasList: !!newest, updated: Date.now(),
muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
}
// The Android wrapper hands us a UnifiedPush endpoint on its launch URL
// (?up=), because the distributor talks to the app and only the web app
// knows which pubkeys are worth watching. On a phone without Google Play
// Services this is the ONLY way to be reached while closed, so it takes
// precedence over the browser's own push — which there doesn't work at all.
const UP_KEY = 'coinos-unifiedpush';
const upEndpoint = () => { try { return localStorage.getItem(UP_KEY) || null; } catch { return null; } };
(() => {
try {
const u = new URLSearchParams(location.search).get('up');
if (!u) return;
localStorage.setItem(UP_KEY, u);
// tidy the URL the way the payment intent does
history.replaceState(null, '', location.pathname || '/');
} catch {}
})();
// What to tell someone whose phone can't be woken. In a browser that's
// "your browser has no push service"; inside the UnifiedPush build of the
// Android app it's about the distributor, because that build IS the push —
// telling a WebView to go and use Firefox would be nonsense.
function pushAdvice() {
const env = hostEnv();
if (!env || env.app !== 'graphene') return t('nwcNoPushService');
if (!env.distributors) return t('upNoDistributor');
if (!env.endpoint) return t('upWaiting', { app: (env.distributor || '').split('.').pop() });
return t('nwcNoPushService');
}
function hostEnv() {
try {
const h2 = typeof window !== 'undefined' && window.CoinosHost;
if (!h2 || typeof h2.env !== 'function') return null;
return JSON.parse(h2.env());
} catch { return null; }
}
async function registerPush({ interactive = false } = {}) {
try {
// A UnifiedPush endpoint needs no permission and no push service: it's
// a URL the distributor gave the wrapper, and the notifier POSTs to it.
const up = upEndpoint();
if (up) {
const r = await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, notify: pushWatch() }),
});
if (!r.ok) return false;
const s2 = st();
if (!s2.push || s2.noPushService) { s2.push = true; delete s2.noPushService; save(s2); }
return true;
}
if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false;
if (Notification.permission !== 'granted') {
if (!interactive) return false;
if ((await Notification.requestPermission()) !== 'granted') return false;
}
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
const wantKey = b64ToBytes(publicKey);
let sub = await reg.pushManager.getSubscription();
// see nwc.js: a Chromium without Google Play Services (GrapheneOS, and
// any de-Googled Android) has no push service at all, and says so in a
// way nobody can act on. Remember it so the offer can explain itself.
const noPushService = (e) => /push service|Registration failed|AbortError|NotSupportedError/i.test((e && (e.message || e.name)) || '');
// A subscription made against a DIFFERENT VAPID key can never receive a
// push — the notifier's sends fail with a permanent 403 and it keeps
// reusing the dead sub, so the device goes silent forever (this is what
// stranded notifications after the key last rotated). Re-subscribe when
// the existing sub's server key doesn't match the current one.
const keyMatches = (s) => {
try {
const cur = new Uint8Array(s.options.applicationServerKey || []);
return cur.length === wantKey.length && cur.every((b, i) => b === wantKey[i]);
} catch { return false; }
};
if (sub && !keyMatches(sub)) { try { await sub.unsubscribe(); } catch {} sub = null; }
if (!sub) {
try {
sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: wantKey });
} catch (e) {
if (noPushService(e)) { const s2 = st(); s2.noPushService = true; save(s2); render(); }
return false;
}
}
{ const s2 = st(); if (s2.noPushService) { delete s2.noPushService; save(s2); } }
const r = await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON(), notify: pushWatch() }),
});
if (!r.ok) return false;
const s = st();
if (!s.push) { s.push = true; save(s); }
return true;
} catch {
return false;
}
}
// ---- device sync: Community List (13302) + Invite List (13303) ----------
// Self-encrypted replaceables (CORD-02 §8 / CORD-05 §4) so memberships and
// minted link keys follow the user to any device or client. Published under
// every identity we can encrypt for: the wallet key is the cross-device
// constant (same seed on every device), the login npub makes the list
// readable by other Concord clients serving the same identity.
async function selfCryptors() {
const out = [];
if (wallet.nostr && wallet.nostr.sk && wallet.nostr.ck) out.push({
pk: wallet.nostr.pk,
sign: async (e) => finalizeEvent(e, wallet.nostr.sk),
enc: async (txt) => nip44.encrypt(txt, wallet.nostr.ck),
dec: async (ct) => nip44.decrypt(ct, wallet.nostr.ck),
});
const login = hook('nostrLoginIdentity');
if (login && login.signer && login.signer.encryptSelf && !login.signer.interactive) out.push({
pk: login.pubkey,
sign: (e) => login.signer.signEvent(e),
enc: (txt) => login.signer.encryptSelf(txt),
dec: (ct) => login.signer.decryptSelf(ct),
});
return out;
}
// The channel entries of join material: id + name, plus key + epoch for a
// private channel (a public one derives from the root, so nothing to carry).
// Every entry carries key + epoch on the wire (CORD-05 §1 `{id, key, epoch,
// name}`): a public channel's key IS the community_root at the base epoch.
// Vector's parser has no default for either field, so an entry without them
// fails its whole bundle — which is why our invite links never opened there.
const channelList = (b) => (b.channels || []).slice(0, 256).map((c) =>
channelIsPrivate(b, c) ? { id: c.id, name: c.name, key: c.key, epoch: c.epoch || 0 }
: { id: c.id, name: c.name, key: b.community_root, epoch: b.root_epoch || 0 });
// Join material subset (never the icon, never link fields). We don't
// Refound ourselves, so seed and current coincide.
const jmSubset = (jm) => ({
community_id: jm.community_id, owner: jm.owner, owner_salt: jm.owner_salt,
community_root: jm.community_root, root_epoch: jm.root_epoch || 0,
...(jm.control_pk ? { control_pk: jm.control_pk } : {}),
channels: channelList(jm),
relays: jm.relays, name: jm.name,
});
const buildCommunityList = (s) => JSON.stringify({
entries: s.communities.slice(0, 50).map((jm) => ({
community_id: jm.community_id, seed: jmSubset(jm), current: jmSubset(jm), added_at: jm.added_at || 0,
})),
tombstones: Object.entries(s.tombstones).map(([community_id, removed_at]) => ({ community_id, removed_at })),
});
const buildInviteList = (s) => JSON.stringify({
entries: Object.entries(s.invites).map(([community_id, inv]) => ({
token: inv.token, signer_sk: inv.sk, community_id, url: inv.url, created_at: inv.created_at || 0,
})),
tombstones: [],
});
function mergeCommunityList(doc) {
const s = st();
let changed = false;
for (const tb of doc.tombstones || []) {
if ((tb.removed_at || 0) > (s.tombstones[tb.community_id] || 0)) {
s.tombstones[tb.community_id] = tb.removed_at;
changed = true;
}
}
for (const e of (doc.entries || []).slice(0, 50)) {
const jm = e.current || e.seed;
if (!jm || jm.community_id !== e.community_id) continue;
if (jm.community_id === COMMUNITY.community_id) continue; // built-in
if ((e.added_at || 0) <= (s.tombstones[e.community_id] || 0)) continue; // tombstone wins
if (communityId(jm.owner, jm.owner_salt) !== jm.community_id) continue; // self-certify before adopting keys
if (!/^[0-9a-f]{64}$/.test(jm.community_root || '')) continue;
const have = s.communities.find((c) => c.community_id === e.community_id);
if (have) {
// Same community, fresher keys: a device that saw a Refounding holds
// a higher root epoch; one that joined via a newer invite holds the
// control_pk and channel keys this entry predates. Adopt, keep ours
// otherwise — never step an epoch backwards.
const newer = (jm.root_epoch || 0) > (have.root_epoch || 0);
const fill = !newer && jm.community_root === have.community_root
&& ((jm.control_pk && !have.control_pk) || channelList(jm).some((c) => c.key && !(have.channels || []).some((h) => h.id === c.id && h.key)));
if (!newer && !fill) continue;
Object.assign(have, jmSubset(jm), { name: have.name || jm.name, relays: have.relays && have.relays.length ? have.relays : jm.relays });
const stale = rooms.get(have.community_id); // rebuilt with the new keys on next open
if (stale) {
for (const u of stale.unsubs || []) { try { u(); } catch {} }
for (const id of stale.wrapIds || []) seenWraps.delete(id);
rooms.delete(have.community_id);
}
changed = true;
continue;
}
s.communities.push({ ...jmSubset(jm), added_at: e.added_at || Date.now() });
changed = true;
}
// a tombstone newer than an entry's added_at removes it locally too
const keep = s.communities.filter((c) => (s.tombstones[c.community_id] || 0) <= (c.added_at || 0));
if (keep.length !== s.communities.length) { s.communities = keep; changed = true; }
if (changed) save(s);
return changed;
}
function mergeInviteList(doc) {
const s = st();
let changed = false;
for (const e of doc.entries || []) {
if (!e.token || !e.community_id || !e.signer_sk) continue;
if (!s.invites[e.community_id]) {
s.invites[e.community_id] = { sk: e.signer_sk, token: e.token, url: e.url, created_at: e.created_at };
changed = true;
}
}
if (changed) save(s);
return changed;
}
// The list writers remember what they last sent and when. A relay that
// refuses a copy (rate limit, ban) leaves the remote copy stale, and the
// next sync would ask for the same write again — and again, every couple
// of seconds, forever: that storm filled relay.coinos.io's per-IP budget
// for a whole household, and a phone behind the same IP could no longer
// reach its remote signer (every NIP-46 request refused as rate-limited),
// so its DMs stopped decrypting. An unchanged document is re-sent at most
// once per LIST_REPUBLISH_MS; a real local change is a new document and
// goes out at once. Our own writes echoing back on the live subscription
// are not news either.
const LIST_REPUBLISH_MS = 5 * 60_000;
const lastListWrite = new Map(); // 'lists' | 'frags' -> { key, at }
const ownListIds = new Set();
function listWriteDue(what, key) {
const prev = lastListWrite.get(what);
if (prev && prev.key === key && Date.now() - prev.at < LIST_REPUBLISH_MS) return false;
lastListWrite.set(what, { key, at: Date.now() });
return true;
}
async function publishLists() {
const ids = await selfCryptors();
const s = st();
publishFragments(ids, fragAt).catch(() => {});
const docs = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
if (!listWriteDue('lists', ids.map((i) => i.pk).join() + '|' + docs.map(([k, d]) => k + ':' + d).join('|'))) return;
for (const id of ids)
for (const [kind, doc] of docs) {
try {
const evt = await id.sign({
kind, content: await id.enc(doc), tags: [], created_at: Math.floor(Date.now() / 1000),
});
ownListIds.add(evt.id);
publishOn(DM_RELAYS, evt);
} catch {}
}
}
// ---- CORD-02 §8 fragmented Community List (kind 33302) ----
// The form Vector and the other Concord clients write: addressable, one
// event per fragment (d = index, every fragment declares the total), the
// content NIP-44-encrypted to self, every 32-byte value unpadded base64url,
// an entry's embedded join material carrying no community_id (it inherits
// the entry's) and no seed when it equals current. Reading it is what lets
// a community joined in Vector under the same key show up here; writing it
// is what lets Vector see one joined here. The retired single-event 13302
// is still read and written for older coinos devices.
const LIST_FRAG_KIND = 33302;
const STOCK_RELAYS = ['wss://jskitty.com/nostr', 'wss://asia.vectorapp.io/nostr', 'wss://relay.ditto.pub', 'wss://relay.dreamith.to'];
const LIST_FRAG_BYTES = 48 * 1024;
const b64of = (v) => (/^[0-9a-f]{64}$/i.test(v || '') ? base64urlnopad.encode(hexToBytes(v.toLowerCase())) : v);
const hexOf = (v) => {
if (typeof v !== 'string' || v.length !== 43) return v;
try { const b = base64urlnopad.decode(v); return b.length === 32 ? bytesToHex(b) : v; } catch { return v; }
};
// where a list can live: our relays, the stock CORD set (where Vector's
// lands when its own relays refuse the kind), and every held community's
const listRelays = () => [...new Set([...DM_RELAYS, ...STOCK_RELAYS, ...communities().flatMap((jm) => jm.relays || [])])].slice(0, 12);
const fragMaterial = (jm) => {
const m = jmSubset(jm);
return {
owner: b64of(m.owner), owner_salt: b64of(m.owner_salt), community_root: b64of(m.community_root), root_epoch: m.root_epoch || 0,
...(m.control_pk ? { control_pk: b64of(m.control_pk) } : {}),
channels: (m.channels || []).map((c) => ({ id: b64of(c.id), key: b64of(c.key || m.community_root), epoch: c.epoch || 0, name: c.name })),
relays: m.relays || [], name: m.name,
};
};
const buildFragments = (s) => {
const entries = s.communities.filter((jm) => (s.tombstones[jm.community_id] || 0) <= (jm.added_at || 0))
.map((jm) => ({ community_id: b64of(jm.community_id), current: fragMaterial(jm), added_at: jm.added_at || 0 }));
const tombstones = Object.entries(s.tombstones).map(([community_id, removed_at]) => ({ community_id: b64of(community_id), removed_at }));
const frags = [{ frags: 1, entries: [], tombstones: [] }];
const size = (f) => JSON.stringify(f).length;
for (const e of entries) {
const last = frags[frags.length - 1];
if (!last.entries.length || size(last) + JSON.stringify(e).length < LIST_FRAG_BYTES) last.entries.push(e);
else frags.push({ frags: 1, entries: [e], tombstones: [] });
}
for (const t of tombstones) {
const last = frags[frags.length - 1];
if (!last.tombstones.length || size(last) + JSON.stringify(t).length < LIST_FRAG_BYTES) last.tombstones.push(t);
else frags.push({ frags: 1, entries: [], tombstones: [t] });
}
for (const f of frags) f.frags = frags.length;
return frags;
};
const unmaterial = (m, cid) => ({
community_id: cid, owner: hexOf(m.owner), owner_salt: hexOf(m.owner_salt), community_root: hexOf(m.community_root),
root_epoch: Number(m.root_epoch) || 0, ...(m.control_pk ? { control_pk: hexOf(m.control_pk) } : {}),
channels: (m.channels || []).map((c) => ({ id: hexOf(c.id), ...(c.key ? { key: hexOf(c.key) } : {}), epoch: Number(c.epoch) || 0, name: c.name })),
relays: Array.isArray(m.relays) ? m.relays : [], name: m.name || '',
});
// the fragments a reader holds, unioned into the 13302-shaped document
const defragment = (frags) => {
const doc = { entries: [], tombstones: [] };
for (const f of frags) {
for (const e of f.entries || []) {
if (!e || !e.current) continue;
const cid = hexOf(e.community_id);
const current = unmaterial(e.current, cid);
doc.entries.push({ community_id: cid, seed: e.seed ? unmaterial(e.seed, cid) : current, current, added_at: Number(e.added_at) || 0 });
}
for (const t of f.tombstones || []) doc.tombstones.push({ community_id: hexOf(t.community_id), removed_at: Number(t.removed_at) || 0 });
}
return doc;
};
// Fetch one identity's fragments: newest per index wins (an age tie falls to
// the lower id), the newest fragment's declared total governs, and each
// index's created_at is kept so a rewrite can exceed it.
async function readFragments(id, evs) {
const newest = new Map();
for (const e of evs) {
if (e.kind !== LIST_FRAG_KIND || e.pubkey !== id.pk) continue;
const idx = parseInt(e.tags.find((t) => t[0] === 'd')?.[1], 10);
if (!(idx >= 0)) continue;
const cur = newest.get(idx);
if (!cur || e.created_at > cur.created_at || (e.created_at === cur.created_at && e.id < cur.id)) newest.set(idx, e);
}
const frags = new Map(); const createdAt = {};
for (const [idx, e] of newest) {
try { const f = JSON.parse(await id.dec(e.content)); if (f && typeof f === 'object') { frags.set(idx, f); createdAt[idx] = e.created_at; } } catch {}
}
if (!frags.size) return null;
let top = null; for (const [idx, f] of frags) if (!top || createdAt[idx] > createdAt[top] || (createdAt[idx] === createdAt[top] && (f.frags || 1) > (frags.get(top).frags || 1))) top = idx;
const declared = Math.max(1, Number(frags.get(top).frags) || 1);
const live = [...frags.entries()].filter(([idx]) => idx < declared).map(([, f]) => f);
return { doc: defragment(live), createdAt, declared, complete: live.length === declared };
}
async function publishFragments(ids, prevAt = {}) {
const s = st();
const frags = buildFragments(s);
if (!listWriteDue('frags', ids.map((i) => i.pk).join() + '|' + JSON.stringify(frags))) return;
const now = Math.floor(Date.now() / 1000);
for (const id of ids)
for (let i = 0; i < frags.length; i++) {
try {
const created_at = Math.max(now, ((prevAt[id.pk] || {})[i] || 0) + 1);
const evt = await id.sign({ kind: LIST_FRAG_KIND, tags: [['d', String(i)]], content: await id.enc(JSON.stringify(frags[i])), created_at });
ownListIds.add(evt.id);
publishOn(listRelays(), evt);
} catch {}
}
}
let listsSynced = false;
let listsSyncedAt = 0;
let listLiveSub = null;
const fragAt = {}; // pk -> { index: created_at } as last read
// Re-sync is cheap and a join made in another client should show up soon
// after: the home view asks again after a minute, and a live subscription
// on the fragment kind brings a fresh write straight in.
async function syncLists({ force = false } = {}) {
if (listsSynced && !force) return;
const ids = await selfCryptors();
if (!ids.length) return;
listsSynced = true; listsSyncedAt = Date.now();
const authors = ids.map((i) => i.pk);
const [evs, fragEvs] = await Promise.all([
queryOn(DM_RELAYS, { kinds: [13302, 13303], authors }, 3500),
queryOn(listRelays(), { kinds: [LIST_FRAG_KIND], authors }, 4500),
]);
if (!listLiveSub) {
listLiveSub = subscribeOn(listRelays(), { kinds: [LIST_FRAG_KIND], authors, since: Math.floor(Date.now() / 1000) }, (e) => {
if (ownListIds.has(e.id)) return; // our own write coming back is not news
// another device writing: read it soon, but never faster than once
// every ten seconds — a device stuck rewriting must not drag every
// other one into a query storm alongside it
const wait = Math.max(1500, 10_000 - (Date.now() - listsSyncedAt));
clearTimeout(listLiveSub.t); listLiveSub.t = setTimeout(() => syncLists({ force: true }).catch(() => {}), wait);
});
allUnsubs.push(() => { try { listLiveSub(); } catch {} listLiveSub = null; });
}
let changed = false;
const remoteDocs = new Set();
const remoteLive = new Set(); let anyFrag = false;
for (const id of ids) {
const got = await readFragments(id, fragEvs);
if (!got) continue;
anyFrag = true; fragAt[id.pk] = got.createdAt;
for (const e of got.doc.entries) if (!(got.doc.tombstones.find((t) => t.community_id === e.community_id)?.removed_at > e.added_at)) remoteLive.add(e.community_id);
changed = mergeCommunityList(got.doc) || changed;
}
for (const kind of [13302, 13303])
for (const id of ids) {
const newest = evs.filter((e) => e.kind === kind && e.pubkey === id.pk).sort((a, b) => b.created_at - a.created_at)[0];
if (!newest) continue;
try {
const raw = await id.dec(newest.content);
remoteDocs.add(kind + ':' + raw);
const doc = JSON.parse(raw);
if (kind === 13302) changed = mergeCommunityList(doc) || changed;
else changed = mergeInviteList(doc) || changed;
} catch {}
}
healInviteBundles().catch(() => {});
if (changed) {
// cache-only here too — subscription waits for the user to open Chat
for (const jm of communities()) ensureRoom(jm, { subscribe: ui.chatOpen });
scheduleRepaint();
}
// republish when any identity's copy is missing or stale
const s = st();
const current = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
const anyMissing = ids.length * 2 > remoteDocs.size
|| current.some(([kind, doc]) => !remoteDocs.has(kind + ':' + doc));
if (anyMissing && (s.communities.length || Object.keys(s.invites).length || Object.keys(s.tombstones).length))
publishLists();
// the fragmented form: write it when no copy exists, or ours knows a
// membership (or a leave) the relay copy doesn't
const localLive = s.communities.filter((jm) => (s.tombstones[jm.community_id] || 0) <= (jm.added_at || 0)).map((jm) => jm.community_id);
const fragStale = !anyFrag || localLive.some((cid) => !remoteLive.has(cid)) || [...remoteLive].some((cid) => !localLive.includes(cid) && s.tombstones[cid]);
if (fragStale && (localLive.length || Object.keys(s.tombstones).length)) publishFragments(ids, fragAt);
}
async function leaveCommunity(room) {
const cid = room.jm.community_id;
const id = await identity();
const s = st();
s.tombstones[cid] = Date.now();
s.communities = s.communities.filter((c) => c.community_id !== cid);
delete s.joined[cid];
save(s);
if (id) {
const { created_at, ms } = msTags(Date.now());
wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'leave', tags: [ms], created_at }, id.signer, room.guestbook)
.then((w) => publishOn(room.relays, w)).catch(() => {});
}
rooms.delete(cid);
publishLists();
ui.msgView = 'home';
ui.msgLeaveArm = false;
ui.msgInvitePanel = false;
render();
}
// ---- DMs ----------------------------------------------------------------
const threadOf = (peer) => threads.get(peer) || threads.set(peer, new Map()).get(peer);
function noteDM(peer, rumor, mine, wnId) {
if (!peer || !rumor.id) return;
threadOf(peer).set(rumor.id, wnId ? { rumor, mine, wn: wnId } : { rumor, mine });
bumpMsgRev();
scheduleRepaint();
}
// Wraps nothing could open are NOT consumed. The usual reason is the
// remote signer losing the race with the relay backfill on a fresh load
// (the login signer resumes lazily, the stored wraps arrive first), or a
// single bunker round-trip failing — and dropping the wrap there was how
// whole stretches of DM history quietly went missing, differently on every
// device. Keep them and retry as the decryptor set improves; a wrap that
// never opens (a stranger's wrap that merely p-tags us) falls out after a
// few full-strength passes.
const pendingWraps = new Map(); // wrap.id -> { wrap, tries }
const PENDING_MAX = 800;
// Full-strength attempts before giving a wrap up. Kept low on purpose: a
// bunker signer pays two relay round-trips per attempt, and a wrap that a
// complete decryptor set failed to open twice is essentially never ours.
const PENDING_TRIES = 3;
let drainTimer = 0;
let draining = false;
function dmDecryptors() {
const out = [];
if (wallet.nostr && wallet.nostr.sk) out.push(wallet.nostr.sk);
const login = hook('nostrLoginIdentity');
// an interactive signer (Amber by intent) is a trip through another app
// per call: never for a wrap that arrives on its own
if (login && login.signer && login.signer.decryptFrom && !login.signer.interactive) out.push(login.signer);
return out;
}
// A "full-strength" attempt has every key this wallet expects: retries
// only count against a wrap once the login signer (if one is linked) has
// actually had its chance — the wallet key alone can never open a wrap
// sealed to the login npub.
//
// A linked login whose signer is NOT live (a reload before the bunker
// resumed, or a session selfHealing just dropped) is incomplete, not
// "nothing to wait for": counting those passes gave every wrap sealed to
// the login npub up after ~40 s with only the wallet key tried, and the
// wrap log then hid it from every later session (a phone kept 9 of 186
// messages). An interactive signer (Amber by intent) never decrypts here
// either, so it is incomplete too: counted as complete, every wrap sealed
// to the login npub was given up on the wallet key and logged as opened
// — an Amber user's threads held only what that device had sent.
function decryptorsComplete() {
const login = hook('nostrLoginIdentity');
return !login || !!(login.signer && !login.signer.interactive && login.signer.decryptFrom);
}
const interactiveLogin = () => { const l = hook('nostrLoginIdentity'); return !!(l && l.signer && l.signer.interactive); };
// Wraps logged while an interactive signer stood in for the login npub
// may be messages no key here ever tried. Once a signer that can decrypt
// takes over (Amber reconnected over NIP-46), the heal pass re-opens
// every wrap the relays still hold.
function noteSignerGap() {
const s = st();
if (interactiveLogin()) { if (!s.dmSignerGap) { s.dmSignerGap = true; save(s); } return; }
if (!s.dmSignerGap || !decryptorsComplete()) return;
delete s.dmSignerGap;
s.dmHeal = 0;
save(s);
wrapLog = null; healed.clear(); seenWraps.clear();
if (dmStarted) resubscribeStreams();
}
// Raw-key unwraps (seed-derived / pasted nsec — the common case) run in
// the crypto worker: two ECDH ops + a seal verify per wrap is the boot
// secp256k1 that used to run here. Remote signers can't export their key,
// so their decryptFrom stays on the main thread, exactly as before.
async function unwrapDMAny(wrap, d) {
if (d instanceof Uint8Array) {
const job = unwrapDMsOffthread([wrap], d);
if (job) {
const r = await job;
if (r) return r[0]; // null here means "not ours", not "worker failed"
}
}
return unwrapDM(wrap, d).catch((e) => { if (SIGNER_SILENT.test(e?.message || '')) throw e; return null; });
}
// true: opened. false: not ours. 'later': a remote signer never answered,
// so nothing is known yet — the wrap must be tried again, uncounted.
async function openInboxWrap(wrap, heal = false) {
const g = session;
for (const d of dmDecryptors()) {
let got;
try { got = await unwrapDMAny(wrap, d); } catch { return g === session ? 'later' : 'stale'; }
if (g !== session) return 'stale'; // opened with the previous account's key
if (!got) continue;
if (heal && ![7, 14, 15].includes(got.rumor.kind)) return true;
if (got.rumor.kind === 14 || got.rumor.kind === 15) {
// unwrapDM judges "mine" against the key that DECRYPTED, but this
// wallet can hold several identities (wallet key + nostr login). A
// sent-copy authored by any of them must thread under the RECIPIENT,
// or every welcome DM the registrar sends lands in a self-thread.
const mine = isMe(got.author);
// a message that neither comes from nor names this identity isn't
// this account's conversation, however it reached us
if (!mine && !(got.rumor.tags || []).some((x) => x[0] === 'p' && isMe(x[1]))) return true;
const to = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
const peer = mine ? (to || got.peer || got.author) : got.author;
noteEmoji(got.rumor);
noteDM(peer, got.rumor, mine);
persistDms();
} else if (got.rumor.kind === 7) {
// a DM reaction (ours echoed back, or the peer's — 0xchat's shape)
const target = got.rumor.tags?.find((x) => x[0] === 'e')?.[1];
noteEmoji(got.rumor);
if (target) {
const key = target + '|' + got.author;
const at = got.rumor.created_at * 1000;
if (at >= (dmReactAt.get(key) || 0)) {
dmReactAt.set(key, at);
(dmReacts.get(target) || dmReacts.set(target, new Map()).get(target)).set(got.author, got.rumor.content);
}
scheduleRepaint();
}
} else if (got.rumor.kind === 3313 && !isMe(got.author)) {
try {
const b = openDirectBundle(got.rumor.content);
if (b && !st().declined[got.rumor.id] && !communityById(b.community_id))
pendingDirect.set(got.rumor.id, { bundle: b, from: got.author, rid: got.rumor.id });
scheduleRepaint();
} catch {}
} else if (got.rumor.kind === 444 && !isMe(got.author)) {
// a White Noise invitation: the group itself says who is asking
whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {});
}
return true;
}
return false;
}
async function handleInboxWrap(wrap) {
let heal = false;
if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) {
if (!healing(wrap)) return;
heal = true;
}
seenWraps.add(wrap.id);
const r = await openInboxWrap(wrap, heal);
if (r === 'stale') return;
if (r === true) { rememberWrap(wrap.id); return; }
if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value);
pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 });
scheduleDrain(8000);
}
function scheduleDrain(ms) {
if (!pendingWraps.size) return;
clearTimeout(drainTimer);
drainTimer = setTimeout(() => { drainPendingWraps().catch(() => {}); }, ms);
}
async function drainPendingWraps() {
if (draining || !pendingWraps.size) return;
draining = true;
try {
// Wraps sealed to the login npub can't open without its signer. Wake it
// up ourselves — the boot-time resume is a single attempt, and if it
// loses to cold-start churn nothing else asks until the user happens to
// touch a screen that calls identity(). Resume single-flights and
// rate-limits itself, so asking here is cheap.
if (!decryptorsComplete()) await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
// Still no login signer: a pass would only re-run the wallet key, which
// already had its try when each wrap arrived. Wait for the signer
// (nostrSignerLive or the slow retry below) instead.
const complete = decryptorsComplete();
if (!complete || !dmDecryptors().length) return;
// Each openInboxWrap decrypts a gift wrap — NIP-44 ECDH, real
// secp256k1 work. A backlog decrypted in one tight loop blocks the main
// thread for the whole burst (the boot-time jank that made a carousel
// drag lurch). Cap each synchronous run to a frame's budget and yield so
// input and paint get a turn; the backfill just takes a few extra frames.
let chunkStart = performance.now();
for (const [id, p] of [...pendingWraps]) {
// A remote signer can die mid-pass; counting the remaining wraps as
// full-strength failures would evict messages that were never really
// tried. Stop here — the slow retry picks the rest up.
if (complete && !decryptorsComplete()) break;
const r = await openInboxWrap(p.wrap, p.heal);
if (r === 'stale') break; // the account changed under this pass
if (r === true) { pendingWraps.delete(id); rememberWrap(id); }
else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later
else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); }
if (performance.now() - chunkStart > 8) {
await new Promise((r) => setTimeout(r));
chunkStart = performance.now();
}
}
} finally {
draining = false;
// still holding wraps: the signer may connect later — keep a slow retry
// (in finally: an early return above must not end the retries)
if (pendingWraps.size) scheduleDrain(decryptorsComplete() ? 30_000 : 60_000);
healDone();
}
}
function openDirectBundle(json) {
const b = JSON.parse(json);
if (communityId(b.owner, b.owner_salt) !== b.community_id) return null;
if (!/^[0-9a-f]{64}$/.test(b.community_root || '')) return null;
if (!Array.isArray(b.channels) || b.channels.length > 256) return null;
if (b.expires_at && Date.now() > b.expires_at) return null;
return b;
}
function startDMs() {
if (dmStarted) return;
// Poll/advertise only the single DM identity (see dmSubKeys) — not the seed
// key as a phantom second inbox.
const pks = dmSubKeys();
if (!pks.length) return;
dmStarted = true;
if (interactiveLogin()) noteSignerGap();
loadSeenWraps();
// warm threads from the local cache. A self-thread of our own messages is
// the residue of the old sent-copy misthreading (each welcome DM landed
// under our own key) — sweep it rather than resurrect it; the cached
// rows carry no tags, so they cannot be re-threaded to their recipients.
const s = st();
let swept = false;
for (const [peer, list] of Object.entries(s.dms)) {
if (isMe(peer) && list.every((m) => isMe(m.from))) {
delete s.dms[peer];
swept = true;
continue;
}
// a row from someone who is neither us nor the peer: another
// identity's conversation, saved here by an account switch mid-decrypt
// (fixed — see session). The whole thread goes; ours refetches.
if (list.some((m) => !isMe(m.from) && m.from !== peer)) {
delete s.dms[peer];
swept = true;
continue;
}
for (const m of list) threadOf(peer).set(m.id, { rumor: rumorFromRow(m), mine: isMe(m.from) });
}
bumpMsgRev();
if (swept) save(s);
const dmSub = (relays) => {
const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
handleInboxWrap(wrap).catch(() => {});
}, { onclose: noteSubClosed });
allUnsubs.push(u); dmUnsubs.push(u);
};
dmSub(DM_RELAYS);
// Senders deliver to the relays our kind-10050 advertises — a list other
// clients may have published with relays beyond our defaults. Read those
// too, or a compliant sender's DM lands somewhere we never look (a reply
// went only to damus/primal and no device ever showed it).
(async () => {
try {
const lists = await Promise.all(pks.map((pk) => fetchInboxRelays(pk).catch(() => [])));
const extras = [...new Set(lists.flat().map((r) => String(r || '').trim().replace(/\/$/, '')))]
.filter((r) => /^wss:\/\//i.test(r) && !DM_RELAYS.includes(r))
// every inbox the list names: a sender may deliver to just one of
// them (crypticCypher's replies reached only primal, the 4th extra
// in Adam's list, while a cap of three read the first three)
.slice(0, 8);
ownInbox = extras;
// inbox relays are often NIP-42 gated: authenticate as the identity
if (extras.length && dmStarted) {
const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
handleInboxWrap(wrap).catch(() => {});
}, { onauth: relayAuth, onclose: noteSubClosed });
allUnsubs.push(u); dmUnsubs.push(u);
}
} catch {}
})();
ensureDmRelayList().catch(() => {});
}
// Publish a kind 10050 DM-relay list for the wallet key if none exists, so
// other NIP-17 clients can find our inbox. Never touch a login npub's list
// — 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).
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;
const evt = finalizeEvent({
kind: 10050,
content: '',
tags: DM_RELAYS.map((r) => ['relay', r]),
created_at: Math.floor(Date.now() / 1000),
}, wallet.nostr.sk);
publishOn(DM_RELAYS, evt);
}
async function sendDM(peer) {
const text = draftFor('dm:' + peer).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
const road = await wnRoute(peer).catch(() => null);
if (road) return sendWnDirect(peer, road, text);
// Same optimistic shape as channel sends: the rumor is synchronous and on
// screen at once. Wrapping the same rumor keeps the id, so the sent-copy
// echo folds into this entry instead of duplicating it.
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
ui.emojiAc = null;
const rumor = makeDMRumor(id.pubkey, peer, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]);
const entry = { rumor, mine: true, pending: true };
threadOf(peer).set(rumor.id, entry);
clearDraft('dm:' + peer);
ui.msgStick = true;
render();
try {
// Sequential on purpose — a remote signer is happier signing one at a time.
const toPeer = await wrapDM(id.signer, peer, rumor);
const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
// Our own relays take the wrap immediately; the peer's declared inbox
// (a lookup that can take seconds) is chased off the critical path.
// Publishing the same wrap twice is harmless — relays dedupe on id.
const ok = await publishOn(DM_RELAYS, toPeer);
publishOn(DM_RELAYS, toSelf);
toOwnInbox(toSelf);
fetchInboxRelays(peer).then((inbox) => {
const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r));
if (extra.length) publishOn(extra, toPeer, { onauth: relayAuth });
}).catch(() => {});
if (!ok) { toast(t('msgSendFailed')); return; }
delete entry.pending;
persistDms();
} catch (e) {
threadOf(peer).delete(rumor.id);
toast(e.message || String(e));
render();
}
}
// React to a DM: the same kind-7-in-a-gift-wrap shape 0xchat uses, sent to
// the peer and to our own inbox (the echo is what other devices fold in).
// Optimistic like sendDM; one reaction per author, latest wins.
async function sendDmReaction(peer, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
ui.msgSheet = null;
// a reaction travels with the message it answers
if (m.wn) { const g = findGroup(m.wn); if (g) sendWnReaction(g, m, emoji); return; }
const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji, reactEmojiTags(emoji));
const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
r.set(id.pubkey, emoji);
dmReactAt.set(m.rumor.id + '|' + id.pubkey, rumor.created_at * 1000);
render();
try {
const toPeer = await wrapDM(id.signer, peer, rumor);
const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
seenWraps.add(toPeer.id); seenWraps.add(toSelf.id);
const ok = await publishOn(DM_RELAYS, toPeer);
publishOn(DM_RELAYS, toSelf);
toOwnInbox(toSelf);
fetchInboxRelays(peer).then((inbox) => {
const extra = inbox.slice(0, 4).filter((x) => !DM_RELAYS.includes(x));
if (extra.length) publishOn(extra, toPeer, { onauth: relayAuth });
}).catch(() => {});
if (!ok) toast(t('msgSendFailed'));
} catch (e) {
if (prev) r.set(id.pubkey, prev); else r.delete(id.pubkey);
toast(e.message || String(e));
render();
}
}
// A cached DM row. A file message (kind 15) keeps its kind and tags — the
// url alone is an encrypted blob, and without the key and nonce a reload
// turned the picture into a bare link.
function rowOf(rumor) {
const row = { id: rumor.id, from: rumor.pubkey, text: rumor.content, t: rumor.created_at };
if (rumor.kind === 15) {
row.k = 15;
row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p');
} else {
// a message's custom emoji ride along, or they'd be text after a reload
const em = [...emojiTagMap(rumor.tags)];
if (em.length) row.em = em;
}
return row;
}
function rumorFromRow(m) {
return {
id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: m.k || 14,
tags: m.tg || (m.em || []).map(([c, u]) => ['emoji', c, u]),
};
}
// The cache is shared by every tab of this wallet, and each tab only holds
// the messages IT has seen: a tab left asleep since yesterday used to
// rewrite the cache from its own memory and erase what another tab had
// received meanwhile — while the wrap log still said those were opened, so
// nothing ever fetched them again. Rows are merged by id with what's
// stored; the fuller row (the one carrying file fields) wins.
function persistDms() {
const s = st();
const merged = new Map(); // peer -> Map(id -> row)
for (const [peer, list] of Object.entries(s.dms || {}))
merged.set(peer, new Map(list.map((r) => [r.id, r])));
for (const [peer, m] of threads) {
const rows = merged.get(peer) || merged.set(peer, new Map()).get(peer);
for (const e of m.values()) {
if (e.pending || e.wn) continue;
const row = rowOf(e.rumor);
const had = rows.get(row.id);
if (!had || !had.tg || row.tg) rows.set(row.id, row);
}
}
const byRecent = [...merged.entries()]
.map(([peer, rows]) => [peer, [...rows.values()].sort((a, b) => a.t - b.t)])
.filter(([, list]) => list.length)
.sort((a, b) => (b[1].at(-1)?.t || 0) - (a[1].at(-1)?.t || 0))
.slice(0, 30);
s.dms = {};
for (const [peer, list] of byRecent) s.dms[peer] = list.slice(-CACHE_MAX);
save(s);
}
// ---- White Noise (Marmot) -----------------------------------------------
// The third way a conversation can travel, and the one nobody should have
// to choose: MLS groups over nostr, the protocol the White Noise app
// speaks. A two-person group with no name IS a direct message — it lands
// in the same thread as that person's NIP-17 messages, and a reply goes
// back the way their last message came. Groups with a name or more people
// get rows of their own, beside the DMs. The MLS code is a chunk of its
// own (marmot-client.js), fetched once a wallet is open.
let wn = null; // the client for the identity we speak as
let wnReadied = false; // asked (once a session) to make us invitable
let wnLoading = null; // { pk, p } while it loads
const wnReactOf = new Map(); // reaction event id -> { target, pk }: what a later delete undoes
function whiteNoise() {
const pk = identityPk();
if (!pk || !wallet._cacheKey) return Promise.resolve(null);
if (wn && wn.pubkey === pk) return Promise.resolve(wn);
if (wnLoading && wnLoading.pk === pk) return wnLoading.p;
if (wn) { try { wn.stop(); } catch {} wn = null; }
const p = import('../marmot-client.js').then(async (m) => {
if (identityPk() !== pk) return null; // the identity changed while the chunk loaded
// the relays new groups are founded on can be pointed elsewhere (dev and tests)
let relays = null;
try { relays = JSON.parse(localStorage.getItem('coinos-group-relays') || 'null'); } catch {}
const c = m.marmotClient({
scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onWn, subClosed: noteSubClosed,
...(Array.isArray(relays) && relays.length ? { relays } : {}),
});
wn = c;
wnLoading = null;
await c.start();
return c;
}).catch((e) => { console.warn('chat: group chats unavailable', e); wnLoading = null; return null; });
wnLoading = { pk, p };
return p;
}
// Cordn groups (cordn-client.js): MLS again, delivered by a coordinator
// instead of relays. The same interface as the White Noise client, so the
// group screens below drive either; gClient(g) says which one a group is.
let cd = null, cdLoading = null, cdReadied = false;
function cordnChat() {
const pk = identityPk();
if (!pk || !wallet._cacheKey) return Promise.resolve(null);
if (cd && cd.pubkey === pk) return Promise.resolve(cd);
if (cdLoading && cdLoading.pk === pk) return cdLoading.p;
if (cd) { try { cd.stop(); } catch {} cd = null; }
const p = import('../cordn-client.js').then(async (m) => {
if (identityPk() !== pk) return null;
let coordinator = null;
try { coordinator = JSON.parse(localStorage.getItem('coinos-cordn-coordinator') || 'null'); } catch {}
const c = m.cordnClient({
scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onCd, subClosed: noteSubClosed,
...(coordinator && coordinator.pubkey ? { coordinator } : {}),
});
cd = c;
cdLoading = null;
await c.start();
return c;
}).catch((e) => { console.warn('chat: cordn groups unavailable', e); cdLoading = null; return null; });
cdLoading = { pk, p };
return p;
}
function onCd(type, g, data) {
if (!cd) return;
if (type === 'message') { try { noteEmoji(data); } catch {} }
wnFolds.delete(g && g.id);
bumpMsgRev();
scheduleRepaint();
}
const gClient = (g) => (g && g.cordn ? cd : wn);
const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null;
// One delivered event of a direct group, into that person's thread.
function wnIntoThread(g, peer, m) {
const th = threadOf(peer);
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
if (m.kind === 9) {
if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
} else if (m.kind === 7 && target) {
const key = target + '|' + m.pubkey, at = m.created_at * 1000;
if (at >= (dmReactAt.get(key) || 0)) {
dmReactAt.set(key, at);
(dmReacts.get(target) || dmReacts.set(target, new Map()).get(target)).set(m.pubkey, m.content);
wnReactOf.set(m.id, { target, pk: m.pubkey });
}
} else if (m.kind === 5) {
// only the author takes a message (or a reaction) back
for (const [, id] of m.tags.filter((x) => x[0] === 'e')) {
const hit = th.get(id);
if (hit && hit.rumor.pubkey === m.pubkey) th.delete(id);
const r = wnReactOf.get(id);
if (r && r.pk === m.pubkey) dmReacts.get(r.target)?.delete(r.pk);
}
} else if (m.kind === 1009 && target) {
const hit = th.get(target);
if (hit && hit.rumor.pubkey === m.pubkey) th.set(target, { ...hit, rumor: { ...hit.rumor, content: m.content }, edited: true });
}
}
function wnReplay() {
if (!wn) return;
for (const g of wn.groups.values()) {
const peer = wn.peer(g);
if (!peer) continue;
if (g.fresh) wn.accept(g); // a direct message needs no invitation card
for (const m of g.log) wnIntoThread(g, peer, m);
}
}
function onWn(type, g, data) {
if (!wn) return;
if (type === 'message') {
const peer = wn.peer(g);
if (peer) wnIntoThread(g, peer, data);
try { noteEmoji(data); } catch {}
} else if (type === 'loaded' || type === 'joined') wnReplay();
else if (type === 'withdrawn') for (const th of threads.values()) for (const id of data) th.delete(id);
wnFolds.delete(g && g.id);
bumpMsgRev();
scheduleRepaint();
}
// The live direct group with this person (the newest, if a chat was remade).
function wnDirect(peer) {
if (!wn) return null;
let best = null;
for (const g of wn.groups.values())
if (!g.removed && !g.leaving && wn.peer(g) === peer && (!best || g.at > best.at)) best = g;
return best;
}
// Which way a message to this person travels: a group record, 'new' (found
// a direct group first), or null for NIP-17. A reply goes back the way
// their last message came. A first message goes by White Noise only to
// someone whose KeyPackage was published by another app: a coinos wallet
// reads NIP-17 on every device it has, where an MLS group reaches just the
// one device whose KeyPackage was picked. A route pinned by hand in the
// thread header (st().route) overrides all of that.
const wnTheirs = (peer) => {
let theirs = null;
for (const m of threads.get(peer)?.values() || [])
if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m;
return theirs;
};
const routePref = (peer) => (st().route || {})[peer] || 'auto';
async function wnRoute(peer) {
const c = await whiteNoise();
if (!c) return null;
await c.load();
const pref = routePref(peer);
if (pref === 'nip17') return null;
const g = wnDirect(peer);
const lookup = () => Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]);
if (pref === 'wn') return g || ((await lookup()) ? 'new' : null);
const theirs = wnTheirs(peer);
if (g) return theirs && !theirs.wn ? null : g;
if (theirs) return null;
const cand = await lookup();
return cand && cand.client !== 'coinos' ? 'new' : null;
}
// The same decision without waiting, for the thread header: which way the
// next message will go and what was pinned — or null when there is nothing
// to choose (they are not on White Noise, or only through another coinos
// wallet, where NIP-17 is simply the better road).
const wnCands = new Map(); // peer -> { at, cand } : their KeyPackage, as last looked up
function wnKnow(peer) {
const hit = wnCands.get(peer);
if (hit && Date.now() - hit.at < 10 * 60_000) return;
wnCands.set(peer, { at: Date.now(), cand: hit ? hit.cand : undefined });
whiteNoise().then((c) => (c ? c.lookup(peer) : null)).then((cand) => {
wnCands.set(peer, { at: Date.now(), cand: cand || null });
scheduleRepaint();
}).catch(() => {});
}
function routeChoice(peer) {
if (!wn) return null;
const g = wnDirect(peer), cand = (wnCands.get(peer) || {}).cand;
if (!g && !(cand && cand.client !== 'coinos')) return null;
const pref = routePref(peer), theirs = wnTheirs(peer);
const auto = g ? (theirs && !theirs.wn ? 'nip17' : 'wn') : (theirs ? 'nip17' : 'wn');
return { pref, now: pref === 'auto' ? auto : pref };
}
function setRoute(peer, pref) {
const s = st();
s.route ||= {};
if (pref === 'auto') delete s.route[peer]; else s.route[peer] = pref;
save(s);
}
async function sendWnDirect(peer, road, text) {
const c = wn;
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
ui.emojiAc = null;
const inner = c.event(9, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]);
const entry = { rumor: inner, mine: true, pending: true, wn: true };
threadOf(peer).set(inner.id, entry);
clearDraft('dm:' + peer);
ui.msgStick = true;
render();
try {
const g = road === 'new' ? await c.create({ members: [peer] }) : road;
entry.wn = g.id;
if (!(await c.send(g, inner))) throw new Error(t('msgSendFailed'));
delete entry.pending;
render();
} catch (e) {
threadOf(peer).delete(inner.id);
toast(e.message === 'no signer' ? t('msgNoIdentity') : (e.message || String(e)));
render();
}
}
async function sendWnReaction(g, m, emoji) {
const wn = gClient(g);
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);
}
// ---- 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() : [])];
function wnTitle(g) {
const c = gClient(g);
const v = c.view(g);
if (v.name) return v.name;
const others = c.accounts(g).filter((pk) => !isMe(pk));
return others.length ? others.slice(0, 3).map(displayName).join(', ') : t('msgGroupUnnamed');
}
// A group's log folded for the screen: chat and system rows in time order
// (edits applied, deletions gone) and who reacted with what.
const wnFolds = new Map(); // gid -> { n, last, rows, reacts }
function wnFold(g) {
const memo = wnFolds.get(g.id), last = g.log.length ? g.log[g.log.length - 1].id : '';
if (memo && memo.n === g.log.length && memo.last === last) return memo;
const byId = new Map(), reacts = new Map(), reactOf = new Map();
const sorted = [...g.log].sort((a, b) => a.created_at - b.created_at);
for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
for (const m of sorted) {
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
if (m.kind === 7 && target) {
(reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content);
reactOf.set(m.id, { target, pk: m.pubkey });
} else if (m.kind === 5) {
for (const [, id] of m.tags.filter((x) => x[0] === 'e')) {
const hit = byId.get(id);
if (hit && hit.rumor.pubkey === m.pubkey && hit.rumor.kind === 9) byId.delete(id);
const r = reactOf.get(id);
if (r && r.pk === m.pubkey) reacts.get(r.target)?.delete(r.pk);
}
} else if (m.kind === 1009 && target) {
const hit = byId.get(target);
if (hit && hit.rumor.pubkey === m.pubkey) byId.set(target, { ...hit, rumor: { ...hit.rumor, content: m.content }, edited: true });
}
}
const fold = { n: g.log.length, last, rows: [...byId.values()], reacts };
wnFolds.set(g.id, fold);
return fold;
}
const wnChat = (g) => wnFold(g).rows.filter((m) => m.rumor.kind === 9);
const wnNewest = (g) => newestFrom(wnChat(g), (m) => !m.mine);
const wnUnread = (g) => !g.fresh && wnNewest(g) > (st().read[wnRead(g.id)] || 0);
// "Ana added Ben", from a locally derived kind-1210 row
function wnSystemText(m) {
let c;
try { c = JSON.parse(m.content); } catch { return ''; }
const d = c.data || {};
const a = d.actor ? displayName(d.actor) : '', b = d.subject ? displayName(d.subject) : '';
switch (c.system_type) {
case 'member_added': return d.actor && d.actor !== d.subject ? t('msgSysAdded', { a, b }) : t('msgSysJoined', { b });
case 'member_removed': return t('msgSysRemoved', { a, b });
case 'member_left': return t('msgSysLeft', { b });
case 'admin_added': return t('msgSysAdminAdded', { b });
case 'admin_removed': return t('msgSysAdminRemoved', { b });
case 'group_renamed': return t('msgSysRenamed', { a, name: d.name || '' });
case 'group_disbanded': return t('msgSysDisbanded', { a });
default: return c.text || '';
}
}
// People being gathered for a group (the New group panel, or Add person):
// each is checked for a KeyPackage as they are picked.
const wnReach = new Map(); // pk -> 'checking' | 'ok' | 'no'
const cdReach = new Map(); // the same, on the Cordn coordinator
const reachOf = (proto) => (proto === 'cd' ? cdReach : wnReach);
function wnCheck(pk, proto = 'wn') {
const reach = reachOf(proto);
if (reach.get(pk) === 'ok' || reach.get(pk) === 'checking') return;
reach.set(pk, 'checking');
(proto === 'cd' ? cordnChat() : whiteNoise()).then((c) => (c ? c.lookup(pk) : null)).then((cand) => { reach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); })
.catch(() => { reach.set(pk, 'no'); scheduleRepaint(); });
}
const wnFail = (e) => {
if (e && e.missing) toast(t('msgGroupSomeCant', { names: e.missing.map(displayName).join(', ') }));
else if (e && e.message === 'no signer') toast(t('msgNoIdentity'));
else toast(t('msgGroupFailed'));
dlog('chat: group', e);
};
async function wnCreateGroup() {
const members = (ui.msgGroupPicks || []).filter((pk) => !isMe(pk));
if (!members.length) { toast(t('msgGroupNeedPeople')); return; }
if (ui.msgBusy) return;
ui.msgBusy = true; render();
try {
const cordn = ui.msgGroupProto === 'cd';
const c = await (cordn ? cordnChat() : whiteNoise());
if (!c) throw new Error('no signer');
// a group with no name is still a group once it has a third person;
// with two it would be a direct chat, so it takes a name
const name = (ui.msgGroupName || '').trim() || (members.length === 1 || cordn ? t('msgGroupUnnamed') : '');
const g = await c.create({ name, members });
ui.msgGroupPicks = []; ui.msgGroupName = ''; ui.msgGroupTo = ''; ui.msgHomePanel = null;
dmSearcher.clear();
ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true;
} catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); }
}
async function wnSend(g) {
const text = draftFor('wn:' + g.id).trim();
const wn = gClient(g);
if (!text || !wn) return;
const replyTo = ui.msgReplyTo && wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
ui.emojiAc = null;
const inner = wn.event(9, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]);
clearDraft('wn:' + g.id);
ui.msgStick = true;
// the engine logs our own event as it encrypts it, so it is on screen at once
const sent = wn.send(g, inner).catch(() => false);
wnFolds.delete(g.id);
render();
if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); }
}
async function wnAct(fn) {
if (ui.msgBusy) return;
ui.msgBusy = true; render();
try { await fn(); } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); }
}
// ---- views --------------------------------------------------------------
const timeLabel = (tms) => {
if (feedPaint?.time.has(tms)) return feedPaint.time.get(tms);
const d = new Date(tms);
const today = new Date().toDateString() === d.toDateString();
const label = today
? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
feedPaint?.time.set(tms, label);
return label;
};
// Which bytes a face paints from, in order of preference:
// our own punk art, straight off disk at the size being drawn;
// the thumbnail we keep locally, which needs no network at all;
// the original, when we have nothing else yet.
// The 64px profile avatar layers the original OVER the local copy: CSS
// paints the first layer that has arrived, so the face is there instantly
// and sharpens when the full-size one lands.
function avatarBg(p, big) {
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 FACE_WAIT_MS = 1000;
const faceAsked = new Map(); // pk -> when its face was first wanted this session
let faceTimer = null;
function faceWaiting(pk) {
let at = faceAsked.get(pk);
if (at == null) { at = Date.now(); faceAsked.set(pk, at); }
const left = at + FACE_WAIT_MS - Date.now();
if (left <= 0) return false;
// repaint when the wait runs out, so the punk shows without an event
if (!faceTimer) faceTimer = setTimeout(() => { faceTimer = null; scheduleRepaint(); }, left + 20);
return true;
}
const avatar = (pk, cls = 'chat-avatar', clickable = true) => {
const p = profileOf(pk);
// Someone we've never cached used to get an empty circle until a relay
// answered — seconds of blankness on every boot, and for anyone whose
// kind 0 carries no picture (or none at all, which never gets cached)
// that repeated forever. The punk is derived from the pubkey alone, so
// it can be drawn in the first frame: it's already the right answer for
// everyone without a picture, and for a coinos user who kept the default
// it IS their published picture. A real photo replaces it when it lands.
//
// `loading` is different and still gets the quiet circle: a name lookup
// is in flight for that specific person, so a picture is expected and
// punk art must not flash in front of it.
// Small faces wait at most a second. An unknown payment portrait stays
// hidden until its lookup settles; suggestions warm the real photo
// before selection. A name-only search placeholder can retain `loading`
// after a miss; the request state lets
// it fall back once we've finished looking.
const sendFace = cls.includes('send-avatar');
// A profile page's own face is the same: the one big picture on the
// page, never punk art while that person's lookup is still running.
const pageFace = cls.includes('profile-avatar');
const waiting = (p === null || (p && p.loading && !p.picture))
&& (sendFace || pageFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) || fullInFlight.has(pk) : faceWaiting(pk));
const node = feedPaint && !p.picture
? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2))
: waiting && (sendFace || pageFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room'))
? h('div', { class: cls + ' fallback loading', style: sendFace ? 'visibility:hidden' : undefined })
: p && p.loading && !p.picture
? fallbackAvatar(h, pk, p.name, cls)
: p === null
? fallbackAvatar(h, pk, null, cls)
: p.picture
// A background paints synchronously from cache; a fresh decodes
// async, so recreating one per render made avatars visibly flash.
//
// The thumbnail we keep locally is the whole picture at this size, so
// a 30px circle never asks the network for the original again. The
// Larger profile/payment avatars layer them: CSS paints the first layer that
// has arrived, so the thumbnail shows instantly and the full-size
// original takes over on top when it lands.
? h('div', { class: cls + ' ava-img', style: 'background-image:' + avatarBg(p, cls.includes('profile-avatar') || cls.includes('send-avatar')) })
: fallbackAvatar(h, pk, p.name, cls);
// A face we keep painting is one worth keeping a thumbnail of.
if (!feedPaint && p && p.picture) makeThumb(pk, p);
if (clickable) {
node.classList.add('clickable');
// Start the profile's own fetches on touch-DOWN, not on click. The gap
// between the two is a hundred-odd milliseconds of free head start,
// and unlike prefetching every row on paint (which cost sixty requests
// for twenty rows and starved the profile batch) this only ever asks
// about the person actually being tapped.
// As on* properties claimed in _evs, the way h() attaches handlers, so
// the morph transplants them: a list that re-sorts (a new message
// lifting a conversation to the top) patches this node into a
// DIFFERENT person's avatar, and an addEventListener'd handler stayed
// behind holding the old person — a tap on Bill's row opened someone
// else's profile, until the next fresh render.
node.onpointerdown = () => { try { prefetchProfilePage(pk); } catch {} };
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
// The hats feature is a deferred chunk — until it lands, replay last
// session's boot stamps (hat art + geometry it persisted) so a hat sits on
// the head from the FIRST frame instead of popping in a beat later. Once
// hatsReady answers, the stamps stand down: a null from wrapAvatar then
// genuinely means "bare head".
let _hatStamps = null;
function hatStamp(pk, node) {
if (hook('hatsReady')) return null;
if (_hatStamps === null) {
try { _hatStamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { _hatStamps = {}; }
}
const s = _hatStamps[pk];
if (!s || !s.a) return null;
return h('span', { class: 'hat-wrap' }, node, h('span', { class: 'ava-hat', style: s.s, html: s.a }));
}
// ---- profiles: view + own kind-0 editor ---------------------------------
const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at }
const fullFetched = new Set(); // fetched from relays this session
const fullInFlight = new Map();
const fullMisses = new Set();
// The profile-page cache SURVIVES refreshes: bios and recent posts serve
// instantly from storage while a background fetch freshens them. Bounded:
// the last handful of viewed/warmed pages, notes trimmed to what the rows
// render.
const PROF_PAGE_CACHE = 'profPages';
const pageCache = () => {
try {
const c = wallet.loadFeatureState(PROF_PAGE_CACHE, {}) || {};
return { full: c.full || {}, notes: c.notes || {} };
} catch { return { full: {}, notes: {} }; }
};
function persistPage(kind, pk, value) {
try {
const c = pageCache();
c[kind][pk] = { t: Date.now(), v: value };
for (const k of ['full', 'notes']) {
const keys = Object.keys(c[k]).sort((a, b) => c[k][b].t - c[k][a].t);
for (const drop of keys.slice(10)) delete c[k][drop];
}
wallet.saveFeatureState(PROF_PAGE_CACHE, c);
} catch {}
}
// (an article keeps its kind, its card's tags and, near enough, all of
// itself: a tap opens it to read)
const ARTICLE_TAGS = ['d', 'title', 'summary', 'image', 'published_at'];
const slimNote = (ev) => ev.kind === 30023 ? {
id: ev.id, pubkey: ev.pubkey, kind: 30023, created_at: ev.created_at,
content: String(ev.content || '').slice(0, 30000),
tags: (ev.tags || []).filter((x) => ARTICLE_TAGS.includes(x[0])),
} : {
id: ev.id, pubkey: ev.pubkey, kind: 1, created_at: ev.created_at,
content: String(ev.content || '').slice(0, 3000),
tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'),
};
function fetchFullProfile(pk) {
if (!fullProfiles.has(pk)) {
const cached = pageCache().full[pk];
if (cached) fullProfiles.set(pk, cached.v);
}
if (fullFetched.has(pk)) return Promise.resolve();
if (fullInFlight.has(pk)) return fullInFlight.get(pk);
fullMisses.delete(pk);
// Use the same index-relay + author's-relay fallbacks as feed avatars.
// Only a real kind-0 response marks a page fetched; relay silence is
// retryable and leaves cached profile details intact.
const pending = fetchProfiles([pk]).catch(() => {}).finally(() => {
fullInFlight.delete(pk);
if (!fullFetched.has(pk)) fullMisses.add(pk);
if (ui.profilePk === pk) render();
else scheduleRepaint();
});
fullInFlight.set(pk, pending);
return pending;
}
// A person's profile the way their profile page finds it: index relays and
// their own relays, not just ours. The zap path asks here — a lightning
// address on a kind 0 that our relays have long since pruned is still
// somebody's lightning address.
wallet.deepProfile = async (pk) => {
await fetchFullProfile(pk);
const m = fullProfiles.get(pk);
if (!m || typeof m !== 'object') return null;
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
return {
name: str(m.display_name) || str(m.name), picture: str(m.picture), about: str(m.about), nip05: str(m.nip05),
lud16: str(m.lud16), lud06: str(m.lud06),
};
};
// Warm a profile PAGE (full kind-0 + latest notes + relay list) before
// anyone taps it, so the page opens complete instead of behind spinners.
// Queued with a small concurrency cap: likely tap-targets only, never a
// whole room's member list at once.
const prefetched = new Set();
const prefetchQueue = [];
let prefetching = 0;
const bannerOf = (pk) => {
const full = fullProfiles.get(pk), lp = profiles.get(pk) || {};
const b = (full && full.banner) || lp.banner;
return typeof b === 'string' && /^https?:\/\//i.test(b.trim()) ? b.trim() : null;
};
function warmBanner(pk) {
const b = bannerOf(pk);
if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint());
}
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
prefetched.add(pk);
prefetchQueue.push(pk);
drainPrefetch();
}
function drainPrefetch() {
while (prefetching < 2 && prefetchQueue.length) {
const pk = prefetchQueue.shift();
prefetching++;
Promise.allSettled([
// the cover photo as a column-sized copy, ready before the page opens
Promise.resolve(fetchFullProfile(pk)).then(() => warmBanner(pk)),
Promise.resolve(notesFor(pk)),
]).finally(() => { prefetching--; drainPrefetch(); });
}
}
// A profile opens at its top: keeping the feed's scroll offset landed the
// reader deep in that person's posts, which read as a thread. Back (a
// popstate) puts them where they were on the page they left.
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
let y = 0; try { y = window.scrollY || 0; } catch {}
pageReturn.push({ key, y });
if (pageReturn.length > 20) pageReturn.shift();
}
if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
const top = pageReturn[pageReturn.length - 1];
if (!top || pageKey() === top.key) return;
pageReturn.pop();
const y = top.y;
// twice: the page under it may paint its rows a frame late
requestAnimationFrame(() => { try { window.scrollTo(0, y); } catch {}
requestAnimationFrame(() => { try { if (Math.abs(window.scrollY - y) > 2) window.scrollTo(0, y); } catch {} }); });
});
function openProfile(pk) {
rememberPlace('p:' + pk);
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
// router otherwise keeps a thread above the profile, which is the order
// for the other direction (a note row tapped on a profile page).
ui.profOverThread = !!ui.noteThread;
ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null;
ui.profPeople = null; ui.npubShown = null;
render();
try { window.scrollTo(0, 0); } catch {}
fetchFullProfile(pk);
notesFor(pk);
}
// ---- profile notes: latest public posts & replies -----------------------
const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
// that e-tag it — NIP-57 kind 9735 (the recipient's LNURL server signs one
// once the invoice is paid) and coinos' own Ark zap receipt (kind 9737,
// the zapper publishes it beside the mailbox delivery). Summed per id,
// deduped by receipt, painted as a little bolt + sats. A chat message is a
// rumor with an id like any event, so a zap aimed at it e-tags that id —
// the receipt reveals nothing but the id itself.
const ZAP_KINDS = [9735, 9737];
// ...and, in the same REQ, what else happened to a note: likes (kind 7)
// and boosts (kind 6). One round trip for all three.
const NOTE_KINDS = [9735, 9737, 7, 6];
// Replies (kind 1, and NIP-22 comments) so the reply button can say how
// many — asked separately: relays cap a REQ's answer, and a busy post's
// replies must not crowd its likes and zaps out of it.
const REPLY_KINDS = [1, 1111];
const replyIds = new Map(); // note id -> Map(reply id -> author pubkey)
const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set })
// Resolve from this reaction's own tag: packs can reuse the same shortcode
// for different pictures, and an unrelated event must not change its face.
const postReactNode = ({ emoji, url }) => url ? reactImg(shortcodeOf(emoji), url) : emoji;
const boosts = new Map(); // note id -> Set
const noteCountsReady = new Set(); // a relay batch is complete for this note
const seenNoteEv = new Set(); // event ids already counted
const myReactEv = new Map(); // note id -> the id of OUR reaction, so it can be withdrawn
const zapTotals = new Map(); // id -> { sats, seen: Set, mine }
const zapWho = new Map(); // id -> Map(receipt id -> { pk, sats, ts, text }) — the tally, by person
const zapAsked = new Set();
let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = [];
// Scrolling brings new ids every few hundred ms; asking (two queries) and
// swapping the live sub (CLOSE + REQ) per batch put ~4 REQs a second on
// every zap relay. Flush at most every 2 s, swap the live sub at most
// every 10 s — the tallies still land within a couple of seconds.
let zapLastFlush = 0, zapLiveAt = 0, zapLiveTimer = null;
const ZAP_FLUSH_MS = 2000, ZAP_LIVE_MS = 10_000;
const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])];
function noteCountSnapshot(id) {
const rm = reacts.get(id);
return { likes: rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0,
boosts: visiblePks(boosts.get(id) || new Set()).length,
sats: zapTotals.get(id)?.sats || 0 };
}
function refreshThreadCount(id, persist = true) {
if (!noteCountsReady.has(id)) return;
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
if (!c?.root || !(c.root.id === id || c.replies.some((e) => e.id === id))) return;
const live = noteCountSnapshot(id), old = c.counts[id];
// Relay answers can be incomplete. A short/empty answer must not erase
// numbers that were already visible when this conversation opened.
c.counts[id] = old ? { likes: Math.max(old.likes, live.likes), boosts: Math.max(old.boosts, live.boosts),
sats: Math.max(old.sats, live.sats) } : live;
if (persist) persistThread(c);
}
const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
function receiptSats(ev) {
if (ev.kind === 9737) {
const net = tagOf(ev, 'network');
if (net && net !== getNetwork()) return 0;
return Math.max(0, parseInt(tagOf(ev, 'amount'), 10) || 0);
}
const b11 = tagOf(ev, 'bolt11');
if (b11) { try { const d = decodeBolt11(b11); if (d && d.amountSat) return d.amountSat; } catch {} }
// no readable invoice: the zap request inside says what was asked for
try {
const req = JSON.parse(tagOf(ev, 'description') || 'null');
return Math.floor((parseInt(tagOf(req, 'amount'), 10) || 0) / 1000);
} catch { return 0; }
}
// The note the zapper typed with it, if any (it rides inside the zap
// request the receipt carries).
function zapText(ev) {
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 ''; }
}
function zapperOf(ev) {
if (ev.kind === 9737) {
// Signed by the wallet key; the sender tag names the person behind it.
// Older receipts of ours have no such tag — read them as us all the same.
const sender = tagOf(ev, 'P') || ev.pubkey;
const id = hook('nostrLoginIdentity');
return id && wallet.nostr && sender === wallet.nostr.pk ? id.pubkey : sender;
}
if (tagOf(ev, 'P')) return tagOf(ev, 'P');
try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; }
}
// ---- edits and deletions of posts ----------------------------------------
// A deletion is NIP-09 (kind 5 naming the post); an edit is kind 1010 — the
// draft NIP-41 Amethyst reads and writes: the new text, e-tagging the post
// it replaces. Either counts only when the post's own author signed it (the
// pair is filed by id AND pubkey, since the post may not be here yet when
// its edit arrives). Remembered between sessions so a deleted post never
// flashes back in from a cached feed before the relays have answered.
const EDIT = 1010;
const EDIT_KINDS = [5, EDIT];
const POST_EDITS = 'postEdits';
let postEditsCache = null;
function postEdits() {
if (!postEditsCache) {
let s = null;
try { s = wallet.loadFeatureState(POST_EDITS, null); } catch {}
postEditsCache = { del: new Set((s && s.del) || []), ed: new Map(Object.entries((s && s.ed) || {})) };
}
return postEditsCache;
}
let postEditsTimer = null;
function savePostEdits() {
if (postEditsTimer) return;
postEditsTimer = setTimeout(() => {
postEditsTimer = null;
const p = postEdits();
try { wallet.saveFeatureState(POST_EDITS, { del: [...p.del].slice(-2000), ed: Object.fromEntries([...p.ed].slice(-1000)) }); } catch {}
}, 500);
}
const editKey = (id, pk) => id + ':' + pk;
const isDeleted = (ev) => !!(ev && ev.id && ev.pubkey && postEdits().del.has(editKey(ev.id, ev.pubkey)));
// the newest edit by the post's author: { at, c, tags } or undefined
const editOf = (ev) => (ev && ev.id && ev.pubkey && (ev.kind === 1 || ev.kind === 1111)
? postEdits().ed.get(editKey(ev.id, ev.pubkey)) : undefined);
// what a post says now, and the emoji its words may use
const noteText = (ev) => { const e = editOf(ev); return e ? e.c : ev.content; };
const noteEmojiTags = (ev) => { const e = editOf(ev); return emojiTagMap(e ? [...ev.tags, ...e.tags] : ev.tags); };
function notePostEdit(ev) {
const ids = (ev.tags || []).filter((x) => x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]);
if (!ids.length) return;
const p = postEdits();
let changed = false;
if (ev.kind === 5) {
for (const id of ids) {
const k = editKey(id, ev.pubkey);
if (!p.del.has(k)) { p.del.add(k); changed = true; }
}
} else if (ev.kind === EDIT) {
const k = editKey(ids[0], ev.pubkey);
const cur = p.ed.get(k);
if (!cur || cur.at < ev.created_at) {
p.ed.delete(k); // re-inserted last: the newest are the ones kept
p.ed.set(k, { at: ev.created_at, c: String(ev.content || ''), tags: (ev.tags || []).filter((x) => x[0] === 'emoji' || x[0] === 'imeta') });
changed = true;
}
}
if (changed) { savePostEdits(); scheduleRepaint(); }
}
// Sign as the post's author: the login identity, or the wallet's own key
// for what it posted before a nostr login was linked.
async function signAs(pk, partial) {
const id = await requireIdentity();
if (id.pubkey === pk) return id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
if (wallet.nostr && wallet.nostr.sk && wallet.nostr.pk === pk) return finalizeEvent(partial, wallet.nostr.sk);
throw new Error(t('postNotYours'));
}
const editRelays = async (ev) => [...new Set([
...(await noteRelaysFor(ev).catch(() => [])), ...(await notesRelays(ev.pubkey).catch(() => [])), ...wallet.nostrRelays(),
])];
async function deletePost(ev) {
const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000),
tags: [['e', ev.id], ['k', String(ev.kind)]] };
const del = await signAs(ev.pubkey, partial);
notePostEdit(del); // gone from every list at once
render();
const ok = await publishOn(await editRelays(ev), del);
if (!ok) throw new Error(t('msgSendFailed'));
}
async function editPost(ev, text) {
const emojis = [...emojiTagMap(ev.tags)].filter(([code]) => text.includes(':' + code + ':')).map(([code, url]) => ['emoji', code, url]);
const partial = { kind: EDIT, content: text, created_at: Math.max(Math.floor(Date.now() / 1000), (editOf(ev)?.at || 0) + 1),
tags: [['e', ev.id], ['p', ev.pubkey], ...emojis, ['alt', 'Edit of a post: ' + text.slice(0, 200)], CLIENT_TAG] };
const evt = await signAs(ev.pubkey, partial);
const prev = editOf(ev);
notePostEdit(evt);
render();
const ok = await publishOn(await editRelays(ev), evt);
if (!ok) {
const p = postEdits(), k = editKey(ev.id, ev.pubkey);
if (prev) p.ed.set(k, prev); else p.ed.delete(k);
savePostEdits(); render();
throw new Error(t('msgSendFailed'));
}
}
// ---- translation ----------------------------------------------------------
// A post in a language other than the app's is shown in the app's, the way
// Amethyst does it: "Translated from Spanish · Show original". The
// browser's own on-device translator does it when it has the language pair
// ready (desktop Chrome); otherwise the registrar's LibreTranslate, cached
// there by text. Automatic for posts we can tell are foreign (a Settings
// switch); Translate in ⋯ for the ones the guess misses.
const TRANSLATE_API = 'https://names.coinos.io/translate';
const TRANSLATE_KEEP = 300;
const AUTO_TRANSLATE_KEY = 'coinos-auto-translate';
const autoTranslateOn = () => { try { return localStorage.getItem(AUTO_TRANSLATE_KEY) !== '0'; } catch { return true; } };
const translations = new Map(); // key -> { text, source } (done) — remembered
const translating = new Map(); // key -> 'loading' | 'fail'
const transOriginal = new Set(); // keys the reader flipped back to the original
const transAsked = new Set(); // keys the reader asked for by hand (shown even with auto off)
const loadTranslations = () => mergeStored(translations, 'coinos-translations');
loadTranslations();
let transFlush = null;
const saveTranslations = () => {
if (transFlush) return;
transFlush = setTimeout(() => {
transFlush = null;
try { cacheStorage.setItem('coinos-translations', JSON.stringify([...translations].slice(-TRANSLATE_KEEP))); } catch {}
}, 1000);
};
// LibreTranslate has no Swahili, Marathi, Tamil or Telugu models: readers
// in those get no offer rather than one that always fails
const TRANSLATE_NO = new Set(['sw', 'mr', 'ta', 'te']);
const canTranslateTo = (lang) => !!lang && !TRANSLATE_NO.has(lang);
const transKey = (ev) => ev.id + '|' + getLang() + '|' + (editOf(ev)?.at || 0);
const translatable = (ev) => !!ev && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT);
// foreign as far as the cheap guess can tell (a script, or a clear other
// language); unknown is never translated unasked
// A post that is only hashtags is judged by them: its tags in a script
// the reader can't read are what wants translating (as glosses).
const looksForeign = (ev) => {
if (!translatable(ev) || !canTranslateTo(getLang())) return false;
const text = noteText(ev), { prose, tags } = transParts(text, getLang());
return prose.length ? !inLanguage(text, getLang()) : tags.length > 0;
};
const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } };
// the translation to SHOW for this post now, or null
function shownTranslation(ev) {
if (!translatable(ev)) return null;
const k = transKey(ev);
if (transOriginal.has(k)) return null;
const tr = translations.get(k);
if (!tr) return null;
return transAsked.has(k) || (autoTranslateOn() && looksForeign(ev)) ? tr : null;
}
const browserTranslators = new Map(); // 'es>en' -> Promise(translator | null)
async function browserTranslate(text, from, to) {
const T = typeof self !== 'undefined' ? self.Translator : null;
if (!T || !from || from === to) return null;
const pair = from + '>' + to;
if (!browserTranslators.has(pair)) {
browserTranslators.set(pair, (async () => {
// only a pair already on the device: a download needs a user gesture
// and minutes; the registrar answers in a second meanwhile
if ((await T.availability({ sourceLanguage: from, targetLanguage: to })) !== 'available') return null;
return T.create({ sourceLanguage: from, targetLanguage: to });
})().catch(() => null));
}
const tr = await browserTranslators.get(pair);
if (!tr) return null;
return translatePieces(text, (qs) => Promise.all(qs.map((q) => tr.translate(q))), to);
}
async function translateNote(ev, { asked = false } = {}) {
const k = transKey(ev);
if (asked) { transAsked.add(k); transOriginal.delete(k); }
if (translations.has(k) || translating.get(k) === 'loading') { scheduleRepaint(); return; }
if (!asked && translating.get(k) === 'fail') return;
translating.set(k, 'loading');
scheduleRepaint();
const text = noteText(ev);
const to = getLang();
try {
let out = null;
const guess = guessLang(text);
try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {}
if (!out) {
const r = await fetch(TRANSLATE_API, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text, target: to }), signal: AbortSignal.timeout(20_000),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'translate ' + r.status);
out = { text: String(j.text || ''), source: j.source || null, nothing: !!j.nothing };
}
// already in the reader's language after all: nothing to show, and
// nothing to offer again
if (!out.text || out.source === to || out.text.trim() === text.trim()) {
translating.set(k, 'same');
if (asked) toast(t(out.nothing ? 'translateNothing' : 'translateSame'));
} else {
translations.set(k, out);
translating.delete(k);
saveTranslations();
}
} catch {
translating.set(k, 'fail');
if (asked) toast(t('translateFailed'));
}
scheduleRepaint();
}
// Under the post's words: where it was translated from, and the way back —
// or, for a post that reads foreign with auto off, the offer.
function translateLine(ev) {
if (!translatable(ev)) return null;
const k = transKey(ev);
const tr = shownTranslation(ev);
const state = translating.get(k);
const btn = (label, onClick) => h('button', { type: 'button', class: 'note-translate-btn', onClick: (e) => { e.stopPropagation(); onClick(); } }, label);
if (tr) {
return h('div', { class: 'note-translate small faint' },
h('span', {}, tr.source ? t('translatedFrom', { lang: langName(tr.source) }) : t('translated')), ' · ',
btn(t('translateShowOriginal'), () => { transOriginal.add(k); transAsked.delete(k); render(); }));
}
if (state === 'loading' && (transAsked.has(k) || (autoTranslateOn() && looksForeign(ev)))) {
return h('div', { class: 'note-translate small faint' }, t('translating'));
}
if (!looksForeign(ev) || state === 'same') return null;
if (autoTranslateOn() && !transOriginal.has(k) && state !== 'fail') { translateNote(ev); return null; }
return h('div', { class: 'note-translate small faint' }, btn(t('translateBtn'), () => translateNote(ev, { asked: true })));
}
// Where an event about a note goes. A zap receipt is money and has its own
// bookkeeping (see below); a like and a boost are just tallies of who.
function noteEvent(ev) {
if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; }
if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; }
if (ev.kind === 7 || ev.kind === 6) {
if (seenNoteEv.has(ev.id)) return;
seenNoteEv.add(ev.id);
// the LAST e tag is the note being reacted to (NIP-25)
const ids = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]).map((x) => x[1]);
const id = ids[ids.length - 1];
if (!id) return;
if (ev.kind === 6) {
if (!boosts.has(id)) boosts.set(id, new Set());
boosts.get(id).add(ev.pubkey);
} else {
// '+' and an empty content both mean a plain like
const code = shortcodeOf(ev.content);
const emoji = !ev.content || ev.content === '+' ? '\u2764\ufe0f' : code ? ev.content : ev.content.slice(0, 12);
const url = code ? emojiTagMap(ev.tags).get(code) || null : null;
const key = JSON.stringify([emoji, url]);
noteEmoji(ev);
if (!reacts.has(id)) reacts.set(id, new Map());
const m = reacts.get(id);
if (!m.has(key)) m.set(key, { emoji, url, who: new Set() });
m.get(key).who.add(ev.pubkey);
// ours, wherever it was sent from — so it can be taken back here
if (myPubkeys().includes(ev.pubkey)) myReactEv.set(id, ev.id);
}
refreshThreadCount(id);
scheduleRepaint();
return;
}
noteReceipt(ev);
}
// A reply counts under every post of the conversation it names: NIP-10's
// root and reply (or the bare positional e tags of older clients), NIP-22's
// E and e. A 'mention' e tag is a quote, not a reply.
function noteReply(ev) {
let changed = false;
for (const x of ev.tags || []) {
if (!x[1] || x[1] === ev.id) continue;
if (!(x[0] === 'e' || (ev.kind === 1111 && x[0] === 'E')) || x[3] === 'mention') continue;
let m = replyIds.get(x[1]);
if (!m) replyIds.set(x[1], m = new Map());
if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; }
}
if (changed) scheduleRepaint();
}
function replyCount(id) {
const m = replyIds.get(id);
if (!m) return 0;
let n = 0;
for (const pk of m.values()) if (!hiddenPk(pk)) n++;
return n;
}
function noteReceipt(ev) {
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
const from = zapperOf(ev);
let changed = false;
for (const x of ev.tags) {
if (x[0] !== 'e' || !x[1]) continue;
const cur = zapTotals.get(x[1]) || zapTotals.set(x[1], { sats: 0, seen: new Set(), mine: false }).get(x[1]);
if (cur.seen.has(ev.id)) continue;
cur.seen.add(ev.id);
cur.sats += sats;
if (from) {
if (!zapWho.has(x[1])) zapWho.set(x[1], new Map());
zapWho.get(x[1]).set(ev.id, { pk: from, sats, ts: ev.created_at, text: zapText(ev) });
}
// our own zap's receipt: the real thing has landed, so the optimistic
// amount the chip has been carrying since the tap steps aside
if (from && my.includes(from)) { cur.mine = true; voidPending(x[1], ev.created_at * 1000, sats); }
changed = true;
refreshThreadCount(x[1]);
}
if (changed) { saveZapTotals(); scheduleRepaint(); }
}
// Ask once per id (batched per paint), and keep ONE live subscription on
// the ids most recently on screen, so a zap landing while you watch —
// yours included — shows up without a reload.
function watchZaps(ids) {
let fresh = false;
for (const id of ids) if (id && !zapAsked.has(id)) { zapAsked.add(id); zapQueue.add(id); fresh = true; }
if (!fresh || zapTimer) return;
zapTimer = setTimeout(() => {
zapTimer = null;
zapLastFlush = Date.now();
const batch = [...zapQueue]; zapQueue = new Set();
const relays = zapRelays();
for (let i = 0; i < batch.length; i += 150) {
const slice = batch.slice(i, i + 150);
// replies, tallies and the author's edits in one REQ, not one each
queryManyOn(relays, [{ kinds: REPLY_KINDS, '#e': slice }, { kinds: NOTE_KINDS, '#e': slice }, { kinds: EDIT_KINDS, '#e': slice }], 4000)
.then((all) => {
for (const e of all) if (REPLY_KINDS.includes(e.kind)) noteReply(e); else noteEvent(e);
// the relays have spoken for these ids: their live tally is the
// truth now, remembered or not (a zap that was deleted has to be
// able to disappear)
for (const id of slice) zapSeeds().delete(id);
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
for (const id of slice) {
noteCountsReady.add(id);
if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false);
}
if (c?.root) persistThread(c);
saveZapTotals();
scheduleRepaint();
}).catch(() => {});
}
zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200);
if (!zapLiveTimer) zapLiveTimer = setTimeout(() => {
zapLiveTimer = null;
zapLiveAt = Date.now();
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
zapLiveUnsub = subscribeOn(zapRelays(), { kinds: [...NOTE_KINDS, ...REPLY_KINDS, ...EDIT_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent);
}, Math.max(0, zapLiveAt + ZAP_LIVE_MS - Date.now()));
}, Math.max(250, zapLastFlush + ZAP_FLUSH_MS - Date.now()));
}
// Tallies are asked of the relays again on every boot, and that takes
// seconds — long enough that a room full of zapped messages paints bare and
// fills in afterwards. So the totals themselves are remembered (just the
// numbers, never the receipts) and painted straight away; the moment the
// relays answer for a message, their word replaces it.
const ZAP_REMEMBER = 400; // tallies kept between sessions
let zapSeed = null;
function zapSeeds() {
if (!zapSeed) {
zapSeed = new Map();
try {
for (const [id, v] of Object.entries(st().zaps || {})) if (v && v.s) zapSeed.set(id, { sats: v.s, mine: !!v.m });
} catch {}
}
return zapSeed;
}
let zapSaveT = null;
function saveZapTotals() {
clearTimeout(zapSaveT);
zapSaveT = setTimeout(() => {
try {
const s2 = st();
// Update what's recently been on screen, and KEEP the rest: opening a
// DM thread must not forget what the community chat had learned.
const out = { ...(s2.zaps || {}) };
for (const id of zapRecent) {
const z = zapTotals.get(id) || zapSeeds().get(id);
if (z && z.sats) out[id] = z.mine ? { s: z.sats, m: 1 } : { s: z.sats };
else delete out[id]; // the relays answered, and it's zero
}
let n = Object.keys(out).length;
if (n > ZAP_REMEMBER) {
const recent = new Set(zapRecent);
for (const k of Object.keys(out)) { // insertion order: oldest first
if (n <= ZAP_REMEMBER) break;
if (!recent.has(k)) { delete out[k]; n--; }
}
}
s2.zaps = out;
save(s2);
} catch {}
}, 1500);
}
// After OUR zap the receipt trails the payment by seconds — ask again.
function recheckZap(id) {
for (const ms of [4000, 12000, 30000]) setTimeout(() => { zapAsked.delete(id); watchZaps([id]); }, ms);
}
const fmtSats = (n) => n < 1000 ? String(n)
: n < 10000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
: n < 1e6 ? Math.round(n / 1000) + 'k'
: (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
const BOLT_SVG = '';
// A tapped zap takes a few seconds to pay, and its public receipt trails
// the payment by seconds more — long enough that the tap looked ignored.
// So the chip appears the instant you tap, carrying your amount and
// pulsing while the payment is in flight; it goes solid when the payment
// lands and hands over to the real receipt when that arrives. A failure
// takes it straight back off.
const ZAP_FLIGHT_MS = 45_000; // nothing reported back: assume it's gone
const ZAP_PAID_MS = 5 * 60_000; // paid, but the receipt never showed
// id -> [{ sats, at, state }], oldest first: one entry per zap of ours on
// that id, so tapping again while the first is still in the air stacks a
// second zap on top instead of being refused. state is:
// flying — tapped, payment in the air: counted on top, chip pulses
// paid — the flow reported success: counted on top, chip solid
// void — one of OUR receipts has been counted into the real total for
// this zap, so its optimistic amount must not be added again.
// Kept rather than deleted, because the receipt usually beats
// the flow's own report: an ark zap publishes that receipt
// itself, and it returns over the live subscription before the
// send call resolves. Deleting would let the later settle
// resurrect the amount — which is how a 21-sat zap displayed 42.
// Reports and receipts name the id, never the tap, so each is matched to
// the oldest entry it can describe (same amount first). Two default-amount
// zaps are told apart by order alone, which is all the chip needs: it shows
// the sum, and pulses while any is in the air.
const zapPending = new Map();
const pendTtl = (p) => (p.state === 'flying' ? ZAP_FLIGHT_MS : ZAP_PAID_MS);
// Everything still alive for this id, and what the chip makes of it.
function pendingOf(id) {
const all = zapPending.get(id);
if (!all) return null;
const now = Date.now();
const list = all.filter((p) => now - p.at <= pendTtl(p));
if (!list.length) { zapPending.delete(id); return null; }
zapPending.set(id, list); // the array handed back is the one stored: splices land
let sats = 0, flying = false;
for (const p of list) if (p.state !== 'void') { sats += p.sats; flying = flying || p.state === 'flying'; }
return { sats, flying, list };
}
const pickPending = (list, sats, pred) =>
(sats && list.find((p) => pred(p) && p.sats === sats)) || list.find(pred) || null;
const dropPending = (id, list, p) => {
list.splice(list.indexOf(p), 1);
if (!list.length) zapPending.delete(id);
};
function markZapPending(id, sats, origin) {
if (!id || !sats) return;
const list = pendingOf(id)?.list || [];
zapPending.set(id, [...list, { sats, at: Date.now(), state: 'flying' }]);
animateZap(sats, origin, id, zapSoundOn());
render();
// repaint when the in-flight chip would expire, so a zap nobody ever
// reported on doesn't pulse forever
setTimeout(scheduleRepaint, ZAP_FLIGHT_MS + 200);
}
// A zap flow reporting back: paid (keep the amount, stop pulsing) or
// failed (the chip was never real — take it off).
function settleZap(id, ok, sats) {
if (!id) return;
const list = pendingOf(id)?.list || [];
if (!ok) {
const p = pickPending(list, sats, (x) => x.state === 'flying') || pickPending(list, sats, (x) => x.state === 'paid');
if (p) { dropPending(id, list, p); scheduleRepaint(); }
return;
}
const p = pickPending(list, sats, (x) => x.state === 'flying');
if (p) { p.state = 'paid'; p.at = Date.now(); scheduleRepaint(); return; }
// nothing in the air: the receipt already beat this report (its marker
// stays, so a repeated confirmation can't count it either), or a form
// zap paid without a tap — count that one until its receipt lands
if (pickPending(list, sats, (x) => x.state === 'void')) return;
zapPending.set(id, [...list, { sats: sats || 0, at: Date.now(), state: 'paid' }]);
scheduleRepaint();
}
// One of our own receipts has been counted for this id: the real total
// speaks for that zap now, so its optimistic amount stands down.
function voidPending(id, evMs, sats) {
const list = pendingOf(id)?.list || [];
// ...unless it's the receipt of an OLDER zap of ours arriving (the first
// paint fetches every receipt a message has). That one is already in the
// total and says nothing about the zaps currently in the air.
const fresh = (p) => p.state !== 'void' && !(evMs && evMs < p.at - 120_000);
const p = pickPending(list, sats, fresh);
if (p) { p.state = 'void'; p.at = Date.now(); return; }
if (evMs && evMs < Date.now() - 120_000) return; // old: no report is coming for it
zapPending.set(id, [...list, { sats: sats || 0, at: Date.now(), state: 'void' }]);
}
// The chip: a little bolt + the sats total. Absent until the first receipt
// — or until you zap it yourself, which is its own kind of receipt.
const zapSoundOn = () => (ctx.zapSound ? ctx.zapSound() : true);
function zapChip(id, { onClick, onHold, cls = '' } = {}) {
if (onClick && zapSoundOn()) warmZapSound();
const z = zapTotals.get(id) || zapSeeds().get(id);
const p = pendingOf(id);
const optimistic = p ? p.sats : 0;
const sats = (z ? z.sats : 0) + optimistic;
if (!sats) return null;
const flying = !!p && p.flying;
return h('span', {
class: 'zap-tally' + ((z && z.mine) || optimistic ? ' on' : '') + (flying ? ' flying' : '')
+ (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''),
title: flying ? t('zapSending') : t('zapTallyTitle', { n: sats.toLocaleString() }),
...(onClick ? holdable(onHold, (e) => { e.stopPropagation(); onClick(); }) : {}),
}, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(sats));
}
// Hold the ⚡ to change the one-tap amount instead of paying it. The
// press's state lives on the element, not in this closure: a background
// repaint swaps handlers mid-press, and the lift must still find the timer
// the press set, or a finger already gone would count as a hold.
const HOLD_MS = 500;
function holdable(onHold, onTap) {
const clear = (e) => { const el = e.currentTarget; if (el._hold) { clearTimeout(el._hold); el._hold = 0; } };
if (!onHold) return { onClick: onTap };
return {
onPointerdown: (e) => {
const el = e.currentTarget;
clear(e); el._held = false;
if (e.button) return; // right button has its own menu
el._hold = setTimeout(() => {
el._hold = 0; el._held = true;
try { navigator.vibrate?.(12); } catch {}
onHold(el);
}, HOLD_MS);
},
onPointerup: clear, onPointercancel: clear, onPointerleave: clear,
onContextmenu: (e) => { e.preventDefault(); },
onClick: (e) => { const el = e.currentTarget; if (el._held) { el._held = false; e.stopPropagation(); return; } onTap(e); },
};
}
// Zap a chat message or DM: same one-tap flow as a post (ark first,
// Lightning fallback, the amount remembered from the first time).
function zapMessage(pk, id, origin) {
zapNote(pk, { id }, origin);
recheckZap(id);
}
// The one-tap ⚡ beside someone else's bubble: zap without opening the
// message sheet first (tap pays the default, hold sets the amount).
function msgZapBtn(pk, id) {
if (!canZapPk(pk) || isMe(pk)) return null;
if (zapSoundOn()) warmZapSound();
return h('button', {
class: 'chat-zap', title: t('msgZap'), 'aria-label': t('msgZap'),
...holdable((el) => openZapSettings(pk, id, el.getBoundingClientRect()),
(e) => { e.stopPropagation(); zapMessage(pk, id, e.currentTarget.getBoundingClientRect()); }),
}, h('span', { style: 'display:flex', html: BOLT_SVG }));
}
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.
const relayListCache = new Map(); // pk -> Promise
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(() => []);
relayListCache.set(pk, p);
return p;
}
// 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.
const PROFILE_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr21.com', 'wss://offchain.pub'];
const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk)), ...PROFILE_ARCHIVE_RELAYS])];
// Posts of theirs we already hold. Tapping a face in the feed is the
// common way onto a profile, and the feed we just came from is full of
// that person's posts — an open thread holds their replies too. Painting
// those first means the page opens with real content instead of an empty
// column that fills in a beat later, and the relay fetch below replaces
// them the moment it answers.
function notesInHand(pk) {
const out = new Map();
for (const e of (feed && feed.notes) || []) if (e.pubkey === pk) out.set(e.id, e);
for (const c of threadCache.values()) {
if (c.root && c.root.pubkey === pk) out.set(c.root.id, c.root);
for (const e of c.replies || []) if (e.pubkey === pk) out.set(e.id, e);
}
return [...out.values()].sort((a, b) => b.created_at - a.created_at);
}
// Asked again when the page is reopened after this long: one bad answer
// (a relay down, the phone just back from sleep) must not freeze a
// profile at two posts for the rest of the session.
const NOTES_REFRESH_MS = 60_000;
// A profile's Posts / Replies tab (Posts unless chosen on this profile).
const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts');
// what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment
const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e));
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
if (!c.fetching && Date.now() - (c.fetchedAt || 0) > NOTES_REFRESH_MS) fetchNotes(pk, c);
return c;
}
// stored posts paint the page instantly; the relay fetch below freshens
const stored = pageCache().notes[pk];
const seed = stored ? stored.v : notesInHand(pk);
// Seeded from memory the status stays 'loading': these are posts we
// happen to have, not their page, so there is something to read while
// the real answer comes.
c = stored
? { status: 'ready', notes: stored.v }
: { status: 'loading', notes: seed };
notesCache.set(pk, c);
fetchNotes(pk, c);
return c;
}
function fetchNotes(pk, c) {
c.fetching = true;
(async () => {
const evs = await queryOn(await notesRelays(pk), { kinds: PROFILE_KINDS, authors: [pk], limit: 30 }, 4500);
// Merged with what's already on the page, never swapped for it: the
// relays answer unevenly (primal held 2 of Joe Nakamoto's posts, ours
// 18, the archives 30), and a thin answer used to erase a full page.
const byId = new Map(c.notes.map((e) => [e.id, e]));
let added = 0;
for (const e of evs || []) if (!byId.has(e.id)) { byId.set(e.id, e); added++; }
if (!added && c.notes.length) return;
const merged = newestArticles([...byId.values()]).sort((a, b) => b.created_at - a.created_at);
await notesReady(merged.slice(0, FEED_PAGE)); // the first screen arrives whole
c.notes = merged;
c.end = false;
persistPage('notes', pk, merged.slice(0, 20).map(slimNote)); // cache stays bounded
})().catch(() => {}).finally(() => {
c.status = 'ready';
c.fetching = false;
c.fetchedAt = Date.now();
if (ui.profilePk === pk) render();
});
}
// Infinite scroll: pull the next page of OLDER notes (until = oldest seen)
// when the profile page nears its bottom. Guarded so scroll spam costs
// nothing: one fetch in flight, and a short answer marks the end.
// Registered at CONSTRUCTION, not init(): the public no-wallet profile
// (a /username deep link over the unlock screen) scrolls too, and init
// only runs once a wallet opens.
let lastFeedY = 0, pillFrame = 0;
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed);
// back at the top by yourself: the new posts are under your eyes, so
// the notice has done its job (only the pill repaints — the rows are
// keyed and stay put, so nothing moves under a finger)
// Upward only: holding the page still as posts go in above scrolls it
// DOWN, and that must not count as having gone up to look at them.
const y = window.scrollY || 0, up = y < lastFeedY;
lastFeedY = y;
// Scrolling recounts the pill: posts you've gone up past drop off it,
// and once none are left above you the notice is done.
if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => {
pillFrame = 0;
if (!feed) return;
markReached(feed);
if (!feed.unseen) return;
const n = pillCount(feed);
if (!n) { feed.unseen = 0; render(); }
else if (n !== feed.pillN) render();
});
if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed);
if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
}
async function loadOlderNotes(pk) {
const c = notesCache.get(pk);
if (!c || c.status !== 'ready' || c.loadingMore || c.end) return;
const oldest = c.notes[c.notes.length - 1];
if (!oldest) { c.end = true; return; }
c.loadingMore = true;
render();
try {
const evs = await queryOn(await notesRelays(pk),
{ kinds: PROFILE_KINDS, authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
const seen = new Set(c.notes.map((e) => e.id));
const older = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at);
if (older.length) { await notesReady(older); c.notes = newestArticles([...c.notes, ...older]); }
else c.end = true; // the relays have nothing older
} catch {} finally {
c.loadingMore = false;
if (ui.profilePk === pk) render();
}
}
// ---- follows: the kind-3 contact list -------------------------------------
// Who you follow. Kept locally so the Follow button and the feed are right
// in the first frame, and refreshed from relays behind that. A contact list
// is a shared document — other clients keep relay hints in its content and
// petnames in its tags — so publishing preserves everything we didn't write
// and only ever adds or removes one p tag.
const FOLLOWS = 'follows';
let follows = null; // { set: Set, tags, content, at }
let followsAt = 0; // when we last asked the relays
let followsPub = false; // a publish is in flight
const mePk = () => (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostr && wallet.nostr.pk) || null;
const pTags = (tags) => (tags || []).filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || ''));
function followsNow() {
if (!follows) {
let s = null;
try { s = wallet.loadFeatureState(FOLLOWS, null); } catch {}
const tags = (s && s.tags) || [];
follows = { set: new Set(pTags(tags).map((x) => x[1])), tags, content: (s && s.c) || '', at: (s && s.at) || 0 };
}
return follows;
}
function saveFollows(f) {
follows = f;
try { wallet.saveFeatureState(FOLLOWS, { tags: f.tags, c: f.content, at: f.at }); } catch {}
}
const isFollowing = (pk) => followsNow().set.has(pk);
// Everywhere someone's follow list might be: our relays, the profile
// indexes, the archives, their own outbox relays, and Primal's cache (its
// copy is the signed event, checked here like any other). A kind 3 is
// REPLACEABLE: publishing one built on a list we failed to find wipes the
// real one everywhere it lands. 2026-10-02: an Amethyst user's 398 follows,
// kept only on her own relays, became our two starter follows the first
// time she signed in here. Returns the newest list (or null) and whether
// this key has any nostr life at all (a list or a profile).
async function deepContactList(pk) {
const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS,
...(await relaysOf(pk).catch(() => []))])];
const [relayEvs, primalEvs] = await Promise.all([
queryOn(relays, { kinds: [0, 3], authors: [pk] }, 7000).catch(() => []),
primalCache('contact_list', { pubkey: pk }, 7000)
.then((evs) => verifiedPrimal((evs || []).filter((e) => e.pubkey === pk), [0, 3])).catch(() => []),
]);
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 };
}
// Fetch the newest list from the relays. Older than what we hold is
// ignored: a relay that missed our last publish must not un-follow people.
// `deep` widens the search to everywhere above — required before
// publishing on top of a list we have never seen.
async function syncFollows({ force = false, deep = false } = {}) {
const me = mePk();
if (!me) return followsNow();
if (!force && Date.now() - followsAt < 10 * 60_000) return followsNow();
followsAt = Date.now();
try {
const evs = deep
? [(await deepContactList(me)).list].filter(Boolean)
: await queryOn(zapRelays(), { kinds: [3], authors: [me] }, 5000);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
const cur = followsNow();
if (newest && newest.created_at > cur.at) {
saveFollows({
set: new Set(pTags(newest.tags).map((x) => x[1])),
tags: newest.tags || [], content: newest.content || '', at: newest.created_at,
});
feedAuthorsChanged();
scheduleRepaint();
}
} catch { followsAt = 0; }
return followsNow();
}
// Follow or unfollow, on the freshest list the relays will give us — the
// local copy alone would quietly drop anyone another client added since.
// The button flips immediately and goes back if the publish fails.
// A brand-new identity starts with something to read: the people behind
// coinos, a Bitcoin feed, and one topic as an example of what a feed can
// be. Once per identity; a person who unfollows stays unfollowed.
const STARTER_FOLLOWS = [
'98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', // Adam
'72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb', // coinos
];
// The Bitcoin feed's follow pack (NIP-51 kind 39089): { pk, d, relays, title }.
// Until one is chosen there is no Bitcoin feed — a #bitcoin topic feed was
// too noisy to hand a newcomer.
const STARTER_BITCOIN_PACK = null;
async function seedNewIdentity({ onlyIfNoFollows = false } = {}) {
const s = st();
if (s.seeded) return;
s.seeded = Date.now();
const at = Date.now();
const feeds = [
STARTER_BITCOIN_PACK ? { id: 'starter-bitcoin', name: 'Bitcoin', follows: false, authors: [], packs: [STARTER_BITCOIN_PACK], topics: [], at } : null,
{ id: 'starter-gardenstr', name: '#gardenstr', follows: false, authors: [], packs: [], topics: ['gardenstr'], at },
].filter(Boolean);
for (const f of feeds) if (!s.feeds.some((x) => x.id === f.id) && !(s.feedsGone || {})[f.id]) s.feeds.push(f);
save(s);
if (onlyIfNoFollows) {
// "New" here means new to coinos, not new to nostr: anyone with a
// profile or a follow list anywhere already has a following, and a
// starter list would replace it. Only a key with no trace gets one.
const me = mePk();
if (!me) return;
let found;
try { found = await deepContactList(me); } catch { return; }
if (found.existing || followsNow().set.size) { syncFollows({ force: true, deep: true }).catch(() => {}); return; }
}
followMany(STARTER_FOLLOWS).catch(() => {});
}
// Follow several people in one kind-3 update (toggleFollow is one at a time
// and locks while it publishes).
async function followMany(pks) {
if (followsPub) return;
const id = await requireIdentity();
const want = pks.filter((pk) => pk && pk !== id.pubkey);
if (!want.length) return;
followsPub = true;
const before = followsNow();
try {
// never seen this person's list: look everywhere before writing one
await syncFollows({ force: true, deep: !before.at }).catch(() => {});
const fetched = followsNow();
const base = fetched.at > before.at ? fetched : before;
const add = want.filter((pk) => !base.set.has(pk));
if (!add.length) return;
const tags = [...base.tags, ...add.map((pk) => ['p', pk])];
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
const partial = { kind: 3, content: base.content || '', created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
saveFollows({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
feedAuthorsChanged();
publishOn(zapRelays(), evt).catch(() => {});
syncInbox({ force: true }).catch(() => {});
} finally {
followsPub = false;
render();
}
}
async function toggleFollow(pk) {
if (!pk || followsPub) return;
const id = await requireIdentity();
if (pk === id.pubkey) return;
followsPub = true;
const before = followsNow();
// paint the answer now, decide the real list a beat later
saveFollows({ ...before, set: new Set(before.set.has(pk) ? [...before.set].filter((x) => x !== pk) : [...before.set, pk]) });
render();
try {
// never seen this person's list: look everywhere before writing one
await syncFollows({ force: true, deep: !before.at });
// The list to publish is the relays' copy if it's genuinely newer than
// what we held, and otherwise what we held BEFORE the optimistic paint
// — reading our own paint back as fact made a follow publish a list
// without it, and a mute publish a list without the mute.
const fetched = followsNow();
const base = fetched.at > before.at ? fetched : before;
const had = base.set.has(pk);
const tags = had
? base.tags.filter((x) => !(x[0] === 'p' && x[1] === pk))
: [...base.tags, ['p', pk]];
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
const partial = { kind: 3, content: base.content || '', created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
saveFollows({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
feedAuthorsChanged();
syncInbox({ force: true }).catch(() => {}); // the SW's friend filter reads this list
toast(had ? t('feedUnfollowed') : t('feedFollowed'));
} catch (e) {
saveFollows(before);
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
} finally {
followsPub = false;
render();
}
}
// ---- outbox: where each person actually publishes -------------------------
// NIP-65. Asking our own three relays for everyone's posts only ever finds
// the people who happen to use them — the rest are invisible, and it looks
// like they stopped posting. So each author's own WRITE relays are read
// instead. Lists are fetched in batches and kept for a week; the authors are
// then covered greedily, so a feed of hundreds of people is a handful of
// sockets that between them reach everyone rather than one socket each.
const RELAY_LISTS = 'relayLists';
const RELAY_LIST_TTL = 7 * 24 * 3600_000;
// Sockets we'll open for one pass. Ten covered all but 66 of a real
// 862-follow list; twenty-five covered all but 26. Amethyst runs near a
// hundred — this is the same idea, kept to a number a phone can hold.
const OUTBOX_MAX = 24;
// Write relays taken from any one list. Three looks sufficient — everyone
// ends up covered — but covered is not the same as read: people publish to
// several relays and each holds a different slice. Measured over 250 real
// authors, of the 60 with a write list, 45 declared MORE than three, and
// seven of twenty-five sampled had posts living only on their fourth or
// later relay.
const PER_AUTHOR = 6;
let relayLists = null; // pk -> { r: [url], t }
function relayListsNow() {
if (!relayLists) {
relayLists = new Map();
try {
for (const [pk, v] of Object.entries(wallet.loadFeatureState(RELAY_LISTS, {}) || {}))
if (v && Date.now() - (v.t || 0) < RELAY_LIST_TTL) relayLists.set(pk, v);
} catch {}
}
return relayLists;
}
function saveRelayLists() {
try {
const out = {};
// the people we follow are the ones worth remembering; a page visit
// shouldn't push them out
const keep = new Set([...followsNow().set]);
let spare = 200 - keep.size;
for (const [pk, v] of relayListsNow()) {
if (keep.has(pk)) out[pk] = v;
else if (spare-- > 0) out[pk] = v;
}
wallet.saveFeatureState(RELAY_LISTS, out);
} catch {}
}
// A relay list is written by whoever owns it, and some of them are wrong:
// one in this wallet's follows has three URLs crammed into a single r tag,
// space-separated. Fed to the pool that throws, and every author covered by
// that relay quietly returns nothing. So each one has to parse as a URL,
// and anything else is dropped rather than trusted.
const okRelay = (u) => {
if (typeof u !== 'string' || /\s/.test(u) || u.length > 200) return false;
try { const p2 = new URL(u); return p2.protocol === 'wss:' && !!p2.host; } catch { return false; }
};
const writeRelaysIn = (ev) => [...new Set((ev.tags || [])
.filter((x) => x[0] === 'r' && x[2] !== 'read' && okRelay(x[1]))
.map((x) => String(x[1]).replace(/\/+$/, '')))].slice(0, PER_AUTHOR);
// One REQ per hundred authors, against our own relays — a relay list is
// 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) {
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 newest = new Map();
for (const ev of evs || []) {
const cur = newest.get(ev.pubkey);
if (!cur || ev.created_at > cur.created_at) newest.set(ev.pubkey, ev);
}
for (const pk of batch) {
const ev = newest.get(pk);
relayListsNow().set(pk, { r: ev ? writeRelaysIn(ev) : [], t: Date.now() });
}
}
saveRelayLists();
}
// The fewest relays that between them carry every author: take the relay
// covering the most people not yet covered, repeat.
//
// Our own relays are then asked about EVERYONE as well, not just the people
// with no list. Measured over eight busy accounts, each source holds posts
// the other misses — reading only the author's own relays found 7 to 40
// notes ours had never seen, and ours held plenty theirs didn't. The feed
// wants the union, so the outbox adds reach rather than replacing it.
function outboxPlan(authors) {
const lists = relayListsNow();
const byRelay = new Map();
const orphans = [];
for (const pk of authors) {
const r = (lists.get(pk) || {}).r || [];
if (!r.length) { orphans.push(pk); continue; }
for (const url of r) {
if (!byRelay.has(url)) byRelay.set(url, new Set());
byRelay.get(url).add(pk);
}
}
const orphaned = new Set(orphans);
const covered = authors.filter((pk) => !orphaned.has(pk)); // the rest are on our relays below
let left = new Set(covered);
const plan = [];
// Covering every author ONCE was the old stopping rule, and it was the
// wrong goal: it finished in nineteen sockets of a twenty-four socket
// budget and left posts unread on the relays it never opened. So the
// greedy runs again over whoever the unused relays still reach, until
// the budget is actually spent. Measured on the same 250 authors: 1303
// notes before, 1624 after — a quarter more, for sockets we were already
// willing to open.
for (;;) {
while (left.size && plan.length < OUTBOX_MAX) {
let best = null, bestN = 0;
for (const [url, set] of byRelay) {
let n = 0;
for (const pk of set) if (left.has(pk)) n++;
if (n > bestN) { bestN = n; best = url; }
}
if (!best) break;
// The socket is open either way, so it's asked about EVERYONE who
// writes there, not just the people it was picked to cover. Covering
// each author once read a five-relay author on nos.lol alone, and his
// posts that only reached damus and primal — both in the plan, both
// open — never showed, while Amethyst had them.
const take = [...byRelay.get(best)];
plan.push({ relays: [best], authors: take });
for (const pk of take) left.delete(pk);
byRelay.delete(best);
}
if (plan.length >= OUTBOX_MAX || !byRelay.size) break;
// everyone the relays we HAVEN'T opened can still reach
const again = new Set();
for (const set of byRelay.values()) for (const pk of set) again.add(pk);
if (!again.size) break;
left = again;
}
plan.push({ relays: zapRelays(), authors });
return plan;
}
// ---- the feed: posts from the people you follow ---------------------------
// Their kind-1 notes, newest first, replies left out — a reply belongs to
// its thread, and a timeline of half-conversations reads like eavesdropping.
// Cached events avoid waiting for relays; their presentation is prepared
// before the reading surface is shown.
const FEED_CACHE = 'feedNotes';
const FEED_LIMIT = 30;
const FEED_PAGE = 10; // posts on screen at once, grown as you scroll
const FEED_KEEP = 80; // memory target; retain rows already being read
const FEED_STORE = FEED_CACHE_POSTS; // ...and on disk
let feed = null; // the feed on screen: { id, status, notes, shown, end, loadingMore, at }
let feedUnsubs = [];
// ---- many feeds ----------------------------------------------------------
// "Following" is one feed among any number the user defines: a set of
// people (or everyone they follow), a set of topics (hashtags, any of
// them), or both at once — people AND topics, the way a nostr filter
// composes them. Definitions live in the synced messages state, so a feed
// made on the phone is on the laptop too; each feed keeps its own notes,
// clock and disk cache. A topic opened from search or a #tag in a post is
// a feed too, until saved just a session one.
const FOLLOWING = 'following';
// The feed last on screen, remembered per wallet on this device. It used to
// be one device-wide key, so a brand-new account opened on whatever the
// last one was reading (its #gardenstr starter feed has the same id in
// every account). Nothing remembered: Popular.
const FEED_LS = 'btc-wallet-feed';
const feedLsKey = () => FEED_LS + ':' + (wallet._cacheKey ? wallet._cacheKey() : '');
const storedFeedId = () => { try { return localStorage.getItem(feedLsKey()) || null; } catch { return null; } };
const feedStates = new Map(); // id -> state
const adhocFeeds = new Map(); // id -> definition, this session only
// the feed of your own you last had on (never a session one)
const homeFeedId = () => { const id = storedFeedId() || EVERYTHING; return feedDef(id) && !adhocFeeds.has(id) ? id : EVERYTHING; };
let curFeedId = storedFeedId() || 'all'; // EVERYTHING, declared just below (re-read per wallet in init)
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
const EVERYTHING = 'all';
// Amethyst's feed model, for the people who asked: everyone you follow plus
// everyone on your lists, and on top of that posts from anyone on a
// hashtag you follow — your topic feeds' tags and your NIP-51 interests
// (kind 10015). Not chips of their own: a custom feed ticks "everyone on
// my lists" (f.lists) and/or "hashtags I follow" (f.interests), and is
// expanded from your other feeds each time it's read, never stored so.
const INTERESTS_KIND = 10015;
const INTERESTS = 'interests'; // feature state: { tags, at } from your kind 10015
function listsUnion() {
const authors = new Set(), priv = new Set(), packs = new Map();
for (const f of st().feeds) {
for (const pk of f.authors || []) authors.add(pk);
for (const pk of f.priv || []) priv.add(pk);
for (const p of f.packs || []) packs.set(packKey(p), p);
}
return { authors: [...authors], priv: [...priv], packs: [...packs.values()] };
}
function followedTopics() {
const out = new Set();
try { for (const x of (wallet.loadFeatureState(INTERESTS, {}) || {}).tags || []) out.add(normTopic(x)); } catch {}
for (const f of st().feeds) {
if (f.follows || (f.authors || []).length || (f.packs || []).length || (f.priv || []).length) continue;
for (const x of f.topics || []) out.add(normTopic(x));
}
out.delete('');
return [...out].slice(0, 30);
}
const expands = (f) => !!f && (!!f.lists || !!f.interests);
function expandFeed(f) {
if (!expands(f) || f.all) return f;
const out = { ...f };
if (f.lists) {
const u = listsUnion();
out.authors = [...new Set([...(f.authors || []), ...u.authors])];
out.priv = [...new Set([...(f.priv || []), ...u.priv])];
const packs = new Map((f.packs || []).map((p) => [packKey(p), p]));
for (const p of u.packs) packs.set(packKey(p), p);
out.packs = [...packs.values()];
}
if (f.interests) {
const tags = followedTopics();
// nobody to read: the hashtags ARE the feed (posts from anyone on them)
const people = out.follows || (out.authors || []).length || (out.priv || []).length || (out.packs || []).length;
if (people) out.orTopics = tags;
else out.topics = [...new Set([...(f.topics || []), ...tags])];
}
return out;
}
const feedList = () => {
const br = st().builtinRelays || {};
return [
{ id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true, relays: br[FOLLOWING] || [] },
{ id: EVERYTHING, name: t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: true, builtin: true, relays: oldPopularDefault(br[EVERYTHING]) ? [] : br[EVERYTHING] || [] },
...st().feeds.map(expandFeed)];
};
function feedDef(id = curFeedId) {
if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null;
const own = st().feeds.find((f) => f.id === id);
return own ? expandFeed(own) : adhocFeeds.get(id) || null;
}
const normTopic = (x) => String(x || '').trim().replace(/^#/, '').toLowerCase();
// ---- follow packs (NIP-51 kind 39089, what following.space publishes) ---
// A feed can name packs instead of (or as well as) people: the pack's
// current members are its authors, read from the pack event itself, so
// a curator adding someone reaches your feed without you doing anything.
// Members are cached on disk so a cold boot paints the feed at once, and
// refreshed from the relays when a feed that uses them is opened.
const PACK_KIND = 39089;
const PACK_CACHE = 'followPacks';
const PACK_RELAYS = [...new Set([...PROFILE_RELAYS, 'wss://relay.nostr.band'])];
const packKey = (p) => p.pk + ':' + p.d + (p.kind && p.kind !== PACK_KIND ? ':' + p.kind : '');
let packMembers = null; // key -> { pks, title, at }
const packsNow = () => {
if (!packMembers) { try { packMembers = wallet.loadFeatureState(PACK_CACHE, {}) || {}; } catch { packMembers = {}; } }
return packMembers;
};
const packOf = (p) => packsNow()[packKey(p)] || null;
// A pasted following.space link (/d/?p=), a bare naddr, or
// nostr:naddr — the pack it names.
function parsePackLink(input) {
const s = String(input || '').trim();
const link = /\/d\/([^/?#\s]+)[^?\s]*\?(?:[^#\s]*&)?p=([0-9a-f]{64})/i.exec(s);
if (link) return { pk: link[2].toLowerCase(), d: decodeURIComponent(link[1]), relays: [] };
const m = /naddr1[a-z0-9]+/i.exec(s);
if (!m) return null;
const ref = parseNostrRef(m[0].toLowerCase());
if (!ref || ref.type !== 'addr') return null;
if (ref.kind === PACK_KIND) return { pk: ref.pk, d: ref.d, relays: ref.relays || [] };
// someone's follow set (kind 30000) is a pack by another name
if (ref.kind === LIST_KIND) return { pk: ref.pk, d: ref.d, relays: ref.relays || [], kind: LIST_KIND };
return null;
}
// Draftable (mutable.top) links name a pack by its id and only the first
// eight hex of its author: /draftable/d/?p=. The id is asked
// for and the author matched by that prefix.
const PACK_INDEX_RELAYS = () => [...new Set([...PACK_RELAYS, 'wss://relay.damus.io', 'wss://nos.lol', 'wss://nostr.oxtr.dev'])];
async function resolveShortPackLink(input) {
const m = /\/d\/([^/?#\s]+)[^?\s]*\?(?:[^#\s]*&)?p=([0-9a-f]{4,63})(?![0-9a-f])/i.exec(String(input || ''));
if (!m) return null;
const d = decodeURIComponent(m[1]), prefix = m[2].toLowerCase();
const evs = await queryOn(PACK_INDEX_RELAYS(), { kinds: [PACK_KIND], '#d': [d], limit: 50 }, 5000).catch(() => []);
const ev = evs.filter((e) => e.kind === PACK_KIND && e.pubkey.startsWith(prefix)).sort((a, b) => b.created_at - a.created_at)[0];
return ev ? { pk: ev.pubkey, d, relays: [] } : null;
}
// Every pack the wide relays hold, once per session: the way to find one
// by a few letters of its title — NIP-50 search only knows the packs the
// search relay happens to carry, and most live on damus / nos.lol.
let packIndex = null;
function loadPackIndex() {
if (packIndex) return packIndex;
packIndex = (async () => {
const relays = PACK_INDEX_RELAYS();
const all = await Promise.all(relays.map((r) => queryOn([r], { kinds: [PACK_KIND], limit: 500 }, 6000).catch(() => [])));
const newest = new Map();
for (const ev of all.flat()) {
const p = packFromEvent(ev);
if (!p.pks.length) continue;
const cur = newest.get(packKey(p));
if (!cur || p.at > cur.at) newest.set(packKey(p), { ...p, desc: (ev.tags.find((x) => x[0] === 'description') || [])[1] || '' });
}
return [...newest.values()];
})();
packIndex.catch(() => { packIndex = null; });
return packIndex;
}
const packFromEvent = (ev) => ({
pk: ev.pubkey, d: (ev.tags.find((x) => x[0] === 'd') || [])[1] || '',
title: (ev.tags.find((x) => x[0] === 'title') || [])[1] || '',
pks: [...new Set(ev.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/i.test(x[1] || '')).map((x) => x[1].toLowerCase()))],
at: ev.created_at,
});
const packFetching = new Map();
async function fetchPack(p) {
const key = packKey(p);
if (packFetching.has(key)) return packFetching.get(key);
const job = (async () => {
const relays = [...new Set([...(p.relays || []), ...PACK_RELAYS])];
const evs = await queryOn(relays, { kinds: [p.kind || PACK_KIND], authors: [p.pk], '#d': [p.d] }, 4500).catch(() => []);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
if (!newest) return packOf(p);
const got = packFromEvent(newest);
const cur = packOf(p);
if (cur && cur.at >= got.at) return cur;
packsNow()[key] = { pks: got.pks, title: got.title, at: got.at };
try { wallet.saveFeatureState(PACK_CACHE, packsNow()); } catch {}
return packsNow()[key];
})().finally(() => packFetching.delete(key));
packFetching.set(key, job);
return job;
}
// Make sure a feed's packs are read (or re-read: once an hour is plenty
// for a curated list), and rebuild the feed when its authors grew.
const PACK_TTL = 60 * 60_000;
const packRefreshed = new Map(); // key -> ms
function resolvePacks(def) {
const packs = (def && def.packs) || [];
if (!packs.length) return;
const due = packs.filter((p) => !packOf(p) || Date.now() - (packRefreshed.get(packKey(p)) || 0) > PACK_TTL);
if (!due.length) return;
for (const p of due) packRefreshed.set(packKey(p), Date.now());
Promise.all(due.map((p) => fetchPack(p).catch(() => null))).then(() => {
const c = feedStates.get(def.id);
if (!c) return;
c.at = 0;
if (c === feed) refreshFeed({ live: false }, c); else feedStates.delete(def.id);
scheduleRepaint();
});
}
// The packs' members, from cache; asks the relays on the side when stale.
function packAuthors(def) {
const out = new Set();
for (const p of (def && def.packs) || []) for (const pk of (packOf(p) || {}).pks || []) out.add(pk);
resolvePacks(def);
return out;
}
// 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);
// 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.
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'];
const normRelay = (u) => {
let x = String(u || '').trim();
if (!x) return null;
if (!/^wss?:\/\//i.test(x)) x = 'wss://' + x;
try { const url = new URL(x); if (!/^wss?:$/.test(url.protocol) || !url.hostname) return null; return url.href.replace(/\/$/, ''); } catch { return null; }
};
const feedRelays = (def) => (def && Array.isArray(def.relays) && def.relays.length ? def.relays : null);
// ---- curation for the public firehose --------------------------------
// Everything a relay carries is mostly noise: machine output, every
// language at once, posts nobody read. Popular keeps a post when it reads
// as prose in the reader's language and at least two people reacted,
// replied, reposted or zapped it — one batch ask per page, remembered.
// A brand-new post has no reactions yet (of 73 fresh English posts on the
// four relays, one had two), so Popular starts from the other end: the
// latest reactions, reposts and zaps, counted per post by distinct
// people, then the posts enough people touched. Older pages walk the
// reactions back in time. The prose and language tests run at the door.
const POPULAR_ASK = 600; // reactions per round
const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang) && !notForPublic(e)); };
async function popularPass(extra, merge, c, def) {
// Only the default public feed shares a snapshot. Custom relays and older
// pages keep their own queries. A static/offline install falls back quickly;
// the single file opened from a disk has no server to ask at all.
if (extra.until == null && !feedRelays(def) && /^https?:$/.test(location.protocol)) {
try {
const response = await fetch('/api/feed', { signal: AbortSignal.timeout(1500) });
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('No feed cache');
const data = await response.json();
if (data.version !== 1 || !Number.isFinite(data.generatedAt) || Date.now() - data.generatedAt > 86400_000
|| !Array.isArray(data.notes) || data.notes.length > 240 || !Array.isArray(data.profiles) || data.profiles.length > 240
|| !Number.isFinite(data.popUntil)) throw new Error('Invalid feed cache');
const events = [...data.notes, ...data.profiles];
// HTTP is another untrusted source: retain the relay signature checks.
const valid = await verifyEventsAsync(events);
if (c.stopped) return false;
const posts = data.notes.filter((e, i) => valid[i] && e.kind === 1);
const usable = curateFeed(posts).filter((e) => !isReply(e) && !hidden(e));
if (usable.length) {
warmProfiles();
for (let i = 0; i < data.profiles.length; i++) {
const e = data.profiles[i];
if (valid[data.notes.length + i] && e.kind === 0) applyProfile(e.pubkey, e);
}
c.popUntil = c.popUntil || data.popUntil;
const got = await mergeFeed(posts, { ...merge, immediate: true }, c);
if (got) scheduleRepaint();
return got; // already-known posts also count as a successful cache hit
}
} catch {} // relay path also covers snapshots with no posts in this language
}
const relays = feedRelaysOr(def, POPULAR_RELAYS);
const wide = [...new Set([...relays, ...NOTE_RELAYS])];
let until = extra.until != null && c.popUntil ? c.popUntil - 1 : undefined;
let got = false;
for (let round = 0; round < 3 && !c.stopped; round++) {
const re = await queryOn(wide, { kinds: [6, 7, 9735], limit: POPULAR_ASK, ...(until ? { until } : {}) }, 4500).catch(() => []);
if (!re.length) break;
const candidates = popularCandidates(re);
const oldest = Math.min(...re.map((r) => r.created_at));
c.popUntil = c.popUntil && until ? Math.min(c.popUntil, oldest) : (c.popUntil || oldest);
until = oldest - 1;
const ids = candidates.ids;
if (!ids.length) continue;
const asks = [];
for (let i = 0; i < ids.length; i += 40) asks.push(queryFeed(wide, { ids: ids.slice(i, i + 40) }, merge, c, 4500, candidates.qualifies)
.then((ok) => { if (ok) got = true; }));
await Promise.all(asks);
if (c.stopped) return got;
if (got && round >= 0 && (c.notes.length >= FEED_PAGE || extra.until != null)) break;
}
return got;
}
const feedRelaysOr = (def, usual) => feedRelays(def) || usual;
const feedCacheKey = (id) => (id === FOLLOWING ? FEED_CACHE : FEED_CACHE + ':' + id);
const feedDisk = () => typeof wallet.featureStateKey === 'function'
? createFeedCache(localStorage, wallet.featureStateKey(FEED_CACHE)) : null;
const saveFeedCache = (c) => {
if (adhocFeeds.has(c.id) || isVisitor()) return; // a session feed leaves nothing behind
try {
const notes = c.notes.slice(0, FEED_STORE);
const disk = feedDisk();
if (disk) disk.save(c.id, notes);
else wallet.saveFeatureState(feedCacheKey(c.id), notes);
} catch {}
};
// posts on a topic come from the big public relays as well as ours: a
// hashtag has no author whose outbox we could read
// A topic has no authors whose relays to read, and the big relays prune:
// #gardenstr was 5 posts across damus/primal/nos.lol/ours, 774 once these
// archive-keeping relays were asked too (ditto 500+, nostr.mom 448,
// oxtr 247, nostrplebs 175, nostr21 102, offchain 74 — 2026-09-30).
const TOPIC_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr.mom', 'wss://nostr.oxtr.dev',
'wss://relay.nostrplebs.com', 'wss://nostr21.com', 'wss://offchain.pub'];
const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', ...TOPIC_ARCHIVE_RELAYS])];
// ---- lists (NIP-51 follow sets, kind 30000) ------------------------------
// A feed of hand-picked people IS a nostr list. Saved here it is published
// under your key as a follow set, so Coracle, Nostria, Amethyst and the
// rest show the same list; a list made in one of them turns up here as a
// feed. The relays' copy wins when it is newer than ours (edited over
// there); ours goes out on top of whatever they hold, carrying the
// encrypted content other clients keep their private members in — those
// are read (nip44 to yourself) and followed, never rewritten.
const LIST_KIND = 30000;
const LIST_SYNC_MS = 10 * 60_000;
let listsAt = 0;
let listsJob = null;
const dTagOf = (ev) => (ev.tags.find((x) => x[0] === 'd') || [])[1];
const listTopics = (ev) => [...new Set(ev.tags.filter((x) => x[0] === 't' && x[1]).map((x) => normTopic(x[1])).filter(Boolean))].slice(0, 20);
async function privateMembers(ev) {
if (!ev.content) return [];
const c = (await selfCryptors()).find((x) => x.pk === ev.pubkey);
if (!c) return [];
try {
const tags = JSON.parse(await c.dec(ev.content));
return Array.isArray(tags) ? [...new Set(pTags(tags).map((x) => x[1].toLowerCase()))] : [];
} catch { return []; } // nip04 from an older client, or not ours to read
}
// Your lists as the relays hold them, folded into the feeds: a new list
// becomes a feed, a newer copy of a known one brings its members over.
function syncFollowSets({ force = false } = {}) {
const me = mePk();
if (!me) return Promise.resolve();
if (listsJob) return listsJob;
if (!force && Date.now() - listsAt < LIST_SYNC_MS) return Promise.resolve();
listsAt = Date.now();
listsJob = (async () => {
const mine = await Promise.resolve(relaysOf(me)).catch(() => []);
const relays = [...new Set([...zapRelays(), ...(mine || []), ...PACK_RELAYS])];
let evs;
try { evs = await queryOn(relays, { kinds: [LIST_KIND, INTERESTS_KIND], authors: [me] }, 5000); } catch { listsAt = 0; return; }
// your followed hashtags (NIP-51 interests): Everything reads them
const interest = (evs || []).filter((e) => e.kind === INTERESTS_KIND).sort((a, b) => b.created_at - a.created_at)[0];
if (interest) {
const prev = wallet.loadFeatureState(INTERESTS, {}) || {};
if (!(prev.at >= interest.created_at)) {
wallet.saveFeatureState(INTERESTS, { tags: interest.tags.filter((x) => x[0] === 't' && x[1]).map((x) => normTopic(x[1])), at: interest.created_at });
for (const f of st().feeds) if (f.interests && feedStates.get(f.id) !== feed) feedStates.delete(f.id);
scheduleRepaint();
}
}
evs = (evs || []).filter((e) => e.kind === LIST_KIND);
const newest = new Map();
for (const ev of evs || []) {
const d = dTagOf(ev);
if (d == null) continue;
const cur = newest.get(d);
if (!cur || ev.created_at > cur.created_at) newest.set(d, ev);
}
const s = st();
let changed = false;
for (const [d, ev] of newest) {
const gone = (s.listsGone || {})[d];
if (gone && ev.created_at <= gone) continue; // deleted here; a relay still serving it
// a feed synced from another device before its list link did is that list
const cur = s.feeds.find((f) => f.d === d) || s.feeds.find((f) => !f.d && f.id === d);
if (cur && (cur.listAt || 0) >= ev.created_at) continue;
const got = packFromEvent(ev);
const priv = await privateMembers(ev);
const pub = got.pks.filter((pk) => !priv.includes(pk));
const topics = listTopics(ev);
if (!cur && !pub.length && !priv.length && !topics.length) continue; // an empty list is not a feed
const patch = { name: got.title || (cur && cur.name) || d, authors: pub.slice(0, FEED_AUTHORS_MAX), priv, topics, d, listAt: ev.created_at, listContent: ev.content || '', at: Date.now() };
if (cur) Object.assign(cur, patch);
else s.feeds.push({ id: 'list:' + d, follows: false, packs: [], ...patch });
feedStates.delete(cur ? cur.id : 'list:' + d); // its query changed
changed = true;
}
if (changed) { save(s); feedsChanged(); scheduleRepaint(); }
})().finally(() => { listsJob = null; });
return listsJob;
}
// Publish a feed as a follow set: its people as public p tags (a list's
// private members stay in the content, untouched), its topics as t tags.
async function publishFollowSet(def) {
const id = await requireIdentity();
const d = def.d || def.id;
const tags = [['d', d], ['title', def.name || d],
...(def.authors || []).map((pk) => ['p', pk]),
...feedTopics(def).map((x) => ['t', x])];
const created_at = Math.max(Math.floor(Date.now() / 1000), (def.listAt || 0) + 1);
const partial = { kind: LIST_KIND, content: def.listContent || '', created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
const s = st();
const cur = s.feeds.find((f) => f.id === def.id);
if (cur) { cur.d = d; cur.listAt = created_at; cur.at = Date.now(); save(s); feedsChanged(); }
return d;
}
// NIP-09: ask the relays to drop the list — and remember that we did, so
// a relay that keeps it anyway doesn't bring it back as a feed.
async function deleteFollowSet(def) {
if (!def || !def.d) return;
const s = st();
const created_at = Math.floor(Date.now() / 1000);
const gone = Object.entries(s.listsGone || {}).sort((a, b) => b[1] - a[1]).slice(0, 99);
s.listsGone = Object.fromEntries([[def.d, created_at], ...gone]);
save(s);
const id = await requireIdentity();
const partial = { kind: 5, content: '', created_at, tags: [['a', LIST_KIND + ':' + id.pubkey + ':' + def.d], ['k', String(LIST_KIND)]] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
await publishOn(zapRelays(), evt);
}
// Someone's lists — follow sets and packs — for their profile page; the
// members are cached as packs, so a tapped list opens as a feed at once.
const profileLists = new Map(); // pk -> { at, status, items: [{ pk, d, kind, title, n }] }
const LISTS_TTL = 60 * 60_000;
function listsFor(pk) {
let c = profileLists.get(pk);
if (c && Date.now() - c.at < LISTS_TTL) return c;
c = { at: Date.now(), status: 'loading', items: c ? c.items : [] };
profileLists.set(pk, c);
(async () => {
const own = await Promise.resolve(relaysOf(pk)).catch(() => []);
const relays = [...new Set([...PACK_RELAYS, ...(own || [])])];
const evs = await queryOn(relays, { kinds: [LIST_KIND, PACK_KIND], authors: [pk] }, 5000).catch(() => []);
const newest = new Map();
for (const ev of evs || []) {
const p = { ...packFromEvent(ev), kind: ev.kind };
const key = packKey(p);
const cur = newest.get(key);
if (!cur || p.at > cur.at) newest.set(key, p);
}
const items = [...newest.values()].filter((p) => p.pks.length).sort((a, b) => b.at - a.at);
for (const p of items) {
const cur = packOf(p);
if (!cur || cur.at < p.at) packsNow()[packKey(p)] = { pks: p.pks, title: p.title, at: p.at };
}
if (items.length) { try { wallet.saveFeatureState(PACK_CACHE, packsNow()); } catch {} }
c.items = items.map((p) => ({ pk: p.pk, d: p.d, kind: p.kind, title: p.title || p.d, n: p.pks.length }));
c.status = 'ready';
scheduleRepaint();
})().catch(() => { c.status = 'ready'; });
return c;
}
// Open someone's list as a feed: yours is the saved feed it already is;
// anyone else's a session feed built on it as a pack, savable from there.
function openListFeed(p) {
const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null;
const id = own ? own.id : 'list:' + packKey(p);
if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] });
ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
// Which of your feeds someone is in — and a tap to put them in another.
function listPickSheet() {
if (!ui.listPick) return null;
const pk = ui.listPick;
const close = () => { ui.listPick = null; render(); };
const feeds = st().feeds.filter((f) => !f.of); // a timeline is someone, not a list
const toggle = (f) => {
const s = st();
const def = s.feeds.find((x) => x.id === f.id);
if (!def || (def.priv || []).includes(pk)) return;
const had = (def.authors || []).includes(pk);
def.authors = had ? def.authors.filter((x) => x !== pk) : [...(def.authors || []), pk].slice(0, FEED_AUTHORS_MAX);
def.at = Date.now();
save(s);
feedsChanged();
feedStates.delete(def.id);
render();
if (def.d) publishFollowSet(def).catch((e) => { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); });
};
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col confirm-pop list-pick', style: 'gap:8px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
avatar(pk, 'chat-avatar', false),
h('div', { class: 'col', style: 'min-width:0' },
h('div', { class: 'chat-name' }, displayName(pk)),
h('div', { class: 'small muted' }, t('listPickTitle')))),
feeds.length ? null : h('div', { class: 'small faint' }, t('listPickEmpty')),
...feeds.map((f) => {
const on = (f.authors || []).includes(pk) || (f.priv || []).includes(pk);
return h('button', {
class: 'btn-block list-pick-row' + (on ? ' on' : ''), style: 'text-align:left', type: 'button',
'aria-pressed': on ? 'true' : 'false',
onClick: () => toggle(f),
}, (on ? '\u2611' : '\u2610') + ' ' + f.name);
}),
h('button', {
class: 'btn-block', style: 'text-align:left', type: 'button',
onClick: () => {
ui.listPick = null;
// the editor lives on the feed screen; the profile gives way to it
ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
render();
},
}, '+ ' + t('listNew')),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
// A reply, as against a post that merely POINTS at another one. NIP-10
// marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and
// NIP-18 quotes carry a q tag instead. Treating every e tag as a reply hid
// quote-posts from the feed — the thing Amethyst shows as a card with the
// quoted note inside it.
const isReply = (ev) => {
const es = (ev.tags || []).filter((x) => x[0] === 'e');
if (!es.length) return false;
if (es.some((x) => x[3] === 'root' || x[3] === 'reply')) return true;
if (es.every((x) => x[3] === 'mention')) return false;
// unmarked (the deprecated positional form): a q tag means it's a quote,
// otherwise assume the older convention, where e meant reply
return !(ev.tags || []).some((x) => x[0] === 'q');
};
// Everyone you follow. This was capped at 500, which on an 862-follow list
// meant 362 people were silently missing from the feed — 164 of the 166
// posts a wider net found in one six-hour window were theirs.
const FEED_AUTHORS_MAX = 2000;
const REQ_AUTHORS = 400; // authors per REQ, so one filter stays a sane size
const feedAuthors = (def = feedDef()) => {
if (!def) return [];
const set = new Set(def.follows ? followsNow().set : []);
for (const pk of def.authors || []) if (pk) set.add(pk);
for (const pk of def.priv || []) if (pk) set.add(pk); // a list's private members
for (const pk of packAuthors(def)) set.add(pk);
if (def.of) for (const pk of ofFollows.get(def.of)?.authors || []) set.add(pk);
return [...set].slice(0, FEED_AUTHORS_MAX);
};
// Someone's timeline is their follow list, read from the relays when the
// feed is opened (and again after ten minutes) — never copied into the
// feed itself, so a saved one stays small in sync and follows them.
const ofFollows = new Map(); // pk -> { authors, at, loading }
const ofLoaded = (def) => !!ofFollows.get(def.of)?.at;
function loadOfFollows(def) {
const pk = def.of;
let f = ofFollows.get(pk);
if (!f) { f = { authors: [], at: 0, loading: false }; ofFollows.set(pk, f); }
if (f.loading || (f.at && Date.now() - f.at < 600_000)) return;
f.loading = true;
liveProfileOf(pk); // their name and face for the title
// Their relays and Primal's cache at once: a list kept only on their own
// outbox relays reads as nobody followed. Primal's copy is the signed
// event itself, with the follows' profiles beside it; both are checked
// before anything is believed.
Promise.all([
queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000).catch(() => []),
primalCache('contact_list', { pubkey: pk }, 6000).then((evs) => verifiedPrimal(evs, [0, 3])).catch(() => []),
])
.then(([relayEvs, primalEvs]) => {
const evs = [...(relayEvs || []), ...primalEvs.filter((e) => e.kind === 3 && e.pubkey === pk)];
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
const had = f.at;
if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
f.at = Date.now(); f.loading = false;
// whatever was built before the list was known
for (const id of [...feedStates.keys()]) {
if (feedDef(id)?.of !== pk || id === curFeedId) continue;
dropFeedState(id);
}
if (feedDef(curFeedId)?.of === pk) { dropFeedState(curFeedId); selectFeed(curFeedId); }
scheduleRepaint();
});
}
// ---- follows & followers -------------------------------------------------
// Whom someone follows is their own kind 3 (ofFollows, above). Who follows
// THEM is everyone else's kind 3 — no relay will count that — so the
// number and the list come from Primal's cache, which indexes the network
// for exactly this. Only pubkeys are taken from it: names and faces still
// come from the relays, as everywhere else.
const socialCounts = new Map(); // pk -> { follows, followers, at, loading }
function loadSocial(pk) {
const cur = socialCounts.get(pk);
if (cur && (cur.loading || Date.now() - (cur.at || 0) < 600_000)) return cur;
socialCounts.set(pk, { ...(cur || {}), loading: true });
primalCache('user_profile', { pubkey: pk }, 6000).then((evs) => {
let c = {};
try { c = JSON.parse(evs.find((e) => e.kind === 10000105)?.content || '{}'); } catch {}
const num = (n) => (Number.isFinite(n) && n >= 0 ? n : null);
socialCounts.set(pk, { follows: num(c.follows_count), followers: num(c.followers_count), at: Date.now() });
}).catch(() => socialCounts.set(pk, { ...(cur || {}), at: Date.now() })).finally(scheduleRepaint);
return socialCounts.get(pk);
}
// Primal's events, signature-checked off the main thread; the profiles
// among them fill names and faces the relays didn't have.
async function verifiedPrimal(evs, kinds) {
const want = (evs || []).filter((e) => kinds.includes(e.kind) && e.sig && /^[0-9a-f]{64}$/.test(e.pubkey || ''));
const ok = await verifyEventsAsync(want);
const good = want.filter((e, i) => ok[i]);
let applied = 0;
for (const e of good) if (e.kind === 0 && applyProfile(e.pubkey, e)) applied++;
if (applied) scheduleRepaint();
return good;
}
const followersOf = new Map(); // pk -> { pks, at, loading }
function loadFollowers(pk) {
const cur = followersOf.get(pk);
if (cur && (cur.loading || Date.now() - (cur.at || 0) < 600_000)) return cur;
followersOf.set(pk, { pks: cur?.pks || [], loading: true });
const ask = (limit) => primalCache('user_followers', { pubkey: pk, limit }, 8000)
.then((evs) => verifiedPrimal(evs, [0])).then((evs) => [...new Set(evs.map((e) => e.pubkey))]);
// a first screenful fast, then the long list behind it
ask(60).then((pks) => {
const now = followersOf.get(pk);
if (now?.loading && pks.length > (now.pks || []).length) { followersOf.set(pk, { pks, loading: true }); scheduleRepaint(); }
}).catch(() => {});
ask(500).then((pks) => {
const now = followersOf.get(pk);
// the people already on screen keep their places; the rest go after
const shown = now?.pks || [];
const seen = new Set(shown);
followersOf.set(pk, { pks: [...shown, ...pks.filter((x) => !seen.has(x))], at: Date.now() });
}).catch(() => followersOf.set(pk, { pks: followersOf.get(pk)?.pks || [], at: Date.now() })).finally(scheduleRepaint);
return followersOf.get(pk);
}
const compactNum = (n) => { try { return new Intl.NumberFormat(getLang(), { notation: 'compact', maximumFractionDigits: 1 }).format(n); } catch { return String(n); } };
// "116 Following · 572 Followers" under the bio; each opens its list
// right below, on the same page.
function socialLine(pk) {
const c = loadSocial(pk);
const f = ofFollows.get(pk);
const follows = f?.at ? f.authors.length : c?.follows;
const pick = (which) => {
ui.profPeople = ui.profPeople === which ? null : which;
ui.profPeopleN = 50;
if (which === 'following') loadOfFollows({ of: pk });
else loadFollowers(pk);
render();
};
// not known yet: an invisible stand-in holds the place, and the number
// fades in over it (no dash flashing first)
const btn = (which, n, label) => h('button', {
type: 'button', class: 'social-count' + (ui.profPeople === which ? ' on' : ''), 'aria-expanded': String(ui.profPeople === which),
onClick: () => pick(which),
}, h('b', { class: 'social-n' + (n == null ? ' pending' : '') }, n == null ? '0' : compactNum(n)), ' ', label);
const shown = ui.npubShown === pk;
return h('div', { class: 'row social-line' },
btn('following', follows, t('profFollowing')),
btn('followers', c?.followers, t('profFollowers')),
h('button', {
type: 'button', class: 'social-count npub-toggle' + (shown ? ' on' : ''), style: 'margin-left:auto', 'aria-expanded': String(shown),
onClick: () => { ui.npubShown = shown ? null : pk; render(); },
}, t('profKeyLink')));
}
function socialList(pk) {
const which = ui.profPeople;
if (!which) return null;
const src = which === 'following' ? ofFollows.get(pk) : followersOf.get(pk);
const all = which === 'following' ? (src?.authors || []) : (src?.pks || []);
const pks = all.filter((x) => !hiddenPk(x));
const n = ui.profPeopleN || 50;
const loading = !src || src.loading || (which === 'following' && !src.at);
const total = which === 'followers' ? socialCounts.get(pk)?.followers : null;
return h('div', { class: 'col social-list', style: 'gap:6px' },
loading && !pks.length ? h('div', { class: 'row', style: 'justify-content:center;padding:10px' }, h('span', { class: 'spinner sm' })) : null,
!loading && !pks.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t(which === 'following' ? 'profFollowsNone' : 'profFollowersNone')) : null,
pks.length ? h('div', { class: 'list' }, ...pks.slice(0, n).map((x) => {
const p = profileOf(x) || {};
const sub = p.nip05 ? String(p.nip05).replace(/^_@/, '') : (npubOf(x) || '').slice(0, 20) + '…';
return h('div', { class: 'item chat-thread-row', 'data-key': 'sp:' + x, onClick: () => openProfile(x) },
avatar(x, 'chat-avatar', false),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'chat-name' }, displayName(x)),
h('div', { class: 'muted small chat-preview' }, sub)));
})) : null,
pks.length > n ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profPeopleN = n + 50; render(); } }, t('showMore')) : null,
// Primal hands back the followers it knows best, not always all of them
which === 'followers' && !loading && total && pks.length < total && pks.length <= n
? h('div', { class: 'small faint', style: 'text-align:center' }, t('profFollowersPartial', { n: compactNum(pks.length), total: compactNum(total) }))
: null);
}
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device
let c = feedStates.get(curFeedId);
if (!c) {
let stored = [];
try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
if (!Array.isArray(stored)) stored = [];
if (!stored.length) stored = borrowedNotes(feedDef(curFeedId));
stored = stored.slice(0, FEED_STORE);
c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
feedStates.set(curFeedId, c);
while (feedStates.size > 4) {
const oldest = feedStates.keys().next().value;
feedStates.get(oldest).stopped = true;
feedStates.delete(oldest);
}
feed = c;
// Only the opening page gates first paint. The next page warms on the
// side, so an offscreen image cannot delay the whole feed.
c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint();
});
c.boot.then(() => {
if (c.stopped) return;
notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2));
});
prefetchFeed(c);
}
feed = c;
return c;
}
let feedWarmTimer = null, feedWarmSession = 0;
async function prefetchFeed(c) {
const def = feedDef(c.id);
const authors = feedAuthors(def).slice(0, REQ_AUTHORS), topics = feedTopics(def);
if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; }
c.at = Date.now();
const merge = { newerThan: c.notes.length ? c.notes[0].created_at : Infinity };
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(); } }
}
return;
}
try {
const orT = orTopicsOf(def);
await Promise.all([
queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE,
...(authors.length ? { authors } : {}), ...(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]);
} catch {} finally {
if (!c.stopped) {
c.status = 'ready';
if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); seedOtherFeeds(); }
}
}
}
// A feed never opened on this device (or pushed out of the disk cache)
// would open on a bare spinner. Posts another open feed already holds —
// by its people, on its topics — stand in until the relays answer.
function borrowedNotes(def) {
if (!def || def.all) return [];
const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def));
if (!authors.size && !topics.size) return [];
const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1])));
const fits = (e) => ((!authors.size || authors.has(e.pubkey))
&& (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT));
const seen = new Set(), out = [];
for (const o of feedStates.values()) {
for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); }
}
return out.sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE);
}
// And once the feed on screen has settled, the other feeds' first few
// posts are fetched ahead into the disk cache, one feed at a time, so
// switching to any of them paints at once.
const SEED_EVERY = 15 * 60_000;
let feedsSeededAt = 0;
async function seedOtherFeeds() {
const disk = feedDisk();
if (!disk || isVisitor() || Date.now() - feedsSeededAt < SEED_EVERY) return;
feedsSeededAt = Date.now();
for (const def of feedList()) {
if (def.curated || def.of || feedStates.has(def.id) || disk.has(def.id)) continue;
const authors = feedAuthors(def).slice(0, REQ_AUTHORS), topics = feedTopics(def);
if (!authors.length && !topics.length) continue;
try {
const events = await queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2,
...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
if (feedStates.has(def.id)) continue; // opened while we were asking
disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)));
} catch {}
}
}
// Make a feed the current one (no paint): Back/Forward through the
// history restores ui.feedId and the view catches up from here.
function selectFeed(id) {
if (!feedDef(id)) return;
if (feedDef(id).of) loadOfFollows(feedDef(id));
if (id !== curFeedId) {
curFeedId = id;
if (!adhocFeeds.has(id)) { try { localStorage.setItem(feedLsKey(), id); } catch {} }
}
ui.feedId = id;
stopFeedWatch();
const c = feedNow();
c.unseen = 0; c.fresh = null; c.ahead = null; c.shown = FEED_PAGE;
admitFeed(c.deferred || [], c, true);
// An already warmed feed can paint synchronously on entry.
if (!c.booting) c.presentations.clear();
watchFeed();
refreshFeed({}, c);
}
function switchFeed(id) {
if (!feedDef(id)) return;
selectFeed(id);
try { window.scrollTo({ top: 0 }); } catch {}
render();
}
// ---- 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;
const showFeed = (id) => {
ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it
switchFeed(id);
};
const hexList = (pks) => [...new Set(pks.filter((pk) => /^[0-9a-f]{64}$/i.test(pk || '')).map((pk) => pk.toLowerCase()))];
// The timeline someone else sees: their follow list, read from the relays,
// becomes the authors of a feed for this session.
function openFeedOf(pk, relays = [], readFrom = []) {
const id = 'of:' + pk + (readFrom.length ? '@' + readFrom.join(',') : '');
let def = adhocFeeds.get(id);
if (!def) {
def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, relays: readFrom };
adhocFeeds.set(id, def);
loadOfFollows({ ...def, relays: [...relays, ...readFrom] });
}
showFeed(id);
}
// A follow pack (or someone's follow set: a pack by another name).
function openPackFeed(p, readFrom = []) {
const id = 'pack:' + packKey(p) + (readFrom.length ? '@' + readFrom.join(',') : '');
if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: (packOf(p) || {}).title || t('feedPackName'), follows: false, authors: [], packs: [p], topics: [], relays: readFrom });
const def = adhocFeeds.get(id);
fetchPack(p).then((got) => {
if (got && got.title) def.name = got.title;
const c = feedStates.get(id);
if (c) { c.at = 0; if (c === feed) refreshFeed({ live: false }, c); else dropFeedState(id); }
scheduleRepaint();
}).catch(() => {});
showFeed(id);
}
// Any mix of people, packs and topics, carried in the URL itself.
const urlRelays = (q) => [...new Set((q.get('r') || '').split(',').map(normRelay).filter(Boolean))].slice(0, 12);
// Plain /feed: every post the coinos relay carries (or the relays named).
// /feed is Popular: everything the relays carry, curated (no machine
// posts, the reader's language, and only what someone reacted to).
// /feed/all is the same relays raw.
function openFirehose(readFrom = [], { raw = false } = {}) {
if (!raw && !readFrom.length && !isVisitor()) { showFeed(EVERYTHING); return; }
const id = (raw ? 'raw' : 'all') + (readFrom.length ? '@' + readFrom.join(',') : ':visitor');
if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: raw ? t('feedFirehose') : t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: !raw, relays: readFrom });
showFeed(id);
}
function openQueryFeed(q) {
const authors = hexList((q.get('p') || '').split(',').map((x) => parseNostrPubkey(x.trim()) || ''));
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);
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;
const id = 'url:' + hsh.toString(36);
if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: (q.get('name') || '').trim().slice(0, 40) || t('feedSharedName'), follows: false, authors, packs, topics, relays });
showFeed(id);
return true;
}
function openFeedUrl(path, search) {
let q; try { q = new URLSearchParams(search || ''); } catch { q = new URLSearchParams(); }
const readFrom = urlRelays(q);
for (const r of readFrom) { try { resetRelay(r); } catch {} } // named in the link: tried even if it was sick
if (!path) return openQueryFeed(q);
if (/^all$/i.test(path)) { openFirehose(readFrom, { raw: true }); return true; }
if (/^t\//i.test(path)) { const tag = normTopic(decodeURIComponent(path.slice(2))); if (!tag) return false; openTopicFeed(tag, readFrom); return true; }
const pk = parseNostrPubkey(path);
if (pk) { let relays = []; try { relays = parseNostrRef(path.toLowerCase())?.relays || []; } catch {} openFeedOf(pk, relays, readFrom); return true; }
const pack = /^naddr1/i.test(path) ? parsePackLink(path) : null;
if (pack) { openPackFeed(pack, readFrom); return true; }
return false;
}
// The URL a feed answers to. `share` also names the built-in Following
// feed (as this person's timeline); the address bar shows only feeds that
// came from a URL, so a saved feed is never doubled by its own link.
function feedPath(def, { share = false } = {}) {
if (!def) return null;
const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : '';
if (def.all) return (def.curated ? '/feed' : '/feed/all') + r;
if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null;
if (def.of) return '/feed/' + npubOf(def.of) + r;
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) {
const p = packs[0];
const n = packNaddr(`${p.kind || PACK_KIND}:${p.pk}:${p.d}`);
if (n) return '/feed/' + n + r;
}
if (!authors.length && !packs.length && !topics.length) return null;
const q = new URLSearchParams();
if (feedRelays(def)) q.set('r', feedRelays(def).join(','));
if (authors.length) q.set('p', authors.map((pk) => npubOf(pk)).join(','));
if (topics.length) q.set('t', topics.join(','));
if (packs.length) q.set('pack', packs.map((p) => packNaddr(`${p.kind || PACK_KIND}:${p.pk}:${p.d}`)).filter(Boolean).join(','));
if (def.name && !def.builtin) q.set('name', def.name);
return '/feed?' + q.toString();
}
// The relays a feed reads from: a list to add to and take from, kept on
// the feed (a session feed in memory, a saved one in the state, Following
// beside it). Changing it rebuilds the feed from those relays.
// Forget a feed's state so it is rebuilt; a fetch still in flight for the
// old one lands nowhere (it would otherwise fill the orphan, not the new).
const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); };
// Popular's defaults before 2026-10-04: a list saved that matches them
// exactly was the defaults, kept by an edit, and follows the new ones.
function oldPopularDefault(list) {
const OLD_POPULAR = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
return Array.isArray(list) && list.length === OLD_POPULAR.length && OLD_POPULAR.every((r) => list.includes(r));
}
function setFeedRelays(def, relays) {
relays = [...new Set(relays.map(normRelay).filter(Boolean))].slice(0, 12);
// the usual list, picked by hand, is no list of one's own
const usual = usualRelays(def);
if (usual && relays.length === usual.length && usual.every((r) => relays.includes(r))) relays = [];
for (const r of relays) { try { resetRelay(r); } catch {} } // chosen by hand: tried again even if it was sick
if (def.builtin) { const s = st(); s.builtinRelays = { ...(s.builtinRelays || {}), [def.id]: relays }; save(s); }
else if (adhocFeeds.has(def.id)) def.relays = relays;
else { const s = st(); s.feeds = s.feeds.map((f) => (f.id === def.id ? { ...f, relays, at: Date.now() } : f)); save(s); feedsChanged(); }
dropFeedState(def.id);
try { wallet.saveFeatureState(feedCacheKey(def.id), []); } catch {}
if (curFeedId === def.id) selectFeed(def.id);
// the address bar carries the relays of a feed that came from a URL
if (adhocFeeds.has(def.id) && ui.chatOpen && ui.msgView === 'feed') { try { history.replaceState(history.state, '', feedPath(def) || '/'); } catch {} }
}
const usualRelays = (def) => (def.curated ? POPULAR_RELAYS : def.all ? FIREHOSE_RELAYS : (feedAuthors(def).length ? null : TOPIC_RELAYS));
function relayPanel(def) {
const e = ui.feedRelayEdit;
const custom = feedRelays(def);
const usual = usualRelays(def);
const list = custom || usual || [];
const host = (u) => u.replace(/^wss?:\/\//, '');
const commit = (next) => { setFeedRelays(def, next); e.other = null; e.input = ''; render(); };
// each relay is a pick from the popular list (the one in place stays
// offered even when it isn't popular), or Other… for one typed in
const rowSelect = (u, i) => h('select', { class: 'grow', style: 'min-width:0', onChange: (ev) => {
const v = ev.target.value;
if (v === '__other') { e.other = u; e.input = ''; render(); return; }
const next = list.slice(); next[i] = v; commit(next);
} },
...[...new Set([u, ...RELAY_PICKS])].filter((x) => x === u || !list.includes(x)).map((x) => h('option', { value: x, selected: x === u ? 'selected' : undefined }, host(x))),
h('option', { value: '__other' }, t('feedRelayOther')));
const inputRow = (onSave) => h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow', placeholder: t('feedRelayPh'), value: e.input, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', autofocus: true,
onInput: (ev) => { e.input = ev.target.value; }, onKeyDown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); onSave(); } } }),
h('button', { class: 'btn-sm', type: 'button', onClick: onSave }, t('feedRelayAdd')),
h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('back'), onClick: () => { e.other = null; render(); } }, '\u00d7'));
const saveOther = (i) => () => {
const r = normRelay(e.input);
if (!r) { toast(t('feedRelayBad')); return; }
const next = list.slice(); if (i == null) next.push(r); else next[i] = r;
commit(next);
};
return h('div', { class: 'card col', style: 'gap:8px;padding:10px 12px;background:var(--surface2)' },
h('div', { class: 'small muted' }, t('feedRelaysHint')),
...list.map((u, i) => e.other === u
? inputRow(saveOther(i))
: h('div', { class: 'row gap6', style: 'align-items:center' },
rowSelect(u, i),
h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('remove'), onClick: () => commit(list.filter((_, j) => j !== i)) }, '\u00d7'))),
!list.length && !usual ? h('div', { class: 'small faint' }, t('feedRelaysOutbox')) : null,
e.other === '__new'
? inputRow(saveOther(null))
: h('select', { 'aria-label': t('feedRelayPick'), onChange: (ev) => {
const v = ev.target.value; ev.target.value = '';
if (!v) return;
if (v === '__other') { e.other = '__new'; e.input = ''; render(); return; }
commit([...list, v]);
} },
h('option', { value: '', selected: 'selected' }, t('feedRelayPick')),
...RELAY_PICKS.filter((x) => !list.includes(x)).map((x) => h('option', { value: x }, host(x))),
h('option', { value: '__other' }, t('feedRelayOther'))),
h('div', { class: 'row gap6' },
custom ? h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => commit([]) }, t('feedRelaysDefault')) : null,
h('button', { class: 'btn-sm', type: 'button', style: 'margin-left:auto', onClick: () => { ui.feedRelayEdit = null; render(); } }, t('feedRelaysDone'))));
}
async function shareFeed(def) {
const path = feedPath(def, { share: true });
if (!path) return;
const url = location.origin + path;
try {
if (navigator.share) await navigator.share({ url });
else { await navigator.clipboard.writeText(url); toast(t('copied')); }
} catch {}
}
// A follow list that changed means every feed built on it has the wrong
// authors — the one on screen is rebuilt in place, not offered behind the
// pill; the others are simply forgotten and rebuilt when next opened.
function feedAuthorsChanged() {
for (const c of [...feedStates.values()]) {
const def = feedDef(c.id);
if (!def || !def.follows) continue;
if (c === feed) { c.at = 0; refreshFeed({ live: false }, c); } else feedStates.delete(c.id);
}
}
// A topic straight from search or a #tag in a post: a feed for this
// session (a saved single-topic feed with the same tag is reused).
function openTopicFeed(tag, relays = []) {
tag = normTopic(tag);
if (!tag) return;
const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag);
const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : '');
if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays });
ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(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.
const headBusy = () => {
if (ui.profCompose != null || ui.feedRelayEdit) return true;
try {
const a = document.activeElement;
// a field, not a button: a tapped chip keeps the focus afterwards
return !!a && !!a.closest('input, textarea, select, [contenteditable], coinos-text')
&& !!a.closest('.feed-page') && !a.closest('.notes-feed');
} catch { return false; }
};
// Resolve the whole first presentation before admitting a row. The
// deadline leaves unavailable resources as stable, clickable fallbacks;
// a late response is useful on the next visit, not a mid-read replacement.
const READY_MS = 8000;
const mediaReady = new Map(); // URL -> decoded dimensions
const mediaFailed = new Set(); // URLs whose picture refused to load (not merely slow)
const mediaWarming = new Map();
// A post's photo is often the camera's original — 3024px wide, twelve
// megapixels — painted into a column a sixth of that. Chrome decodes it
// again whenever the page that holds it is rebuilt (its decoded copy is
// long evicted by then), and until it has, the picture is a blank box: the
// feed came back from a profile with every post's text in place and its
// photo 200ms behind. So a big picture is painted from a copy the size the
// column can actually show, made once and kept on disk; it decodes in a few
// milliseconds, inside the same frame as the text. The original is still
// what the lightbox opens. A host that won't hand over its bytes (no CORS)
// keeps the old way.
const MEDIA_THUMB_W = 1100; // the 560px column at 2x
const MEDIA_THUMB_CACHE = 'coinos-media-thumbs-v1';
const MEDIA_THUMB_MAX = 400; // copies kept on disk, oldest out first
const thumbStore = () => (typeof caches !== 'undefined' ? caches.open(MEDIA_THUMB_CACHE) : Promise.reject(new Error('no cache')));
async function storedMediaThumb(url) {
try {
const r = await (await thumbStore()).match(url);
if (!r) return null;
const dims = JSON.parse(r.headers.get('x-dims') || 'null');
if (!dims || !dims.width) return null;
const src = r.headers.get('x-original') === '1' ? null : URL.createObjectURL(await r.blob());
return { ...dims, thumb: src };
} catch { return null; }
}
async function makeMediaThumb(url) {
if (noCorsHost(url) || typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined') return null;
let blob;
try {
const res = await corsFetch(url, AbortSignal.timeout(READY_MS));
if (!res.ok) return null;
blob = await res.blob();
} catch (e) {
if (e && e.corsRefused) noteNoCors(url); // refused, as corsFetch confirmed
return null;
}
const result = await makeImageThumbnails(blob, {
width: MEDIA_THUMB_W, tileSize: tileThumbs.has(url) ? 0 : TILE_PX,
});
if (!result) return null;
const dims = { width: result.width, height: result.height }, out = result.media;
// Both sizes share one decode in the worker; only cache writes and object
// URLs come back to the page. The lightbox still uses the original.
if (result.tile) await saveTileThumb(url, result.tile).catch(() => {});
try {
const store = await thumbStore();
await store.put(url, new Response(out || new Blob(), { headers: { 'x-dims': JSON.stringify(dims), 'x-original': out ? '0' : '1' } }));
const keys = await store.keys();
for (const k of keys.slice(0, Math.max(0, keys.length - MEDIA_THUMB_MAX))) store.delete(k);
} catch {}
return { ...dims, thumb: out ? URL.createObjectURL(out) : null };
}
// Decode a copy ahead, so the that paints it finds it ready.
const decodeAhead = (src) => new Promise((resolve) => {
const img = new Image();
img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(resolve, resolve);
img.onerror = resolve;
img.src = src;
});
const warmMedia = (url, { thumb = false } = {}) => {
if (!url || mediaReady.has(url) || typeof Image === 'undefined') return Promise.resolve();
if (mediaWarming.has(url)) return mediaWarming.get(url);
if (thumb && /^https?:/i.test(url)) {
const task = (async () => {
const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null));
if (got) {
// no copy (small already, or a GIF): the original is what paints,
// so it's the one decoded ahead — the row mustn't decode it on sight
await decodeAhead(got.thumb || url);
mediaReady.set(url, got);
return;
}
await plainWarm(url);
})().finally(() => mediaWarming.delete(url));
mediaWarming.set(url, task);
return task;
}
return plainWarm(url);
};
const plainWarm = (url) => {
if (mediaWarming.has(url) && mediaWarming.get(url).plain) return mediaWarming.get(url);
const task = new Promise((resolve) => {
const img = new Image();
const timer = setTimeout(() => done(false), READY_MS);
const done = (ok) => {
clearTimeout(timer);
img.onload = img.onerror = null;
if (ok) { mediaReady.set(url, { width: img.naturalWidth, height: img.naturalHeight }); mediaFailed.delete(url); }
resolve();
};
img.onload = () => { (img.decode ? img.decode() : Promise.resolve()).then(() => done(true), () => done(false)); };
img.onerror = () => { mediaFailed.add(url); done(false); };
img.src = url;
}).finally(() => { if (mediaWarming.get(url) === task) mediaWarming.delete(url); });
task.plain = true;
if (!mediaWarming.has(url)) mediaWarming.set(url, task);
return task;
};
// What an for this picture should load: the column-sized copy when
// there is one.
const mediaSrc = (url, d = feedMedia(url)) => (d && d.thumb) || url;
// A video's shape, so the feed paints its box at the clip's own aspect
// instead of a 16:9 frame with black bands either side of a portrait clip.
// The post's imeta `dim` says it for free (Amethyst, Primal and others
// publish it); otherwise the metadata alone is fetched — a few KB for a
// moov-first file — with a short cap, since it gates the row. Remembered
// on disk so a clip seen once is never probed again.
const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
const AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?[^\s]*)?$/i;
const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims-v2'; // v2: turned clips stand upright
const videoDims = new Map(); // url -> { width, height }
const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again
const loadVideoDims = () => mergeStored(videoDims, VIDEO_DIMS_KEY);
loadVideoDims();
// the v1 caches held upright phone clips as landscape
onCacheReady(() => { for (const k of ['coinos-video-dims', 'coinos-video-info']) try { cacheStorage.removeItem(k); } catch {} });
let videoDimsFlush = null;
const rememberVideo = (url, width, height) => {
if (!(width > 0 && height > 0)) return;
const d = { width, height };
mediaReady.set(url, d);
if (videoDims.get(url)?.width === width && videoDims.get(url)?.height === height) return;
videoDims.delete(url); videoDims.set(url, d);
if (videoDimsFlush) return;
videoDimsFlush = setTimeout(() => {
videoDimsFlush = null;
try { cacheStorage.setItem(VIDEO_DIMS_KEY, JSON.stringify([...videoDims].slice(-VIDEO_DIMS_KEEP))); } catch {}
}, 1000);
};
const imetaDim = (ev, url) => {
for (const tag of ev?.tags || []) {
if (tag[0] !== 'imeta' || !tag.includes('url ' + url)) continue;
const m = /^dim (\d+)x(\d+)$/.exec(tag.find((x) => /^dim /.test(x)) || '');
if (m) return { width: +m[1], height: +m[2] };
}
return null;
};
// ---- what a clip costs, before it's downloaded --------------------------
// A post's imeta often says (size in bytes, dim, a still); otherwise one
// 64 KB ranged request does: the total from Content-Range, whether the
// server takes ranges at all, and the file's layout — an MP4 whose index
// (moov) sits behind its data can't start until the whole file is in when
// the server won't serve ranges. Its track header gives the size on screen
// too. Clips over the budget, above 1080p, or that we couldn't measure
// wait for a tap — unless the reader asked for large clips to play.
const VIDEO_INFO_KEY = 'coinos-video-info-v2', VIDEO_INFO_KEEP = 2000, VIDEO_PROBE_BYTES = 65536;
const VIDEO_LARGE_KEY = 'coinos-video-large';
const videoInfo = new Map(); // url -> { size, w, h, image, faststart, ranges, moovEnd, probed, failedAt }
const loadVideoInfo = () => mergeStored(videoInfo, VIDEO_INFO_KEY);
loadVideoInfo();
let videoInfoFlush = null;
const saveVideoInfo = () => {
if (videoInfoFlush) return;
videoInfoFlush = setTimeout(() => {
videoInfoFlush = null;
try { cacheStorage.setItem(VIDEO_INFO_KEY, JSON.stringify([...videoInfo].slice(-VIDEO_INFO_KEEP))); } catch {}
}, 1000);
};
const noteVideoInfo = (url, d) => { videoInfo.set(url, { ...(videoInfo.get(url) || {}), ...d }); saveVideoInfo(); };
// what the post itself says about its clips (NIP-92)
function learnVideoMeta(ev) {
for (const tag of ev?.tags || []) {
if (tag[0] !== 'imeta') continue;
const f = {};
for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); }
if (!f.url || !(VIDEO_RE.test(f.url) || /^video\//.test(f.m || ''))) continue;
const have = videoInfo.get(f.url) || {};
const d = {};
if (!have.size && +f.size > 0) d.size = +f.size;
// no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough
else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8);
const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || '');
if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; }
if (!have.image && /^https?:\/\//.test(f.image || '')) d.image = f.image;
if (Object.keys(d).length) noteVideoInfo(f.url, d);
}
}
// the top-level boxes of an MP4's first bytes: is moov ahead of mdat, and
// how big is the picture (a video track's tkhd ends in its width/height)
function readMp4Head(b) {
const u32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
const type = (i) => String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
if (b.length >= 4 && u32(0) === 0x1a45dfa3) return { faststart: true }; // WebM/Matroska streams from the start
let faststart, moovEnd = 0, i = 0;
while (i + 8 <= b.length) {
let size = u32(i);
const tp = type(i + 4);
if (tp === 'moov') { faststart = true; moovEnd = i + size; break; }
if (tp === 'mdat') { faststart = false; break; }
if (size === 1) size = i + 16 <= b.length ? u32(i + 8) * 2 ** 32 + u32(i + 12) : 0;
if (size < 8) break;
i += size;
}
let w = 0, h = 0;
for (let j = 4; j + 4 <= b.length && !w; j++) {
if (b[j] !== 0x74 || type(j) !== 'tkhd') continue; // 't'
const end = j - 4 + u32(j - 4);
if (end > b.length || end < j + 8) continue;
const tw = u32(end - 8) >>> 16, th = u32(end - 4) >>> 16;
// a phone held upright stores its picture sideways with a quarter-turn
// matrix (the 36 bytes before width/height): a = 0 and b ≠ 0 there
const a = u32(end - 44), b2 = u32(end - 40);
const turned = end - 44 >= j + 8 && a === 0 && b2 !== 0;
if (tw && th) { w = turned ? th : tw; h = turned ? tw : th; }
}
// an iPhone's HEVC (hvc1/hev1 in the sample description)
let hevc = false;
for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
const videoProbeQueue = [];
const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000;
function probeVideo(url, layout = false) {
const have = videoInfo.get(url);
// a post that states size and shape is taken at its word: no request at
// all — unless the file's layout is wanted (a thumbnail)
// a host that didn't answer is asked again after an hour (a phone on
// a bad connection must not mark a clip unmeasurable for good)
if (have && (have.probed || probeFailedLately(have) || (!layout && have.size && have.w))) return Promise.resolve(have);
if (videoProbes.has(url)) return videoProbes.get(url);
const task = new Promise((resolve) => videoProbeQueue.push({ url, resolve })).finally(() => videoProbes.delete(url));
videoProbes.set(url, task);
pumpVideoProbes();
return task;
}
function pumpVideoProbes() {
while (videoProbing < 3 && videoProbeQueue.length) {
const { url, resolve } = videoProbeQueue.shift();
videoProbing++;
runVideoProbe(url).then(resolve, () => resolve(videoInfo.get(url) || {}))
.finally(() => { videoProbing--; pumpVideoProbes(); });
}
}
async function runVideoProbe(url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10_000);
try {
// Past the browser's cache (no-store), always: some hosts (Primal's R2
// bucket) send CORS headers only to a request that has an Origin, and
// say nothing of it in Vary — so the copy the cache kept from a
// player's plain load answers this request without them, and it fails.
// Any clip that had played in the feed was "unmeasurable" that way.
const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
const ranges = r.status === 206;
const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
// never more than the first bytes, even from a server that ignored the range
const reader = r.body.getReader();
const parts = [];
let got = 0;
while (got < VIDEO_PROBE_BYTES) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value); got += value.length;
}
try { reader.cancel(); } catch {}
const buf = new Uint8Array(Math.min(got, VIDEO_PROBE_BYTES));
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
// says Content-Length to a HEAD, which any page may read
try {
const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) });
const n = +hr.headers.get('content-length');
if (hr.ok && n > 0) d.size = n;
} catch {}
}
if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
if (head.w) rememberVideo(url, head.w, head.h);
noteVideoInfo(url, d);
} catch {
noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
// How much a clip may cost before it waits for a tap: less on a slow or
// metered connection.
const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
function videoBudget() {
try {
const c = navigator.connection;
if (c && c.saveData) return 3e6;
if (c && /(^|-)2g$|^3g$/.test(c.effectiveType || '')) return 8e6;
} catch {}
return 25e6;
}
const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
function videoHeavy(url) {
if (tappedClips.has(url) || largeVideosOk()) return false;
const d = videoInfo.get(url) || {};
if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
if (!d.size) return true; // nothing known: don't spend data blind
if (d.size > videoBudget()) return true;
// the whole file before the first frame: only worth it for a small one
if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
return false;
}
const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
function heavyLabel(url) {
const d = videoInfo.get(url) || {};
const edge = d.w && d.h ? Math.min(d.w, d.h) : 0;
const res = edge >= 2160 ? '4K' : edge > 1080 ? edge + 'p' : '';
return [d.size ? fmtBytes(d.size) : '', res].filter(Boolean).join(' · ') || t('videoTapToPlay');
}
const warmVideo = (url, dim) => {
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 (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
const task = probeVideo(url).then(() => {
if (mediaReady.has(url)) return;
const d = videoInfo.get(url) || {};
if (d.probed) { videoUnknown.add(url); return; }
return playerShape(url);
}).finally(() => mediaWarming.delete(url));
mediaWarming.set(url, task);
return task;
};
// a host that wouldn't answer the probe: ask a player for the shape, briefly
const playerShape = (url) => {
const task = new Promise((resolve) => {
const v = document.createElement('video');
const done = () => {
clearTimeout(timer);
v.onloadedmetadata = v.onerror = null;
if (v.videoWidth && v.videoHeight) rememberVideo(url, v.videoWidth, v.videoHeight);
else videoUnknown.add(url);
v.removeAttribute('src'); try { v.load(); } catch {} // let go of the connection
resolve();
};
const timer = setTimeout(done, VIDEO_PROBE_MS);
v.onloadedmetadata = done; v.onerror = done;
v.muted = true; v.preload = 'metadata'; v.src = url;
});
return task;
};
// ---- link previews --------------------------------------------------
// A plain link in a post becomes the card its page describes (Open Graph:
// title, a line, a picture), fetched through the registrar because a
// browser can't read another site's HTML. Warmed with the rest of a post
// so the row arrives whole; remembered across sessions, a few hundred at
// a time, so a feed you've seen paints its cards at once.
const PREVIEW_API = 'https://names.coinos.io/preview?url=';
const PREVIEW_KEEP = 400;
const previews = new Map(); // url -> { title, description, image, site } | null (nothing there)
const previewWarming = new Map();
const loadPreviews = () => mergeStored(previews, 'coinos-link-previews');
loadPreviews();
let previewFlush = null;
const savePreviews = () => {
if (previewFlush) return;
previewFlush = setTimeout(() => {
previewFlush = null;
const rows = [...previews].filter(([, m]) => m).slice(-PREVIEW_KEEP);
try { cacheStorage.setItem('coinos-link-previews', JSON.stringify(rows)); } catch {}
}, 1000);
};
const previewable = (url) => /^https?:\/\//i.test(url) && !youtubeId(url)
&& !/\.(png|jpe?g|gif|webp|avif|mp4|webm|mov|m4v|mp3|m4a|ogg|wav|pdf|zip)(\?[^\s]*)?$/i.test(url);
const warmPreview = (url) => {
if (!url || previews.has(url) || typeof fetch === 'undefined') return Promise.resolve();
if (previewWarming.has(url)) return previewWarming.get(url);
const task = (async () => {
let meta = null;
try {
const r = await fetch(PREVIEW_API + encodeURIComponent(url), { signal: AbortSignal.timeout(READY_MS) });
const j = r.ok ? await r.json() : null;
if (j && (j.title || j.image)) meta = { title: j.title || '', description: j.description || '', image: j.image || '', site: j.site || '' };
} catch { return; } // asked again another time: the registrar was away, not the page
previews.set(url, meta);
if (meta) { savePreviews(); if (meta.image) await warmMedia(meta.image); }
})().finally(() => previewWarming.delete(url));
previewWarming.set(url, task);
return task;
};
// the first two plain links of a post (a wall of links is a list, not cards)
function notePreviewUrls(content) {
const urls = [];
for (const part of String(content || '').split(NOTE_SPLIT)) {
if (!part || urls.length >= 2) continue;
const md = MD_PARTS.exec(part);
const url = md ? md[3] : (/^https?:\/\//i.test(part) ? part : null);
if (url && !(md && md[1]) && previewable(url) && !PACK_LINK_RE.test(url)) urls.push(url);
}
return urls;
}
function linkCard(url, pv) {
let host = '';
try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {}
const size = pv.image ? (feedPaint ? feedMedia(pv.image) : mediaReady.get(pv.image)) : null;
return h('a', {
class: 'link-card', href: url, target: '_blank', rel: 'noopener noreferrer',
onClick: (e) => e.stopPropagation(),
},
pv.image ? h('img', {
class: 'link-thumb', src: pv.image, alt: '', loading: 'lazy',
width: size?.width, height: size?.height,
style: size && size.width && size.height ? 'aspect-ratio:' + size.width + '/' + size.height : undefined,
onError: (e) => { e.target.style.display = 'none'; },
}) : null,
h('div', { class: 'link-meta' },
h('div', { class: 'link-title' }, pv.title || host),
pv.description ? h('div', { class: 'link-desc small muted' }, pv.description) : null,
h('div', { class: 'link-site small faint' }, pv.site || host)));
}
function noteMediaUrls(content) {
const urls = [];
for (const part of String(content || '').split(NOTE_SPLIT)) {
if (!part) continue;
const md = MD_PARTS.exec(part);
const url = md ? md[3] : (/^https?:\/\//i.test(part) ? part : null);
if (!url) continue;
if ((md && md[1]) || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) urls.push(url);
else if (youtubeId(url)) urls.push('https://i.ytimg.com/vi/' + youtubeId(url) + '/hqdefault.jpg');
}
return urls;
}
function noteVideoUrls(content) {
const urls = [];
for (const part of String(content || '').split(NOTE_SPLIT)) {
const md = part && MD_PARTS.exec(part);
const url = md ? md[3] : (/^https?:\/\//i.test(part || '') ? part : null);
if (url && !(md && md[1]) && VIDEO_RE.test(url)) urls.push(url);
}
return urls;
}
const avatarUrl = (p, pk) => p?.picture
? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
: punkSmallUrl(pk);
async function warmAvatar(pk, deadline) {
let p = liveProfileOf(pk);
while ((p === null || (p && p.loading && !p.picture)) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
p = profiles.get(pk);
}
await warmMedia(avatarUrl(p, pk));
if (p?.picture) makeThumb(pk, p);
}
async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, ''));
if (ref?.pk) tasks.push(warmAvatar(ref.pk, deadline));
} else if (!depth && /^nostr:(note|nevent)1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref?.type === 'event') {
const quote = quotedNote(ref);
tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1)));
}
}
}
let timer;
try {
await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]);
} catch {} finally { clearTimeout(timer); }
}
const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
const keyed = (node, key) => { node.setAttribute('data-key', key); return node; };
// Keep only the resources this row actually uses. Copying the entire
// session media cache into every row grows quadratically as you scroll.
function feedMedia(url) {
if (!feedPaint) return mediaReady.get(url);
if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null);
return feedPaint.media.get(url);
}
function feedRow(c, ev) {
let presentation = c.presentations.get(ev.id);
if (!presentation) {
presentation = { profiles: new Map(), media: new Map(), quotes: new Map(), time: new Map() };
c.presentations.set(ev.id, presentation);
}
const prev = feedPaint;
feedPaint = presentation;
try {
const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
return row;
}
finally { feedPaint = prev; }
}
// A new post wears its tint until any of it reaches the screen; from that
// moment it fades out (the row's 3s background transition, in the CSS)
// whether you stay or scroll on. It used to wait for half the row to sit
// in view for two seconds, with the clock reset by every scroll and lost
// when the row's node was replaced, so a tall post read in passing, or one
// the window remounted, could stay green for good.
let freshObserver = null;
function watchFresh(c) {
if (typeof IntersectionObserver === 'undefined') return;
freshObserver ||= new IntersectionObserver((entries) => {
for (const en of entries) {
if (!en.isIntersecting) continue;
const el = en.target, id = el.getAttribute('data-key');
feed?.fresh?.delete(id); // by id: the row may be re-rendered mid-fade
el.classList.remove('note-fresh');
freshObserver.unobserve(el);
}
}, { threshold: 0 });
// after the paint, when the row nodes are the mounted ones
requestAnimationFrame(() => {
for (const el of document.querySelectorAll('.notes-feed > .row.note-fresh')) freshObserver.observe(el);
});
}
// 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();
async function mergeFeed(evs, opts = {}, c = feedNow()) {
let staged = feedStaged.get(c);
if (!staged) feedStaged.set(c, staged = new Set());
const known = new Set([...c.notes, ...(c.deferred || [])].map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
let add = (evs || []).filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)
&& !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
const def = feedDef(c.id);
if (def && def.curated) {
const kept = curateFeed(add);
for (const e of add) if (!kept.includes(e)) staged.delete(e.id);
add = kept;
if (!add.length) return false;
}
if (c.stopped) return false;
if (opts.immediate && !c.notes.length && !opts.catchup) {
// The public snapshot already contains verified posts and profiles.
// Paint text now using the same progressive rows as the local cache;
// slow media/quotes must not hold the whole first page behind a spinner.
c.booting = true;
for (const e of add) staged.delete(e.id);
admitFeed(add, c);
c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && c === feed) scheduleRepaint();
});
scheduleRepaint();
return true;
}
// Each row waits only for its own presentation. A slow photo or quote
// cannot hold ready text posts (or their new-post notice) behind it.
const ready = await Promise.all(add.map(async (e) => {
await noteReady(e);
staged.delete(e.id);
if (c.stopped) return false;
// Cached rows can still be warming. This new row is already prepared
// and can take its stable presentation without waiting for that page.
if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); }
admitFeed([...(c.deferred || []), e], c, false, opts.newerThan);
scheduleRepaint();
return true;
}));
return ready.some(Boolean);
}
// Never insert between the rows currently under the reader's eyes. Gap
// fills wait until that part of the feed is offscreen; new posts above
// the reading position can be inserted immediately with an anchor.
function admitFeed(add, c, explicit = false, newerThan = c.notes[0]?.created_at || 0) {
const onScreen = c === feed && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread;
const rows = onScreen && !explicit ? [...document.querySelectorAll('.notes-feed > .row[data-key]')] : [];
// An empty feed has no original head. Once its first row paints, a
// newer row arriving later still needs a notice if it must wait.
if (newerThan === Infinity) newerThan = c.notes[0]?.created_at ?? Infinity;
// Count against the head when the query began, so posts arriving out of
// order still count. Include deferred prepends: the notice can reveal
// them explicitly without moving the header out from under the reader.
let noticed = false;
if (rows.length) {
c.fresh ||= new Set();
const known = new Set(c.notes.map((e) => e.id));
for (const e of add) if (e.created_at > newerThan && !known.has(e.id) && !c.fresh.has(e.id)) {
c.fresh.add(e.id);
(c.ahead ||= new Set()).add(e.id);
c.unseen = (c.unseen || 0) + 1;
noticed = true;
}
}
const result = mergeFeedWindow(c.notes, c.shown, add, {
rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })),
height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE,
holdHead: headBusy(),
});
c.deferred = result.deferred;
if (!result.added.length) { if (noticed) scheduleRepaint(); return; }
c.shown = c.opened ? result.shown : FEED_PAGE;
c.notes = result.notes;
const retained = new Set(c.notes.map((e) => e.id));
for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id);
if (c.prepared) {
const waiting = new Set(c.deferred.map((e) => e.id));
for (const id of c.prepared) if (!retained.has(id) && !waiting.has(id)) c.prepared.delete(id);
}
saveFeedCache(c);
if (rows.length) {
holdScroll(render);
}
}
// The pill's tap: the posts are already in, so this just goes up to them —
// to the OLDEST of them, the one right above where you were reading, so
// you read on upward through the rest. Reaching the top by yourself clears
// the pill too (see the scroll listener).
// Drag the new-posts pill any way but up and let go past a short
// distance: it flies off that way and onDismiss runs. A short drag springs
// back, and the click a drag ends in is swallowed.
function pillSwipe(onDismiss) {
let d = null;
const move = (el, dx, dy) => {
el.style.transition = 'none';
el.style.transform = `translate(calc(-50% + ${dx}px), ${dy}px)`;
el.style.opacity = String(Math.max(0.2, 1 - Math.hypot(dx, dy) / 160));
};
return {
onPointerdown: (e) => {
d = { x: e.clientX, y: e.clientY, id: e.pointerId, dx: 0, dy: 0, moved: false };
},
onPointermove: (e) => {
if (!d || e.pointerId !== d.id) return;
const el = e.currentTarget;
d.dx = e.clientX - d.x; d.dy = Math.max(0, e.clientY - d.y); // down or sideways
// a repaint mid-drag would wipe the inline transform: hold the morph off
if (!d.moved && Math.hypot(d.dx, d.dy) > 6) { d.moved = true; el._skipMorph = true; try { el.setPointerCapture(d.id); } catch {} }
if (d.moved) move(el, d.dx, d.dy);
},
onPointerup: (e) => {
if (!d || e.pointerId !== d.id) return;
const el = e.currentTarget, { dx, dy, moved } = d;
d = null;
if (!moved) return;
el._skipMorph = false;
el._swiped = true;
setTimeout(() => { el._swiped = false; }, 400);
el.style.transition = 'transform .18s ease-out, opacity .18s ease-out';
if (Math.hypot(dx, dy) > 60) {
const k = 400 / Math.max(1, Math.hypot(dx, dy));
el.style.transform = `translate(calc(-50% + ${dx * k}px), ${dy * k}px)`;
el.style.opacity = '0';
setTimeout(onDismiss, 180);
} else {
el.style.transform = ''; el.style.opacity = '';
}
},
onPointercancel: (e) => {
if (!d) return;
d = null;
const el = e.currentTarget;
el._skipMorph = false;
el.style.transition = 'transform .18s ease-out, opacity .18s ease-out';
el.style.transform = ''; el.style.opacity = '';
},
};
}
// Where you are in the feed: the rows on screen, top first, as indexes
// into the list (none when the feed isn't showing).
function feedRowsOnScreen() {
if (!(ui.chatOpen && ui.msgView === 'feed') || ui.profilePk || ui.noteThread) return [];
const out = [];
for (const r of document.querySelectorAll('.notes-feed > .row[data-key]')) {
const b = r.getBoundingClientRect();
if (b.bottom > 8) out.push({ id: r.getAttribute('data-key'), top: b.top });
}
return out;
}
// c.ahead: new posts you haven't scrolled up to yet. The pill counts
// them; going up to one (or past it) takes it, and everything below it,
// off. The tint (c.fresh) is separate — it fades on first sight, and a
// post that went in above you shows a sliver of itself without you
// having gone up there.
function markReached(c) {
if (!c || !c.ahead || !c.ahead.size) return;
const top = feedRowsOnScreen().find((r) => r.top >= -8);
if (!top) return;
const list = c.winList ? c.winList() : [];
const k = list.findIndex((ev) => ev.id === top.id);
if (k >= 0) for (let j = k; j < list.length; j++) c.ahead.delete(list[j].id);
}
function pillCount(c) {
if (!c || !c.unseen || !c.ahead || !c.ahead.size) return 0;
let n = 0;
for (const ev of c.notes) if (c.ahead.has(ev.id) && !hidden(ev)) n++;
for (const ev of c.deferred || []) if (c.ahead.has(ev.id)) n++;
return n;
}
// Only ever up: to the nearest new post above the one you're reading.
// New posts below it are already behind you.
function jumpToNew() {
const c = feed;
if (!c) return;
// the first row whose top is on screen, as markReached reads it: one
// peeking in above that is still up there to go to
const at = feedRowsOnScreen().find((r) => r.top >= -8)?.id;
admitFeed(c.deferred || [], c, true);
c.unseen = 0;
render();
const list = c.winList ? c.winList() : [];
let r = at ? list.findIndex((ev) => ev.id === at) : -1;
if (r < 0) r = list.length;
let i = -1;
for (let j = r - 1; j >= 0; j--) if (c.ahead && c.ahead.has(list[j].id)) { i = j; break; }
if (i < 0) { if (r === list.length || !at) glideToTop(); return; }
glideToPost(c, list, i);
}
// Scroll so post i's top sits just under the top of the window. A row
// outside the rendered window is placed from the height cache first, then
// corrected once it's mounted.
function glideToPost(c, list, i) {
const id = list[i].id, MARGIN = 8;
const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(id) + '"]');
try {
const H = window.innerHeight || 800;
if (!find() && c.heights) {
let sum = 0, n = 0;
for (const v of c.heights.values()) { sum += v; n++; }
const avg = n ? sum / n : 200;
let y = 0;
for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP;
window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - MARGIN));
render();
}
const el = find();
if (!el) { glideToTop(); return; }
const target = Math.max(0, window.scrollY + el.getBoundingClientRect().top - MARGIN);
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { window.scrollTo({ top: target }); return; }
// a long way off: close most of the distance in one step, glide the rest
if (Math.abs(window.scrollY - target) > H * 2) window.scrollTo({ top: target + Math.sign(window.scrollY - target) * H * 2 });
requestAnimationFrame(() => { try { window.scrollTo({ top: target, behavior: 'smooth' }); } catch { window.scrollTo(0, target); } });
} catch { glideToTop(); }
}
// Up to the new posts as a glide, not a cut: an instant scrollTo read as a
// page refresh. A long way down, most of the distance is closed in one
// step first — so the glide is short, and the rows it passes are the
// ones the window has mounted — and the last couple of screens roll by.
function glideToTop() {
try {
const H = window.innerHeight || 800;
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { window.scrollTo({ top: 0 }); return; }
if (window.scrollY > H * 2) window.scrollTo({ top: H * 2 });
requestAnimationFrame(() => { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } });
} catch { try { window.scrollTo(0, 0); } catch {} }
}
// One pass over the plan: each relay is asked only for the authors it
// actually carries. The slowest relay doesn't hold up the rest — every
// answer merges as it lands.
// `merge` rides along to mergeFeed: a catch-up after being away is exactly
// as disruptive as a live arrival if you were reading halfway down, so it
// goes behind the pill too. A first load, or paging older posts onto the
// bottom, does not.
// The oldest event the relays handed back, admitted or not: a page of
// replies, muted or spam posts is not the end of the feed — the next ask
// goes on from there.
function sawBack(c, evs) {
for (const e of evs || []) if (e && e.created_at && !(c.seenBack <= e.created_at)) c.seenBack = e.created_at;
c.sawAny = (c.sawAny || 0) + ((evs || []).length);
}
async function queryFeed(relays, filter, merge, c, maxWait = 5000, qualifies = () => true) {
let got = false;
await queryStreamOn(relays, filter, async (ev) => {
if (c.stopped) return;
if (merge && merge.sink) { if (qualifies(ev)) { merge.sink(ev); got = true; } return; } // the media crawl's: into the index, not the page
sawBack(c, [ev]);
if (qualifies(ev) && await mergeFeed([ev], merge, c)) got = true;
}, maxWait);
return got;
}
async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) {
const def = feedDef(c.id);
if (!def) return false;
const authors = feedAuthors(def), topics = feedTopics(def);
const tag = topics.length ? { '#t': topics } : {};
let got = false;
if (def.curated) return popularPass(extra, merge, c, def);
const orT = orTopicsOf(def);
const orPass = orT.length
? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; })
: null;
if (!authors.length) {
// topics alone: nobody's outbox to read, so the wide relays, one ask;
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c);
}
const readPlan = async (plan) => {
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(async (chunk) => {
if (await queryFeed(relays, { kinds, authors: chunk, ...tag, limit, ...extra }, merge, c)) got = true;
});
}));
};
if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]);
else {
const initial = outboxPlan(authors);
const reading = readPlan(initial);
// Read known/default relays now. Discovery can add coverage later,
// without holding every post behind a sequence of relay-list queries.
await Promise.all([reading, (async () => {
await fetchRelayLists(authors);
if (c.stopped) return;
const asked = new Map();
for (const p of initial) for (const relay of p.relays) {
const key = normRelay(relay);
if (!asked.has(key)) asked.set(key, new Set());
for (const pk of p.authors) asked.get(key).add(pk);
}
const additional = outboxPlan(authors).flatMap((p) => p.relays.map((relay) => ({ relays: [relay],
authors: p.authors.filter((pk) => !asked.get(normRelay(relay))?.has(pk)),
})).filter((p) => p.authors.length));
await readPlan(additional);
})()]);
}
await orPass;
return got;
}
// Whatever a refresh finds goes behind the pill whenever posts are already
// on screen — the cached page at boot included. Posts used to slide in a
// beat after the page painted, moving what you had started reading. Only
// a first load (nothing to disturb) or a rebuilt follow list goes straight in.
async function refreshFeed(opts = {}, c = feedNow()) {
// Ask at once; each answer prepares its own row at the door (mergeFeed).
if (!feedHasQuery(feedDef(c.id))) { c.status = 'ready'; return; }
if (!opts.force && Date.now() - c.at < 30_000) return;
c.at = Date.now();
const catchup = opts.live != null ? !!opts.live : !!c.notes.length;
const newerThan = c.notes.length ? c.notes[0].created_at : Infinity;
try { await feedPass({}, { catchup, newerThan }, c); } catch {} finally {
c.status = 'ready';
scheduleRepaint();
}
if (c === feed) watchFeed();
}
// Repaint with the page held still: the post at the top of the viewport
// stays where it was, however much was inserted above it. Measured after
// the paint, so a browser that anchored the scroll itself isn't corrected
// twice.
// ---- windowing: only the rows near the viewport are in the DOM ----------
// A long scroll used to leave hundreds of rows mounted, and every repaint
// (a zap count landing, the thirty-second sync) rebuilt and diffed all of
// them — the slowdown you feel deep in the feed. Now a spacer stands in
// for the rows above and below the window at their measured heights, so
// the page keeps its length (scrollbar, Back, anchoring all behave), while
// a repaint touches a few dozen rows however far you have scrolled. The
// shape is svelte-virtual-list's: measure after paint, put unmeasured rows
// in at the running average, and hold the reader's row still when a
// measurement corrects an estimate.
const WINDOW_AHEAD = 2.5; // screens kept rendered beyond the viewport, each way
const WINDOW_MIN = 30; // below this many rows, no windowing at all
const ROW_GAP = 1; // the hairline between rows
function listWindow(c, list) {
const H = typeof window === 'undefined' ? 0 : window.innerHeight;
if (!H || list.length <= WINDOW_MIN) return { start: 0, end: list.length, top: 0, bottom: 0 };
const heights = c.heights || (c.heights = new Map());
let sum = 0, n = 0;
for (const v of heights.values()) { sum += v; n++; }
const avg = n ? sum / n : 200;
const y0 = (window.scrollY || 0) - (c.listTop || 0); // viewport top, relative to the list
const lo = y0 - WINDOW_AHEAD * H, hi = y0 + (1 + WINDOW_AHEAD) * H;
let y = 0, start = 0, end = list.length, top = 0, bottom = 0;
for (let i = 0; i < list.length; i++) {
const rowH = (heights.get(list[i].id) || avg) + ROW_GAP;
if (y + rowH <= lo) { top += rowH; start = i + 1; }
else if (y >= hi) { bottom += rowH; if (end === list.length) end = i; }
y += rowH;
}
return { start, end, top, bottom };
}
// The rows for a windowed list: hairline + row for what is in the window,
// a spacer either side. Every child keyed, so the morph moves by key.
function windowedRows(c, list, rowOf) {
const win = listWindow(c, list);
c.win = win;
const hr = (ev) => h('div', { 'data-key': 'hr:' + ev.id, style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' });
const out = [];
if (win.top) out.push(h('div', { 'data-key': 'win-top', class: 'feed-spacer', style: 'height:' + Math.round(win.top) + 'px' }));
for (let i = win.start; i < win.end; i++) {
if (i) out.push(hr(list[i]));
out.push(rowOf(list[i]));
}
if (win.bottom) out.push(h('div', { 'data-key': 'win-bottom', class: 'feed-spacer', style: 'height:' + Math.round(win.bottom) + 'px' }));
measureRows(c);
return out;
}
// After the paint: remember each mounted row's height and where the list
// starts. A spacer that was built on estimates gets its real numbers on a
// repaint that holds the reader's row still.
function measureRows(c, { repaint = true } = {}) {
if (c._measuring || typeof requestAnimationFrame === 'undefined') return;
c._measuring = true;
requestAnimationFrame(() => {
c._measuring = false;
const changed = measureNow(c);
if (changed && repaint && c.win && (c.win.top || c.win.bottom)) holdScroll(render);
});
}
function measureNow(c) {
const list = document.querySelector('.notes-feed');
if (!list) return false;
c.listTop = list.getBoundingClientRect().top + (window.scrollY || 0);
const heights = c.heights || (c.heights = new Map());
let changed = false;
for (const r of list.querySelectorAll(':scope > .row[data-key]')) {
const hgt = r.getBoundingClientRect().height;
const key = r.getAttribute('data-key');
if (hgt > 0 && Math.abs((heights.get(key) || 0) - hgt) > 0.5) { heights.set(key, hgt); changed = true; }
}
return changed;
}
// On scroll: replenish the buffer before the reader reaches its edge,
// holding their row still. Coalesce scroll events into one check per frame.
let windowRaf = 0;
function windowScrolled(c) {
if (!c || !c.win || windowRaf) return;
windowRaf = requestAnimationFrame(() => {
windowRaf = 0;
const list = c.winList ? c.winList() : null;
if (!list || !c.win || (!c.win.start && c.win.end >= list.length)) return;
// Keep using the 2.5-screen overscan until only one screen remains.
// Recentring it every time an offscreen row crossed the ideal boundary
// rebuilt the whole pane repeatedly during a coast. Two edge reads
// suffice here; measure all rows only when the window actually moves.
const rows = document.querySelectorAll('.notes-feed > .row[data-key]');
const first = rows[0], last = rows[rows.length - 1], H = window.innerHeight;
if (!first || !last) return;
const nearTop = c.win.start > 0 && first.getBoundingClientRect().top > -H;
const nearBottom = c.win.end < list.length && last.getBoundingClientRect().bottom < 2 * H;
if (!nearTop && !nearBottom) return;
measureNow(c);
holdScroll(render, c);
});
}
function holdScroll(paint, c = feed) {
let key = null, top = 0;
try {
for (const r of document.querySelectorAll('.notes-feed > .row[data-key]')) {
const b = r.getBoundingClientRect();
if (b.bottom > 0 && b.top < window.innerHeight) { key = r.getAttribute('data-key'); top = b.top; break; }
}
} catch {}
paint();
if (!key) return;
const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(key) + '"]');
try {
let r = find();
// Not mounted any more: a batch went in above (a catch-up of thirty
// posts, say) and the window, computed for the old scroll offset, now
// holds the newcomers instead of the reader's row. Estimate where the
// row went from the height cache, scroll there, and paint once more
// so the window catches up — then the usual correction lands it.
if (!r && c && c.heights && c.winList) {
const list = c.winList();
const i = list.findIndex((ev) => ev.id === key);
if (i >= 0) {
let sum = 0, n = 0;
for (const v of c.heights.values()) { sum += v; n++; }
const avg = n ? sum / n : 200;
let y = 0;
for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP;
window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - top));
paint();
r = find();
}
}
const d = r ? r.getBoundingClientRect().top - top : 0;
if (Math.abs(d) > 1) window.scrollBy(0, d);
} catch {}
}
// While the feed is what's on screen, new posts arrive by themselves — on
// the same relays the pass above reads, one subscription each.
function watchFeed() {
stopFeedWatch();
const def = feedDef();
if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const c = feedNow();
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 on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
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: FEED_KINDS, ...tag, since }, on)); return; }
const orT = orTopicsOf(def);
if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_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: FEED_KINDS, authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
feedUnsubs = [];
}
// Prepare older pages several screens ahead of the reader. A scroll while
// preparation is underway shares the same work instead of exposing it early.
let feedAheadTimer = null;
function prepareFeedAhead(c) {
if (feedAheadTimer || c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
feedAheadTimer = setTimeout(() => {
feedAheadTimer = null;
if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return;
if (mediaMode !== 'all') {
// a grid holds only the posts with media: keep fetching while its
// foot is near (within reason — a firehose has no end)
const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid');
if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {});
return;
}
if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging
const rows = document.querySelectorAll('.notes-feed > .row[data-key]');
const last = rows[rows.length - 1];
if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {});
}, 100);
}
async function loadOlderFeed() {
const c = feedNow();
if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
c.loadingMore = true;
if (c === feed) render(); // the footer says so while it happens
try {
if (c.shown < c.notes.length) {
const end = Math.min(c.shown + FEED_PAGE, c.notes.length);
const boundary = c.notes[end - 1].id;
await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)));
c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
return;
}
if (c.status !== 'ready' || c.end) return;
const oldest = c.notes.at(-1);
if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
let until = oldest.created_at - 1;
for (let tries = 0; tries < 3; tries++) {
c.sawAny = 0;
if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
// nothing older at all (or no step back): that really is the end
if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
until = c.seenBack - 1;
}
} finally {
c.loadingMore = false;
if (c === feed) render();
}
}
// Note content, safely: text stays text nodes — relay content must never
// reach innerHTML. URLs become links (image URLs inline), npub mentions a
// clickable @name, other nostr: refs a dim stub.
// Markdown, narrowly. A kind 1 is specified as plain text — NIP-10 says
// markup SHOULD NOT be used — but bridges and cross-posters emit it anyway,
// and what we did with it was the worst of the three options: the URL
// inside  rendered as a picture while the ]+>?[^)\\n]{0,300}\\)';
// A bare @handle ("Founder of @MusiKnow") names a person without a key.
// Nothing can resolve it for certain, so it becomes a tap that opens the
// people search with that name filled in. Not the @ inside an email or a
// nip05 (a word character sits before those), and never inside a URL
// (the URL token starts earlier and swallows it).
const MENTION = '(?)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
// in: the long one, the short one, and a Shorts link.
const YT = /^https?:\/\/(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?(?:[^\s]*&)?v=([\w-]{6,})|shorts\/([\w-]{6,})|live\/([\w-]{6,}))|youtu\.be\/([\w-]{6,}))/i;
const youtubeId = (url) => { const m = YT.exec(url || ''); return m ? (m[1] || m[2] || m[3] || m[4]) : null; };
const ytStart = (url) => {
const m = /[?&](?:t|start)=(\d+)/.exec(url || '') || /[?&]t=(\d+)s/.exec(url || '');
return m ? Math.max(0, parseInt(m[1], 10) || 0) : 0;
};
// ---- autoplay: a video plays, muted, while it is on screen --------------
// The way a timeline does it: a video starts by itself as it scrolls into
// view — muted, the only way a browser lets a page start one — pauses as
// it leaves, and a corner button turns the sound on. YouTube gets the same
// treatment: the player loads muted once its box is in view, and the
// button asks it to unmute over the iframe API. Data Saver keeps the old
// tap-to-play. One observer watches every player.
const autoplayOk = () => { try { return !(navigator.connection && navigator.connection.saveData); } catch { return true; } };
let playerWatch = null;
function watchPlayer(el, onIn, onOut) {
if (typeof IntersectionObserver === 'undefined' || !autoplayOk()) return;
if (!playerWatch) playerWatch = new IntersectionObserver((entries) => {
for (const en of entries) {
const el2 = en.target;
if (!el2.isConnected) { playerWatch.unobserve(el2); continue; }
if (en.isIntersecting && en.intersectionRatio >= 0.5) { if (el2._onIn) el2._onIn(); } else if (el2._onOut) el2._onOut();
}
}, { threshold: [0, 0.5] });
el._onIn = onIn; el._onOut = onOut;
playerWatch.observe(el);
}
// The corner button: what it says is the state it would switch to.
function soundBtn(onClick) {
const btn = h('button', { class: 'vid-sound', type: 'button', onClick: (e) => { e.stopPropagation(); e.preventDefault(); onClick(e.currentTarget); } });
btn.setSound = (on) => {
btn.classList.toggle('on', !!on);
btn.textContent = (on ? '\u{1F50A} ' : '\u{1F507} ') + (on ? t('videoMute') : t('videoUnmute'));
btn.setAttribute('aria-label', on ? t('videoMute') : t('videoUnmute'));
btn.title = on ? t('videoMute') : t('videoUnmute');
};
btn.setSound(false);
return btn;
}
// An inline video, in a box with the sound button; plays on its own while
// on screen. Once it has played, the box is the viewer's (no morph).
// A clip the reader turned the sound on for stays that way for the session,
// however its box is rebuilt: a repaint pairing children differently, the
// window unmounting the row and mounting it again, a quoted note loading.
// Pinning the box alone left every one of those a muted restart.
const unmutedClips = new Set();
const wokeClips = new Set(); // played, or tried to: the player's controls show
// A feed box spans the column and takes the clip's own shape when it's
// 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: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;
box._skipMorph = true;
const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'),
onClick: (e) => {
e.stopPropagation(); e.preventDefault();
tappedClips.add(url);
cover.remove();
onPlay && onPlay();
const v = box.querySelector('video');
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; });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
}
// ---- clips this browser can't picture, posters a post didn't give -------
// The server (vmedia/server.js) makes a still for a clip that came without
// one — a clip waiting for a tap was a black box — and an H.264 copy of
// one whose picture this browser can't decode: an iPhone's HEVC on Chrome
// for Linux plays its sound over black.
const VMEDIA = APP_BASE + '/vmedia/';
const vmediaOk = (url) => /^https:\/\//i.test(url || '');
// v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served)
const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined);
const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
const noPicture = new Set(), h264Failed = new Set();
const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session)
let hevcPlays;
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
})();
// what a player for this clip loads: the copy, once the original is known
// not to show here
const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
&& (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
// 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.
function guardPicture(v, url) {
if (!vmediaOk(url)) return;
const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA);
v.addEventListener('loadedmetadata', () => {
if (v.videoWidth || onCopy() || h264Failed.has(url)) return;
noPicture.add(url);
const want = !v.paused;
v.setAttribute('src', h264Of(url));
if (want) v.play().catch(() => {});
});
v.addEventListener('error', () => {
if (!onCopy()) return;
h264Failed.add(url);
const want = !v.paused;
v.setAttribute('src', url);
if (want) v.play().catch(() => {});
const box = v.parentElement;
if (box && !box.querySelector('.vid-nopic')) {
box._skipMorph = true;
box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoNoPicture')),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile'))));
}
});
}
// 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
// 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.
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
function videoTap(e, url) {
const v = e.currentTarget;
if (!v || v.tagName !== 'VIDEO') return;
const r = v.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
// a video's taps are its own: never the post's (which opened the thread
// and, full screen, took the player out from under the reader)
e.stopPropagation();
const fs = fsElement() === v;
// full screen, the video's box is the screen; its controls sit along the bottom
const H = fs ? window.innerHeight : r.height, W = fs ? window.innerWidth : r.width;
if (v.controls && y > H - 64) return; // the control bar
if (v.controls && v.paused && Math.hypot(x - W / 2, y - H / 2) < 48) return; // the big play button
e.preventDefault();
if (fs) {
if (!v.paused) v.pause();
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return;
}
// 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;
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return;
Promise.resolve(go.call(v)).then(() => {
// a wide clip fills a phone on its side
if (v.videoWidth > v.videoHeight) screen.orientation?.lock?.('landscape').catch(() => {});
}).catch(() => {});
}
if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => {
if (!fsElement()) { try { screen.orientation?.unlock?.(); } catch {} }
});
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
// Nothing is fetched until the clip is about to play (preload none): a
// window of rows used to pull every clip's metadata, which for a file
// with its index at the end can be most of the file.
// Until it plays, a clip is its picture (filling the box) and at most a
// play button: no seek bar, no sound button. Without autoplay the
// player's own controls are the way in, so they stay.
const idle = autoplayOk() && !wokeClips.has(url);
const v = h('video', { src: playSrc(url), class: 'note-video', controls: idle ? undefined : true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || serverThumb(url),
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
onClick: (e) => videoTap(e, url),
onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
guardPicture(v, url);
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);
if (loud) box._skipMorph = true;
// 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;
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
// Morphing copies handlers from a fresh tree onto the live button.
// Resolve its player at tap time instead of changing a detached video.
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) 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
});
v.onvolumechange = () => {
if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) 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);
// Off screen, a muted preview lets go of its media entirely (a paused
// player keeps buffering the whole file — a scroll past ten clips
// streamed seventy megabytes); back in view it reattaches and plays
// from the start. A clip the reader unmuted keeps its place instead.
watchPlayer(v,
() => {
box._inView = true;
clearTimeout(box._release);
if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) {
v.setAttribute('src', playSrc(url));
v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
}
// a clip over the budget waits for a tap, with what it costs on it
probeVideo(url).then(() => {
if (!box._inView || !v.isConnected) return;
// known by now not to show here: the copy, before a byte of the original
if (v.paused && v.getAttribute('src') && v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
if (videoHeavy(url)) { heavyCover(box, url, () => { v.muted = false; unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); }); return; }
if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(wake);
});
},
() => {
box._inView = false;
if (!v.paused) v.pause();
if (!(v.muted && box._skipMorph)) return;
// Let go a moment later, and hold the box at its height meanwhile:
// an instant release collapsed a tall clip the reader had just
// scrolled past, and the page jumped under their thumb.
clearTimeout(box._release);
box._release = setTimeout(() => {
if (box._inView || !v.isConnected) return;
const hgt = v.getBoundingClientRect().height;
if (hgt > 0) v.style.minHeight = hgt + 'px';
try { v.removeAttribute('src'); v.load(); } catch {}
}, 2500);
});
return box;
}
// The still, with a play button over it, and the player itself only once
// it's tapped — or, with autoplay, once the box scrolls into view, muted.
// A feed of ten videos would otherwise load ten YouTube players at once,
// every one of them telling Google what you're scrolling past; loading on
// view (and never with sound uninvited) keeps that to what you look at.
function youtubeEmbed(url, vid) {
const start = ytStart(url);
const embedSrc = (muted) => 'https://www.youtube-nocookie.com/embed/' + vid
+ '?autoplay=1&rel=0&playsinline=1&enablejsapi=1' + (muted ? '&mute=1' : '') + (start ? '&start=' + start : '')
+ (typeof location !== 'undefined' ? '&origin=' + encodeURIComponent(location.origin) : '');
const src = embedSrc(false);
// the iframe API listens for commands once told someone is listening
const post = (box, func) => {
const f = box.querySelector('iframe');
try { if (f && f.contentWindow) f.contentWindow.postMessage(JSON.stringify({ event: 'command', func, args: [] }), '*'); } catch {}
};
const load = (box, muted) => {
if (!box || box.dataset.playing) return;
box.dataset.playing = '1';
// The page repaints in the background all the time (a zap count,
// a profile landing, the thirty-second sync), and every repaint
// rebuilds this box as poster + play button and patches the live
// one into that shape — which threw the playing iframe out a few
// seconds into every video. Once playing, the box is the viewer's
// and the morph leaves it exactly as it stands.
box._skipMorph = true;
box.textContent = '';
const f = document.createElement('iframe');
f.src = muted ? embedSrc(true) : src;
f.title = 'YouTube';
f.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture; web-share';
f.referrerPolicy = 'strict-origin-when-cross-origin';
f.allowFullscreen = true;
f.onload = () => { try { f.contentWindow.postMessage(JSON.stringify({ event: 'listening', id: vid }), '*'); } catch {} };
box.append(f);
if (muted) {
let on = false;
const btn = soundBtn(() => { on = !on; btn.setSound(on); post(box, on ? 'unMute' : 'mute'); if (on) post(box, 'playVideo'); });
box.append(btn);
}
};
const frame = h('div', { class: 'yt-embed' },
h('img', {
class: 'yt-poster', loading: 'lazy', alt: '',
src: !feedPaint || feedMedia('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg')
? 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg' : undefined,
onError: (e) => { e.target.style.display = 'none'; },
}),
h('button', {
class: 'yt-play', 'aria-label': t('playVideo'), title: t('playVideo'),
onClick: (e) => {
e.stopPropagation();
const box = e.currentTarget.parentElement;
if (!box || box.dataset.playing) return;
load(box, false); // a tap means sound
},
}, h('span', { style: 'display:flex', html: '' })),
h('a', {
class: 'yt-open', href: url, target: '_blank', rel: 'noopener noreferrer',
title: t('openInYouTube'), onClick: (e) => e.stopPropagation(),
}, '\u2197'));
// in view: load muted the first time, resume after; out of view: pause
watchPlayer(frame,
() => { if (!frame.dataset.playing) load(frame, true); else if (frame._pausedByUs) { frame._pausedByUs = false; post(frame, 'playVideo'); } },
() => { if (frame.dataset.playing) { frame._pausedByUs = true; post(frame, 'pauseVideo'); } });
return frame;
}
// ---- quoted notes ---------------------------------------------------------
// A nostr:note1/nevent1 in someone's post IS a post, so show it: the thing
// they're talking about, inside what they said about it. Fetched once per
// id and remembered, so a feed that quotes the same note ten times asks for
// it once.
// A miss is not final: a relay that was down when we asked (ours was, for
// an evening) answered nothing, and the note sat "not on your relays"
// until a reload. A missing note is asked for again the next time it is
// wanted, half a minute on, with any relay hints the reference carried.
const QUOTE_RETRY_MS = 30_000;
const ARTICLE = 30023; // NIP-23 long-form
const quoted = new Map(); // id -> { status, ev, at }
// Where a referenced note may be: the reference's hints, its author's own
// relays, ours — and when none of those has it, a wider net of big public
// relays (a quoted note in the wild sat only on nostr.mom, which neither
// the reference nor its author's relay list named).
// (the archive-keeping ones hold what the big relays have pruned — a poll
// two days old was only on ditto and nostrplebs by 2026-09-30)
const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com', 'wss://nostr21.com',
'wss://nostr.wine', 'wss://relay.snort.social', 'wss://offchain.pub', 'wss://nostr.oxtr.dev',
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// Lookups wait 150 ms and go out together: one REQ per relay carrying
// every id wanted there, not one per quoted note (a scrolled feed sent a
// dozen relays a fresh REQ for each quote it met).
const findQ = new Map(); // relay -> Map(id -> waiters)
let findTimer = null;
function askNote(relays, id) {
relays = readRelayList(relays);
if (!relays.length) return Promise.resolve(null);
return new Promise((resolve) => {
const w = { left: relays.length, resolve, done: false };
for (const r of relays) {
let q = findQ.get(r);
if (!q) findQ.set(r, (q = new Map()));
if (!q.has(id)) q.set(id, []);
q.get(id).push(w);
}
if (!findTimer) findTimer = setTimeout(flushFind, 150);
});
}
function flushFind() {
findTimer = null;
const batch = [...findQ]; findQ.clear();
for (const [relay, wanted] of batch) {
const ids = [...wanted.keys()];
for (let i = 0; i < ids.length; i += 100) {
const slice = ids.slice(i, i + 100);
queryOn([relay], { ids: slice }, 4500).catch(() => []).then((evs) => {
const got = new Map(evs.map((e) => [e.id, e]));
for (const id of slice) for (const w of wanted.get(id)) {
if (w.done) continue;
const ev = got.get(id);
if (ev) { w.done = true; w.resolve(ev); }
else if (--w.left === 0) { w.done = true; w.resolve(null); }
}
});
}
}
}
// All at once, first answer wins: asked in turn, a note on a far relay
// took longer than the feed waits for a row.
function findNote(ref) {
const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
const asks = [
ask(near),
ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
];
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); });
});
}
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
c.at = Date.now();
c.promise = (async () => {
c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
if (c.ev) cacheNotifNotes([c.ev]);
// the open thread quotes it: keep it with the thread for the next reload
const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId);
if (th && th.root) persistThread(th);
scheduleRepaint();
})();
return c;
}
// A feed row that froze before its quote arrived showed a link — and
// nothing asked for the note again. Keep asking, and once the note and its
// own faces/pictures are in, the row takes the card.
function quoteWarm(ref) {
const c = quotedNote(ref);
if (c.ev && !c.warm && !c.warming) {
c.warming = noteReady(c.ev, Date.now() + READY_MS, 1).catch(() => {})
.then(() => { c.warm = true; c.warming = null; scheduleRepaint(); });
}
return c;
}
// The card. Deliberately not a noteRow: a quote is context, not another
// post to act on — no reply, no boost, no zap of its own. Tapping it opens
// the note properly, which is where those live.
function quoteCard(ref, depth) {
if (feedPaint && !feedPaint.quotes.has(ref.id)) feedPaint.quotes.set(ref.id, { ...quoted.get(ref.id) });
let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
if (feedPaint && !c?.ev) {
const live = quoteWarm(ref);
// not (yet) found: the card says so where the quote will be — never a
// bare "view note" link that only leads to the same answer
if (!(live.ev && live.warm)) return h('div', { class: 'quote-card' },
h('span', { class: 'small faint' }, t(live.status === 'missing' ? 'noteRefNotFound' : 'noteRefLoading')));
c = { ...live };
feedPaint.quotes.set(ref.id, c);
}
if (c.status === 'loading') {
return h('div', { class: 'quote-card quote-loading' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')));
}
if (!c.ev) {
return h('div', { class: 'quote-card' },
h('span', { class: 'small faint' }, t('noteRefNotFound')));
}
const ev = c.ev;
if (hidden(ev)) return h('div', { class: 'quote-card' }, h('span', { class: 'small faint' }, t('quoteHidden')));
return h('div', {
class: 'quote-card clickable',
onClick: (e) => { e.stopPropagation(); openNoteThread(ev); },
},
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
avatar(ev.pubkey, 'chat-avatar mini', false),
h('span', { class: 'quote-name' }, displayName(ev.pubkey)),
h('span', { class: 'small faint', style: 'white-space:nowrap' }, timeLabel(ev.created_at * 1000))),
h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
// one level deep only: a quote of a quote of a quote is a rabbit
// hole, and the inner one stays a link you can follow
...noteBody(shownTranslation(ev)?.text ?? noteText(ev), depth + 1, noteEmojiTags(ev))),
ev.kind === POLL ? pollBox(ev, { interactive: false }) : null);
}
// A nostr:naddr: a replaceable thing by its address (kind:author:d) — an
// article, a list. Its newest copy, from the hint relays, the author's,
// ours, and the wide net at once; the newest answer within the wait wins.
const addrKey = (ref) => 'a:' + ref.kind + ':' + ref.pk + ':' + ref.d;
async function findAddr(ref) {
const filter = { kinds: [ref.kind], authors: [ref.pk], '#d': [ref.d] };
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
const theirs = await Promise.resolve(relaysOf(ref.pk)).catch(() => []);
const relays = [...new Set([...near, ...(theirs || []), ...WIDE_RELAYS])];
const evs = await queryOn(relays, filter, 4500).catch(() => []);
return (evs || []).filter((e) => e.kind === ref.kind && e.pubkey === ref.pk)
.sort((a, b) => b.created_at - a.created_at)[0] || null;
}
function addrNote(ref) {
const key = addrKey(ref);
let c = quoted.get(key);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(key, c); }
c.at = Date.now();
profileOf(ref.pk);
findAddr(ref).catch(() => null).then((ev) => {
c.ev = ev || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
scheduleRepaint();
});
return c;
}
const tagStr = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1] || '';
// The card for it. An article shows its cover, title and summary and opens
// to read; a list or pack opens as a feed; anything else says what it is.
function addrCard(ref) {
const key = addrKey(ref);
let c = feedPaint ? feedPaint.quotes.get(key) : null;
if (!c?.ev) {
c = addrNote(ref);
if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c });
}
const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND;
if (!c.ev && isList) {
// a list needs nothing fetched to open: the feed resolves its members
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } },
h('span', { class: 'quote-name' }, '\u{1F465} ' + t('addrList', { name: displayName(ref.pk) })));
}
if (!c.ev) return h('div', { class: 'quote-card' + (c.status === 'loading' ? ' quote-loading' : '') },
c.status === 'loading' ? h('span', { class: 'spinner sm' }) : null,
h('span', { class: 'small faint' }, t(c.status === 'loading' ? 'addrLoading' : 'addrNotFound')));
const ev = c.ev;
if (hidden(ev)) return h('div', { class: 'quote-card' }, h('span', { class: 'small faint' }, t('quoteHidden')));
const title = tagStr(ev, 'title') || tagStr(ev, 'name');
const byline = h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
avatar(ev.pubkey, 'chat-avatar mini', false),
h('span', { class: 'quote-name' }, displayName(ev.pubkey)),
h('span', { class: 'small faint', style: 'white-space:nowrap' }, timeLabel((+tagStr(ev, 'published_at') || ev.created_at) * 1000)));
if (isList) {
const n = ev.tags.filter((x) => x[0] === 'p').length;
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title }); } },
byline, h('div', { class: 'addr-title' }, '\u{1F465} ' + (title || ref.d)),
n ? h('span', { class: 'small muted' }, t('feedPackMembers', { n })) : null);
}
if (ref.kind === ARTICLE) return articleCard(ev);
const alt = tagStr(ev, 'alt');
return h('div', { class: 'quote-card' }, byline,
h('div', { class: 'small muted' }, title || alt || t('noteForeignKind', { kind: ev.kind })));
}
// An article as a card: its cover, title and summary; a tap opens it to read.
// Its cover: the image tag, else the first pictures in its body. Covers
// die (a Blossom host drops the file and no other server has it): a dead
// one is remembered for the session and the next candidate takes its
// place, or the card goes without — never a grey box with a broken icon.
const deadCovers = new Set();
function articleCover(ev) {
const cands = [tagStr(ev, 'image').trim(), ...noteMediaUrls(ev.content).slice(0, 3)];
return cands.find((u) => /^https:\/\/\S+$/.test(u) && !deadCovers.has(u)) || null;
}
function articleCard(ev) {
const img = articleCover(ev);
const summary = tagStr(ev, 'summary');
return h('div', { class: 'quote-card clickable addr-article', onClick: (e) => { e.stopPropagation(); openNoteThread(ev); } },
img ? h('img', {
class: 'addr-cover', src: img, alt: '', loading: 'lazy', 'data-key': 'cover:' + img,
onError: (e) => { deadCovers.add(img); e.target.style.display = 'none'; scheduleRepaint(); },
}) : null,
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
avatar(ev.pubkey, 'chat-avatar mini', false),
h('span', { class: 'quote-name' }, displayName(ev.pubkey)),
h('span', { class: 'small faint', style: 'white-space:nowrap' }, timeLabel((+tagStr(ev, 'published_at') || ev.created_at) * 1000))),
h('div', { class: 'addr-title' }, tagStr(ev, 'title') || t('addrArticle')),
summary ? h('div', { class: 'small muted addr-summary' }, summary) : null,
h('span', { class: 'small faint' }, t('addrRead', { min: Math.max(1, Math.round(ev.content.split(/\s+/).length / 230)) })));
}
// An article's body: its markdown headings as headings, the rest as any post.
function articleBody(ev) {
const out = [];
const title = tagStr(ev, 'title');
let text = ev.content;
// most clients repeat the title as the first heading
if (title) text = text.replace(new RegExp('^\\s*#\\s+' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\n'), '');
if (title) out.push(h('div', { class: 'article-title' }, title));
const em = emojiTagMap(ev.tags);
for (const chunk of text.split(/^(#{1,6} .*)$/m)) {
const hd = /^(#{1,6}) (.*)$/.exec(chunk);
if (hd) out.push(h('div', { class: 'article-h' + Math.min(hd[1].length, 3) }, hd[2].trim()));
else if (chunk.trim()) out.push(...noteBody(chunk.replace(/^\n+|\n+$/g, ''), 0, em));
}
return out;
}
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
let c = feedPaint ? feedPaint.quotes.get(ref.id) : null;
if (!c?.ev) {
c = quotedNote(ref);
if (feedPaint && c.ev) feedPaint.quotes.set(ref.id, { ...c });
}
const open = (e) => { e.preventDefault(); e.stopPropagation(); if (c.ev) openNoteThread(c.ev); else openNoteRef(ref); };
if (!c.ev || hidden(c.ev)) return h('a', { href: '#', class: 'quote-mini small', onClick: open },
t(c.status === 'loading' ? 'noteRefLoading' : 'noteRefLink'));
const words = plainExcerpt(c.ev.content.replace(/https?:\/\/\S+|nostr:(note|nevent|naddr)1[a-z0-9]+/gi, ' '));
return h('div', { class: 'quote-mini clickable', onClick: open },
avatar(c.ev.pubkey, 'chat-avatar mini', false),
h('span', { class: 'quote-name' }, displayName(c.ev.pubkey)),
h('span', { class: 'small muted quote-mini-text' }, words || '\u2026'));
}
// One URL, rendered as whatever it points at. `isImage` is markdown saying
// so outright — plenty of perfectly good picture URLs carry no extension
// (a CDN path, a /media/ route), and ![…] is the author telling us what it
// is, which beats guessing from the filename.
// A picture is fitted inside its box (object-fit: contain), so a tall one
// leaves blank bands either side. A tap there is a tap on the post — it
// opens the thread — not on the picture.
function onPicture(e) {
const el = e.currentTarget;
const r = el.getBoundingClientRect();
const nw = el.naturalWidth, nh = el.naturalHeight;
if (!nw || !nh || !r.width || !r.height) return true;
const k = Math.min(r.width / nw, r.height / nh);
const w = nw * k, hh = nh * k;
const x = e.clientX - (r.left + (r.width - w) / 2), y = e.clientY - (r.top + (r.height - hh) / 2);
return x >= 0 && x <= w && y >= 0 && y <= hh;
}
function urlNode(url, { label = null, isImage = false } = {}) {
if (VIDEO_RE.test(url)) {
return videoNode(url, { stable: !!feedPaint });
}
if (AUDIO_RE.test(url)) return audioNode(url, label);
if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
const size = feedPaint ? feedMedia(url) : null;
if (feedPaint && !size) {
// a picture that refused to load is a link; one that was merely
// slow (a 3.5 MB PNG on a phone outran the row's deadline) keeps
// loading inside a box of fixed shape, so nothing moves when it lands
if (mediaFailed.has(url)) return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label || url);
return h('img', {
src: url, class: 'note-img note-img-late clickable', loading: 'lazy', decoding: 'async', alt: label || '',
onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
onError: (e) => { e.target.style.display = 'none'; },
});
}
// tap it to see it full size (the feed shows it uncropped, but
// height-capped: see .note-img)
const known = size || mediaReady.get(url);
return h('img', {
src: mediaSrc(url, known), class: 'note-img clickable', loading: feedPaint || known ? 'eager' : 'lazy', alt: label || '',
decoding: known && known.thumb ? 'sync' : undefined, // a few-ms decode: paint it with the page
width: size?.width, height: size?.height,
style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height + ';--ar:' + (size.width / size.height).toFixed(4) : undefined,
onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
// a picture we were TOLD was a picture and which won't load leaves
// nothing behind — the alt text is already in the sentence above it
onError: (e) => { if (!size) e.target.style.display = 'none'; },
});
}
if (youtubeId(url)) return youtubeEmbed(url, youtubeId(url));
if (!label && previewable(url)) {
// a frozen feed row keeps the card (or the bare link) it first painted
let pv = previews.get(url) || null;
if (feedPaint) {
if (!feedPaint.media.has('pv:' + url)) feedPaint.media.set('pv:' + url, pv);
pv = feedPaint.media.get('pv:' + url);
}
if (pv) return linkCard(url, pv);
}
const shown = label || (url.length > 64 ? url.slice(0, 61) + '…' : url);
return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, shown);
}
// An audio file (a podcast episode on Blossom, a voice note) as a player,
// the way Amethyst shows one. Nothing is fetched until play; once playing
// the box is the listener's and no repaint touches it.
function audioNode(url, label) {
let name = label;
if (!name) {
try {
const u = new URL(url);
const file = decodeURIComponent(u.pathname.split('/').pop() || '');
// a Blossom name is the file's hash: the host says more than that does
name = /^[0-9a-f]{64}\./i.test(file) ? u.hostname : file || u.hostname;
} catch { name = url; }
}
return h('div', { class: 'note-audio', onClick: (e) => e.stopPropagation() },
h('div', { class: 'note-audio-head' },
h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)),
h('audio', {
controls: true, preload: 'none', src: url,
onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; },
}));
}
// ---- encrypted attachments (NIP-92 imeta + NIP-17 file fields) ----
// A picture in a Concord channel never reaches the media server in the
// clear: the sender encrypts it under a one-off key, uploads the blob to
// Blossom, and puts the pointer in an imeta tag — url, mime, aes-gcm key
// and nonce, the plaintext's sha256 (ox), a size and dimensions. The bubble
// used to show nothing for such a message (its content is empty). Now the
// blob is fetched, decrypted here, checked against the hash, and shown
// from an object URL. A blob that fails the check fails closed.
const ATTACH_MAX = 25 * 1024 * 1024;
const attachCache = new Map(); // url -> { state: 'loading'|'ready'|'error', src, blob }
const parseImeta = (tag) => {
const a = { fallback: [] };
for (const f of tag.slice(1)) {
const i = f.indexOf(' ');
if (i < 0) continue;
const k = f.slice(0, i), v = f.slice(i + 1);
if (k === 'fallback') a.fallback.push(v); else a[k] = v;
}
return a.url ? a : null;
};
const attachmentsOf = (rumor) => {
const out = (rumor.tags || []).filter((t) => t[0] === 'imeta').map(parseImeta).filter(Boolean);
// a NIP-17 file message: the same fields as flat tags, the url as content
if (rumor.kind === 15 && /^https?:\/\//.test(rumor.content || '')) {
const a = { url: rumor.content.trim(), fallback: [] };
for (const [k, v] of rumor.tags || []) {
if (k === 'file-type') a.m = v;
else if (k === 'fallback') a.fallback.push(v);
else if (['encryption-algorithm', 'decryption-key', 'decryption-nonce', 'ox', 'x', 'size', 'dim', 'name'].includes(k)) a[k] = v;
}
out.push(a);
}
return out;
};
async function loadAttachment(a) {
const entry = { state: 'loading' };
attachCache.set(a.key || a.url, entry);
try {
if (+a.size > ATTACH_MAX) throw new Error('too big');
let buf = null;
for (const url of [a.url, ...a.fallback]) {
try {
const res = await fetch(url, { mode: 'cors', signal: AbortSignal.timeout(30_000) });
if (!res.ok) continue;
buf = await res.arrayBuffer();
if (buf.byteLength > ATTACH_MAX) throw new Error('too big');
break;
} catch (e) { if (String(e.message).includes('too big')) throw e; }
}
if (!buf) throw new Error('unreachable');
let bytes = new Uint8Array(buf);
const alg = (a['encryption-algorithm'] || '').toLowerCase();
if (a.wn) {
// White Noise: the key is the group's, for the epoch the message came in
const g = wn && wn.groups.get(a.wn.gid);
if (!g) throw new Error('group gone');
bytes = wn.media.open(g, a.wn.epoch, bytes, a.wn.ref);
} else if (alg) {
if (alg !== 'aes-gcm' || !a['decryption-key'] || !a['decryption-nonce']) throw new Error('unsupported');
const key = await crypto.subtle.importKey('raw', hexToBytes(a['decryption-key']), 'AES-GCM', false, ['decrypt']);
bytes = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(a['decryption-nonce']) }, key, bytes));
}
if (a.ox) {
const digest = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
if (digest !== a.ox.toLowerCase()) throw new Error('hash mismatch');
}
const blob = new Blob([bytes], { type: a.m || 'application/octet-stream' });
Object.assign(entry, { state: 'ready', blob, src: URL.createObjectURL(blob) });
} catch (e) {
entry.state = 'error';
console.warn('attachment:', a.url, e.message);
}
scheduleRepaint();
}
// The nodes for a rumor's attachments: a picture inline (tap to view),
// any other file as a download by name, a grey box the picture's shape
// while it loads, and a quiet note when it can't be had.
// A White Noise message's attachments (encrypted-media-v2): the blob is
// found by its locators, then by its hash on the usual Blossom hosts.
const WN_BLOB_HOSTS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net', 'https://blossom.divine.video', 'https://cdn.hzrd149.com'];
const wnAttachmentsOf = (rumor, gid) => (wn ? (rumor.tags || []).map(wn.media.read).filter(Boolean) : []).map((x) => ({
key: 'wn:' + x.ref.cipher,
url: x.urls[0] || WN_BLOB_HOSTS[0] + '/' + x.ref.cipher,
fallback: [...x.urls.slice(1), ...WN_BLOB_HOSTS.map((u) => u + '/' + x.ref.cipher)].filter((u) => u !== x.urls[0]),
m: x.ref.m, name: x.ref.name, dim: x.dim,
wn: { gid, epoch: rumor.epoch, ref: x.ref },
}));
// `gid`: the White Noise group the message came through, if it did
function attachmentNodes(rumor, gid) {
return (gid ? wnAttachmentsOf(rumor, gid) : attachmentsOf(rumor)).map((a) => {
const isImg = /^image\//i.test(a.m || '') || /\.(png|jpe?g|gif|webp|avif)$/i.test(a.name || a.url.split('?')[0]);
const encrypted = !!a['encryption-algorithm'] || !!a.wn;
if (!encrypted && isImg) return urlNode(a.url, { isImage: true });
const ck = a.key || a.url;
let entry = attachCache.get(ck);
if (!entry && isImg) { loadAttachment(a); entry = attachCache.get(ck); }
const [w, hgt] = String(a.dim || '').split('x').map(Number);
const ratio = w > 0 && hgt > 0 ? `${w}/${hgt}` : '4/3';
if (entry && entry.state === 'ready') {
if (isImg) return h('img', {
src: entry.src, class: 'note-img clickable', alt: a.name || '',
onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(entry.src); },
});
return h('a', { class: 'chat-attach', href: entry.src, download: a.name || 'file', onClick: (e) => e.stopPropagation() },
'📎 ' + (a.name || 'file') + (a.size ? ` · ${Math.round(+a.size / 1024)} KB` : ''));
}
if (entry && entry.state === 'error') return h('div', { class: 'small muted' }, t('msgAttachFailed'));
if (!isImg) return h('button', {
class: 'chat-attach linklike', onClick: (e) => { e.stopPropagation(); if (!attachCache.has(ck)) loadAttachment(a); },
}, '📎 ' + (a.name || 'file') + (a.size ? ` · ${Math.round(+a.size / 1024)} KB` : ''));
return h('div', { class: 'chat-attach-ph', style: `aspect-ratio:${ratio}` });
});
}
// ---- sending a picture: encrypt it, put the blob on Blossom, post the
// pointer. Vector's shape exactly, so its users see it: a fresh AES-GCM
// key and 16-byte nonce per file, the ciphertext uploaded as an opaque
// blob, the key riding inside the (already encrypted) message. The media
// host never sees a picture. coinos's own Blossom server stores only sync
// envelopes, so these go to public hosts that take anonymous blobs.
const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com'];
const MEDIA_MAX = 20 * 1024 * 1024;
async function encryptFile(file) {
if (file.size > MEDIA_MAX) throw new Error(t('msgAttachTooBig'));
const plain = new Uint8Array(await file.arrayBuffer());
const key = crypto.getRandomValues(new Uint8Array(32));
const nonce = crypto.getRandomValues(new Uint8Array(16));
const k = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, k, plain));
let dim = '';
if (/^image\//.test(file.type)) {
try { const bmp = await createImageBitmap(file); dim = `${bmp.width}x${bmp.height}`; bmp.close(); } catch {}
}
return {
cipher, key: bytesToHex(key), nonce: bytesToHex(nonce), ox: bytesToHex(sha256(plain)), x: bytesToHex(sha256(cipher)),
m: file.type || 'application/octet-stream', name: file.name || 'file', size: String(cipher.length), dim,
};
}
// BUD-01 upload with a kind-24242 auth signed by whoever is sending.
async function uploadEncrypted(id, enc) {
const now = Math.floor(Date.now() / 1000);
const evt = { kind: 24242, pubkey: id.pubkey, created_at: now - 5, content: 'upload',
tags: [['t', 'upload'], ['x', enc.x], ['expiration', String(now + 600)]] };
const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt);
const header = 'Nostr ' + btoa(JSON.stringify(auth));
const urls = [];
for (const server of MEDIA_SERVERS) {
try {
const r = await fetch(server + '/upload', {
method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000),
headers: { authorization: header, 'content-type': 'application/octet-stream', 'x-sha-256': enc.x },
});
if (!r.ok) continue;
const j = await r.json();
if (j && j.url) urls.push(j.url);
} catch {}
// one copy is enough for a big file, two for a small one — later hosts are fallbacks
if (urls.length >= (enc.cipher.length > 4 * 1024 * 1024 ? 1 : 2)) break;
}
if (!urls.length) throw new Error(t('msgUploadFailed'));
return urls;
}
const imetaTag = (enc, urls) => ['imeta',
'url ' + urls[0], 'm ' + enc.m, 'encryption-algorithm aes-gcm', 'decryption-key ' + enc.key, 'decryption-nonce ' + enc.nonce,
'size ' + enc.size, 'ox ' + enc.ox, 'x ' + enc.x, 'name ' + enc.name, ...(enc.dim ? ['dim ' + enc.dim] : []),
...urls.slice(1).map((u) => 'fallback ' + u)];
// the local copy shows at once, from the plaintext we still hold
const cacheLocal = (enc, urls, file) => {
const blob = new Blob([file], { type: enc.m });
for (const u of urls) attachCache.set(u, { state: 'ready', blob, src: URL.createObjectURL(blob) });
};
async function sendAttachment(room, chId, file) {
const id = await identity();
if (!id) { noIdToast(); return; }
ui.msgUploading = true; render();
try {
const enc = await encryptFile(file);
const urls = await uploadEncrypted(id, enc);
cacheLocal(enc, urls, file);
const { created_at, ms } = msTags(Date.now());
const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
const rumor = rumorWithId({
kind: 9, pubkey: id.pubkey, content: '',
tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], imetaTag(enc, urls), ...(replyTo ? [['e', replyTo]] : []), ms], created_at,
});
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
msgs.set(rumor.id, entry);
ui.msgStick = true; ui.msgUploading = false; render();
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
seenWraps.add(wrap.id);
const ok = await publishOn(room.relays, wrap);
if (!ok) { msgs.delete(rumor.id); toast(t('msgSendFailed')); render(); return; }
delete entry.pending;
ensureJoined(room, id).catch(() => {});
persistCache(room);
} catch (e) { toast(e.message || String(e)); }
ui.msgUploading = false; render();
}
// A file into a White Noise group (encrypted-media-v2): sealed under the
// group's media secret for the epoch it goes out in, uploaded, and sent as
// a kind 9 with an imeta tag. A commit landing during the upload moves the
// epoch and would leave the key behind it — then it is sealed again.
// `road`: a group record, or 'new' (a direct chat with `peer` to found).
async function wnSendFile(road, file, peer) {
const id = await identity();
if (!id) { noIdToast(); return; }
if (file.size > MEDIA_MAX) { toast(t('msgAttachTooBig')); return; }
ui.msgUploading = true; render();
try {
const plain = new Uint8Array(await file.arrayBuffer());
let dim = '';
if (/^image\//.test(file.type)) {
try { const bmp = await createImageBitmap(file); dim = `${bmp.width}x${bmp.height}`; bmp.close(); } catch {}
}
const g = road === 'new' ? await wn.create({ members: [peer] }) : road;
const thread = peer ? threadOf(peer) : null;
const replyTo = ui.msgReplyTo && (thread ? thread.has(ui.msgReplyTo) : wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo)) ? ui.msgReplyTo : null;
for (let tries = 0; ; tries++) {
const sealed = wn.media.seal(g, plain, { type: file.type, name: file.name });
const urls = await uploadEncrypted(id, { cipher: sealed.cipher, x: sealed.ref.cipher });
if (g.tip.epoch !== sealed.epoch && tries < 2) continue;
// the local copy shows at once, from the plaintext we still hold
const blob = new Blob([file], { type: sealed.ref.m });
attachCache.set('wn:' + sealed.ref.cipher, { state: 'ready', blob, src: URL.createObjectURL(blob) });
const inner = wn.event(9, '', [wn.media.tag(sealed.ref, urls, dim), ...(replyTo ? [['e', replyTo]] : [])]);
ui.msgReplyTo = null;
if (!(await wn.send(g, inner))) throw new Error(t('msgSendFailed'));
if (thread) thread.set(inner.id, { rumor: g.log.find((m) => m.id === inner.id) || inner, mine: true, wn: g.id });
wnFolds.delete(g.id);
break;
}
ui.msgStick = true;
} catch (e) { toast(e.message === 'no signer' ? t('msgNoIdentity') : (e.message || String(e))); }
ui.msgUploading = false; render();
}
// A DM picture is a NIP-17 kind-15 file message: url as content, the
// same fields as flat tags, wrapped to the peer and to ourselves.
async function sendDMFile(peer, file) {
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
const road = await wnRoute(peer).catch(() => null);
if (road) return wnSendFile(road, file, peer);
ui.msgUploading = true; render();
try {
const enc = await encryptFile(file);
const urls = await uploadEncrypted(id, enc);
cacheLocal(enc, urls, file);
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
const rumor = rumorWithId({
kind: 15, pubkey: id.pubkey, content: urls[0], created_at: Math.floor(Date.now() / 1000),
tags: [['p', peer], ...(replyTo ? [['e', replyTo]] : []),
['file-type', enc.m], ['encryption-algorithm', 'aes-gcm'], ['decryption-key', enc.key], ['decryption-nonce', enc.nonce],
['x', enc.x], ['ox', enc.ox], ['size', enc.size], ['name', enc.name], ...(enc.dim ? [['dim', enc.dim]] : []),
...urls.slice(1).map((u) => ['fallback', u])],
});
const entry = { rumor, mine: true, pending: true };
threadOf(peer).set(rumor.id, entry);
ui.msgStick = true; ui.msgUploading = false; render();
const toPeer = await wrapDM(id.signer, peer, rumor);
const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
const ok = await publishOn(DM_RELAYS, toPeer);
publishOn(DM_RELAYS, toSelf);
toOwnInbox(toSelf);
fetchInboxRelays(peer).then((inbox) => {
const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r));
if (extra.length) publishOn(extra, toPeer, { onauth: relayAuth });
}).catch(() => {});
if (!ok) { threadOf(peer).delete(rumor.id); toast(t('msgSendFailed')); render(); return; }
delete entry.pending;
persistDms();
} catch (e) { toast(e.message || String(e)); }
ui.msgUploading = false; render();
}
// `em` is the event's own ["emoji", code, url] map (NIP-30): a :code: it
// names becomes its picture, any other :code: stays text — a message
// never borrows pictures from packs the READER happens to hold.
// Inline markdown in a post's plain text, the subset people actually type
// and Amethyst already shows: **bold** / __bold__, *italic* / _italic_,
// ~~struck~~ and `code`. Strict about edges so snake_case, 2*3*4 and a lone
// asterisk stay as typed; markers never reach across a link, mention or
// hashtag (those are split out first). No headings or lists in posts —
// "#" at a line's start is a hashtag here, and articles have their own.
const MD_INLINE = /(`[^`\n]+`)|(\*\*\*(?=\S)[^\n]*?\S\*\*\*)|(\*\*(?=\S)[^\n]*?\S\*\*)|((? em.get(c) : null;
for (const part of String(text || '').split(NOTE_SPLIT)) {
if (!part) continue;
const md = MD_PARTS.exec(part);
if (md) {
const [, bang, label, url] = md;
out.push(urlNode(url, { label: label || null, isImage: !!bang }));
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk)));
else out.push(h('span', { class: 'faint' }, bare.slice(0, 12) + '…'));
} else if (/^nostr:(note|nevent)1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'event') {
// the note they're quoting, shown inside what they said about it —
// except one level down, where it goes back to being a link
out.push(depth === 1 ? quoteMini(ref) : depth ? h('a', {
href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); },
}, t('noteRefLink')) : quoteCard(ref, depth));
} else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:naddr1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref));
else if (ref && ref.type === 'addr') out.push(h('a', {
href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
}, t('noteRefLink')));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:/i.test(part)) {
out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^@[A-Za-z0-9_]{2,32}$/.test(part)) {
out.push(h('a', {
href: '#', title: t('searchPeopleFor', { q: part.slice(1) }),
onClick: (e) => { e.preventDefault(); e.stopPropagation(); openPeopleSearch(part.slice(1)); },
}, part));
} else if (/^#[A-Za-z0-9_\u00C0-\uFFFF]{2,64}$/.test(part)) {
out.push(h('a', {
href: '#', title: t('searchTopicFor', { q: part.slice(1) }),
onClick: (e) => { e.preventDefault(); e.stopPropagation(); openTopicFeed(part.slice(1)); },
}, part));
} else if (emojiAt && part.includes(':')) {
for (const p of splitEmoji(part, emojiAt)) out.push(...(typeof p === 'string' ? mdInline(p) : [emojiImg(p.code, p.url)]));
} else out.push(...mdInline(part));
}
return out;
}
// The header magnifier's search, opened with a name already typed.
function openPeopleSearch(q) {
ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null;
ui.userSearch = { q, rows: null };
warmSearch();
userSearcher.update(q);
render();
}
// Zap a specific note. With a default amount configured this is ONE TAP:
// the zap fires instantly (ark first, Lightning fallback) and reports by
// toast, no form, no leaving the page. Without one, a small setup screen
// asks once and remembers.
// Whether a ⚡ makes sense for this author from this wallet: not
// ourselves, and an instant path (Ark) or a Lightning fallback exists.
// Your own posts and messages too: a zap to yourself is a harmless
// round-trip, and one bar that behaves the same everywhere beats a special case.
const canZapPk = (pk) => !!pk && !isVisitor() && !!(hook('arkReady') || hook('canLnZap')); // a visitor's tap asks them in instead
function zapNote(pk, ev, origin) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return;
}
markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally
if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
settleZap(ev.id, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
}
// Held ⚡: the same screen, opened to change the amount — saving alone is
// the main action, zapping this note with the new amount the second.
function openZapSettings(pk, id, origin) {
const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
// the reader's place in the feed, to return to: the settings screen
// replaces the page, and the page comes back at its top otherwise
ui.zapSetup = { pk, npub: npubOf(pk), eventId: id, amount: String(cur || 21), origin, edit: true, sound: zapSoundOn(), scrollY };
render();
}
// Leave the zap settings the way they were opened: the page repaints,
// then the window is put back where the reader was (the feed's windowing
// mounts the rows for that place on the scroll).
function closeZapSetup() {
const y = ui.zapSetup && ui.zapSetup.scrollY;
ui.zapSetup = null;
render();
if (y) { try { window.scrollTo(0, y); } catch {} }
}
// First ⚡ tap ever: pick the amount one time, then every zap is one tap.
function zapSetupScreen() {
const s = ui.zapSetup;
const saved = () => {
const n = parseInt(s.amount, 10);
if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return 0; }
ctx.setZapDefaultSat(n);
if (s.edit && ctx.setZapSound && s.sound !== zapSoundOn()) ctx.setZapSound(s.sound);
return n;
};
const saveAndZap = () => {
const n = saved();
if (!n) return;
const { pk, npub, eventId } = s;
closeZapSetup();
markZapPending(eventId, n, s.origin);
if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
settleZap(eventId, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
recheckZap(eventId);
};
const saveOnly = () => {
const n = saved();
if (!n) return;
toast('⚡ ' + t('zapAmountSaved', { n: n.toLocaleString() }));
closeZapSetup();
};
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, '⚡ ' + t('zapSetupTitle')),
h('div', { class: 'small muted' }, t(s.edit ? 'zapSettingsDesc' : 'zapSetupDesc')),
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '1', value: s.amount, onInput: (e) => { s.amount = e.target.value; } }),
h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, 'sats')),
s.edit ? h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', checked: s.sound, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (e) => { s.sound = e.target.checked; } }),
h('span', { class: 'small' }, t('zapSoundToggle'))) : null,
s.edit
? [h('button', { class: 'btn-primary btn-block', onClick: saveOnly }, t('save')),
h('button', { class: 'btn-ghost btn-block', onClick: saveAndZap }, t('zapSetupSave'))]
: h('button', { class: 'btn-primary btn-block', onClick: saveAndZap }, t('zapSetupSave'))),
h('button', { class: 'btn-ghost btn-block', onClick: closeZapSetup }, t('back')));
}
// One post as a feed row (avatar · name · time · body), twitter/jumble
// style: rows share a scrollable container and are split by hairlines
// rather than floating in their own cards. Tapping a row opens its thread.
// ---- what you can do to a post -------------------------------------------
// Like, boost, quote, and — behind the ellipsis — mute or block its author.
// Each publishes to the author's own relays as well as ours, so the person
// being answered actually sees it.
const myReactOn = (id) => {
const m = reacts.get(id);
if (!m) return null;
const mine = myPubkeys();
for (const reaction of m.values()) for (const pk of mine) if (reaction.who.has(pk)) return reaction;
return null;
};
const iBoosted = (id) => {
const set = boosts.get(id);
return !!set && myPubkeys().some((pk) => set.has(pk));
};
const noteRelaysFor = async (ev) =>
[...new Set([...zapRelays(), ...(await relaysOf(ev.pubkey))])];
// A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
function replyToNote(ev) {
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
render();
// Focus in the tap handler so mobile browsers can open the keyboard.
focusThreadReply(s);
}
function focusThreadReply(s) {
if (ui.noteThread !== s || ui.profOverThread) return;
const input = document.querySelector('.thread-reply-input');
if (!input) return;
input.focus({ preventScroll: true });
input.scrollIntoView({ block: 'nearest' });
}
async function unreact(ev) {
const id = await requireIdentity();
const relays = await noteRelaysFor(ev);
{
// Taking a like back is the only withdrawal nostr has: a deletion
// request naming the reaction. Relays that honour it drop it; the ones
// that don't go on showing it to other people — worth knowing, not
// worth refusing to try.
const m = reacts.get(ev.id);
if (m) for (const [key, { who }] of [...m]) { for (const pk of myPubkeys()) who.delete(pk); if (!who.size) m.delete(key); }
render();
const mineEv = myReactEv.get(ev.id);
if (!mineEv) return;
myReactEv.delete(ev.id);
const gone = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', mineEv], ['k', '7']] };
const del = id.signer instanceof Uint8Array ? finalizeEvent(gone, id.signer) : await id.signer.signEvent(gone);
publishOn(relays, del).catch(() => {});
return;
}
}
// React with whichever emoji was picked (NIP-25 takes any content).
// ---- polls (NIP-88) -------------------------------------------------------
// A kind 1068 poll is its question (content) plus ["option", id, label]
// tags; votes are kind 1018 events e-tagging it with ["response", id].
// Each person's LATEST vote counts (one option unless the poll says
// "multiplechoice"), and nothing cast after its endsAt.
const POLL = 1068, POLL_VOTE = 1018;
const pollOptions = (ev) => ev.tags.filter((x) => x[0] === 'option' && x[1] != null && x[2])
.map((x) => ({ id: String(x[1]), label: String(x[2]) }));
const pollMulti = (ev) => (ev.tags.find((x) => x[0] === 'polltype') || [])[1] === 'multiplechoice';
const pollEnds = (ev) => { const v = Number((ev.tags.find((x) => x[0] === 'endsAt') || [])[1]); return Number.isFinite(v) && v > 0 ? v : 0; };
const pollEnded = (ev) => !!pollEnds(ev) && Date.now() / 1000 > pollEnds(ev);
const pollRelays = async (ev) => [...new Set([
...ev.tags.filter((x) => x[0] === 'relay' && /^wss?:\/\//i.test(x[1] || '')).map((x) => x[1]),
...(await noteRelaysFor(ev).catch(() => zapRelays())),
])].slice(0, 12);
const pollVotes = new Map(); // poll id -> { at, loaded, loading, by: Map(pk -> { t, ids }) }
function pollState(ev) {
let st = pollVotes.get(ev.id);
if (!st) { st = { at: 0, loaded: false, loading: false, by: new Map() }; pollVotes.set(ev.id, st); }
return st;
}
function pollCount(poll, v) {
const st = pollState(poll);
const ends = pollEnds(poll);
if (ends && v.created_at > ends) return;
const prev = st.by.get(v.pubkey);
if (prev && prev.t >= v.created_at) return;
const valid = new Set(pollOptions(poll).map((o) => o.id));
let ids = [...new Set(v.tags.filter((x) => x[0] === 'response').map((x) => String(x[1])))].filter((id) => valid.has(id));
if (!pollMulti(poll)) ids = ids.slice(0, 1);
st.by.set(v.pubkey, { t: v.created_at, ids });
}
// Votes are asked for when a poll is painted, and again a minute on.
function pollTally(ev) {
const st = pollState(ev);
if (!st.loading && Date.now() - st.at > 60_000) {
st.loading = true; st.at = Date.now();
(async () => {
const evs = await queryOn(await pollRelays(ev), { kinds: [POLL_VOTE], '#e': [ev.id], limit: 1000 }, 5000).catch(() => []);
for (const v of evs || []) if (v.kind === POLL_VOTE) pollCount(ev, v);
})().finally(() => { st.loading = false; st.loaded = true; scheduleRepaint(); });
}
return st;
}
async function votePoll(poll, optId) {
if (pollEnded(poll)) return;
const id = await requireIdentity();
const st = pollState(poll);
const prev = st.by.get(id.pubkey);
const mine = prev?.ids || [];
const ids = pollMulti(poll) ? (mine.includes(optId) ? mine.filter((x) => x !== optId) : [...mine, optId]) : [optId];
if (!pollMulti(poll) && mine[0] === optId) return;
const partial = {
kind: POLL_VOTE, content: '',
// strictly newer than our last vote, or relays and counters keep the old one
created_at: Math.max(Math.floor(Date.now() / 1000), (prev?.t || 0) + 1),
tags: [['e', poll.id], ['p', poll.pubkey], ...ids.map((x) => ['response', x]), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
pollCount(poll, evt); // counted on screen before the relays answer
render();
const ok = await publishOn(await pollRelays(poll), evt).catch(() => false);
if (!ok) toast(t('msgSendFailed'));
}
// The options as rows with their share of the vote. In a quote card
// (`interactive` off) they are a picture of the poll — the tap opens it.
function pollBox(ev, { interactive = true } = {}) {
const opts = pollOptions(ev);
if (!opts.length) return null;
const st = pollTally(ev);
const counts = new Map(opts.map((o) => [o.id, 0]));
let voters = 0;
for (const v of st.by.values()) {
if (!v.ids.length) continue;
voters++;
for (const x of v.ids) counts.set(x, (counts.get(x) || 0) + 1);
}
const mine = new Set(myPubkeys().flatMap((pk) => st.by.get(pk)?.ids || []));
const ended = pollEnded(ev);
const canVote = interactive && !ended;
const shown = st.loaded || voters > 0;
const foot = [
shown ? t(voters === 1 ? 'pollVoter' : 'pollVoters', { n: voters }) : null,
pollMulti(ev) ? t('pollMulti') : null,
ended ? t('pollEnded') : pollEnds(ev) ? t('pollEndsAt', { when: new Date(pollEnds(ev) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }) : null,
].filter(Boolean).join(' \u00b7 ');
return h('div', { class: 'poll' },
...opts.map((o) => {
const n = counts.get(o.id) || 0;
const pct = voters ? Math.round((n / voters) * 100) : 0;
return h(canVote ? 'button' : 'div', {
class: 'poll-opt' + (mine.has(o.id) ? ' mine' : '') + (canVote ? '' : ' static'),
type: canVote ? 'button' : undefined,
onClick: canVote ? (e) => { e.stopPropagation(); votePoll(ev, o.id).catch(() => {}); } : undefined,
},
h('span', { class: 'poll-bar', style: 'width:' + (shown ? pct : 0) + '%' }),
h('span', { class: 'poll-label' }, (mine.has(o.id) ? '\u2713 ' : '') + o.label),
shown ? h('span', { class: 'poll-pct' }, pct + '%') : null);
}),
foot ? h('div', { class: 'small faint poll-foot' }, foot) : null);
}
async function reactTo(ev, emoji) {
const id = await requireIdentity();
const partial = {
kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000),
tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
myReactEv.set(ev.id, evt.id);
noteEvent(evt); // on screen before the relays answer
render();
// (the author's relay list can take seconds to find: it isn't needed to paint)
publishOn(await noteRelaysFor(ev), evt).catch(() => {});
}
// A boost is kind 6 carrying the note it repeats (NIP-18).
async function boostNote(ev) {
if (iBoosted(ev.id)) { toast(t('postBoostedAlready')); return; }
const id = await requireIdentity();
const relays = await noteRelaysFor(ev);
const partial = {
kind: 6, content: JSON.stringify(ev), created_at: Math.floor(Date.now() / 1000),
tags: [['e', ev.id, relays[0] || '', 'mention'], ['p', ev.pubkey], CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
noteEvent(evt);
render();
const ok = await publishOn(relays, evt);
toast(ok ? t('postBoosted') : t('msgSendFailed'));
}
// A quote is your own post with theirs referenced inside it: the composer
// opens with the reference already in the text, and the q tag goes on at
// publish time so clients can render the embed.
function quoteNote(ev) {
ui.quoteOf = { id: ev.id, pubkey: ev.pubkey };
const ref = 'nostr:' + (neventOf(ev.id, ev.pubkey) || ev.id);
const cur = composeText().replace(/\s+$/, '');
const next = (cur ? cur + '\n\n' : '') + ref;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
ui.noteThread = null;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'feed';
feedNow();
render();
setTimeout(() => { const el = document.querySelector('.chat-page coinos-text'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
}
// ---- mute list (NIP-51 kind 10000) ---------------------------------------
// Muting hides someone's posts everywhere in this app and publishes the
// list, so it follows you to other clients. Blocking is the same list plus
// an unfollow — the stronger, more deliberate door.
const MUTES = 'mutes';
let mutes = null; // { set, words, hashtags, threads, tags, priv, content, at }
let mutesAt = 0;
// Everything a mute list holds (NIP-51): people, words, hashtags, whole
// threads — public tags and the private half (nip44 to yourself) alike.
const muteSets = (tags) => ({
set: new Set(pTags(tags).map((x) => x[1])),
words: [...new Set(tags.filter((x) => x[0] === 'word' && x[1]).map((x) => String(x[1]).toLowerCase().trim()).filter(Boolean))],
hashtags: new Set(tags.filter((x) => x[0] === 't' && x[1]).map((x) => normTopic(x[1])).filter(Boolean)),
threads: new Set(tags.filter((x) => x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1])),
});
const withSets = (m) => ({ ...m, priv: m.priv || [], ...muteSets([...(m.tags || []), ...(m.priv || [])]) });
function mutesNow() {
if (!mutes) {
let st2 = null;
try { st2 = wallet.loadFeatureState(MUTES, null); } catch {}
mutes = withSets({ tags: (st2 && st2.tags) || [], priv: (st2 && st2.priv) || [], content: (st2 && st2.c) || '', at: (st2 && st2.at) || 0 });
}
return mutes;
}
const isMuted = (pk) => mutesNow().set.has(pk);
function saveMutes(m) {
mutes = withSets(m);
try { wallet.saveFeatureState(MUTES, { tags: mutes.tags, priv: mutes.priv, c: mutes.content, at: mutes.at }); } catch {}
}
// The private half of a list: nip44 to yourself, a JSON array of tags.
async function privateTagsOf(ev) {
if (!ev || !ev.content) return [];
const c = (await selfCryptors()).find((x) => x.pk === ev.pubkey);
if (!c) return [];
try {
const tags = JSON.parse(await c.dec(ev.content));
return Array.isArray(tags) ? tags.filter((x) => Array.isArray(x) && x[0] && x[1]) : [];
} catch { return []; } // nip04 from an older client, or not ours to read
}
async function syncMutes({ force = false, deep = false } = {}) {
const me = mePk();
if (!me) return mutesNow();
if (!force && Date.now() - mutesAt < 10 * 60_000) return mutesNow();
mutesAt = Date.now();
try {
// replaceable like the follow list: never written over one we haven't
// looked for everywhere (see deepContactList)
const relays = deep
? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS, ...(await relaysOf(me).catch(() => []))])]
: zapRelays();
const evs = await queryOn(relays, { kinds: [10000], authors: [me] }, deep ? 7000 : 5000);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
const cur = mutesNow();
if (newest && newest.created_at > cur.at) {
saveMutes({ tags: newest.tags || [], priv: await privateTagsOf(newest), content: newest.content || '', at: newest.created_at });
scheduleRepaint();
}
} catch { mutesAt = 0; }
return mutesNow();
}
// Same care as the follow list: a change is applied to the freshest copy
// the relays will give us (the note in toggleFollow), never to our own
// optimistic paint. The private half is re-encrypted only when the change
// touched it and we hold the key; otherwise it rides along untouched.
async function updateMutes(change) {
const id = await requireIdentity();
const before = mutesNow();
const paint = change(before);
if (!paint) return;
saveMutes({ ...before, ...paint });
render();
try {
await syncMutes({ force: true, deep: !before.at });
const fetched = mutesNow();
const base = fetched.at > before.at ? fetched : before;
const next = change(base) || {};
const tags = next.tags || base.tags;
const priv = next.priv || base.priv || [];
let content = base.content || '';
if (next.priv && JSON.stringify(next.priv) !== JSON.stringify(base.priv || [])) {
const c = (await selfCryptors()).find((x) => x.pk === id.pubkey);
if (c) content = priv.length ? await c.enc(JSON.stringify(priv)) : '';
}
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
const partial = { kind: 10000, content, created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
saveMutes({ tags, priv, content, at: created_at });
render();
} catch (e) {
saveMutes(before);
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
render();
throw e;
}
}
const tagIn = (tags, k, v) => (tags || []).some((x) => x[0] === k && x[1] === v);
const tagOut = (tags, k, v) => (tags || []).filter((x) => !(x[0] === k && x[1] === v));
// One entry in or out (want: true/false, or null to flip), both halves.
const muteToggle = (k, v, want = null) => (m) => {
const has = tagIn(m.tags, k, v) || tagIn(m.priv, k, v);
const on = want == null ? !has : want;
if (on === has) return null;
return on ? { tags: [...m.tags, [k, v]] } : { tags: tagOut(m.tags, k, v), priv: tagOut(m.priv, k, v) };
};
async function toggleMute(pk, { block = false } = {}) {
const id = await requireIdentity();
if (pk === id.pubkey) return;
const had = isMuted(pk);
try {
await updateMutes(muteToggle('p', pk));
if (block && !had && isFollowing(pk)) await toggleFollow(pk);
toast(had ? t('postUnmuted') : block ? t('postBlocked') : t('postMuted'));
} catch {}
}
const toggleMuteThread = (rootId) => updateMutes(muteToggle('e', rootId)).catch(() => {});
const toggleMuteWord = (word, want = null) => updateMutes(muteToggle('word', String(word || '').trim().toLowerCase(), want)).catch(() => {});
const toggleMuteTag = (tag, want = null) => updateMutes(muteToggle('t', normTopic(tag), want)).catch(() => {});
// ---- moderation: one door for everything that shows a post --------------
// hiddenWhy(ev) says why a post is not shown (or null); hiddenPk(pk) the
// same for a person (their zaps, reactions, DMs, chat). Every list in this
// file asks here: the feed, threads, notifications, the who-reacted panel,
// rooms, DMs, quote cards. Two rules are Amethyst's, and are what makes
// its feeds feel clean: repeated content from strangers is spam, and a
// post your follows reported is folded (not hidden) behind who said so.
const modOn = (k) => (st().mod || {})[k] !== false;
// ---- sensitive posts (NIP-36 content warnings, NSFW accounts) -------------
// A post its author marked sensitive carries a content-warning tag — what a
// Misskey artist's "sensitive" media becomes on the mostr bridge, and what
// Amethyst and Damus put on a CW'd note. Popular, shown to anyone, leaves
// such posts out, and the posts of accounts whose own profile says they're
// adult or NSFW (":role_nsfw:", "🔞", "R18", "成人向け"…); elsewhere a marked
// post is folded behind its warning with a Show, as NIP-36 asks.
const NSFW_TAG = /^(nsfw|porn|porno|r18|r-18|18\+|nude|nudes|nudity|hentai|lewd|onlyfans|xxx|adult|エロ|成人向け|r18g)$/i;
const NSFW_TEXT = /:nsfw:|:role_nsfw:|🔞|(^|\s)#(nsfw|porn|r18|hentai|lewd)\b/i;
const NSFW_PROFILE = /(^|[^a-z])nsfw([^a-z]|$)|:role_nsfw:|🔞|(^|[^a-z0-9])r-?18(g)?([^0-9]|$)|(^|[^0-9])18\+|成人向け|アダルト|エロ|porn|onlyfans|fansly|hentai|(^|[^a-z])lewd/i;
// the post's own warning: its reason (possibly ''), or null when unmarked
function sensitiveWhy(ev) {
if (!ev || !ev.tags) return null;
const cw = ev.tags.find((x) => x[0] === 'content-warning');
if (cw) return cw[1] || '';
// NIP-32 labels in the content-warning namespace, and NSFW topics
if (ev.tags.some((x) => (x[0] === 'l' && /^(content-warning|nsfw)$/i.test(x[2] || '')) || (x[0] === 't' && NSFW_TAG.test(x[1] || '')))) return '';
if (NSFW_TEXT.test(String(ev.content || ''))) return '';
return null;
}
const nsfwAuthor = (pk) => { const p = profiles.get(pk); return !!p && NSFW_PROFILE.test((p.name || '') + ' ' + (p.about || '')); };
// left out of the public feed (and of every media grid): marked, or by an
// account that says it posts adult work
const notForPublic = (ev) => !isMe(ev.pubkey) && (sensitiveWhy(ev) !== null || nsfwAuthor(ev.pubkey));
function sensitiveFold(ev, why) {
return h('div', { class: 'row note-folded', style: 'gap:10px;align-items:center;padding:10px 0;opacity:.75' },
avatar(ev.pubkey, 'chat-avatar note-avatar', false),
h('div', { class: 'grow small muted', style: 'min-width:0' }, '\u26a0 ' + (why ? t('foldSensitiveWhy', { why: why.slice(0, 80) }) : t('foldSensitive'))),
h('button', {
class: 'btn-sm', type: 'button', style: 'flex-shrink:0',
onClick: (e) => { e.stopPropagation(); (ui.revealed ||= new Set()).add(ev.id); render(); },
}, t('foldShow')));
}
const setMod = (k, v) => { const s = st(); (s.mod ||= {})[k] = v; save(s); render(); };
// A post of 60+ characters seen again under another id is a duplicate;
// five duplicates and the author is hidden for the session. Short posts
// ("GM") and nostr:-mention commands are exempt. Session memory only.
const spamSeen = new Map(); // content+tags -> first id
const spamDup = new Set(); // ids that were repeats
const spamBy = new Map(); // pk -> Set of repeated ids
function noteForSpam(ev) {
if (!ev || ev.kind !== 1 || !ev.id || ev.pending) return;
const c = String(ev.content || '');
if (c.length < 60) return;
if (c.length < 180 && c.startsWith('nostr:')) return;
const key = c + '\u0000' + JSON.stringify(ev.tags || []);
const first = spamSeen.get(key);
if (!first) {
spamSeen.set(key, ev.id);
if (spamSeen.size > 3000) spamSeen.delete(spamSeen.keys().next().value);
return;
}
if (first === ev.id) return;
spamDup.add(ev.id);
const s = spamBy.get(ev.pubkey) || spamBy.set(ev.pubkey, new Set()).get(ev.pubkey);
s.add(first); s.add(ev.id);
}
const SPAM_DUPES = 5;
const isSpammer = (pk) => modOn('spam') && (spamBy.get(pk) || { size: 0 }).size >= SPAM_DUPES && !trusted(pk) && !isMe(pk);
const HASHTAG_MAX = 10;
const hashtagSpam = (ev) => ev.kind === 1 && modOn('spam') && !isFollowing(ev.pubkey)
&& ((ev.tags || []).filter((x) => x[0] === 't').length > HASHTAG_MAX
|| (String(ev.content || '').match(/(^|\s)#[\p{L}\p{N}_]{2,}/gu) || []).length > HASHTAG_MAX);
function hiddenWhy(ev) {
if (isDeleted(ev)) return 'deleted'; // by its author, yours included
if (!ev || !ev.pubkey || isMe(ev.pubkey)) return null;
const bm = baselineMutes();
for (const m of bm ? [mutesNow(), bm] : [mutesNow()]) {
if (m.set.has(ev.pubkey)) return 'muted';
if (ev.kind !== 1) continue;
if (m.threads.size && m.threads.has(rootIdOf(ev))) return 'thread';
const text = String(ev.content || '').toLowerCase();
if (m.words.length && m.words.some((w) => text.includes(w))) return 'word';
if (m.hashtags.size) {
if ((ev.tags || []).some((x) => x[0] === 't' && m.hashtags.has(normTopic(x[1])))) return 'hashtag';
for (const mm of text.matchAll(/#([\p{L}\p{N}_]+)/gu)) if (m.hashtags.has(normTopic(mm[1]))) return 'hashtag';
}
}
if (reportsNow().mine.has(ev.id)) return 'reported';
if (!trusted(ev.pubkey)) {
if (isSpammer(ev.pubkey) || (modOn('spam') && spamDup.has(ev.id))) return 'spam';
if (hashtagSpam(ev)) return 'hashtags';
}
return null;
}
const hidden = (ev) => !!hiddenWhy(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));
// ---- reports (NIP-56 kind 1984) from the people you follow --------------
// The web-of-trust piece — not a score from a provider, just your follows'
// own flags, read from their outbox relays and ours once every half hour,
// indexed by post and by person. A post (or its author) flagged by anyone
// you follow is folded; anything you flagged yourself is hidden.
const REPORTS = 'reports';
let reportsCache = null; // { ev: Map id -> Set reporter, pk: Map pk -> Set reporter, mine: Set, at }
let reportsAt = 0;
function reportsNow() {
if (!reportsCache) {
let s = null;
try { s = wallet.loadFeatureState(REPORTS, null); } catch {}
const toMap = (o) => new Map(Object.entries(o || {}).map(([k, v]) => [k, new Set(v)]));
reportsCache = { ev: toMap(s && s.ev), pk: toMap(s && s.pk), mine: new Set((s && s.mine) || []), at: (s && s.at) || 0 };
}
return reportsCache;
}
function saveReports() {
const r = reportsNow();
const toObj = (m) => Object.fromEntries([...m.entries()].slice(-1500).map(([k, v]) => [k, [...v].slice(0, 50)]));
try { wallet.saveFeatureState(REPORTS, { ev: toObj(r.ev), pk: toObj(r.pk), mine: [...r.mine].slice(-500), at: r.at }); } catch {}
}
// A report typed 'other' says nothing anyone can act on and is ignored.
function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); }
function indexReport(r, ev, mine) {
const hex = (v) => /^[0-9a-f]{64}$/.test(v || '');
let es = 0;
for (const x of ev.tags || []) {
if (x[0] !== 'e' || !hex(x[1]) || x[2] === 'other') continue;
es++;
(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]);
}
for (const x of ev.tags || []) {
// a PERSON is reported when the p tag carries a reason, or when the
// report names no post at all; a post report's p tag merely credits
// the author and is not held against them
if (x[0] !== 'p' || !hex(x[1]) || x[1] === ev.pubkey || (es && !x[2]) || x[2] === 'other') continue;
(r.pk.get(x[1]) || r.pk.set(x[1], new Set()).get(x[1])).add(ev.pubkey);
if (mine) r.mine.add(x[1]);
}
}
const REPORTS_MS = 30 * 60_000;
async function syncReports({ force = false } = {}) {
if (!modOn('reports') || !myPubkeys().length) return;
if (!force && Date.now() - reportsAt < REPORTS_MS) return;
reportsAt = Date.now();
const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX);
const from = new Set(authors);
try {
await fetchRelayLists(authors);
const plan = outboxPlan(authors);
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(() => []));
}));
for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev);
reportsNow().at = Date.now();
saveReports();
scheduleRepaint();
} catch { reportsAt = 0; }
}
// Who among your follows flagged this post or its author.
// A flag on THIS post from one follow folds it. A flag on the PERSON
// folds everything they write, so it takes two follows to agree — one
// mis-tapped "impersonation" on a well-known key used to fold their whole
// timeline for everyone who followed the tapper.
// ---- a reader with no web of trust borrows coinos's -------------------
// A visitor, or a wallet that follows almost nobody yet, has no follows
// whose flags could fold anything (and a visitor no mute list at all), so
// the public feeds would show the relays raw. They get the moderation the
// coinos account gets: its mute list (visitors) and its follows' reports,
// read from the relays every few hours and kept on the device for every
// wallet. A wallet's own mutes always count too.
const CURATOR_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
const BASELINE_KEY = 'coinos-mod-baseline';
const BASELINE_MS = 6 * 3600_000;
const BASELINE_MIN_FOLLOWS = 5;
let baseline = null; // { follows: Set, mutes (withSets), ev: Map, pk: Map, mine: Set, at }
let baselineSync = null;
function baselineNow() {
if (!baseline) {
let b = null;
try { b = JSON.parse(cacheStorage.getItem(BASELINE_KEY) || 'null'); } catch {}
const toMap = (o) => new Map(Object.entries(o || {}).map(([k, v]) => [k, new Set(v)]));
baseline = { follows: new Set((b && b.follows) || []), mutes: withSets({ tags: (b && b.mutes) || [], priv: [], content: '', at: 0 }),
ev: toMap(b && b.ev), pk: toMap(b && b.pk), mine: new Set(), at: (b && b.at) || 0 };
}
return baseline;
}
const usingBaseline = () => !mePk() || followsNow().set.size < BASELINE_MIN_FOLLOWS;
const baselineMutes = () => (mePk() ? null : baselineNow().mutes);
// someone a web of trust vouches for (the reader's own, or the borrowed
// one while their own is thin): exempt from the heuristics
const trusted = (pk) => isFollowing(pk) || (usingBaseline() && baselineNow().follows.has(pk));
async function syncBaseline() {
const b = baselineNow();
if (baselineSync || Date.now() - b.at < BASELINE_MS) return baselineSync;
baselineSync = (async () => {
try {
const evs = await queryOn(zapRelays(), { kinds: [3, 10000], authors: [CURATOR_PK] }, 6000).catch(() => []);
const newest = (k) => (evs || []).filter((e) => e.kind === k).sort((x, y) => y.created_at - x.created_at)[0];
const k3 = newest(3), k10 = newest(10000);
if (!k3) return; // the relays were away: keep what we have, ask again soon
const follows = hexList(k3.tags.filter((x) => x[0] === 'p').map((x) => x[1]));
const muteTags = k10 ? (k10.tags || []).filter((x) => ['p', 't', 'word', 'e'].includes(x[0])).slice(0, 2000) : b.mutes.tags;
const authors = [CURATOR_PK, ...follows].slice(0, FEED_AUTHORS_MAX);
const from = new Set(authors);
const r = { ev: new Map(), pk: new Map(), mine: new Set() };
await fetchRelayLists(authors);
const got = await Promise.all(outboxPlan(authors).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(() => []));
}));
for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) indexReport(r, ev, false);
baseline = { follows: new Set(follows), mutes: withSets({ tags: muteTags, priv: [], content: '', at: k10 ? k10.created_at : 0 }), ev: r.ev, pk: r.pk, mine: new Set(), at: Date.now() };
const toObj = (m) => Object.fromEntries([...m.entries()].slice(-1500).map(([k, v]) => [k, [...v].slice(0, 50)]));
try { cacheStorage.setItem(BASELINE_KEY, JSON.stringify({ follows, mutes: muteTags, ev: toObj(r.ev), pk: toObj(r.pk), at: baseline.at })); } catch {}
scheduleRepaint();
} catch {} finally { baselineSync = null; }
})();
return baselineSync;
}
const PERSON_REPORTS_MIN = 2;
function reportsOn(ev) {
if (!ev || !ev.id || !modOn('reports') || isMe(ev.pubkey)) return null;
const flagsFrom = (r, f) => {
const onPost = new Set(), onPerson = new Set();
for (const pk of r.ev.get(ev.id) || []) if (f.has(pk) && pk !== ev.pubkey) onPost.add(pk);
for (const pk of r.pk.get(ev.pubkey) || []) if (f.has(pk) && pk !== ev.pubkey) onPerson.add(pk);
if (!onPost.size && onPerson.size < PERSON_REPORTS_MIN) return null;
return [...new Set([...onPost, ...onPerson])];
};
const own = mePk() ? flagsFrom(reportsNow(), followsNow().set) : null;
if (own) return own;
if (!usingBaseline()) return null;
const b = baselineNow();
return flagsFrom(b, b.follows);
}
// A flagged post, folded: who flagged it, and a tap to see it anyway.
function foldedRow(ev, by) {
for (const pk of by.slice(0, 3)) profileOf(pk);
const who = by.length === 1 ? t('foldReported1', { name: displayName(by[0]) }) : t('foldReportedN', { n: by.length });
return h('div', { class: 'row note-folded', style: 'gap:10px;align-items:center;padding:10px 0;opacity:.75' },
avatar(ev.pubkey, 'chat-avatar note-avatar', false),
h('div', { class: 'grow small muted', style: 'min-width:0' }, '\u26a0 ' + who),
h('button', {
class: 'btn-sm', type: 'button', style: 'flex-shrink:0',
onClick: (e) => { e.stopPropagation(); (ui.revealed ||= new Set()).add(ev.id); render(); },
}, t('foldShow')));
}
// Publish a report (NIP-56): the post with a reason, its author credited.
async function reportNote(ev, type) {
const id = await requireIdentity();
const partial = { kind: 1984, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', ev.id, type], ['p', ev.pubkey]] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
noteReport(evt);
saveReports();
toast(t('reportSent'));
render();
}
function reportSheet() {
if (!ui.reportPick) return null;
const ev = ui.reportPick;
const close = () => { ui.reportPick = null; render(); };
const reason = (type, label) => h('button', {
class: 'btn-block report-reason', style: 'text-align:left', type: 'button', 'data-reason': type,
onClick: () => { close(); reportNote(ev, type).catch((e) => { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }); },
}, label);
return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); } },
h('div', { class: 'card col confirm-pop report-pick', style: 'gap:8px' },
h('div', { class: 'chat-name' }, t('reportTitle')),
h('div', { class: 'small muted' }, t('reportHelp')),
reason('spam', t('reportSpam')),
reason('impersonation', t('reportImpersonation')),
reason('nudity', t('reportNudity')),
reason('profanity', t('reportProfanity')),
reason('illegal', t('reportIllegal')),
reason('malware', t('reportMalware')),
reason('other', t('reportOther')),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
// Settings → Nostr: the mute list laid out, and the two spam rules.
// ---- follow list history: undo a client that replaced the list ---------
// A kind 3 is replaceable, so relays that hold the newest copy have dropped
// the old one; the archives often haven't. Restoring is a union: everyone
// in the old copy goes back into the current list, nobody is taken out.
const FOLLOW_HISTORY_RELAYS = ['wss://nostr21.com', 'wss://relay.ditto.pub', 'wss://offchain.pub', 'wss://nostr.mom',
'wss://nostr.oxtr.dev', 'wss://relay.nostrplebs.com', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net',
'wss://relay.snort.social', 'wss://nostr.wine', 'wss://nostr.bitcoiner.social'];
async function findFollowHistory() {
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: [3], 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);
}
async function restoreFollows(old) {
if (followsPub) return;
const id = await requireIdentity();
followsPub = true;
try {
const before = followsNow();
await syncFollows({ force: true, deep: true });
const fetched = followsNow();
const base = fetched.at > before.at ? fetched : before;
const have = new Set(pTags(base.tags).map((x) => x[1]));
const add = pTags(old.tags || []).filter((x) => !have.has(x[1]) && x[1] !== id.pubkey && (have.add(x[1]), true));
if (!add.length) { toast(t('followHistNothing')); return; }
const tags = [...base.tags, ...add];
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
const partial = { kind: 3, content: base.content || '', created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...zapRelays(), ...(await relaysOf(id.pubkey).catch(() => []))])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
saveFollows({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
feedAuthorsChanged();
syncInbox({ force: true }).catch(() => {});
toast(t('followHistRestored', { n: add.length }));
if (ui.followHist) ui.followHist.done = true;
} catch (e) {
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
} finally {
followsPub = false;
render();
}
}
function followHistoryCard() {
if (!mePk()) return null;
const cur = followsNow();
const fh = ui.followHist;
const look = () => {
ui.followHist = { loading: true };
render();
findFollowHistory().then((vs) => { ui.followHist = { versions: vs }; render(); })
.catch(() => { ui.followHist = { versions: [] }; render(); });
};
const versions = (fh && fh.versions) || [];
// worth offering: a copy holding people the current list doesn't
const offers = versions.map((e) => ({ e, extra: pTags(e.tags).filter((x) => !cur.set.has(x[1])).length }))
.filter((v) => v.extra > 0);
const clientOf = (e) => (e.tags.find((x) => x[0] === 'client') || [])[1];
return h('div', { class: 'card col follow-hist-card', style: 'gap:10px' },
h('h3', {}, t('followHistTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('followHistDesc', { n: cur.set.size })),
!fh || fh.done
? h('button', { class: 'btn-block', onClick: look }, t('followHistLook'))
: fh.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 }) => h('div', { class: 'row between gap6', style: 'align-items:center' },
h('div', { class: 'col', style: 'gap:1px;min-width:0' },
h('div', {}, t('followHistPeople', { n: pTags(e.tags).length })),
h('div', { class: 'small faint' }, new Date(e.created_at * 1000).toLocaleDateString()
+ (clientOf(e) ? ' · ' + clientOf(e) : ''))),
h('button', { class: 'btn-sm', disabled: followsPub, onClick: () => restoreFollows(e) }, t('followHistRestore', { n: extra })))))
: h('div', { class: 'small faint' }, t('followHistNone')));
}
// ---- relay management: your NIP-65 list and your DM inbox (NIP-17) -------
// Both are replaceable events other clients write too, so the card only
// ever edits a copy it looked for far and wide first (the follow list was
// once overwritten from a two-relay lookup), and checks again just before
// publishing that nobody saved a newer one meanwhile.
const RELAY_LOOKUP = () => [...new Set([...PROFILE_RELAYS, ...PROFILE_INDEX_RELAYS, ...DM_RELAYS,
...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS])];
async function fetchOwnRelayLists(me) {
const evs = await queryOn(RELAY_LOOKUP(), { kinds: [10002, 10050], authors: [me] }, 6000).catch(() => []);
const newest = (k) => (evs || []).filter((e) => e.pubkey === me && e.kind === k).sort((a, b) => b.created_at - a.created_at)[0] || null;
return { r: newest(10002), dm: newest(10050) };
}
const rowsOf10002 = (e) => {
const out = new Map();
for (const x of (e && e.tags) || []) {
if (x[0] !== 'r') continue;
const url = normRelay(x[1]);
if (!url) continue;
const row = out.get(url) || { url, read: false, write: false };
if (!x[2] || x[2] === 'read') row.read = true;
if (!x[2] || x[2] === 'write') row.write = true;
out.set(url, row);
}
return [...out.values()];
};
const urlsOf10050 = (e) => [...new Set(((e && e.tags) || []).filter((x) => x[0] === 'relay').map((x) => normRelay(x[1])).filter(Boolean))];
const tagsOfRows = (rows) => rows.filter((r) => r.read || r.write)
.map((r) => (r.read && r.write ? ['r', r.url] : ['r', r.url, r.read ? 'read' : 'write']));
const sameTags = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// How long a relay takes to open a socket, measured once per card visit.
function probeRelayLatency(url) {
const rm = ui.relayMgr;
if (!rm || rm.probe[url]) return;
rm.probe[url] = { state: 'wait' };
const done = (state, ms) => {
if (rm.probe[url].state !== 'wait') return;
rm.probe[url] = { state, ms };
try { ws.close(); } catch {}
if (ui.relayMgr === rm) render();
};
const t0 = performance.now();
let ws;
try { ws = new WebSocket(url); } catch { done('bad'); return; }
ws.onopen = () => done('ok', Math.round(performance.now() - t0));
ws.onerror = () => done('bad');
setTimeout(() => done('bad'), 6000);
}
function loadRelayMgr(me, inRender = false) {
const rm = ui.relayMgr = { me, loading: true, probe: {}, add: '', addDm: '' };
if (!inRender) render();
fetchOwnRelayLists(me).then((base) => {
if (ui.relayMgr !== rm) return;
rm.base = base;
rm.rows = base.r ? rowsOf10002(base.r) : PROFILE_RELAYS.map((url) => ({ url, read: true, write: true }));
rm.dm = base.dm ? urlsOf10050(base.dm) : [...DM_RELAYS];
rm.loading = false;
render();
});
}
async function saveRelayMgr() {
const rm = ui.relayMgr;
if (!rm || rm.saving || rm.loading) return;
const tags = tagsOfRows(rm.rows);
const dmTags = rm.dm.map((u) => ['relay', u]);
if (!tags.some((x) => !x[2] || x[2] === 'write') || !tags.some((x) => !x[2] || x[2] === 'read')) { toast(t('relaysNeedBoth')); return; }
if (!dmTags.length) { toast(t('relaysNeedDm')); return; }
const rChanged = !rm.base.r || !sameTags(tags, tagsOfRows(rowsOf10002(rm.base.r)));
const dmChanged = !rm.base.dm || !sameTags(rm.dm, urlsOf10050(rm.base.dm));
if (!rChanged && !dmChanged) { toast(t('relaysUnchanged')); return; }
if (typeof navigator !== 'undefined' && navigator.onLine === false) { toast(t('msgSendFailed')); return; }
rm.saving = true;
render();
try {
const id = await requireIdentity();
if (id.pubkey !== rm.me) throw new Error(t('relaysWrongIdentity'));
// someone else's client may have saved since this card loaded
const now = await fetchOwnRelayLists(rm.me);
const newer = (a, b) => a && (!b || a.created_at > b.created_at);
if ((rChanged && newer(now.r, rm.base.r)) || (dmChanged && newer(now.dm, rm.base.dm))) {
toast(t('relaysChangedElsewhere'));
loadRelayMgr(rm.me);
return;
}
const sign = (partial) => (id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial));
const stamp = (old) => Math.max(Math.floor(Date.now() / 1000), old ? old.created_at + 1 : 0);
const writes = tags.filter((x) => !x[2] || x[2] === 'write').map((x) => x[1]);
// the new list goes to the relays it names, the old ones (so they drop
// the stale copy) and the indexers other clients look lists up on
const everywhere = [...new Set([...PROFILE_RELAYS, ...PROFILE_INDEX_RELAYS,
...rowsOf10002(rm.base.r).map((r) => r.url), ...tags.map((x) => x[1])])];
if (rChanged) {
const evt = await sign({ kind: 10002, content: '', created_at: stamp(rm.base.r), tags });
if (!(await publishOn(everywhere, evt))) throw new Error(t('msgSendFailed'));
rm.base.r = evt;
relayListCache.set(rm.me, Promise.resolve(writes.slice(0, 4)));
relayListsNow().set(rm.me, { r: writes, t: Date.now() });
saveRelayLists();
// wallet sync rides the same write relays (adoptSyncRelays)
setSyncConfig({ enabled: true, relays: [...new Set([...writes.slice(0, 4), ...DEFAULT_SYNC_RELAYS])].slice(0, 5) });
}
if (dmChanged) {
const evt = await sign({ kind: 10050, content: '', created_at: stamp(rm.base.dm), tags: dmTags });
const to = [...new Set([...everywhere, ...urlsOf10050(rm.base.dm), ...rm.dm, ...DM_RELAYS])];
if (!(await publishOn(to, evt))) throw new Error(t('msgSendFailed'));
rm.base.dm = evt;
resubscribeStreams(); // read the inbox from the new list now
}
toast(t('relaysSaved'));
} catch (e) {
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
} finally {
rm.saving = false;
render();
}
}
function relaysCard() {
const me = mePk();
if (!me) return null;
if (!ui.relayMgr || ui.relayMgr.me !== me) loadRelayMgr(me, true);
const rm = ui.relayMgr;
const head = [h('h3', {}, t('relaysTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('relaysDesc'))];
if (rm.loading) {
return h('div', { class: 'card col relays-card', style: 'gap:10px' }, ...head,
h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' })));
}
const dot = (url) => {
probeRelayLatency(url);
const p = rm.probe[url] || {};
const color = p.state === 'ok' ? 'var(--green,#2e9d5b)' : p.state === 'bad' ? 'var(--red,#c0392b)' : 'var(--line)';
return h('span', { title: p.state === 'ok' ? p.ms + ' ms' : p.state === 'bad' ? t('relaysDown') : '',
style: `flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:${color}` });
};
const status = (url) => {
const p = rm.probe[url] || {};
// a green dot says it's up (the ms are its tooltip); only trouble is spelled out
return p.state === 'bad' ? h('span', { class: 'small faint', style: 'white-space:nowrap' }, t('relaysDown')) : null;
};
const name = (url) => h('span', { class: 'grow mono', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px' },
url.replace(/^wss:\/\//, ''));
const x = (onClick) => h('button', { class: 'linklike', type: 'button', 'aria-label': t('remove'), style: 'padding:0 4px', onClick }, '×');
const flag = (row, k, label) => h('button', {
class: 'feed-chip' + (row[k] ? ' on' : ''), type: 'button', style: 'padding:3px 9px;font-size:12px',
onClick: () => { row[k] = !row[k]; if (!row.read && !row.write) row[k === 'read' ? 'write' : 'read'] = true; render(); },
}, label);
const adder = (key, cls, onAdd) => {
const go = (ev) => {
const url = normRelay(rm[key]);
if (!url || !/^[a-z0-9.-]+(:\d+)?$/i.test(new URL(url).host)) { if (rm[key].trim()) toast(t('relaysBadUrl')); return; }
rm[key] = '';
const box = ev && ev.currentTarget && ev.currentTarget.parentNode.querySelector('input');
if (box) box.value = '';
onAdd(url);
render();
};
return h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow ' + cls, placeholder: 'wss://relay.example.com', value: rm[key],
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (ev) => { rm[key] = ev.target.value; },
onKeydown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); go(ev); } } }),
h('button', { class: 'btn-sm', type: 'button', onClick: go }, t('modAdd')));
};
return h('div', { class: 'card col relays-card', style: 'gap:10px' }, ...head,
h('div', { class: 'small muted' }, t('relaysPublic')),
rm.base.r ? null : h('div', { class: 'small faint' }, t('relaysNoneYet')),
h('div', { class: 'col', style: 'gap:8px' }, ...rm.rows.map((row) => h('div', { class: 'row gap6 relay-row', style: 'align-items:center' },
dot(row.url), name(row.url), status(row.url),
flag(row, 'read', t('relaysRead')), flag(row, 'write', t('relaysWrite')),
x(() => { rm.rows = rm.rows.filter((r) => r !== row); render(); })))),
adder('add', 'relay-add', (url) => { if (!rm.rows.some((r) => r.url === url)) rm.rows.push({ url, read: true, write: true }); }),
h('div', { class: 'small muted', style: 'margin-top:6px' }, t('relaysInbox')),
rm.base.dm ? null : h('div', { class: 'small faint' }, t('relaysNoneYet')),
h('div', { class: 'col', style: 'gap:8px' }, ...rm.dm.map((url) => h('div', { class: 'row gap6 relay-dm-row', style: 'align-items:center' },
dot(url), name(url), status(url),
x(() => { rm.dm = rm.dm.filter((u) => u !== url); render(); })))),
adder('addDm', 'relay-dm-add', (url) => { if (!rm.dm.includes(url)) rm.dm.push(url); }),
h('div', { class: 'row gap6', style: 'margin-top:4px' },
h('button', { class: 'grow relays-save', disabled: rm.saving, onClick: () => saveRelayMgr() }, rm.saving ? t('relaysSaving') : t('relaysSave')),
h('button', { class: 'btn-ghost', disabled: rm.saving, onClick: () => loadRelayMgr(me) }, t('relaysReload'))));
}
// Settings → Nostr: whether clips over the budget play by themselves.
function videosCard() {
const on = largeVideosOk();
return h('div', { class: 'card col videos-card', style: 'gap:8px' },
h('h3', {}, t('videoLargeTitle')),
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', class: 'video-large', checked: on, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0',
onChange: (e) => { try { localStorage.setItem(VIDEO_LARGE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }),
h('span', {}, t('videoLargeToggle'))),
h('div', { class: 'small faint' }, t('videoLargeHelp', { mb: Math.round(videoBudget() / 1e6) })));
}
function translateCard() {
if (!canTranslateTo(getLang())) return null;
return h('div', { class: 'card col translate-card', style: 'gap:8px' },
h('h3', {}, t('translateTitle')),
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', class: 'auto-translate', checked: autoTranslateOn(), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0',
onChange: (e) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }),
h('span', {}, t('translateAutoToggle', { lang: langName(getLang()) }))),
h('div', { class: 'small faint' }, t('translateHelp')));
}
function moderationCard() {
const m = mutesNow();
const e = ui.modEdit || (ui.modEdit = { word: '' });
const people = [...m.set];
for (const pk of people.slice(0, 30)) profileOf(pk);
const add = () => {
const w = e.word.trim();
if (!w) return;
e.word = '';
if (w.startsWith('#')) toggleMuteTag(w, true); else toggleMuteWord(w, true);
};
const chip = (label, onX) => h('span', { class: 'feed-chip on mod-chip', style: 'display:inline-flex;align-items:center;gap:6px' },
label, h('button', { class: 'linklike', type: 'button', 'aria-label': t('remove'), style: 'padding:0 2px', onClick: onX }, '\u00d7'));
const toggle = (k, label, help) => h('div', { class: 'col', style: 'gap:4px' },
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', class: 'mod-' + k, checked: modOn(k), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => setMod(k, ev.target.checked) }),
h('span', {}, label)),
h('div', { class: 'small faint' }, help));
return h('div', { class: 'card col mod-card', style: 'gap:10px' },
h('h3', {}, t('modTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('modDesc')),
h('div', { class: 'small muted' }, t('modPeople')),
people.length
? h('div', { class: 'col', style: 'gap:6px' }, ...people.slice(0, 50).map((pk) => h('div', { class: 'row gap6 mod-person', style: 'align-items:center' },
avatar(pk, 'chat-avatar mini', false),
h('span', { class: 'grow', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, displayName(pk)),
h('button', { class: 'btn-sm', type: 'button', onClick: () => toggleMute(pk) }, t('postUnmute')))))
: h('div', { class: 'small faint' }, t('modNoPeople')),
h('div', { class: 'small muted' }, t('modWords')),
m.words.length || m.hashtags.size
? h('div', { class: 'row wrap', style: 'gap:6px' },
...m.words.map((w) => chip(w, () => toggleMuteWord(w, false))),
...[...m.hashtags].map((x) => chip('#' + x, () => toggleMuteTag(x, false))))
: null,
h('div', { class: 'row gap6' },
h('input', {
type: 'text', class: 'grow mod-word', placeholder: t('modWordHint'), value: e.word,
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (ev) => { e.word = ev.target.value; },
onKeydown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } },
}),
h('button', { class: 'btn-sm', type: 'button', onClick: add }, t('modAdd'))),
m.threads.size ? h('div', { class: 'small faint' }, t('modThreads', { n: m.threads.size })) : null,
toggle('spam', t('modSpamToggle'), t('modSpamHelp')),
toggle('reports', t('modReportsToggle'), t('modReportsHelp')),
toggle('sensitive', t('modSensitiveToggle'), t('modSensitiveHelp')));
}
const noteLink = (ev) => location.origin + '/' + (neventOf(ev.id, ev.pubkey) || ev.id);
// your own notes and comments; an article or a poll is edited elsewhere
const canEdit = (ev) => isMe(ev.pubkey) && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT);
// Editing one of your posts: its words, as they stand now, in a box.
function editSheet() {
const e = ui.postEdit;
if (!e) return null;
const close = () => { ui.postEdit = null; render(); };
const save = async () => {
const text = String(e.text || '').trim();
if (!text || text === noteText(e.ev).trim()) { close(); return; }
ui.postEdit = null;
try { await editPost(e.ev, text); toast(t('postEditedToast')); }
catch (err) { if (!(err instanceof NoIdentity)) toast(err.message || String(err)); ui.postEdit = { ev: e.ev, text }; render(); }
};
return h('div', { class: 'confirm-pop-backdrop', onClick: (x) => { if (x.target === x.currentTarget) close(); } },
h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('postEditTitle')),
h('coinos-text', {
class: 'post-edit-input', style: 'font-family:var(--sans);min-height:120px;max-height:50vh',
value: e.text,
onInput: (x) => { e.text = x.target.value; updateMentionAc(x.target, '.post-edit-input'); },
onKeydown: mentionKey,
}),
mentionList('.post-edit-input'),
h('div', { class: 'small faint' }, t('postEditHelp')),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: close }, t('cancel')),
h('button', { class: 'btn-primary grow', onClick: save }, t('save')))));
}
function deleteAskSheet() {
const ev = ui.postDeleteAsk;
if (!ev) return null;
const close = () => { ui.postDeleteAsk = null; render(); };
const go = async () => {
close();
try { await deletePost(ev); toast(t('postDeletedToast')); }
catch (err) { if (!(err instanceof NoIdentity)) toast(err.message || String(err)); }
};
return h('div', { class: 'confirm-pop-backdrop', onClick: (x) => { if (x.target === x.currentTarget) close(); } },
h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('postDeleteTitle')),
h('div', { class: 'small muted' }, t('postDeleteHelp')),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: close }, t('cancel')),
h('button', { class: 'btn-danger grow', onClick: go }, t('postDelete')))));
}
// The ellipsis sheet: the actions that don't earn a button of their own.
function noteSheet() {
if (!ui.noteSheet) return null;
const ev = ui.noteSheet;
const close = () => { ui.noteSheet = null; ui.emojiPick = null; render(); };
const mine = isMe(ev.pubkey);
const item = (icon, label, onClick, danger) => h('button', {
class: 'btn-block', style: 'text-align:left' + (danger ? ';color:var(--red,#c0392b)' : ''),
onClick: () => { close(); onClick(); },
}, icon + ' ' + label);
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col confirm-pop', style: 'gap:8px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
avatar(ev.pubkey, 'chat-avatar', false),
h('div', { class: 'chat-name' }, displayName(ev.pubkey))),
item('❝', t('postQuote'), () => quoteNote(ev)),
item('↻', t('postBoost'), () => boostNote(ev).catch(() => {})),
item('⧉', t('copy'), async () => {
try { await navigator.clipboard.writeText('nostr:' + (neventOf(ev.id, ev.pubkey) || ev.id)); toast(t('copied')); } catch {}
}),
// the post's own address here — the same path the bar shows while
// its thread is open, and what njump-style links resolve to
item('\u{1F517}', t('postCopyLink'), async () => {
try { await navigator.clipboard.writeText(noteLink(ev)); toast(t('copied')); } catch {}
}),
typeof navigator !== 'undefined' && navigator.share
? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); })
: null,
translatable(ev) && canTranslateTo(getLang())
? shownTranslation(ev)
? item('\u{1F310}', t('translateShowOriginal'), () => { const k = transKey(ev); transOriginal.add(k); transAsked.delete(k); render(); })
: item('\u{1F310}', t('translateBtn'), () => translateNote(ev, { asked: true }))
: null,
canEdit(ev) ? item('\u270e', t('postEdit'), () => { ui.postEdit = { ev, text: noteText(ev) }; render(); }) : null,
mine && !ev.pending ? item('\u{1F5D1}', t('postDelete'), () => { ui.postDeleteAsk = ev; render(); }, true) : null,
mine || !myPubkeys().length ? null : item('\u2630', t('listAddTo'), () => { ui.listPick = ev.pubkey; render(); }),
mine ? null : item('\u{1F6A9}', t('postReport'), () => { ui.reportPick = ev; render(); }),
item('\u{1F515}', mutesNow().threads.has(rootIdOf(ev)) ? t('postUnmuteThread') : t('postMuteThread'), () => toggleMuteThread(rootIdOf(ev))),
mine ? null : item('\u{1F507}', isMuted(ev.pubkey) ? t('postUnmute') : t('postMute'), () => toggleMute(ev.pubkey).catch(() => {})),
mine || isMuted(ev.pubkey) ? null : item('⛔', t('postBlock'), () => toggleMute(ev.pubkey, { block: true }).catch(() => {}), true),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
// The things you can do to a post, in a row of their own across the bottom
// of it: reply, boost, quote, react, zap. Counts sit beside their own icon,
// because a count belongs to the thing it counts. Spread across the full
// width so each is a comfortable target rather than five buttons huddled in
// a corner.
const ICON = (d, fill) => '';
const I_REPLY = ICON('');
const I_BOOST = ICON('');
const I_QUOTE = ICON('', true);
const I_HEART = (on) => ICON('', on);
const I_ZAP = ICON('', true);
function noteActions(pk, ev, { canZap }) {
if (canZap && zapSoundOn()) warmZapSound(); // the clip is decoded before the first tap
const mineReact = myReactOn(ev.id);
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;
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',
...(extra || {}),
onClick: (e) => { e.stopPropagation(); if (onClick) onClick(e); },
},
typeof icon === 'string' && icon.startsWith('