import { mediaServers } from '../media-servers.js'; // 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 { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, 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, LANGS } from '../i18n.js'; import { verifyEvent } from 'nostr-tools/pure'; import { createGifPicker, gifMedia } from '../gif-picker.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 { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js'; import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.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 CALL_LOG = 1405; // a call, logged in its conversation (see logCall) 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; const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- 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 // White Noise's own two emoji, which it sends as bare :codes: (no NIP-30 // tag, no picture): drawn from its published artwork, through our picture // proxy — a copy served and cached here, as any picture in a post is, not // bundled (the app is AGPL, this one MIT). const WN_RAW = 'https://raw.githubusercontent.com/marmot-protocol/whitenoise-android/master/app/src/main/res/drawable-nodpi/'; const BUILTIN_EMOJI = { marmot: WN_RAW + 'builtin_emoji_marmot.png', wn: WN_RAW + 'builtin_emoji_wn.png' }; 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] || (BUILTIN_EMOJI[code] ? serverPic(BUILTIN_EMOJI[code], 'tile') : 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)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); } 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 }); }; // NIP-71 video events: a clip posted as itself — normal (21) and short, // vertical (22), and the addressable 34235/34236 that divine.video and // others publish. The file is in an imeta tag, not the text. const VIDEO_KINDS = [21, 22, 34235, 34236]; const isVideoEvent = (ev) => !!ev && VIDEO_KINDS.includes(ev.kind); // what a feed or a profile lists: notes, polls (NIP-88) and video events const FEED_KINDS = [1, 1068, ...VIDEO_KINDS]; // A repost (NIP-18 kind 6) of someone's note shows in a feed of the // people who reposted it, as the note itself under " reposted". // Only reposts that carry the note (every major client embeds it) and // whose copy checks out: the id it names, a valid signature. const REPOST = 6; const repostInners = new Map(); // repost id -> the note, or null function repostInner(ev) { if (!ev || ev.kind !== REPOST) return null; if (repostInners.has(ev.id)) return repostInners.get(ev.id); let inner = null; try { const e = JSON.parse(ev.content || 'null'); const named = (ev.tags || []).filter((x) => x[0] === 'e').map((x) => x[1]); if (e && FEED_KINDS.includes(e.kind) && typeof e.id === 'string' && (!named.length || named.includes(e.id)) && verifyEvent(e)) { inner = { id: e.id, pubkey: e.pubkey, created_at: e.created_at, kind: e.kind, tags: e.tags, content: e.content, sig: e.sig }; } } catch {} if (repostInners.size > 2000) repostInners.delete(repostInners.keys().next().value); repostInners.set(ev.id, inner); return inner; } let repostBy = null; // set while noteRow draws a repost's note // A profile shows its articles (NIP-23) among the notes, as Amethyst does. // Their boosts too, under " reposted", as in a feed. const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST]; // 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); // The wraps this device holds — full id and time — for reconciliation // (NIP-77) with a relay that speaks it: a subscribe then fetches only the // wraps we lack instead of the relay's newest two hundred (half a // megabyte, every boot and every resubscribe), and the live subscription // opens with limit 0, nothing re-served. The prefix log above stays the // "skip this one" memory; a wrap it knows but this list doesn't (logged // before the list existed) is added as the relay re-serves it, once. const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage) let wrapItems = null; // id -> created_at, oldest first function loadWrapItems() { if (wrapItems) return; wrapItems = new Map(); try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {} } function noteWrapItem(id, ts) { if (!id || !ts) return; loadWrapItems(); if (wrapItems.has(id)) return; wrapItems.set(id, ts); while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value); scheduleWrapSave(); } const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); }; function scheduleWrapSave() { clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { // another tab may have logged wraps since this one loaded: keep theirs const s = st(); if (wrapLog) { const mine = new Set(wrapLog); s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); wrapLog = s.wraps; } if (wrapItems) { const all = new Map(s.wrapItems || []); for (const [id, ts] of wrapItems) all.set(id, ts); s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX); } save(s); } catch {} }, 1500); } function rememberWrap(id, ts) { const k = wrapKey(id); if (!wrapLog) wrapLog = []; if (!seenWraps.has(k)) wrapLog.push(k); seenWraps.add(k); if (ts) noteWrapItem(id, ts); scheduleWrapSave(); } 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; const run = () => { // not under a pager in motion: the page beneath is out of sight, and // its repaint (40–55 ms on Adam's phone) dropped frames from flights // through the pictures (2026-10-17) — it waits for the landing if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); }; repaintTimer = setTimeout(run, 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); } // /naddr1… of a live stream (kind 30311) opens the stream, public like a // note: a visitor watches over the front door. A reload the history // restores by itself needs nothing. const urlLive = (() => { if (typeof location === 'undefined' || urlInvite) return null; const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; })(); if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { try { history.replaceState(null, '', '/'); } catch {} ui.pubProf = true; ui.pubNotePending = 'live'; setTimeout(() => { openLiveRef(urlLive).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'), call: want('call') }; // 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'), rowT(t('notifCall'), 'call'), 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, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); // the keys not there yet (a wallet still opening): try again shortly — // this used to count as done for five minutes, and a phone woken by a // push in that time had no key to open a DM or a call with if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } inboxTries = 0; 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()]; // who may ring us when the app is shut: those we've written to (the // follows ride separately); `known` stays the DM filter's wider list const rings = known.filter((pk) => wroteTo(pk)); 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, rings, 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); } // an Amber-by-intent account, whether or not its signer has woken yet: // a wallet whose every wrap was already logged never wakes it, and then // never said why its messages were missing const interactiveLogin = () => { const l = hook('nostrLoginIdentity'); return !!l && (l.signer ? !!l.signer.interactive : l.via === 'amber'); }; // 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(() => {}); } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { // a call, as one of our devices logged it, under the person it was with const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } return false; } async function handleInboxWrap(wrap) { let heal = false; if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full 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, wrap.created_at); 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, p.wrap && p.wrap.created_at); } 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, p.wrap && p.wrap.created_at); } 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 onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); }; const plainDmSub = (relays) => { const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; // Relay by relay. One that speaks negentropy gets the live subscription // with limit 0 — opened first, so nothing slips between — and a // reconciliation of the wraps we hold against its newest four hundred, // the ones we lack fetched by id. One that doesn't (nos.lol has it // disabled; remembered for a day) is asked the old way, its newest // wraps re-served and skipped by the prefix log. So is every relay // while the one-time heal is running: that pass wants them re-served. const dmSub = (relays) => { for (const relay of relays) { if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; } const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); reconcileWraps(relay).then((ok) => { if (ok || !dmStarted || !dmUnsubs.includes(u)) return; // it doesn't speak it after all: the plain subscription, with its backlog try { u(); } catch {} allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u); plainDmSub([relay]); }).catch(() => {}); } }; async function reconcileWraps(relay) { const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld()); if (!r) return false; dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks'); for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) { const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000); for (const w of evs) onWrap(w); } return true; } dmSub(DM_RELAYS); // calls ringing in (ephemeral wraps: only what comes while we listen) const callSub = (relays, authed) => { const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; const on = (w) => { onCallWrap(w).catch(() => {}); }; const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; callSub(DM_RELAYS, false); // 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; if (extras.length && dmStarted) callSub(extras, true); // 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 if (rumor.kind === CALL_LOG) { // a call: its who, how and how long are all in its tags row.k = CALL_LOG; row.tg = rumor.tags || []; } 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 === 7 || m.kind === 9) noteEmoji(m); // a :code: with its picture (NIP-30 tag): learned, so the chip draws it 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 || m.kind === 9) noteEmoji(m); // (a group's :marmot: came as text: its tag was never read) 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'); } // streaming now: a red ring, and the face opens the stream (the name // beside it still opens the profile, which says they're on too) let on = null; try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built) if (on) { node.classList.add('live-ring'); if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); }; } 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, {}) || {}; // (notes saved before nv 2 were all flattened to kind 1: a boost's // JSON, a clip without its imeta — not worth keeping) return { full: c.full || {}, notes: c.nv === 2 ? c.notes || {} : {} }; } catch { return { full: {}, notes: {} }; } }; function persistPage(kind, pk, value) { try { const c = pageCache(); c[kind][pk] = { t: Date.now(), v: value }; c.nv = 2; 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']; // (a note keeps its kind and what draws it: its clips, a poll's options) const NOTE_TAGS = ['e', 'p', 'q', 'd', 'imeta', 'title', 'option', 'polltype', 'endsAt', 'relay', 'emoji', 'content-warning']; 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])), } : ev.kind === REPOST ? { // a boost whole: the note inside is checked against its signature id: ev.id, pubkey: ev.pubkey, kind: REPOST, created_at: ev.created_at, content: String(ev.content || ''), tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'), } : { id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at, content: String(ev.content || '').slice(0, 3000), tags: (ev.tags || []).filter((x) => NOTE_TAGS.includes(x[0])), }; 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.liveStream ? 'live' : 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(); } // Only Back OUT OF a profile or thread puts the reader back. Any other // pop — a full-screen picture closing, a sheet — leaves the page where it // is. It used to restore the newest remembered place on every pop, and a // place goes stale whenever its page is left some other way (the Feed // button from a thread): a photo's × then jumped the feed to wherever an // earlier thread had been opened from, or to its top. let pageLeft = ''; // the page a pop is leaving, read before the app restores the entry under it if (typeof window !== 'undefined') { window.addEventListener('popstate', () => { pageLeft = pageKey(); }, true); // capture: before the app's own handler re-renders window.addEventListener('popstate', () => { const left = pageLeft; pageLeft = ''; if (!left || pageKey() === left) return; const i = pageReturn.map((e) => e.key).lastIndexOf(left); if (i < 0) return; const y = pageReturn[i].y; pageReturn.length = i; // this entry, and anything stale above it // 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.liveStream = null; 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) // The replies themselves, as the count fetch brought them — a thread's // first page, in hand before it is opened: every post on screen has its // replies asked for (watchZaps), so opening it paints the conversation at // once and the relays only add to it. Bounded: the targets most recently // heard from, a few dozen replies each. const replyEvents = new Map(); // note id -> Map(reply id -> event) const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60; function keepReply(target, ev) { let m = replyEvents.get(target); if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); } replyEvents.set(target, m); while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value); } // The replies in hand for a thread: those naming the root, and those // naming only the reply they answer (one hop down), oldest first. function repliesInHand(rootId) { const out = new Map(); const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); }; take(rootId); for (const rid of [...out.keys()]) take(rid); return [...out.values()].sort((a, b) => a.created_at - b.created_at); } // …into a thread's cache entry: on opening it, and again when the count // fetch answers for a thread fetched ahead before the answer came function adoptRepliesInHand(c) { const have = new Set(c.replies.map((e) => e.id)); let added = []; for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); } if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); } return added.length; } // A thread's rows painted in their final shape: the faces, pictures, // previews and quotes of what is in hand are fetched ahead (noteReady, as // the feed does per row), so an opened thread doesn't shift as they land. const threadWarmed = new WeakSet(); const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face function warmThread(evs, deadline = Date.now() + READY_MS) { const fresh = evs.filter((e) => e && !threadWarmed.has(e)); for (const e of fresh) threadWarmed.add(e); return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {}))); } 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) => { if (isVideoEvent(ev)) return videoEventText(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'; let deeplWarned = false; 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); // The language posts are translated INTO: the reader's choice (Settings), // else the phone's own language when the app has no such UI language // (an Italian phone reads coinos in English, but wants posts in Italian), // else the app's. const TRANSLATE_TO_KEY = 'coinos-translate-to'; const TRANSLATE_TARGETS = ['ar', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh']; const APP_LANGS = new Set(LANGS.map(([c]) => c)); function transLang() { try { const v = localStorage.getItem(TRANSLATE_TO_KEY); if (v) return v; } catch {} const phone = (typeof navigator !== 'undefined' && (navigator.languages || [navigator.language])[0] || '').toLowerCase().split(/[-_]/)[0]; if (phone && !APP_LANGS.has(phone) && TRANSLATE_TARGETS.includes(phone)) return phone; return getLang(); } // A DeepL API key of the reader's own (Jumble offers the same): kept on // this device, sent with each request — DeepL won't answer a browser, so // the registrar passes it on, never storing or logging it. const DEEPL_KEY = 'coinos-deepl-key'; const deeplKey = () => { try { return (localStorage.getItem(DEEPL_KEY) || '').trim(); } catch { return ''; } }; const transKey = (ev) => ev.id + '|' + transLang() + (deeplKey() ? '|d' : '') + '|' + (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). // Asked per row per render; the guess reads the whole text, and a post's // text and the target language don't change, so remember it. const foreignMemo = new Map(); const looksForeign = (ev) => { const to = transLang(); if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false; const mk = transKey(ev) + '|' + to; if (foreignMemo.has(mk)) return foreignMemo.get(mk); const text = noteText(ev), { prose, tags } = transParts(text, to); const v = prose.length ? !inLanguage(text, to) : tags.length > 0; if (foreignMemo.size > 3000) foreignMemo.clear(); foreignMemo.set(mk, v); return v; }; 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 = transLang(); const dk = deeplKey(); try { let out = null; const guess = guessLang(text); // with a DeepL key, DeepL is what the reader chose: not the browser's if (!dk) { 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', ...(dk ? { 'x-deepl-key': dk } : {}) }, body: JSON.stringify({ text, target: to }), signal: AbortSignal.timeout(20_000), }); const j = await r.json().catch(() => ({})); if (!r.ok) { // a key DeepL refused, or out of quota: say so once, not "couldn't translate" on every post if (dk && (r.status === 401 || r.status === 402) && !deeplWarned) { deeplWarned = true; toast(t(r.status === 401 ? 'deeplBadKey' : 'deeplQuota')); } 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; } keepReply(x[1], ev); } 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); } // the open thread takes the replies this answer brought if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable(); 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)); // A conversations feed (Amethyst's "Conversations" beside its "New // Threads"): the replies these people write, each under the post it // answers, instead of their posts. Asked for as notes and NIP-22 comments; // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' // (a reply straight to the root), else — the deprecated positional form — // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. function parentRefOf(ev) { if (!ev || !replyish(ev)) return null; const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]); let tag; if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]); else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1]; if (!tag) return null; const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] }; if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase(); return ref; } // A note already in hand — in a feed, a thread, someone's page — before // asking any relay for it: the post a reply answers is usually right // there in the Following feed. function noteInHand(id) { for (const c of feedStates.values()) for (const e of c.notes) { if (e.id === id) return e; if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; } } for (const c of threadCache.values()) { if (c.root && c.root.id === id) return c.root; for (const e of c.replies || []) if (e.id === id) return e; } for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e; return null; } 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; // When the reader last moved the page themselves (a wheel notch coasts for // a while after the last one), and when it last scrolled at all: a batch of // arriving posts waits for a quiet moment (see queueAdmit). let lastInputAt = 0, lastScrollAt = 0; if (typeof window !== 'undefined') { for (const n of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown']) window.addEventListener(n, () => { lastInputAt = Date.now(); }, { passive: true, capture: true }); window.addEventListener('scroll', () => { lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); // Starting to scroll up dismisses the notice: the reader is already // heading toward the new posts. Keyed rows keep their positions. // 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; if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) // Keep tracking which posts are ahead, even after hiding the pill. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { pillFrame = 0; if (!feed) return; markReached(feed); if (!feed.unseen) return; feed.unseen = 0; render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) 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'; // The Conversations view of a feed (the chip beside Notes / Images / // Videos / Articles): the same people, their replies — a feed state of // its own under the feed's id with this suffix, its definition derived // from the feed's (replies: true, see feedKeeps). Nothing is stored // under it but its notes cache. const VIEW_SUFFIX = '~replies'; const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id); // 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 (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) { const base = feedDef(id.slice(0, -VIEW_SUFFIX.length)); return base ? { ...base, id, replies: true, view: true } : null; } 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.liveStream = null; ui.goLive = 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.liveStream = null; ui.goLive = 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 authorChunks = (authors) => { const out = []; for (let i = 0; i < authors.length; i += REQ_AUTHORS) out.push(authors.slice(i, i + REQ_AUTHORS)); return out; }; const feedAuthors = (def = feedDef()) => { if (!def) return []; // your own posts belong in the feed of the people you follow, as in // every other client — not only for the moment after posting. Yours // first: a feed's opening ask takes the authors in chunks of REQ_AUTHORS, // and with 898 follows a key added last sat in the third chunk — the // feed opened without your newest post until a later refresh. const set = new Set(def.follows && mePk() ? [mePk()] : []); if (def.follows) for (const pk of followsNow().set) set.add(pk); 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 const id = viewId(); // the feed, or its Conversations view let c = feedStates.get(id); if (!c) { let stored = []; try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {} if (!Array.isArray(stored)) stored = []; if (!stored.length) stored = borrowedNotes(feedDef(id)); stored = stored.slice(0, FEED_STORE); c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() }; feedStates.set(id, 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), { parent: feedReplies(c) }).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), { parent: feedReplies(c) }); }); prefetchFeed(c); } feed = c; return c; } let feedWarmTimer = null, feedWarmSession = 0; async function prefetchFeed(c) { const def = feedDef(c.id); const authors = feedAuthors(def), topics = feedTopics(def); if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; } 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([ // people's reposts with their posts (a topic or firehose ask: posts only); // every author, a chunk of them per ask ...(authors.length ? authorChunks(authors) : [null]).map((chunk) => queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length ? [...FEED_KINDS, REPOST] : FEED_KINDS, limit: FEED_PAGE, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000)), orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); } 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) => feedKeeps(def, 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), topics = feedTopics(def); if (!authors.length && !topics.length) continue; try { const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) => queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat(); if (feedStates.has(def.id)) continue; // opened while we were asking disk.seed(def.id, events.filter((e) => feedKeeps(def, 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.liveStream = null; ui.goLive = 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() && !def.replies ? '/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) => { for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); } }; // 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.liveStream = null; ui.goLive = 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 corsRefused = new Set(); // pictures whose host wouldn't hand over the bytes (this session) 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); corsRefused.add(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 () => { let got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); // no copy to be made here (the host keeps its bytes, or no worker to // make it): the server makes one if (!got && (noCorsHost(url) || corsRefused.has(url) || typeof OffscreenCanvas === 'undefined')) got = await serverMediaThumb(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|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { 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); } return f; }; // A video event's file: its HLS stream where the browser plays one (the // player picks the rendition); else of the versions its imeta tags // offer, the one nearest 1080 on its short side (a phone's screen; not the // 4K original); else its url tag. function videoEventFile(ev) { let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; const score = !edge ? -400 : -Math.abs(edge - 1080) - (edge > 1080 ? 1 : 0); // nearest 1080; the smaller on a tie if (!best || score > best.score) best = { url: f.url, image: f.image, score }; } if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; } if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) }; if (best) videoUrls.add(best.url); return best; } // what a video event reads as: its title, its words, and its file (drawn // as the clip it is) function videoEventText(ev) { learnVideoMeta(ev); const f = videoEventFile(ev); const title = String((ev.tags.find((x) => x[0] === 'title') || [])[1] || '').trim(); const words = String(ev.content || '').trim(); const parts = []; if (title && title !== words && !words.startsWith(title)) parts.push(title); if (words) parts.push(words); if (f && !words.includes(f.url)) parts.push(f.url); return parts.join('\n\n'); } 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 = imetaFields(tag); if (!f.url || !(VIDEO_RE.test(f.url) || /^video\//.test(f.m || '') || (isVideoEvent(ev) && !f.m))) continue; if (!VIDEO_RE.test(f.url)) videoUrls.add(f.url); 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); if (!have.dur && +f.duration > 0) d.dur = +f.duration; 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; // its length, from the movie header (a long clip plays as HLS: playSrc) let dur = 0; for (let j = 4; j + 32 <= b.length && !dur; j++) { if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm' const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16)); const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20); if (scale && units && units !== 0xffffffff) dur = units / scale; } return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) }; } 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) { if (isHls(url)) return probeStream(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 } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; 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); } // A stream is measured by a player: its playlist says nothing of the // picture's size. Where HLS doesn't play, it can't be measured either. async function probeStream(url) { if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); } const v = document.createElement('video'); v.muted = true; v.preload = 'metadata'; const ok = await new Promise((resolve) => { const timer = setTimeout(() => resolve(false), 15_000); v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); }; v.onerror = () => { clearTimeout(timer); resolve(false); }; v.src = url; }); if (ok) { noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) }); rememberVideo(url, v.videoWidth, v.videoHeight); } else noteVideoInfo(url, { failedAt: Date.now() }); v.removeAttribute('src'); v.load(); 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; } // Data Saver on, or a slow link: the connection to be careful with const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs // A clip too big to play as it is: over the budget, past 1080p, its index // at the end of a big file — or (known) unmeasurable. Such a clip plays as // an HLS stream where it can (playSrc); else it waits for a tap. function bigClip(url, known = false) { const d = videoInfo.get(url) || {}; if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; if (!d.size) return !known || d.probed || probeFailedLately(d); // 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; } function videoHeavy(url) { if (tappedClips.has(url) || largeVideosOk()) return false; const d = videoInfo.get(url) || {}; if (d.stream) return false; // a stream takes the rendition the connection carries // one that streams costs what the connection carries: it plays like any // other (Adam, 2026-10-18) — but not over Data Saver or a slow link, // where even a stream is the reader's call if (streamsHere(url) && !frugalData()) return false; return bigClip(url); } 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]) && isVideoUrl(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); } // parent: the post a reply answers is part of the row (a conversations // feed), so it arrives with the row; elsewhere a reply's parent is fetched // only where it is shown (a profile's Replies tab fills its cards in). async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) { if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; } 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)]; // a reply's parent, shown above it where replies are shown with context const parent = withParent && !depth ? parentRefOf(ev) : null; if (parent) { const quote = quotedNote(parent); tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1))); } 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, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); }; 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); } // a conversations feed: each reply under the post it answers (remembered // on the feed's state; a changed definition rebuilds the state) const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies); 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), { context: feedReplies(c) }), ev.id); if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); } return row; } finally { feedPaint = prev; } } // ---- read posts: a boost doesn't bring them back ----------------------- // A post you've read (its row held on screen for a moment, or shown in the // media pager) is remembered on this device, by the row it was read in: // the post itself, or the repost it came under. A later repost of it — // someone boosting it again, hours on — would put it back at the top of // the feed as if it were new; it stays out. The row you read keeps its // place, so scrolling back down still finds it. Your own reposts show. const SEEN_LS = 'coinos-seen'; const SEEN_MAX = 5000, SEEN_DAYS = 14; const seenLsKey = () => SEEN_LS + ':' + (wallet._cacheKey ? wallet._cacheKey() : ''); let seenNotes = null, seenKey = null, seenSave = 0; // note id -> [row id, seen at (s)] function seenMap() { const k = seenLsKey(); if (seenNotes && seenKey === k) return seenNotes; seenKey = k; seenNotes = new Map(); try { const cut = Date.now() / 1000 - SEEN_DAYS * 86400; for (const [id, row, t] of JSON.parse(localStorage.getItem(k) || '[]')) if (t > cut) seenNotes.set(id, [row, t]); } catch {} return seenNotes; } function markSeen(row) { const note = row && (row.kind === REPOST ? repostInner(row) : row); if (!note) return; const m = seenMap(); if (m.has(note.id)) return; m.set(note.id, [row.id, Math.floor(Date.now() / 1000)]); if (m.size > SEEN_MAX) for (const id of [...m.keys()].slice(0, m.size - SEEN_MAX)) m.delete(id); const k = seenKey; clearTimeout(seenSave); seenSave = setTimeout(() => { try { localStorage.setItem(k, JSON.stringify([...seenMap()].map(([id, [r, t]]) => [id, r, t]))); } catch {} }, 1000); } // a repost of a post already read under another row (the post itself, or // someone else's repost of it) function reboosted(e) { if (!e || e.kind !== REPOST || isMe(e.pubkey)) return false; const note = repostInner(e); const was = note && seenMap().get(note.id); return !!was && was[0] !== e.id; } // Read: at least half the row (or half the screen, for a tall one) in view // for a second without a break. const SEEN_MS = 1000; let seenObserver = null; const seenTimers = new Map(); // row element -> timer const seenWatched = new WeakSet(); function watchSeen() { if (typeof IntersectionObserver === 'undefined') return; seenObserver ||= new IntersectionObserver((entries) => { for (const en of entries) { const el = en.target; const enough = en.isIntersecting && en.intersectionRect.height >= Math.min(en.boundingClientRect.height, window.innerHeight) * 0.5; if (!enough) { clearTimeout(seenTimers.get(el)); seenTimers.delete(el); if (!el.isConnected) seenObserver.unobserve(el); continue; } if (seenTimers.has(el)) continue; seenTimers.set(el, setTimeout(() => { seenTimers.delete(el); if (!el.isConnected) return; const id = el.getAttribute('data-key'); const ev = feed && feed.notes.find((x) => x.id === id); if (ev) markSeen(ev); seenObserver.unobserve(el); }, SEEN_MS)); } }, { threshold: [0, 0.25, 0.5, 0.75, 1] }); requestAnimationFrame(() => { for (const el of document.querySelectorAll('.notes-feed > .row[data-key]')) { if (seenWatched.has(el)) continue; seenWatched.add(el); seenObserver.observe(el); } }); } // 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()); // (with those waiting a moment to go in together — admitQueues — or a // copy from a second relay, or another version of a clip, slips in) const held = [...c.notes, ...(c.deferred || []), ...((admitQueues.get(c) || {}).evs || [])]; const known = new Set(held.map((e) => e.id)); for (const e of evs || []) noteForSpam(e); // A note shows once: as itself, or under the first repost of it to // arrive — counting the ones still warming at the door. // (`staged` holds the note's id too, for a repost) const noteOf = (e) => (e.kind === REPOST ? repostInner(e) : e); const notesIn = new Set(held.map((e) => noteOf(e)?.id).filter(Boolean)); // an addressable post (a 34235/34236 clip) once, however many versions of it come for (const e of held) { const a = noteOf(e) && addrOf(noteOf(e)); if (a) notesIn.add(a); } const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); }; const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); }; const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } }; const def = feedDef(c.id); let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); if (!add.length) return false; if (def && def.curated) { const kept = curateFeed(add); for (const e of add) if (!kept.includes(e)) unstage(e); 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) unstage(e); admitFeed(add, c); c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).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, undefined, 0, { parent: feedReplies(c) }); unstage(e); 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); } queueAdmit(e, c, opts.newerThan); return true; })); return ready.some(Boolean); } // Ready posts go in together: one admission — one render with the reader's // row held still — per short interval, not one per post. A Popular switch // used to run dozens of full renders in a few seconds, 200–450 ms each, // and a wheel scroll through them stuttered on a laptop (the wheel coast // is driven from the main thread, so every long render froze it). While // the reader is moving the page the batch waits for a quiet moment, up to // a cap, so nothing much changes under a coasting scroll. const ADMIT_MS = 120, ADMIT_QUIET_MS = 160, ADMIT_INPUT_MS = 2500, ADMIT_MAX_WAIT_MS = 1500; const admitQueues = new Map(); // c → { evs, newerThan, since, timer } function queueAdmit(e, c, newerThan) { let q = admitQueues.get(c); if (!q) admitQueues.set(c, q = { evs: [], newerThan, since: Date.now(), timer: 0 }); q.evs.push(e); if (!q.timer) q.timer = setTimeout(() => flushAdmit(c), ADMIT_MS); } const readerMoving = () => Date.now() - lastScrollAt < ADMIT_QUIET_MS && Date.now() - lastInputAt < ADMIT_INPUT_MS; function flushAdmit(c) { const q = admitQueues.get(c); if (!q) return; q.timer = 0; if (c === feed && readerMoving() && Date.now() - q.since < ADMIT_MAX_WAIT_MS) { q.timer = setTimeout(() => flushAdmit(c), ADMIT_QUIET_MS); return; } admitQueues.delete(c); if (c.stopped) return; admitFeed([...(c.deferred || []), ...q.evs], c, false, q.newerThan); scheduleRepaint(); } // 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 * (1 + APPETITE_MAX), // room for the pass fetched ahead 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. Starting to scroll up 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; } // (relays: asked in place of the feed's own — the archives, for history // the feed's relays have let go) async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) { const def = feedDef(c.id); if (!def) return false; if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies 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(relays || 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; return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_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) => { // the people's reposts too, unless this pass asks for particular kinds const ks = kinds === FEED_KINDS && !topics.length ? [...kinds, REPOST] : kinds; if (await queryFeed(relays, { kinds: ks, authors: chunk, ...tag, limit, ...extra }, merge, c)) got = true; }); })); }; if (relays || feedRelays(def)) await readPlan([{ relays: 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); }); } // A row changes height after it was measured — a picture lands in a row // that painted before its size was known, a clip learns its shape, a link // card arrives. The stale number is what the spacer uses when the row // leaves the window: the page then shrinks or grows by the difference, // the browser's scroll anchoring pulls the reader back by as much, and a // coasting wheel scroll stalled or even reversed right as the media // appeared. Keep the measurements live instead (no layout forced: the // observer runs after layout). const rowSizer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver((entries) => { for (const en of entries) { const r = en.target, c = r.parentElement && r.parentElement._winC; if (!c || !r.isConnected) continue; const hgt = r.getBoundingClientRect().height, key = r.getAttribute('data-key'); if (hgt > 0 && key) (c.heights || (c.heights = new Map())).set(key, hgt); } }); function measureNow(c) { const list = document.querySelector('.notes-feed'); if (!list) return false; c.listTop = list.getBoundingClientRect().top + (window.scrollY || 0); list._winC = c; const heights = c.heights || (c.heights = new Map()); let changed = false; const seen = new Set(); 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; } if (rowSizer) { seen.add(r); if (!(list._sized ||= new Set()).has(r)) { list._sized.add(r); rowSizer.observe(r); } } } if (rowSizer && list._sized) for (const r of list._sized) if (!seen.has(r)) { list._sized.delete(r); rowSizer.unobserve(r); } 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 c = feedNow(); const def = feedDef(c.id); // the Conversations view has its own if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; const kinds = feedKinds(def); const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } const orT = orTopicsOf(def); if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } 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 === 'live') return; if (!listView()) { // 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; } fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin 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 < feedLead()) loadOlderFeed().catch(() => {}); }, 100); } // ---- the reader's appetite ------------------------------------------- // How hungrily the feed is being scrolled: the screens travelled, by the // reader's own hand, over the last ten seconds. A page of posts is read // at a screen or two in that time; five screens in it is someone flying // through, and the feed answers by paging more posts at a time and // asking for them from further up — up to four pages and four times the // lead at once. (Adam, 2026-10-16: "could we load posts faster if it // seems like the user is scrolling a lot? … try always loading more // posts and loading them sooner when we're further away from the // bottom".) The trail counts only scrolls within a moment of an input, // so a page held still under an insert above doesn't look like travel. const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5; const scrollTrail = []; // [at, scrollY] function noteScrollTrail(y) { const now = Date.now(); scrollTrail.push([now, y]); while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); } function appetite() { const now = Date.now(); while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); if (scrollTrail.length < 2) return 1; const H = (typeof window !== 'undefined' && window.innerHeight) || 800; const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1]; return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H)))); } // how far above the foot of the feed the next page is asked for const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite(); // The relays run AHEAD of the rows. The older page used to be asked for // only once every post in hand was on screen, and shown only once the // whole pass had answered — so the reader who reached the end of what we // held waited a relay round trip, up to the five seconds the slowest // relay in the pass is given. Now a pass for older posts starts as soon // as what we hold beyond the shown rows thins to a page or two (more // with the appetite), its rows warming at the door while the reader is // still on the posts before them; the showing step takes from hand. function fetchOlderAhead(c) { if (c.fetching) return c.fetching; if (c.end || c.booting || c.stopped || c.status !== 'ready') return null; if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null; const oldest = c.notes.at(-1); if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; } c.fetching = (async () => { let until = oldest.created_at - 1; const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip for (let tries = 0; tries < 3; tries++) { c.sawAny = 0; if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true; // nothing older at all (or no step back): that really is the end if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; } until = c.seenBack - 1; } return false; })().catch(() => false).finally(() => { c.fetching = null; if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); } }); return c.fetching; } 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 { fetchOlderAhead(c); if (c.shown >= c.notes.length) { // nothing in hand: the pass underway (started just now, if need be) brings the next page if (!c.fetching) return; await c.fetching; if (c.shown >= c.notes.length) return; } const page = FEED_PAGE * appetite(); const end = Math.min(c.shown + page, c.notes.length); const boundary = c.notes[end - 1].id; await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) }); c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); fetchOlderAhead(c); // and the relays, if that thinned what we hold } 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 ![cover](…) rendered as a picture while the ![cover]( was left // sitting above it as litter. So the whole construct is one token now. // // Images and links ONLY. Not headings, not bold, not italics: # opens a // hashtag and * turns up in ordinary prose, so honouring those would // mangle normal posts to pretty up the rare bridged one. const MD_LINK = '!?\\[[^\\]\\n]{0,300}\\]\\(\\s*)]+>?[^)\\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; } // under the media pager nothing starts: the page laid out again // beneath it (a turn of the phone) "showed" a clip — the pager's own, // as often as not — and a second copy played behind it if (ui.mediaPager && en.isIntersecting) 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); // the clip's very first frame at full size (the pager's slides ahead hold it) const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined); const serverFirstFailed = new Set(); // clips the server had no first frame for (this session) 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) // A picture from a host that won't hand over its bytes (no CORS: primal's // blossom, nostrcheck, most personal sites) can't have its column copy made // here, so the server makes it (pic.webp: 1100px wide, or a 360px square // for the grid; libvips, cached for everyone at the CDN). Without this the // painted the original, and a 9528×6328 camera file froze a wheel // scroll for 400 ms on its texture upload. The copy is kept in the same // cache as the ones made here, so the next visit paints it at once. const serverPic = (url, shape) => (vmediaOk(url) && !/\.gif(\?|$)/i.test(url) ? VMEDIA + 'pic.webp?s=' + shape + '&u=' + encodeURIComponent(url) : null); const serverPicFailed = new Set(); // pictures the server had no copy for (this session) const picDims = (src) => new Promise((resolve) => { const img = new Image(); img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(() => resolve({ width: img.naturalWidth, height: img.naturalHeight }), () => resolve(null)); img.onerror = () => resolve(null); img.src = src; }); async function serverPicBlob(url, shape) { const src = serverPic(url, shape); if (!src || serverPicFailed.has(shape + '|' + url)) return null; let res; try { res = await fetch(src, { signal: AbortSignal.timeout(READY_MS) }); } catch { return null; } if (!res.ok) { if (res.status !== 429 && res.status !== 503) serverPicFailed.add(shape + '|' + url); // busy is tried again return null; } const blob = await res.blob(); return /^image\//.test(blob.type) && blob.size ? blob : null; } async function serverMediaThumb(url) { const blob = await serverPicBlob(url, 'col'); if (!blob) return null; const thumb = URL.createObjectURL(blob); const dims = await picDims(thumb); if (!dims || !dims.width) { URL.revokeObjectURL(thumb); serverPicFailed.add('col|' + url); return null; } try { const store = await thumbStore(); await store.put(url, new Response(blob, { headers: { 'x-dims': JSON.stringify(dims), 'x-original': '0' } })); 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 }; } let hevcPlays; const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); // A long clip (or a big one whose length isn't known) plays as HLS from // vmedia: a 360p and a 720p rendition, the player taking the one the // connection carries, in 4-second pieces made as they're watched — not a // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the // original as before. const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); const hlsFailed = new Set(); let hlsPlays; const canHls = () => hlsPlays ??= (() => { try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } })(); const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; // Long, or too big to play as it is (a 4K clip, a 140 MB one: these // waited for a tap with their cost on them) — streamed at a rendition the // connection carries instead. A small short clip plays as it is: one // request, its own quality, no work for the server. const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); // what a player for this clip loads: the stream for a long or big one; // the copy, once the original is known not to show here const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url : streamsHere(url) ? hlsOf(url) : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A 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; // the stream failed: the copy or the original, as it would have been if (isHls(v.getAttribute('src'))) { hlsFailed.add(url); const want = !v.paused; v.setAttribute('src', playSrc(url)); if (want) v.play().catch(() => {}); 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; let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened function videoTap(e, url) { const v = e.currentTarget; if (!v || v.tagName !== 'VIDEO') return false; 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 false; // the control bar if (v.controls && v.paused && Math.hypot(x - W / 2, y - H / 2) < 48) return false; // the big play button e.preventDefault(); if (fs) { if (!v.paused) v.pause(); else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); return true; } if (openClip(v, url)) return true; // a clip still idle (autoplaying, no controls) becomes the reader's wokeClips.add(url); const box = v.closest('.note-video-box'); if (box) { box.classList.remove('idle'); box._skipMorph = true; } v.controls = true; const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen; if (!go) return true; // (the screen isn't turned for it: a wide clip plays with bars, and the // reader turns the phone if they want it bigger) Promise.resolve(go.call(v)).catch(() => {}); return true; } if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => { const el = fsElement(); // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. // (only a clip the pager has: taken out of full screen for a pager that // then wouldn't open, a quoted post's clip went nowhere at all) if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) { const url = el.dataset.clip; Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}) .then(() => { if (!pager && document.contains(el)) openClip(el, url); }); return; } if (el) return; // out of the pager's full screen some other way (Back on Android, Escape // on a computer): out of the pager too, in the one step if (pagerFs) { pagerFs = false; if (pager) dismissPager(); } }); 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); // (data-clip: which clip it is, for the full-screen button's pager) const v = h('video', { src: playSrc(url), class: 'note-video', controls: idle ? undefined : true, 'data-clip': url, 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), // a touch's tap was taken at pointerup (below): its click, if one comes, is spent onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); guardPicture(v, url); // With its own controls up, Chrome on Android keeps a tap on the clip for // them (it shows or hides the bar) and the page never gets the click — so // a full-screen tap on a clip that had been played did nothing. The // pointer events still arrive: a touch that lifts where it went down, // quickly, is the tap. v.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; e.currentTarget._touch = { x: e.clientX, y: e.clientY, t: Date.now(), id: e.pointerId }; }); v.addEventListener('pointercancel', (e) => { e.currentTarget._touch = null; }); v.addEventListener('pointerup', (e) => { const el = e.currentTarget, d = el._touch; el._touch = null; if (!d || d.id !== e.pointerId || Date.now() - d.t > 500 || Math.hypot(e.clientX - d.x, e.clientY - d.y) > 12) return; if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now(); }); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v); 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); // a tap on the picture is ours, not the player's (whose taps the page // never sees): it takes the clip full screen — YouTube's own control // bar along the bottom stays YouTube's. Full screen, this steps aside. box.append(h('div', { class: 'yt-shield', 'aria-hidden': 'true' })); if (muted) { let on = false; const btn = soundBtn(() => { on = !on; btn.setSound(on); post(box, on ? 'unMute' : 'mute'); if (on) post(box, 'playVideo'); }); box._soundOn = () => { on = true; btn.setSound(true); }; box.append(btn); } }; // One tap, as with any clip in a post: it plays, with sound, full screen // (the screen not turned: the reader turns the phone if they like). The // still loads the player first; a player already going (autoplayed, // muted) is unmuted and carried on. const full = (box) => { const go = box.requestFullscreen || box.webkitRequestFullscreen; if (!go) return; Promise.resolve(go.call(box, { navigationUI: 'hide' })).catch(() => {}); }; const tapped = (e) => { if (e.target.closest('.yt-open, .vid-sound, iframe')) return; e.stopPropagation(); e.preventDefault(); const box = e.currentTarget; if (!box.dataset.playing) load(box, false); // a tap means sound else { post(box, 'unMute'); post(box, 'playVideo'); if (box._soundOn) box._soundOn(); } full(box); }; const frame = h('div', { class: 'yt-embed', onClick: tapped }, 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'; }, }), // (its tap is the box's: play, full screen) h('button', { class: 'yt-play', type: 'button', 'aria-label': t('playVideo'), title: t('playVideo'), }, 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) { const have = noteInHand(ref.id); if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return 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 && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); 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; } // ---- live streams (NIP-53) ----------------------------------------------- // What's on now, newest copy of each stream by its address: asked of the // relays streams are announced on every couple of minutes while a feed or // profile wants it. A feed shows the ones its people are in (Following: // your follows; Everything and Popular: all of them) in a row above its // posts; a profile says when its person is on; a post linking a stream // shows it as a card. Any of them opens the stream's own screen. const liveSeen = new Map(); // addr -> newest 30311 let liveAt = 0, liveAsking = null; const liveAskedOf = new Map(); // pk -> when their own streams were asked for function takeLive(evs) { let changed = false; for (const [a, ev] of newestByAddr(evs)) { const cur = liveSeen.get(a); if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } } if (changed) liveHostsAt = 0; return changed; } // who is on now (host -> their stream), for the rings round faces: worked // out again when a stream changes and every half minute (one goes stale) let liveHostsAt = 0, liveHosts = new Map(); function liveHostNow(pk) { if (!liveSeen.size) return null; if (Date.now() - liveHostsAt > 30_000) { liveHostsAt = Date.now(); const now = Date.now() / 1000; liveHosts = new Map(); for (const ev of liveSeen.values()) { if (!isLiveNow(ev, now) || !liveShowable(ev)) continue; const h = liveHost(ev), cur = liveHosts.get(h); if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev); } } return liveHosts.get(pk) || null; } const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; function refreshLive() { if (liveAsking || Date.now() - liveAt < 120_000) return; liveAt = Date.now(); liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) .catch(() => {}).finally(() => { liveAsking = null; }); } // the streams someone is in, asked of their own relays as well (a // streamer's on a relay we don't otherwise read) function askLiveOf(pk) { if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; liveAskedOf.set(pk, Date.now()); Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; return Promise.all([ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000), queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000), ]); }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); } const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); function liveNowOf(pk) { const now = Date.now() / 1000; return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) .sort((a, b) => b.created_at - a.created_at)[0] || null; } function liveForFeed(def) { const now = Date.now() / 1000; const fol = followsNow().set; const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); if (people && !people.size) return []; return [...liveSeen.values()] .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); } const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; function liveBadge(ev) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } // the feed's Live chip: its streams on now, as a grid of tiles function liveGrid(def) { const evs = liveForFeed(def).slice(0, 60); if (!evs.length) return liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')); return h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map((ev) => { const host = liveHost(ev), n = liveViewers(ev); return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null), h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); })); } // a profile's "on now" line function liveBanner(pk) { askLiveOf(pk); const ev = liveNowOf(pk); if (!ev) return null; return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, h('span', { class: 'live-badge live' }, t('liveBadge')), h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), h('span', { class: 'small muted' }, t('liveWatch'))); } // a post linking a stream (nostr:naddr of a 30311) function liveCard(ev) { const host = liveHost(ev); return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), h('div', { class: 'col', style: 'gap:4px;min-width:0' }, h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); } function openLive(ev) { rememberPlace('live'); const a = liveAddr(ev); takeLive([ev]); setZapAddr(ev.id, a); ui.liveStream = { a, seed: ev }; ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; render(); } // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) async function openLiveRef(ref) { const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); if (ev) openLive(ev); else toast(t('liveNotFound')); } // The open stream: its chat (1311) and zaps (9735, and coinos's Ark // receipts) as they come, and the stream event itself as it's refreshed // (the viewer count, `ended`). let liveWatch = null; function watchLive(s) { if (liveWatch && liveWatch.a === s.a) return liveWatch; stopLiveWatch(); const [, pk, ...rest] = s.a.split(':'); const d = rest.join(':'); takeLive([s.seed]); const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' }; let mine = []; try { mine = wallet.nostrRelays(); } catch {} const relays = [...new Set([...liveRelays(s.seed), ...mine])]; const add = (e) => { if (liveWatch !== w || w.lines.has(e.id)) return; w.lines.set(e.id, e); scheduleRepaint(); }; w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); return w; } function stopLivePlayer(w = liveWatch) { if (!w) return; if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } const v = w.video; w.video = null; if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } } function stopLiveWatch() { const w = liveWatch; if (!w) return; liveWatch = null; for (const stop of w.stops) { try { stop(); } catch {} } stopLivePlayer(w); } // What to play: the stream while it's on, its recording once it's over. const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : ''); // The