// 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, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays, npubOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, } from '../nostr.js'; import { channelKey, 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 { saveInbox } from '../dm-inbox.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { decodeBolt11 } from '../ark/lightning.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.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'; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state // NIP-89 client attribution on PUBLIC notes (posts + thread replies) — other // clients render it as "via coinos". Name-only: the fuller form appends a // kind-31990 handler coordinate, which we haven't published. Deliberately // absent from anything encrypted (DMs, community wraps) — those are private // and need no billboard. const CLIENT_TAG = ['client', 'coinos']; export function messagesFeature(ctx) { const { h, ui, render, wallet, toast, hook } = ctx; // ---- persisted state ---------------------------------------------------- const st = () => { const s = wallet.loadFeatureState('messages', {}); 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 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) => wallet.saveFeatureState('messages', s); const communities = () => [COMMUNITY, ...st().communities]; // ---- 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))); 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; 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); } }, }); } // 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++; 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; } 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 = () => { // 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 ----------------------------------------------------- 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 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; function loadSeenWraps() { if (wrapLog) return; wrapLog = (st().wraps || []).slice(-WRAPS_MAX); for (const k of wrapLog) seenWraps.add(k); } function rememberWrap(id) { const k = wrapKey(id); if (!wrapLog) wrapLog = []; if (!seenWraps.has(k)) wrapLog.push(k); seenWraps.add(k); clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { const s = st(); s.wraps = wrapLog.slice(-WRAPS_MAX); wrapLog = s.wraps; save(s); } catch {} }, 1500); } let dmStarted = false; let allUnsubs = []; let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; repaintTimer = setTimeout(() => { repaintTimer = null; if (ui.screen === 'wallet') render(); }, 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; profilesWarmed = true; const cached = wallet.loadFeatureState('profiles', {}); for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p); } function persistProfile(pk, p) { // A faceless answer is not a fact worth writing down — persisting // {name:null, picture:null} rows only evicts real faces from the cap and // spreads a cold-relay miss across sessions. if (!p || (!p.name && !p.picture)) return; const s = wallet.loadFeatureState('profiles', {}); s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now() }; const keys = Object.keys(s); if (keys.length > 150) { // the user's own faces never churn out — their avatar going punk over // a busy community room is the one eviction people actually notice const keep = new Set([(hook('nostrLoginIdentity') || {}).pubkey, wallet.nostrPubkey && wallet.nostrPubkey()].filter(Boolean)); const evictable = keys.filter((k) => !keep.has(k)); for (const k of evictable.sort((a, b) => (s[a].t || 0) - (s[b].t || 0)).slice(0, keys.length - 150)) delete s[k]; } wallet.saveFeatureState('profiles', s); } // 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) => { try { if (p && p.picture) new Image().src = p.picture; } catch {} }; // A profile that came back EMPTY is usually a cold boot's 5s query racing // relays that were still dialing — not proof there's no kind 0. Trusting it // for the full TTL once dressed a real account in a punk and an npub for a // whole day; an empty answer is retried in minutes instead. const EMPTY_RETRY = 10 * 60_000; function profileOf(pk) { warmProfiles(); const cur = profiles.get(pk); if (cur !== undefined && (cur === null || Date.now() - (cur.t || 0) < (!cur.name && !cur.picture ? EMPTY_RETRY : PROFILE_TTL))) return cur; profiles.set(pk, cur || null); // null = loading, no fallback art yet fetchNostrProfile(pk).then((p) => { // An empty answer on a cold boot (relays still dialing, the 5s query // came back with nothing) must never clobber a remembered face with a // punk — keep the stale fields and just refresh the clock, so the // known avatar holds while the full-profile fetch does its rounds. const prev = profiles.get(pk); const entry = p ? { ...p, t: Date.now() } : { ...(prev || {}), t: Date.now() }; profiles.set(pk, entry); persistProfile(pk, entry); preloadPicture(entry); scheduleRepaint(); }).catch(() => { const prev = profiles.get(pk); profiles.set(pk, { ...(prev || {}), t: Date.now() }); }); return cur || null; } 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); room = { jm, control: controlKey(root, jm.community_id, jm.root_epoch || EPOCH), guestbook: guestbookKey(root, jm.community_id, jm.root_epoch || EPOCH), chStream: (id) => channelKey(root, id, EPOCH), folded: null, controlEntries: [], guestEntries: [], members: new Map(), byChannel: new Map(), edits: new Map(), deletes: new Set(), reactions: new Map(), 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 }); 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(); // 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; allUnsubs.push( subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); openWrapBg(wrap, room.control, (opened) => { if (!opened || opened.rumor.kind !== 3308) return; room.controlEntries.push(opened); scheduleFold(); }); }), subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); openWrapBg(wrap, room.guestbook, (opened) => { if (!opened) return; room.guestEntries.push(opened); scheduleFold(); }); }) ); 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); // 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); allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [stream.pk], limit: 200 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); openWrapBg(wrap, stream, (opened) => { if (!opened) return; onChat(room, id, opened); }); })); } 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(EPOCH)) 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; } 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…" } 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]); } } 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) { 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) return [...room.folded.channels.entries()].map(([id, c]) => ({ id, name: c.name })); return room.jm.channels || []; }; 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); } // 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, ''); 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; const rumor = rumorWithId({ kind: 9, pubkey: id.pubkey, content: text, tags: [['channel', chId], ['epoch', String(EPOCH)], ...(replyTo ? [['e', replyTo]] : []), ms], created_at, }); const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId); const entry = { rumor, author: id.pubkey, pending: true }; msgs.set(rumor.id, entry); 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 = ['👍', '❤️', '😂', '🔥', '🎉', '🙏']; 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, tags: [['channel', chId], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], 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); 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. function msgSnippet(room, m, max = 90) { const edit = room.edits.get(m.rumor.id); const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; const one = String(text || '').replace(/\s+/g, ' ').trim(); return one.length > max ? one.slice(0, max - 1) + '…' : one; } // 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' }, msgSnippet(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; 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' }, h('div', { class: 'msg-sheet-emojis' }, REACT_EMOJIS.map((e2) => h('button', { class: e2 === myReact ? 'on' : '', onClick: () => { close(); sendReaction(room, chId, m, e2); }, }, e2))), item('↩', t('msgReply'), () => { ui.msgReplyTo = m.rumor.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), !mine && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null, item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); }), 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' }, msgSnippet(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(EPOCH)], ['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, channels: roomChannels(room).map((c) => ({ id: c.id, name: c.name })), relays: room.relays, name: (room.folded?.metadata?.name) || room.jm.name, creator_npub: creatorPk, }; } 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) 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) }; 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, channels: (b.channels || []).map((c) => ({ id: c.id, name: c.name })), 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) { pendingLink = { parsed, state: 'loading' }; 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') }; else if (b.revoked) pendingLink = { parsed, state: 'error', error: t('msgInviteRevoked') }; else if (b.expired) pendingLink = { parsed, state: 'error', error: t('msgInviteExpired') }; else pendingLink = { parsed, state: 'ready', bundle: b }; scheduleRepaint(); } function joinFromText(text) { const parsed = parseInviteLink(text); if (!parsed) { toast(t('msgBadInvite')); return; } ui.msgJoinText = ''; ui.msgHomePanel = null; loadLinkInvite(parsed); } // 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.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 if (m && ['chat'].includes(m[1])) return null; return m ? m[1] : null; })(); if (urlProfile) { try { history.replaceState(null, '', '/'); } catch {} } // 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 (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 let pk = 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(channelKey(root, c.id, EPOCH).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 reasons = { payment: s.reasons?.payment !== false, dm: s.reasons?.dm !== false }; // 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('notifDm'), 'dm'), h('div', { class: 'small faint' }, t('notifChatHint'))); } // Hand the service worker what it needs to tell a friend's DM from a // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. let inboxAt = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); if (!id) return; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; const follows = newest ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); if (p && p.name) names[pk] = p.name; } await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, follows, known, names, hasList: !!newest, updated: Date.now(), }); } catch { inboxAt = 0; } } async function registerPush({ interactive = false } = {}) { try { 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(); // 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) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: wantKey }); } 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) 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; } // Join material subset (never the icon, never link fields). We only run // epoch 0 today, 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, channels: (jm.channels || []).map((c) => ({ id: c.id, name: c.name })), 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 (s.communities.some((c) => c.community_id === e.community_id)) continue; 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; 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; } async function publishLists() { const ids = await selfCryptors(); const s = st(); const docs = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]]; 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), }); publishOn(DM_RELAYS, evt); } catch {} } } let listsSynced = false; async function syncLists() { if (listsSynced) return; const ids = await selfCryptors(); if (!ids.length) return; listsSynced = true; const evs = await queryOn(DM_RELAYS, { kinds: [13302, 13303], authors: ids.map((i) => i.pk) }, 3500); let changed = false; const remoteDocs = new Set(); 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 {} } 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(); } 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) { if (!peer || !rumor.id) return; threadOf(peer).set(rumor.id, { 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'); if (login && login.signer && login.signer.decryptFrom) 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. function decryptorsComplete() { const login = hook('nostrLoginIdentity'); return !login || !!(login.signer && login.signer.decryptFrom); } // 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(() => null); } async function openInboxWrap(wrap) { for (const d of dmDecryptors()) { const got = await unwrapDMAny(wrap, d); if (!got) continue; if (got.rumor.kind === 14) { // 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); const to = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; const peer = mine ? (to || got.peer || got.author) : got.author; 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]; if (target) { (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 {} } return true; } return false; } async function handleInboxWrap(wrap) { if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) return; seenWraps.add(wrap.id); if (await openInboxWrap(wrap)) { rememberWrap(wrap.id); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, tries: 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); if (!dmDecryptors().length) return; const complete = decryptorsComplete(); // 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; if (await openInboxWrap(p.wrap)) { pendingWraps.delete(id); rememberWrap(id); } else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); } } } finally { draining = false; } // still holding wraps: the signer may connect later — keep a slow retry if (pendingWraps.size) scheduleDrain(decryptorsComplete() ? 30_000 : 60_000); } 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; 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; } for (const m of list) threadOf(peer).set(m.id, { rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14 }, mine: isMe(m.from) }); } bumpMsgRev(); if (swept) save(s); allUnsubs.push(subscribeOn(DM_RELAYS, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { handleInboxWrap(wrap).catch(() => {}); })); // 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)) .slice(0, 3); if (extras.length && dmStarted) allUnsubs.push(subscribeOn(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { handleInboxWrap(wrap).catch(() => {}); })); } 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; } // 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; const rumor = makeDMRumor(id.pubkey, peer, text, replyTo ? [['e', replyTo]] : []); 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); fetchInboxRelays(peer).then((inbox) => { const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r)); if (extra.length) publishOn(extra, toPeer); }).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; const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, 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); 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); fetchInboxRelays(peer).then((inbox) => { const extra = inbox.slice(0, 4).filter((x) => !DM_RELAYS.includes(x)); if (extra.length) publishOn(extra, toPeer); }).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(); } } function persistDms() { const s = st(); const byRecent = [...threads.entries()] .map(([peer, m]) => [peer, [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)]) .sort((a, b) => (b[1].at(-1)?.rumor.created_at || 0) - (a[1].at(-1)?.rumor.created_at || 0)) .slice(0, 30); s.dms = {}; for (const [peer, list] of byRecent) s.dms[peer] = list.filter((m) => !m.pending).slice(-CACHE_MAX) .map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at })); save(s); } // ---- views -------------------------------------------------------------- const timeLabel = (tms) => { const d = new Date(tms); const today = new Date().toDateString() === d.toDateString(); return 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' }); }; const avatar = (pk, cls = 'chat-avatar', clickable = true) => { const p = profileOf(pk); // While the profile is in flight, a quiet empty circle — the punk is a // statement about having no picture, not a loading state. (`loading` on // an entry marks a provisional seed: name known, picture still unknown.) const node = p === null || (p && p.loading && !p.picture) ? h('div', { class: cls + ' fallback loading' }) : p.picture // A background paints synchronously from cache; a fresh decodes // async, so recreating one per render made avatars visibly flash. ? h('div', { class: cls + ' ava-img', style: `background-image:url(${JSON.stringify(p.picture)})` }) : fallbackAvatar(h, pk, p.name, cls); if (clickable) { node.classList.add('clickable'); node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); } 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 // The profile-page cache SURVIVES refreshes: bios and recent posts serve // instantly from storage while a background fetch freshens them. Bounded: // the last handful of viewed/warmed pages, notes trimmed to what the rows // render. const PROF_PAGE_CACHE = 'profPages'; const pageCache = () => { try { const c = wallet.loadFeatureState(PROF_PAGE_CACHE, {}) || {}; return { full: c.full || {}, notes: c.notes || {} }; } catch { return { full: {}, notes: {} }; } }; function persistPage(kind, pk, value) { try { const c = pageCache(); c[kind][pk] = { t: Date.now(), v: value }; for (const k of ['full', 'notes']) { const keys = Object.keys(c[k]).sort((a, b) => c[k][b].t - c[k][a].t); for (const drop of keys.slice(10)) delete c[k][drop]; } wallet.saveFeatureState(PROF_PAGE_CACHE, c); } catch {} } const slimNote = (ev) => ({ id: ev.id, pubkey: ev.pubkey, kind: 1, created_at: ev.created_at, content: String(ev.content || '').slice(0, 3000), tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'), }); function fetchFullProfile(pk) { if (!fullProfiles.has(pk)) { const cached = pageCache().full[pk]; if (cached) fullProfiles.set(pk, cached.v); } if (fullFetched.has(pk)) return; fullFetched.add(pk); queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3500).then((evs) => { const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; let m = {}; try { m = newest ? JSON.parse(newest.content) : {}; } catch {} fullProfiles.set(pk, m); persistPage('full', pk, m); // A full fetch is the freshest word on this profile — stamp it into the // light cache (and persist) so a picture changed elsewhere replaces the // stale one everywhere within a session, not after the 24h TTL. The // repaint covers the header/chat avatars, not just an open profile page. const entry = { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null, lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, t: Date.now(), }; profiles.set(pk, entry); persistProfile(pk, entry); preloadPicture(entry); if (ui.profilePk === pk) render(); else scheduleRepaint(); }).catch(() => { if (!fullProfiles.has(pk)) fullProfiles.set(pk, {}); }); } // 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; 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([ Promise.resolve(fetchFullProfile(pk)), Promise.resolve(notesFor(pk)), ]).finally(() => { prefetching--; drainPrefetch(); }); } } function openProfile(pk) { ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen // router otherwise keeps a thread above the profile, which is the order // for the other direction (a note row tapped on a profile page). ui.profOverThread = !!ui.noteThread; ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null; render(); 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]; const zapTotals = new Map(); // id -> { sats, seen: Set, mine } const zapAsked = new Set(); let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = []; const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])]; 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; } } function zapperOf(ev) { if (ev.kind === 9737) return ev.pubkey; if (tagOf(ev, 'P')) return tagOf(ev, 'P'); try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; } } 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; // 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); } changed = true; } if (changed) 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; const batch = [...zapQueue]; zapQueue = new Set(); const relays = zapRelays(); for (let i = 0; i < batch.length; i += 150) { queryOn(relays, { kinds: ZAP_KINDS, '#e': batch.slice(i, i + 150) }, 4000) .then((evs) => evs.forEach(noteReceipt)).catch(() => {}); } zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200); if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} } zapLiveUnsub = subscribeOn(relays, { kinds: ZAP_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteReceipt); }, 250); } // 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 }, where 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 id, so the 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. const zapPending = new Map(); const pendTtl = (p) => (p.state === 'flying' ? ZAP_FLIGHT_MS : ZAP_PAID_MS); function pendingOf(id) { const p = zapPending.get(id); if (!p) return null; if (Date.now() - p.at > pendTtl(p)) { zapPending.delete(id); return null; } return p; } function markZapPending(id, sats) { if (!id || !sats) return; zapPending.set(id, { sats, at: Date.now(), state: 'flying' }); scheduleRepaint(); // 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 p = pendingOf(id); if (!ok) { if (p && p.state !== 'void') { zapPending.delete(id); scheduleRepaint(); } return; } if (p && p.state === 'void') return; // the receipt is already carrying it zapPending.set(id, { sats: sats || (p && p.sats) || 0, at: Date.now(), state: 'paid' }); scheduleRepaint(); } // One of our own receipts has been counted for this id: the real total // speaks for the zap now, so the optimistic amount stands down. function voidPending(id, evMs) { const p = zapPending.get(id); // ...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 zap currently in the air. if (p && p.state !== 'void' && evMs && evMs < p.at - 120_000) return; zapPending.set(id, { 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. function zapChip(id, { onClick, cls = '' } = {}) { const z = zapTotals.get(id); const p = pendingOf(id); const optimistic = p && p.state !== 'void' ? p.sats : 0; const sats = (z ? z.sats : 0) + optimistic; if (!sats) return null; const flying = !!p && p.state === '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: onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined, }, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(sats)); } // 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) { zapNote(pk, { id }); recheckZap(id); } 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; 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; } const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk))])]; function notesFor(pk) { let c = notesCache.get(pk); if (c) return c; // stored posts paint the page instantly; the relay fetch below freshens const stored = pageCache().notes[pk]; c = stored ? { status: 'ready', notes: stored.v } : { status: 'loading', notes: [] }; notesCache.set(pk, c); (async () => { const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500); const seen = new Set(); const fresh = (evs || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) .sort((a, b) => b.created_at - a.created_at); if (fresh.length || !c.notes.length) { c.notes = fresh; persistPage('notes', pk, fresh.slice(0, 20).map(slimNote)); // cache stays bounded } })().catch(() => {}).finally(() => { c.status = 'ready'; if (ui.profilePk === pk) render(); }); return c; } // 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. if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { if (!ui.profilePk) return; if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return; loadOlderNotes(ui.profilePk).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: [1], 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) c.notes = [...c.notes, ...older]; else c.end = true; // the relays have nothing older } catch {} finally { c.loadingMore = false; if (ui.profilePk === pk) 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. const NOTE_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi; function noteBody(text) { const out = []; for (const part of String(text || '').split(NOTE_SPLIT)) { if (!part) continue; if (/^https?:\/\//i.test(part)) { if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) { out.push(h('img', { src: part, class: 'note-img', loading: 'lazy', onError: (e) => { e.target.style.display = 'none'; } })); } else if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(part)) { // metadata-only preload: the poster frame paints, nothing streams // until the viewer presses play out.push(h('video', { src: part, class: 'note-video', controls: true, preload: 'metadata', playsinline: true, onError: (e) => { e.target.style.display = 'none'; } })); } else { out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' }, part.length > 64 ? part.slice(0, 61) + '…' : part)); } } else if (/^nostr:(npub|nprofile)1/i.test(part)) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:(note|nevent)1/i.test(part)) { // a referenced note opens right here as a thread — fetched by id // (with the reference's relay hints) when tapped const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'event') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openNoteRef(ref); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else out.push(part); } return out; } // Zap a specific note. With a default amount configured this is ONE TAP: // the zap fires instantly (ark first, Lightning fallback) and reports by // toast, no form, no leaving the page. Without one, a small setup screen // asks once and remembers. // Whether a ⚡ makes sense for this author from this wallet: not // ourselves, and an instant path (Ark) or a Lightning fallback exists. const canZapPk = (pk) => !!pk && !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); function zapNote(pk, ev) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21' }; render(); return; } markZapPending(ev.id, def); // the chip answers the tap; the flow reports back if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { // no instant path in this build — the classic form flow settleZap(ev.id, false); ui.profilePk = null; ui.chatOpen = false; ui.tab = 'send'; render(); if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); } } // First ⚡ tap ever: pick the amount one time, then every zap is one tap. function zapSetupScreen() { const s = ui.zapSetup; return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, '⚡ ' + t('zapSetupTitle')), h('div', { class: 'small muted' }, t('zapSetupDesc')), h('div', { class: 'input-group' }, h('input', { type: 'number', min: '1', value: s.amount, onInput: (e) => { s.amount = e.target.value; } }), h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, 'sats')), h('button', { class: 'btn-primary btn-block', onClick: () => { const n = parseInt(s.amount, 10); if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return; } ctx.setZapDefaultSat(n); const { pk, npub, eventId } = s; ui.zapSetup = null; render(); markZapPending(eventId, n); if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) settleZap(eventId, false); recheckZap(eventId); } }, t('zapSetupSave'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back'))); } // One post as a feed row (avatar · name · time · body), twitter/jumble // style: rows share a scrollable container and are split by hairlines // rather than floating in their own cards. Tapping a row opens its thread. function noteRow(pk, ev, name, { open = true, focus = false } = {}) { prefetchProfilePage(pk); // rows are tap-targets: have the page warm const isReply = ev.tags.some((x) => x[0] === 'e'); const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); // an optimistic post mid-publish: visible but not yet a real event — // dimmed, and no thread/reply/zap until its signed self takes over const pending = !!ev.pending; const openable = open && !pending; if (!pending) watchZaps([ev.id]); return h('div', { class: 'row', style: 'gap:10px;align-items:flex-start;padding:10px 0' + (openable ? ';cursor:pointer' : '') + (pending ? ';opacity:.55' : '') + (focus ? ';background:var(--accent-soft,rgba(128,128,128,.08));border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px' : ''), // closest('button') guard: on touch, a ⚡ tap must never double as a // row tap even if propagation quirks let the click reach us onClick: openable ? (e) => { if (e.target && e.target.closest && e.target.closest('button')) return; openNoteThread(ev); } : undefined, }, // the avatar is its own tap-target (profile), even inside an openable // row — its handler stops propagation, so the row still opens the thread avatar(pk), h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, h('div', { class: 'row between', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { // the name, like the avatar, is its own tap-target (profile) style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer', onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)), zapChip(ev.id, { onClick: canZap ? () => { zapNote(pk, ev); recheckZap(ev.id); } : null })), pending ? null : h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' }, // Reply on every post: opens (or re-targets) its thread and puts // the cursor in the reply box — no hunting for the row tap. h('button', { class: 'btn-sm', title: t('msgReply'), onClick: (e) => { e.stopPropagation(); if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; render(); } else openNoteThread(ev); if (ui.noteThread) ui.noteThread.refocus = true; // survives the box relocating when the thread loads setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120); }, }, '↩'), canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); recheckZap(ev.id); } }, '⚡') : null)), h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)))); } // ---- thread view: a note in its conversation ---------------------------- const threadCache = new Map(); // root id -> { status, root, replies } const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }); function rootIdOf(ev) { const es = ev.tags.filter((x) => x[0] === 'e'); const marked = es.find((x) => x[3] === 'root'); return (marked || es[0] || [])[1] || ev.id; } function threadFor(seed) { const rootId = rootIdOf(seed); let c = threadCache.get(rootId); if (c) return c; c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: [] }; threadCache.set(rootId, c); (async () => { // the conversation's home relays are the root author's (NIP-10 outbox); // the seed's author is the best guess until the root is known const relays = await notesRelays((c.root || seed).pubkey); const [roots, replies] = await Promise.all([ c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000), queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500), ]); if (!c.root) c.root = (roots || [])[0] || null; const seen = new Set([rootId]); c.replies = (replies || []) .filter((e) => !seen.has(e.id) && seen.add(e.id)) .sort((a, b) => a.created_at - b.created_at); c.status = 'ready'; if (ui.noteThread && ui.noteThread.rootId === rootId) { render(); // The inline reply box may have MOVED on this render (it slots under // the focused note once that note exists) — a focus taken before the // load finished died with the old position. Re-take it if the reply // button asked for it. if (ui.noteThread.refocus) { ui.noteThread.refocus = false; setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 30); } } })().catch(() => { c.status = 'ready'; if (ui.noteThread && ui.noteThread.rootId === rootId) render(); }); return c; } function openNoteThread(ev) { ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; ui.profOverThread = false; // a freshly opened thread goes on top render(); } // A nostr:nevent / nostr:note reference: the thread loader needs the real // event (its tags name the root, its author names the home relays), so // fetch it by id — the reference's relay hints first — then open. async function openNoteRef(ref) { toast(t('noteRefLoading')); const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])]; const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []); const ev = (evs || [])[0]; if (ev) openNoteThread(ev); else toast(t('noteRefNotFound')); } // Publish a kind-1 reply to the focused note (NIP-10 markers), addressed to // the conversation's own relays plus ours, and shown optimistically. // A new top-level kind-1 note, published to the identity's own relays — // the profile page's compose button. OPTIMISTIC: a provisional copy sits // at the top of the feed before any signing or relay round-trip (a remote // signer alone can take seconds), swapped for the signed event on success // and withdrawn on failure. async function publishPost(text) { const id = await requireIdentity(); const temp = { id: 'pending:' + Math.random().toString(36).slice(2), pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000), tags: [], pending: true, }; const caches = [...new Set([id.pubkey, ui.profilePk].filter(Boolean))] .map((pk) => notesCache.get(pk)).filter(Boolean); for (const c of caches) c.notes = [temp, ...c.notes]; render(); try { const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [CLIENT_TAG] }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); if (!ok) throw new Error(t('msgSendFailed')); for (const c of caches) c.notes = c.notes.map((e) => (e.id === temp.id ? evt : e)); render(); return evt; } catch (e) { for (const c of caches) c.notes = c.notes.filter((e) => e.id !== temp.id); render(); throw e; } } async function publishReply(c, s, text) { const id = await requireIdentity(); const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed; const rootId = c.rootId; const pTags = [...new Set([ target.pubkey, ...(c.root ? [c.root.pubkey] : []), ...target.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]), ])].filter((pk) => pk !== id.pubkey).slice(0, 8); const partial = { kind: 1, content: text, created_at: Math.floor(Date.now() / 1000), tags: [ ['e', rootId, '', 'root'], ...(target.id !== rootId ? [['e', target.id, '', 'reply']] : []), ...pTags.map((pk) => ['p', pk]), CLIENT_TAG, ], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); if (!ok) throw new Error(t('msgSendFailed')); c.replies = [...c.replies, evt]; return evt; } function threadScreen() { const s = ui.noteThread; const c = threadFor(s.seed); const row = (ev) => noteRow(ev.pubkey, ev, displayName(ev.pubkey), { open: false, focus: ev.id === s.focusId && ev.id !== c.rootId }); // The reply box sits INLINE, right under the note it answers — it used // to live at the bottom of the thread, where nobody scrolled to find it. const replyBox = () => h('div', { class: 'row', style: 'gap:8px;align-items:center;padding:2px 0 10px' }, h('input', { type: 'text', class: 'grow thread-reply-input', placeholder: t('threadReplyHint'), value: s.draft || '', onInput: (e) => { s.draft = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') e.target.parentElement.querySelector('.thread-reply-send')?.click(); }, }), h('button', { class: 'btn-primary thread-reply-send', disabled: !!s.sending, onClick: async () => { const text = (s.draft || '').trim(); if (!text) return; s.sending = true; render(); try { await publishReply(c, s, text); s.draft = ''; // the input may still be focused, and the morph won't touch a // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); if (inp) inp.value = ''; toast(t('threadReplied')); } catch (e) { if (!e.silent) toast(e.message); } s.sending = false; render(); }, }, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend'))); // rows with the reply box slotted under the focused note (the root when // nothing narrower is focused; appended at the end if the focused note // hasn't loaded yet, so the box never disappears entirely) const kids = []; let boxPlaced = false; const place = (ev) => { if (boxPlaced) return; if (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)) { kids.push(replyBox()); boxPlaced = true; } }; if (c.root) { kids.push(row(c.root)); place(c.root); } else { kids.push(h('div', { class: 'small faint', style: 'padding:10px 0' }, c.status === 'loading' ? '…' : t('threadRootMissing'))); } for (const ev of c.replies) { kids.push(noteSep(), row(ev)); place(ev); } if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox()); return h('div', { class: 'col', style: 'gap:16px' }, // full header: search/chat/settings stay reachable mid-thread (only // the public no-wallet surface drops the action row) ctx.brandHeader(!ui.pubProf && wallet.loaded), h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' }, ...kids), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back'))); } async function publishProfileFields(fields, opts = {}) { const id = await requireIdentity(); const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; let base = {}; try { base = newest ? JSON.parse(newest.content) : {}; } catch {} if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be const merged = { ...base }; // fillOnly fields are offers, not orders: the onboarding wizard suggests // a name and a punk for profiles that have none — it must never RENAME // (or re-face) an identity that already has one. const fill = new Set(opts.fillOnly || []); const taken = (k) => (k === 'name' ? !!(base.name || base.display_name) : !!base[k]); for (const [k, v] of Object.entries(fields || {})) { if (!v) continue; if (fill.has(k) && taken(k)) continue; if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); return true; } async function saveProfile() { const id = await identity(); if (!id) { noIdToast(); return; } const e = ui.profEdit; ui.profSaving = true; render(); try { // merge over the newest published kind 0 so unknown fields round-trip const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; let base = {}; try { base = newest ? JSON.parse(newest.content) : {}; } catch {} // A changed username claims first — if the name is taken (or invalid) // the save stops here and the editor stays open to fix it. const oldAddr = hook('namesAddress'); let addr = oldAddr; const want = (e.uname || '').trim().toLowerCase(); if (oldAddr && want && want !== oldAddr.split('@')[0]) { if (!/^[a-z0-9][a-z0-9._-]{0,29}$/.test(want)) throw new Error(t('profUnameInvalid')); await hook('namesClaimName', want, { quietProfile: true }); addr = want + '@' + oldAddr.split('@')[1]; } const merged = { ...base }; for (const [k, v] of [['name', e.name], ['about', e.about], ['picture', e.picture], ['banner', e.banner]]) { if (v.trim()) merged[k] = v.trim(); else delete merged[k]; } // The address doubles as the lightning address and NIP-05. Fill them // when empty; move them when they pointed at our (old) address. A // deliberately foreign lud16/nip05 is the user's business. if (addr) { for (const k of ['lud16', 'nip05']) if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; } if (merged.name) merged.display_name = merged.name; const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); ui.profEdit = null; ui.profEditFilled = false; toast(t('profSaved')); } catch (err) { toast(err.message || String(err)); } finally { ui.profSaving = false; render(); } } function profileScreen() { const pk = ui.profilePk; const mine = isMe(pk) || (ctx.shownPubkey && pk === ctx.shownPubkey()); const logoutBtn = () => h('button', { class: 'btn-block', style: 'color:var(--red,#c0392b);display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => { ui.logoutConfirm = true; render(); }, }, h('span', { style: 'display:flex', html: '' }), t('logout')); // One Log out button; the popup offers the two exits — leave (wallets // stay saved, one unlock away) or leave and take everything with you. // The full wipe still routes through the Delete-all warning after this. // Two stages in one popup: the plain logout choices, then — if "forget // all data" is picked — the delete-everything warning right here. The old // flow detoured to the Accounts page for that confirmation, which read // as broken navigation (and left people thinking data was wiped when // only a confirm screen had opened). const logoutPop = () => !ui.logoutConfirm ? null : h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) { ui.logoutConfirm = null; render(); } }, }, ui.logoutConfirm === 'forget' ? h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('logoutForget') + '?'), h('div', { class: 'notice err small' }, t('clearAllWarn')), h('button', { class: 'btn-block', style: 'color:var(--red,#c0392b)', onClick: () => { ui.logoutConfirm = null; ctx.logoutForget(); }, }, t('clearAll')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('back'))) : h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('logout') + '?'), h('p', { class: 'small muted', style: 'margin:0' }, t('logoutPopBlurb')), h('button', { class: 'btn-primary btn-block', onClick: () => { ui.logoutConfirm = null; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ctx.logout && ctx.logout(); } }, t('logout')), ctx.logoutForget ? h('button', { class: 'btn-block', style: 'color:var(--red,#c0392b)', onClick: () => { ui.logoutConfirm = 'forget'; render(); }, }, t('logoutForget')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back')))); // The identity switcher: every account signed in on this device as an // avatar + name row (tap = switch, nothing logged out), and the door to // add a new one. const switchPop = () => { if (!ui.switchIdPop) return null; const close = () => { ui.switchIdPop = null; render(); }; const others = (ctx.identities ? ctx.identities() : []).filter((x) => !x.active); return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); }, }, h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('switchIdentityTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('switchIdentityDesc')), h('div', { class: 'col', style: 'gap:0' }, others.map((x) => h('div', { class: 'row gap6 clickable', style: 'align-items:center;padding:8px 0;border-bottom:1px solid var(--line)', onClick: () => { ui.switchIdPop = null; ctx.switchIdentity(x.id); }, }, x.pk ? avatar(x.pk, 'chat-avatar', false) : h('div', { class: 'chat-avatar fallback' }), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.pk ? displayName(x.pk) : x.label), h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, (x.pk ? x.label : t('identityNoKey')) + (x.network && x.network !== 'mainnet' ? ' · ' + x.network : '') + (x.watch ? ' · ' + t('watchOnlyTag') : '')))))), h('button', { class: 'btn-primary btn-block', onClick: () => { ui.switchIdPop = null; ctx.signInAnother(); } }, t('signInNew')), h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); }; const full = fullProfiles.get(pk); // Your own profile reads like anyone else's — npub, bio, latest posts — // with the editor behind an explicit Edit button (it used to BE the // page, which made looking at your own profile feel like a form). The // seed comes from the local name/picture cache so the form opens // instantly; saveProfile re-fetches the newest kind 0 before merging, // so a stale seed can't clobber anything. const myAddr = mine ? hook('namesAddress') : null; const openEditor = () => { const p = (full === undefined ? profileOf(pk) : full) || {}; ui.profEdit = { uname: myAddr ? myAddr.split('@')[0] : '', name: (full || {}).display_name || p.name || '', about: p.about || '', picture: p.picture || '', banner: (full || {}).banner || '', }; ui.profEditFilled = full !== undefined; render(); }; // Once the real kind 0 lands, top up fields still sitting empty — never // ones the user (or the cache) already filled. if (mine && ui.profEdit && !ui.profEditFilled && full !== undefined) { ui.profEditFilled = true; const e = ui.profEdit; if (!e.name) e.name = full.display_name || full.name || ''; if (!e.about) e.about = full.about || ''; if (!e.picture) e.picture = full.picture || ''; if (!e.banner) e.banner = full.banner || ''; } const name = displayName(pk); const npub = npubOf(pk) || pk; const field = (label, key, ph = '', multi = false) => h('label', { class: 'field' }, h('span', { class: 'lab' }, label), h(multi ? 'textarea' : 'input', { ...(multi ? { rows: '4', style: 'font-family:var(--sans);min-height:72px' } : { type: 'text' }), placeholder: ph, value: ui.profEdit[key], // render per keystroke so the page-top preview (name/picture/banner) // tracks the draft live — the morph never rewrites a focused field, // so this can't fight the typing onInput: (ev) => { ui.profEdit[key] = ev.target.value; render(); }, })); // A lightning address worth showing: not an npub-shaped machine address // (npub1…@some.relay duplicates the npub below) and not the same string // as the nip05 already on screen. // The address must not wait for the kind 0: our own claimed name is // authoritative locally, a deep link carries the address in the URL // (seeded as `addr`), and the light cache remembers it from last time. const lp = profileOf(pk) || {}; const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; // an about of "~" or a lone character is noise, not a bio const about = full && typeof full.about === 'string' ? full.about.trim() : ''; const showAbout = about.length > 1; // The cover photo: nostr's standard kind-0 `banner`. Legacy coinos.io // wore these proudly — migrated accounts bring theirs along, and anyone // can set one in the editor below. WHILE EDITING, the draft is the // truth: an uploaded (or pasted) picture/banner previews immediately at // the top of the page instead of waiting for Save — and clearing a field // previews the removal too. const draft = mine && ui.profEdit ? ui.profEdit : null; const urlish = (s) => (typeof s === 'string' && /^https?:\/\//i.test(s.trim()) ? s.trim() : null); const bannerUrl = draft ? urlish(draft.banner) : (full && urlish(full.banner)) || null; const draftPic = draft && urlish(draft.picture); return h('div', { class: 'col', style: 'gap:16px' }, // full header on profiles too — losing the search button here made // finding the NEXT person a trek back home ctx.brandHeader(!ui.pubProf && wallet.loaded), h('div', { class: 'card col', style: 'gap:12px' }, bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(bannerUrl)})` }) : null, h('div', { class: 'row gap6', style: 'align-items:center' }, draftPic ? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` }) : avatar(pk, 'chat-avatar profile-avatar', false), h('div', { class: 'col grow', style: 'min-width:0;gap:2px' }, h('div', { class: 'chat-title' }, draft && draft.name.trim() ? draft.name.trim() : name), nip05 ? h('div', { class: 'muted small break' }, nip05) : null, showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null, // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), // no spinner while the kind 0 loads — prefetch keeps this rare, and // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become // @names, other nostr: refs truncate to stubs, URLs linkify — and // overflow-wrap catches any remaining unbreakable token (a raw npub // in a bio was punching clean through the card). showAbout && !ui.profEdit ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(about.slice(0, 1000))) : null, // the npub, tap to copy — on every profile including your own; it // only steps aside while the (long) edit form is open ui.profEdit ? null : h('button', { class: 'addr-box break npub-box', title: t('copy'), style: 'font-size:11px;cursor:pointer;text-align:left;width:100%', onClick: async () => { try { await navigator.clipboard.writeText(npub); toast(t('copied')); } catch {} }, }, npub), ui.profEdit ? h('div', { class: 'col', style: 'gap:8px' }, // the username IS the payment address and NIP-05 — the frozen // @domain suffix is there so people make that connection myAddr ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('profUsername')), h('div', { class: 'row', style: 'align-items:center;gap:0' }, h('input', { type: 'text', style: 'flex:1;min-width:0', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.profEdit.uname, onInput: (ev) => { ui.profEdit.uname = ev.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''); ev.target.value = ui.profEdit.uname; }, }), h('span', { class: 'muted', style: 'white-space:nowrap;padding:0 8px' }, '@' + myAddr.split('@')[1]))) : null, // the migrate door, right where a coinos.io veteran is looking // at the name they want to bring over (mainnet names only) myAddr && myAddr.split('@')[1] === 'coinos.io' ? h('button', { type: 'button', class: 'linklike small', style: 'align-self:flex-start', onClick: () => { location.href = `https://coinos.io/migrate?to=${encodeURIComponent(myAddr)}&back=${encodeURIComponent(location.origin + '/')}`; }, }, t('onbHaveCoinos')) : null, field(t('profDisplayName'), 'name'), field(t('profAbout'), 'about', '', true), field(t('profPicture'), 'picture', 'https://…'), // or skip the URL entirely: pick a photo, we upload and fill it h('div', { class: 'row gap6' }, h('button', { class: 'btn-sm', type: 'button', disabled: !!ui.profUploading, onClick: () => document.getElementById('prof-pic-file')?.click(), }, ui.profUploading ? h('span', { class: 'spinner sm' }) : t('profUploadPic')), h('input', { id: 'prof-pic-file', type: 'file', accept: 'image/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f || !ctx.uploadImage) return; ui.profUploading = true; render(); try { if (ui.profEdit) ui.profEdit.picture = await ctx.uploadImage(f); } catch (err) { toast(err.message); } ui.profUploading = false; render(); }, })), field(t('profBanner'), 'banner', 'https://…'), h('div', { class: 'row gap6' }, h('button', { class: 'btn-sm', type: 'button', disabled: !!ui.profUploading, onClick: () => document.getElementById('prof-banner-file')?.click(), }, ui.profUploading ? h('span', { class: 'spinner sm' }) : t('profUploadBanner')), h('input', { id: 'prof-banner-file', type: 'file', accept: 'image/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f || !ctx.uploadImage) return; ui.profUploading = true; render(); try { if (ui.profEdit) ui.profEdit.banner = await ctx.uploadImage(f); } catch (err) { toast(err.message); } ui.profUploading = false; render(); }, })), h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile }, ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel'))) : mine ? h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'row gap6 wrap' }, h('button', { class: 'btn-primary grow', onClick: openEditor }, t('profEdit')), h('button', { class: 'grow', onClick: () => { ui.profCompose = ui.profCompose == null ? draftFor(POST_DRAFT) : null; render(); } }, t('profNewPost'))), // The post draft rides the same persisted draft store as DMs // and channels: a reload (say, to reconnect a signer) brings // the half-written post back, composer open. Posting clears // it; Cancel just closes the composer and keeps the text. (ui.profCompose == null && !draftFor(POST_DRAFT)) ? null : h('div', { class: 'col', style: 'gap:8px' }, h('textarea', { rows: '3', placeholder: t('profComposePh'), style: 'font-family:var(--sans);min-height:64px', value: ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose, onInput: (ev) => { ui.profCompose = ev.target.value; setDraft(POST_DRAFT, ev.target.value); }, }), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', onClick: async () => { const text = (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || '').trim(); if (!text) return; // the post is on screen instantly (publishPost is // optimistic) — close the composer now; a failure // reopens it with the text intact ui.profCompose = null; setDraft(POST_DRAFT, ''); try { await publishPost(text); toast(t('profPosted')); } catch (e) { ui.profCompose = text; setDraft(POST_DRAFT, text); if (!e.silent) toast(e.message || String(e)); render(); } } }, t('profPostBtn')), h('button', { class: 'btn-ghost', onClick: () => { ui.profCompose = null; render(); } }, t('cancel')))), // the non-destructive way out: add another identity (Nostr, // passkey, Google) next to this one and switch between them // on the Accounts screen — logout stays for actually leaving ctx.signInAnother ? h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => { // other identities on this device → pick from a list; // none yet → straight to the sign-in doors const others = ctx.identities ? ctx.identities().filter((x) => !x.active) : []; if (others.length) { ui.switchIdPop = true; render(); } else ctx.signInAnother(); }, }, h('span', { style: 'display:flex', html: '' }), t('signInAnother')) : null, logoutBtn()) : ui.pubProf ? null // no wallet open: messaging and paying both need one : h('div', { class: 'row gap6 wrap' }, h('button', { class: 'btn-primary grow', onClick: () => { const peer = pk; ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); } }, t('msgDmsTitle')), h('button', { class: 'grow', onClick: () => { const npubStr = npubOf(pk); // leave the whole messaging surface, not just the profile — // a lingering search or thread would win the screen router // and the Send tab would never appear ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; ui.chatOpen = false; ui.tab = 'send'; render(); hook('matchSendText', npubStr); } }, t('profPay')))), // Their public notes: the PAGE scrolls (no inner scrollbox), older // pages stream in as you near the bottom (the init() scroll listener → // loadOlderNotes), and on phones the feed goes full-bleed — edge to // edge, no card walls (see .notes-feed). (() => { const c = notesFor(pk); // while loading: an invisible copy of the empty-state line holds the // height, so the page doesn't jump when the answer lands (no spinner) if (c.status === 'loading') return h('div', { class: 'small faint', style: 'text-align:center;visibility:hidden' }, t('profNotesNone')); if (!c.notes.length) return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone')); return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted', style: 'padding:0 2px' }, t('profNotesTitle')), h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...c.notes.flatMap((ev, i) => [ i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null, noteRow(pk, ev, name), ])), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) : null); })(), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.profOverThread = false; ui.pubProf = null; ui.profEdit = null; ui.profEditFilled = false; ui.profCompose = null; render(); } }, t('back')), mine ? logoutPop() : null, mine ? switchPop() : null); } const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); // ---- user search: the header magnifier ---------------------------------- // Same engine the Send form and DMs use (registrar names + Primal cache + // NIP-50 relays); tapping a result opens their profile, and profile-Back // lands here again since the search state survives. const userSearcher = makeSearcher((q, rows) => { if (ui.userSearch && ui.userSearch.q === q) { ui.userSearch.rows = rows; render(); } for (const r of rows || []) if (r.pk) prefetchProfilePage(r.pk); }); function userSearchScreen() { const s = ui.userSearch; return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(!ui.pubProf && wallet.loaded), h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('searchUsers')), h('input', { type: 'text', class: 'user-search-input', placeholder: t('searchUsersHint'), value: s.q, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, }), s.rows && s.rows.length ? h('div', { class: 'list' }, resultRows(h, s.rows, (r) => { if (r.pk) { openProfile(r.pk); render(); } }, (pk, node) => hook('wrapAvatar', pk, node))) : s.rows && s.q.trim().length >= 2 ? h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchNoResults')) : h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchUsersEmpty'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.userSearch = null; render(); } }, t('back'))); } // ---- presence & typing -------------------------------------------------- // Both ride the channel stream as 21059 wraps, so they stay inside the // community's encryption — who's around is members-only, not a public // status broadcast. 21059 sits in NIP-01's ephemeral range, but measured // against the relays we actually use (relay.coinos.io, nos.lol) neither // drops them, so they carry a NIP-40 expiration and relay.coinos.io sweeps // the kind on a timer; nothing here may assume the relay forgets. // Presence only exists while someone is watching a room. A member nobody // saw beat is dated by their last message instead, the way an offline // contact still shows a last-seen. const PRESENCE = 20100; const TYPING = 20101; const BEAT_MS = 60_000; // how often we announce ourselves const ONLINE_MS = 100_000; // a beat older than this is no longer "online" const TYPING_MS = 7_000; // how long a typing ping stands const TYPE_THROTTLE = 5_000; // and how rarely we send one const lastPing = new Map(); // channelId + kind -> ms async function ping(room, chId, kind) { const key = chId + ':' + kind; const gap = kind === PRESENCE ? BEAT_MS : TYPE_THROTTLE; if (Date.now() - (lastPing.get(key) || 0) < gap) return; lastPing.set(key, Date.now()); try { const id = await identity(); if (!id) return; const { created_at, ms } = msTags(Date.now()); const rumor = { kind, pubkey: id.pubkey, content: '', tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at, }; // NIP-40: a beat is worthless once it's stale, so relays that honour // expiration may drop it instead of keeping it forever. const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId), { ephemeral: true, expiration: created_at + 300 }); await publishOn(room.relays, wrap); } catch { lastPing.delete(key); } } // Keep beating while a room is on screen — otherwise everyone would look // online for exactly one render and then go dark. let beatTimer = null; function keepBeating() { if (beatTimer) return; beatTimer = setInterval(() => { if (!ui.chatOpen || ui.msgView !== 'room') return; const jm = communityById(ui.msgCommunity) || COMMUNITY; const room = rooms.get(jm.community_id); if (!room) return; const chans = roomChannels(room); const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0]; if (ch) ping(room, ch.id, PRESENCE); scheduleRepaint(); // let "online" lapse into "last seen" on its own }, 30_000); } // When we last had any sign of someone: a beat, or failing that the newest // thing they said anywhere in the community. function lastSeenMs(room, pk) { let ts = room.presence.get(pk) || 0; for (const msgs of room.byChannel.values()) for (const m of msgs.values()) if (m.author === pk) ts = Math.max(ts, eventMs(m.rumor) || 0); return ts; } const isOnline = (room, pk) => Date.now() - (room.presence.get(pk) || 0) < ONLINE_MS; function typingNow(room, chId) { const my = myPubkeys(); const out = []; for (const [pk, ts] of room.typing) if (ts.ch === chId && Date.now() - ts.t < TYPING_MS && !my.includes(pk)) out.push(pk); return out; } // Telegram's wording, and its kindness: exact minutes while it's fresh, // then a vague "recently" rather than advertising how long someone's been away. function seenLabel(room, pk) { if (isOnline(room, pk)) return t('msgOnline'); const ts = lastSeenMs(room, pk); if (!ts) return t('msgSeenRecently'); const mins = Math.floor((Date.now() - ts) / 60_000); if (mins < 1) return t('msgSeenJustNow'); if (mins < 60) return t('msgSeenMins', { n: mins }); const hrs = Math.floor(mins / 60); if (hrs < 24) return t('msgSeenHours', { n: hrs }); const days = Math.floor(hrs / 24); if (days <= 7) return t('msgSeenDays', { n: days }); return t('msgSeenRecently'); } // Recipient search for New message: debounced, sequenced, cached upstream. const dmSearch = { rows: null, busy: false }; const dmSearcher = makeSearcher((q, rows) => { dmSearch.rows = rows; dmSearch.busy = false; scheduleRepaint(); }); const dmSearcherUpdate = dmSearcher.update.bind(dmSearcher); dmSearcher.update = (q) => { const willSearch = q && q.trim().length >= 2; if (willSearch) { dmSearch.busy = dmSearch.rows === null || !dmSearch.rows.length; } dmSearcherUpdate(q); }; const stickToBottom = () => { queueMicrotask(() => { const log = document.querySelector('.chat-log'); if (log && ui.msgStick !== false) log.scrollTop = log.scrollHeight; }); }; // The chat card is sized to the viewport, so the mobile keyboard shrinks it: // the composer stays on screen, but the log keeps its old scroll offset and // the newest messages vanish under the fold. Follow the bottom edge through // every viewport change (keyboard, rotation, browser chrome) — unless the // user has deliberately scrolled up, which msgStick already remembers. const onViewportResize = () => { if (ui.chatOpen) stickToBottom(); }; // A remote signer that a reload dropped: say so where the typing happens, // rather than letting someone write a message and only then be told. The // identity itself is still known — it's the ability to sign as it that's // missing — so this offers to fetch it back rather than to log in again. function signerNotice() { if (wallet.watchOnly) { return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' }, h('span', { class: 'small muted grow' }, '\u{1F512} ' + t('msgLockedChat')), h('button', { class: 'btn-sm', onClick: () => { ui.justLocked = false; ui.screen = 'vault'; render(); } }, t('unlock'))); } const id = hook('nostrLoginIdentity'); if (!id || id.signer) return null; return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' }, h('span', { class: 'small muted grow' }, t('msgSignerOff')), h('button', { class: 'btn-sm', disabled: !!ui.msgReconnecting, onClick: async () => { ui.msgReconnecting = true; render(); const s = await hook('nostrLoginResume'); ui.msgReconnecting = false; // Success needs no announcement — the notice itself goes away. if (!s) { ui.chatOpen = false; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = 'nostr'; } render(); }, }, ui.msgReconnecting ? h('span', { class: 'spinner sm' }) : t('msgReconnect'))); } // Enter sends; Shift+Enter (the textarea's native edit) and Ctrl+J insert // a newline. Height follows the text as it's typed — imperatively, since // typing doesn't re-render — with rows as the re-render fallback. const growComposer = (el) => { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; }; const composer = (placeholder, onSend, onType, draftKey) => h('div', { class: 'col', style: 'gap:6px' }, signerNotice(), h('div', { class: 'chat-compose' }, h('textarea', { class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)), value: draftFor(draftKey), maxlength: '2000', onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); if (onType && e.target.value) onType(); }, onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); return; } if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); const el = e.target; const { selectionStart: s0, selectionEnd: s1, value } = el; el.value = value.slice(0, s0) + '\n' + value.slice(s1); el.selectionStart = el.selectionEnd = s0 + 1; setDraft(draftKey, el.value); growComposer(el); } }, // Focus summons the keyboard; some browsers resize only after its // animation, so re-stick once now and once when it has settled. onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); }, }), h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend')))); // ---- home --------------------------------------------------------------- function homeView() { startDMs(); // Threads you've since replied to should stop being strangers to the // worker; throttled inside, so this is cheap on every render. syncInbox().catch(() => {}); for (const jm of communities()) ensureRoom(jm); for (const peer of threads.keys()) prefetchProfilePage(peer); const dmRows = [...threads.entries()] .map(([peer, m]) => { const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1); return { peer, last, unread: dmUnread(peer, m) }; }) .filter((x) => x.last) .sort((a, b) => b.last.rumor.created_at - a.last.rumor.created_at); // Being here answers the header dot — including anything that lands while // the list is open, since arrivals repaint it and the rows below say who. { const my = myPubkeys(); let newest = 0; for (const msgs of threads.values()) newest = Math.max(newest, newestFrom(msgs.values(), (m) => !m.mine)); for (const room of rooms.values()) for (const msgs of room.byChannel.values()) newest = Math.max(newest, newestFrom(msgs.values(), (m) => !my.includes(m.author))); markRead(HOME_READ, newest); } const kids = []; // Chat takes the whole screen, so home carries the way back to the wallet. kids.push(h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { ui.chatOpen = false; render(); }), h('h3', { style: 'margin:0' }, t('tabMessages')), anyUnread() ? h('button', { class: 'linklike small', style: 'margin-left:auto', onClick: markAllRead }, t('msgMarkAllRead')) : null)); // A locked wallet can't decrypt or sign — say so up top, where the rooms // that quietly won't open are listed. const lockedRow = signerNotice(); if (lockedRow && wallet.watchOnly) kids.push(lockedRow); if (pendingLink) kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); // offer push once — it covers messages AND payments if (typeof Notification !== 'undefined' && Notification.permission === 'default' && !st().pushDismissed) kids.push(h('div', { class: 'notice info col', style: 'gap:8px' }, h('div', {}, t('msgPushOffer')), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary btn-sm', onClick: async () => { const ok = await registerPush({ interactive: true }); toast(ok ? t('msgPushOn') : t('msgPushFailed')); render(); }, }, t('msgPushEnable')), h('button', { class: 'btn-ghost btn-sm', onClick: () => { const s = st(); s.pushDismissed = true; save(s); render(); }, }, t('msgDismiss'))))); // ---- DMs kids.push(h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgDmsTitle')), h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; if (ui.msgHomePanel === 'newdm') warmSearch(); render(); } }, t('msgNewDm')))); if (ui.msgHomePanel === 'newdm') { const openThread = (pk) => { dmSearcher.clear(); ui.msgNewDmTo = ''; ui.msgHomePanel = null; ui.msgView = 'dm'; ui.msgPeer = pk; ui.msgStick = true; render(); }; kids.push(h('div', { class: 'col gap6' }, h('div', { class: 'row gap6' }, h('input', { class: 'grow', type: 'text', placeholder: t('msgSearchPlaceholder'), value: ui.msgNewDmTo || '', onInput: (e) => { ui.msgNewDmTo = e.target.value; dmSearcher.update(e.target.value); }, }), h('button', { class: 'btn-sm', onClick: () => { const pk = parseNostrPubkey(ui.msgNewDmTo); if (!pk) { toast(t('msgBadNpub')); return; } openThread(pk); }, }, t('msgOpen'))), dmSearch.rows === null ? null : dmSearch.busy ? h('div', { class: 'row gap6', style: 'align-items:center;padding:4px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgSearching'))) : dmSearch.rows.length ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node))) : h('div', { class: 'small muted' }, t('msgNoMatches')))); } // A long DM history must not bury the communities below it: past a // handful, the rest waits behind "show all". (No cap for barely-over — // a "show 2 more" button costs more than the rows it hides.) const DM_PREVIEW = 5; const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW); kids.push( dmRows.length ? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) => h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); }, }, avatar(peer), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'row between' }, h('span', { class: 'chat-name' }, displayName(peer)), h('span', { class: 'chat-time thread-when' }, timeLabel(last.rumor.created_at * 1000), unread ? h('i', { class: 'thread-dot' }) : null)), h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + last.rumor.content))))) : h('div', { class: 'muted small' }, t('msgNoDms'))); if (shownDms.length < dmRows.length) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } }, t('msgShowAllDms', { n: dmRows.length }))); else if (ui.msgAllDms && dmRows.length > DM_PREVIEW + 2) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } }, t('msgShowFewerDms'))); // ---- communities kids.push(h('div', { class: 'row between mt16', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgCommunitiesTitle')), h('div', { class: 'row gap6' }, h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'join' ? null : 'join'; render(); } }, t('msgJoin')), h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'create' ? null : 'create'; render(); } }, t('msgCreate'))))); if (ui.msgHomePanel === 'join') kids.push(h('div', { class: 'row gap6' }, h('input', { class: 'grow', type: 'text', placeholder: t('msgInvitePlaceholder'), value: ui.msgJoinText || '', onInput: (e) => { ui.msgJoinText = e.target.value; }, }), h('button', { class: 'btn-sm', onClick: () => joinFromText(ui.msgJoinText) }, t('msgJoin')))); if (ui.msgHomePanel === 'create') kids.push(h('div', { class: 'row gap6' }, h('input', { class: 'grow', type: 'text', placeholder: t('msgNamePlaceholder'), maxlength: '64', value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; }, }), h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate')))); kids.push(h('div', { class: 'list' }, communities().map((jm) => { const room = rooms.get(jm.community_id); const name = room?.folded?.metadata?.name || jm.name; // Last session's settled count anchors the number: while the guestbook // replay is still streaming in (multiple relays, bursts past the fold // debounce), the live tally climbs through intermediate values — take // the max so the count doesn't visibly tick upward on every refresh. // Once the replay settles, the cache is rewritten to the live figure, // so a genuine departure still shows (one quiet step down, not a climb). const live = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0; const memberCount = Math.max(live, (st().memberCounts || {})[jm.community_id] || 0); const unread = room ? roomUnread(room) : false; return h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); }, }, h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'row between' }, h('span', { class: 'chat-name' }, name), unread ? h('i', { class: 'thread-dot' }) : null), h('div', { class: 'muted small' }, memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted')))); }))); return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids); } function linkInviteCard() { const pl = pendingLink; return h('div', { class: 'notice info col', style: 'gap:8px' }, pl.state === 'loading' ? h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' }), t('msgInviteLoading')) : pl.state === 'error' ? h('div', { class: 'row between' }, h('span', {}, pl.error), h('button', { class: 'linklike', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss'))) : h('div', { class: 'col', style: 'gap:8px' }, h('div', {}, t('msgInviteTo', { name: pl.bundle.name || 'community' })), h('div', { class: 'muted small' }, t('msgInviteFounder', { npub: (npubOf(pl.bundle.owner) || '').slice(0, 16) + '…' })), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary btn-sm', onClick: () => acceptBundle(pl.bundle, { invitedBy: pl.bundle.creator_npub }) }, t('msgJoin')), h('button', { class: 'btn-ghost btn-sm', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss'))))); } function directInviteCard(rid, inv) { return h('div', { class: 'notice info col', style: 'gap:8px' }, h('div', {}, t('msgDirectInvite', { name: inv.bundle.name || 'community', from: displayName(inv.from) })), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary btn-sm', onClick: () => { pendingDirect.delete(rid); acceptBundle(inv.bundle, { invitedBy: inv.from }); }, }, t('msgJoin')), h('button', { class: 'btn-ghost btn-sm', onClick: () => { const s = st(); s.declined[rid] = 1; save(s); pendingDirect.delete(rid); render(); }, }, t('msgDismiss')))); } // ---- room --------------------------------------------------------------- function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] .filter((m) => !room.deletes.has(m.rumor.id)) .filter((m) => !(room.folded && room.folded.banned.has(m.author))) .sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor)); markRead(chRead(chId), newestFrom(msgs, (m) => !my.includes(m.author))); if (!msgs.length) return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))]; let lastAuthor = null, lastT = 0; watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); return msgs.map((m) => { const tms = eventMs(m.rumor); const mine = my.includes(m.author); const edit = room.edits.get(m.rumor.id); const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; const grouped = m.author === lastAuthor && tms - lastT < 5 * 60_000; lastAuthor = m.author; lastT = tms; const reacts = room.reactions.get(m.rumor.id); const counts = new Map(); if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); // this wallet's own reaction (any of its identities) — highlighted, and // choosing another emoji replaces it (one reaction per author, the fold // keeps the latest) const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); return h( 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), h('div', { class: 'chat-body' }, grouped ? null : h('div', { class: 'chat-meta' }, h('span', { class: 'chat-name clickable' + (m.author === room.jm.owner ? ' owner' : ''), onClick: () => openProfile(m.author), }, displayName(m.author), m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble clickable', // Telegram-style: a tap on the message opens its action sheet // (quick reactions, reply, copy, delete). Links, images and the // hover × keep their own clicks; a desktop text-selection drag // ends in a click too, and must not pop the sheet over the copy. onClick: (e) => { if (e.target.closest && e.target.closest('a, button, img')) return; const sel = window.getSelection && window.getSelection(); if (sel && String(sel).length) return; ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); }, }, replyQuote(room, chId, m), ...noteBody(text), edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null, mine ? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×') : null, // reactions tuck inside the bubble, under the text (Telegram-style); // the zap total leads the row, and tapping it zaps again ((zc) => counts.size || zc ? h('div', { class: 'chat-reacts' }, zc, [...counts.entries()].map(([emoji, n]) => h('span', { class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), title: t('msgReact'), onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); }, }, emoji, n > 1 ? ' ' + n : ''))) : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: !mine && canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null })))) ); }); } function roomView() { const jm = communityById(ui.msgCommunity) || COMMUNITY; const room = ensureRoom(jm); const chans = roomChannels(room); const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0]; const name = room.folded?.metadata?.name || jm.name; const members = [...room.members.entries()].filter(([, m]) => m.state === 'join'); const memberCount = members.length; const onlineCount = members.filter(([pk]) => isOnline(room, pk)).length; stickToBottom(); keepBeating(); if (ch) ping(room, ch.id, PRESENCE); return h('div', { class: 'card col chat-card chat-page' }, h('div', { class: 'row between chat-head' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }), h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => { ui.msgMembers = !ui.msgMembers; render(); }, }, h('div', { class: 'chat-title' }, name), h('div', { class: 'muted small' }, memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'), onlineCount ? h('span', { class: 'online-count' }, ' · ', t('msgNOnline', { n: onlineCount })) : null))), h('div', { class: 'row gap6', style: 'align-items:center' }, // One channel needs no label — naming it only asks people to notice a // choice they don't have. Several become a picker. chans.length > 1 ? h('select', { class: 'chan-pick', onChange: (e) => { ui.msgChannel = e.target.value; ui.msgStick = true; ui.msgReplyTo = null; ui.msgSheet = null; subChannel(room, e.target.value); render(); }, }, chans.map((c) => h('option', { value: c.id, selected: c.id === ch?.id }, '#' + c.name))) : null, h('button', { class: 'iconbtn bell' + (roomNotify(jm.community_id) ? ' on' : ''), title: roomNotify(jm.community_id) ? t('msgNotifyOn') : t('msgNotifyOff'), onClick: () => toggleRoomNotify(jm.community_id), html: roomNotify(jm.community_id) ? '' : '', }), h('button', { class: 'btn-sm', title: t('msgInviteTitle'), onClick: () => { ui.msgInvitePanel = !ui.msgInvitePanel; render(); }, }, t('msgAddPerson')))), ui.msgInvitePanel ? invitePanel(room) : null, ui.msgMembers ? memberPanel(room, members) : null, h('div', { class: 'chat-log', onScroll: (e) => { const el = e.target; ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; }, }, ...(ch ? messageRows(room, ch.id) : [])), ch ? typingLine(room, ch.id) : null, ch ? replyBar(room, ch.id) : null, composer( chans.length > 1 ? t('msgPlaceholder', { channel: ch ? ch.name : '' }) : t('msgPlaceholderPlain'), () => ch && sendMessage(room, ch.id), () => ch && ping(room, ch.id, TYPING), ch ? 'ch:' + ch.id : 'ch:'), ch && ui.msgSheet ? messageSheet(room, ch.id) : null); } // "Alice is typing…" — named up to two, counted beyond that. function typingLine(room, chId) { const who = typingNow(room, chId); if (!who.length) return null; const label = who.length === 1 ? t('msgTyping', { name: displayName(who[0]) }) : who.length === 2 ? t('msgTyping2', { a: displayName(who[0]), b: displayName(who[1]) }) : t('msgTypingMany', { n: who.length }); return h('div', { class: 'chat-typing' }, h('span', { class: 'typing-dots' }, h('i'), h('i'), h('i')), h('span', { class: 'small muted' }, label)); } function memberPanel(room, members) { const rows = members .map(([pk]) => ({ pk, online: isOnline(room, pk), seen: lastSeenMs(room, pk) })) .sort((a, b) => (b.online - a.online) || (b.seen - a.seen)); return h('div', { class: 'col chat-members' }, ...rows.map((r) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(r.pk), }, h('span', { class: 'ava-wrap' + (r.online ? ' online' : '') }, avatar(r.pk)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, h('span', { class: 'chat-name' }, displayName(r.pk)), r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk)))))); } function invitePanel(room) { const builtin = room.jm.community_id === COMMUNITY.community_id; return h('div', { class: 'col chat-invite', style: 'gap:8px' }, h('div', { class: 'row gap6' }, h('button', { class: 'btn-sm', disabled: ui.msgMinting, onClick: async () => { ui.msgMinting = true; render(); try { const url = await mintInviteLink(room); if (url) { await navigator.clipboard.writeText(url); toast(t('msgLinkCopied')); } } finally { ui.msgMinting = false; render(); } }, }, ui.msgMinting ? h('span', { class: 'spinner sm' }) : t('msgCopyInvite')), h('input', { class: 'grow', type: 'text', placeholder: t('msgNpubPlaceholder'), value: ui.msgInviteTo || '', onInput: (e) => { ui.msgInviteTo = e.target.value; }, }), h('button', { class: 'btn-sm', onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); }, }, t('msgSend'))), // Owners can add channels (an owner-signed control edition), but a naming // field sitting open is a question nobody asked — it reads as something // you're meant to fill in right after making a community. myPubkeys().includes(room.jm.owner) ? (ui.msgChannelPanel ? h('div', { class: 'row gap6' }, h('input', { class: 'grow', type: 'text', placeholder: t('msgChannelPlaceholder'), maxlength: '64', value: ui.msgNewChannel || '', onInput: (e) => { ui.msgNewChannel = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') createChannel(room, ui.msgNewChannel); }, }), h('button', { class: 'btn-sm', onClick: () => createChannel(room, ui.msgNewChannel) }, t('msgCreate')), h('button', { class: 'btn-ghost btn-sm', onClick: () => { ui.msgChannelPanel = false; render(); } }, t('cancel'))) : h('div', { class: 'row' }, h('button', { class: 'btn-sm', onClick: () => { ui.msgChannelPanel = true; render(); }, }, t('msgNewChannel')))) : null, // leaving discards the keys on this identity — two taps, default community exempt builtin ? null : h('div', { class: 'row' }, h('button', { class: 'btn-ghost btn-sm ' + (ui.msgLeaveArm ? 'btn-danger' : ''), onClick: () => { if (ui.msgLeaveArm) leaveCommunity(room); else { ui.msgLeaveArm = true; render(); } }, }, ui.msgLeaveArm ? t('msgLeaveConfirm') : t('msgLeave')))); } // ---- dm thread ---------------------------------------------------------- // The DM flavor of the message action sheet: reactions + Reply + Copy. // No Delete — a gift-wrapped DM can't be retracted from the peer's relays. function dmSheet(peer) { const m = threadOf(peer).get(ui.msgSheet); if (!m) { ui.msgSheet = null; return null; } const my = myPubkeys(); const reacts = dmReacts.get(m.rumor.id); const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); const close = () => { ui.msgSheet = 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' }, h('div', { class: 'msg-sheet-emojis' }, REACT_EMOJIS.map((e2) => h('button', { class: e2 === myReact ? 'on' : '', onClick: () => { close(); sendDmReaction(peer, m, e2); }, }, e2))), item('↩', t('msgReply'), () => { ui.msgReplyTo = m.rumor.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), !m.mine && canZapPk(m.rumor.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null, item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {} close(); }))); } function dmView() { startDMs(); const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); // Looking at the thread is reading it — including anything that lands while // it's still open, since every arrival repaints us. markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine)); stickToBottom(); const my = myPubkeys(); const dmQuote = (m) => { const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1]; const src = replyId && thread.get(replyId); if (!src) return null; return h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), h('span', { class: 'chat-quote-text' }, String(src.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))); }; watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); const dmChips = (m) => { const reacts = dmReacts.get(m.rumor.id); const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: !m.mine && canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null }); if ((!reacts || !reacts.size) && !zc) return null; const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); const counts = new Map(); if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); return h('div', { class: 'chat-reacts' }, zc, [...counts.entries()].map(([emoji, n]) => h('span', { class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), onClick: (e) => { e.stopPropagation(); sendDmReaction(peer, m, emoji); }, }, emoji, n > 1 ? ' ' + n : ''))); }; const dmReplyBar = () => { const m = ui.msgReplyTo && thread.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.rumor.pubkey)), h('span', { class: 'small muted chat-quote-text' }, String(m.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')); }; return h('div', { class: 'card col chat-card chat-page' }, h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }), avatar(peer), h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted')))), h('div', { class: 'chat-log', onScroll: (e) => { const el = e.target; ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; }, }, ...(msgs.length ? msgs.map((m) => h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-bubble clickable' + (m.mine ? ' me' : ''), // same tap-for-actions as community bubbles onClick: (e) => { if (e.target.closest && e.target.closest('a, button, img')) return; const sel = window.getSelection && window.getSelection(); if (sel && String(sel).length) return; ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); }, }, dmQuote(m), ...noteBody(m.rumor.content), dmChips(m)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), dmReplyBar(), composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer), ui.msgSheet ? dmSheet(peer) : null); } // ---- feature ------------------------------------------------------------ function messagesTab() { if (ui.msgView === 'room') return roomView(); if (ui.msgView === 'dm') return dmView(); return homeView(); } return { id: 'messages', // A zap flow reporting how its payment went, so the chip that appeared // on tap can stop pulsing (paid) or disappear (failed / never fired). // Also the way a zap sent from a form reaches the tally right away, // without waiting on its receipt. zapSettled(eventId, ok, sats) { settleZap(eventId, ok, sats); return true; }, // Chat lives behind a header button and takes over the whole screen — // no balance card, no tabs; each view carries its own way back. // The bare avatar node for the app header's identity menu. // The header rebuilds every render; recreating the each time makes // the picture visibly flash during boot's render bursts. There is exactly // one header avatar, so the node itself is reused until the profile // (or account) actually changes. // A seed minted seconds ago has no kind-0 anywhere — don't make the // avatar sit white while a relay lookup confirms that; paint the punk now. identityGenerated() { // Synchronous on purpose: identity() can await a signer resume, and // the header sat avatar-less for that beat. A generated seed's pubkey // is right here — paint the punk before the next frame. const pk = (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostr && wallet.nostr.pk); if (!pk) return; const entry = { name: null, picture: null, t: Date.now() }; profiles.set(pk, entry); persistProfile(pk, entry); scheduleRepaint(); }, headerAvatar(pk) { // A fresh node every render: the morph keeps the live element (and its // loaded image) in place when nothing changed, which is exactly what // the old cached-node trick faked — and reusing one node in two trees // let the morph strip its children when positions paired it wrong. return avatar(pk, 'chat-avatar header-ava', false); }, // Conversations waiting on us, for the header's message button. unreadMessages() { // A device with no read watermarks yet (a fresh restore or brand-new // wallet) will show the dot the moment the community messages land — // which is always: the default community is never empty, the welcome // DM is on its way, and read state is device-local. Paint it from the // first frame instead of popping it in when the fetch returns. NB the // guard must key on "no MESSAGES landed yet", not "no rooms": init() // creates the coinos room synchronously before the first paint, so a // rooms.size check defeated the optimism it was written for. const s = st(); const anyMsgs = threads.size > 0 || [...rooms.values()].some((r) => [...r.byChannel.values()].some((m) => m.size)); if (!Object.keys(s.read || {}).length && !anyMsgs) return 1; return unreadCount(); }, notifySettingsCards() { return [notifyCard()]; }, screenView() { // A profile deep link mid-resolution holds the frame over EVERY screen // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. if (ui.pubProfPending) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('div', { class: 'chat-avatar profile-avatar fallback loading' }), h('div', { class: 'chat-title' }, ui.pubProfPending)))); if (ui.screen !== 'wallet') { // The public (no-wallet) surface: a deep-linked profile, and the // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); } return null; } if (ui.zapSetup) return zapSetupScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); if (ui.userSearch && !ui.chatOpen) return userSearchScreen(); if (!ui.chatOpen) return null; return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(true), messagesTab()); }, // The header magnifier: search anyone on nostr, results open profiles. userSearchAvailable() { return true; }, openUserSearch() { ui.chatOpen = false; ui.profilePk = null; ui.userSearch = { q: '', rows: null }; warmSearch(); // the field is about to be typed into — open the pipes return true; }, // nostr-login just connected or resumed a signer: wraps the backfill // couldn't open a moment ago can be opened now — drain right away. nostrSignerLive() { scheduleDrain(0); }, // Anyone (ark's history, other features) can open a profile or render a // small clickable identity chip. showProfile(pk) { openProfile(pk); return true; }, // The light profile cache, read-only — lets the onboarding wizard skip // asks (like the avatar picker) that a loaded identity already answered. cachedProfile(pk) { return profileOf(pk); }, // Start fetching a profile (and its picture bytes) NOW — the login flow // calls this the moment it knows the identity, so the avatar is already // in cache by the time the home screen first paints. warmProfile(pk) { try { prefetchProfilePage(pk); profileOf(pk); } catch {} return true; }, // Publish (merge) kind-0 fields for the current identity — the onboarding // wizard sets name + picture through this. publishProfile(fields, opts = {}) { return publishProfileFields(fields, opts); }, // A migrated coinos.io account brings its face with it: fetch the legacy // avatar and cover art and fill our kind 0's EMPTY slots — fill, never // overwrite, so an existing nostr identity keeps its own look. The light // cache is pre-warmed so the wizard's punk-picker skip sees the picture // before the publish round-trips. async adoptLegacyProfile(username) { try { const u = await fetch(`https://coinos.io/api/users/${encodeURIComponent(username)}`).then((r) => r.json()); if (!u) return true; const hosted = (v) => `https://coinos.io/api/public/${v}.webp`; const picture = u.profile ? hosted(u.profile) : typeof u.picture === 'string' && /^https?:\/\//i.test(u.picture) ? u.picture : null; const banner = !u.banner ? null : /^https?:\/\//i.test(String(u.banner)) ? String(u.banner) : hosted(u.banner); if (!picture && !banner) return true; const pk = myPubkeys()[0]; if (pk && picture) { const cur = profiles.get(pk); if (!(cur && cur.picture)) { profiles.set(pk, { ...(cur || {}), picture, t: Date.now() }); preloadPicture({ picture }); scheduleRepaint(); } } await publishProfileFields({ picture, banner }, { fillOnly: ['picture', 'banner'] }); } catch {} return true; }, // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. addressRenamed(oldAddr, newAddr) { const follows = (k) => (base) => !base[k] || base[k] === oldAddr; return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, }).catch(() => {}); }, profileChip(pk, size) { const big = size === 'lg'; return h('span', { class: 'zap-chip' + (big ? ' lg' : ''), onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, avatar(pk, 'chat-avatar ' + (big ? 'chip-lg' : 'mini'), false), h('span', { class: big ? '' : 'small' }, displayName(pk))); }, init() { // your own profile is the likeliest first tap — warm it early setTimeout(() => { try { const me = ctx.shownPubkey && ctx.shownPubkey(); if (me) prefetchProfilePage(me); } catch {} }, 2500); if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } ui.pubProf = null; // a wallet is open now — its chrome owns the profile window.addEventListener('resize', onViewportResize); window.visualViewport?.addEventListener('resize', onViewportResize); allUnsubs.push(() => { window.removeEventListener('resize', onViewportResize); window.visualViewport?.removeEventListener('resize', onViewportResize); }); startDMs(); // Communities are built from CACHE ONLY at boot (subscribe:false) — the // unread dot reads the last-known messages, but none of the gift-wrap // verify/decrypt runs until the user opens Chat (homeView subscribes // them). This keeps the home screen (and the balance carousel) off the // hook for the ~500ms of secp256k1 a full community backfill costs. for (const jm of communities()) { try { ensureRoom(jm, { subscribe: false }); } catch {} } syncLists().catch(() => {}); syncInbox().catch(() => {}); registerPush().catch(() => {}); // silent refresh when permission already granted }, stop() { for (const u of allUnsubs) { try { u(); } catch {} } allUnsubs = []; rooms.clear(); threads.clear(); pendingDirect.clear(); seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next }, }; }