// 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, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, } 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, unwrapDM, wrapDM } from '../dm.js'; import { saveInbox } from '../dm-inbox.js'; import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; // Genesis output of tools/concord-genesis.js — the coinos community's join // material (CORD-02 §8). The community_root here is deliberately public-ish: // every coinos user is meant to be a member of the default community. const COMMUNITY = { community_id: 'b517fb4ba04c4c4eac2bd486ee800d1a8644fcdca5c5643098062e7733ee4986', owner: '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', owner_salt: '466450cd6cd0e6991a5acea091c5bc9e9a1c1ba27a970e64d2af4860e7f60cb1', community_root: '58b2ce26eba30fbd19d9a57bce4c61e65838686998f10bba1d3e822fb72b372e', root_epoch: 0, channels: [{ id: '56bf8b96c1a3768c85444873df507cdbc3275fcbc21996af09e60003f850f85c', name: 'general' }], relays: ['wss://relay.coinos.io', 'wss://nos.lol'], name: 'coinos', }; const EPOCH = 0; 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 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; 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) { 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) 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. function unreadCount() { 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); // "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')); }; // ---- shared runtime ----------------------------------------------------- const rooms = new Map(); // cid -> room runtime const threads = new Map(); // peerPk -> Map(rumorId -> { rumor, mine }) const pendingDirect = new Map(); // rumor id -> { bundle, from } const profiles = new Map(); // pubkey -> profile | null while loading const seenWraps = new Set(); 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) { 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) { for (const k of keys.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 {} }; function profileOf(pk) { warmProfiles(); const cur = profiles.get(pk); if (cur !== undefined && (cur === null || Date.now() - (cur.t || 0) < PROFILE_TTL)) return cur; profiles.set(pk, cur || null); // null = loading, no fallback art yet fetchNostrProfile(pk).then((p) => { const entry = { ...(p || {}), t: Date.now() }; profiles.set(pk, entry); persistProfile(pk, entry); preloadPicture(entry); scheduleRepaint(); }).catch(() => profiles.set(pk, { 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 rooms ---------------------------------------------------- function ensureRoom(jm) { let room = rooms.get(jm.community_id); if (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(), 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; const 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); }; allUnsubs.push( subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); const opened = openWrap(wrap, room.control); 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); const opened = openWrap(wrap, room.guestbook); if (!opened) return; room.guestEntries.push(opened); scheduleFold(); }) ); for (const c of jm.channels || []) subChannel(room, c.id); // warm from local cache so the page paints before relays answer 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); } return room; } function subChannel(room, id) { if (room.subbed.has(id)) return; room.subbed.add(id); allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); const opened = openWrap(wrap, room.chStream(id)); 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 }); 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 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()); const rumor = rumorWithId({ kind: 9, pubkey: id.pubkey, content: text, tags: [['channel', chId], ['epoch', String(EPOCH)], 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(); } } 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})\/?$/); 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 }; return { ptags: myPubkeys(), authors, reasons }; } 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; let sub = await reg.pushManager.getSubscription(); if (!sub) { const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(publicKey) }); } 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) { for (const jm of communities()) ensureRoom(jm); 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 }); 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); } async function openInboxWrap(wrap) { for (const d of dmDecryptors()) { const got = await unwrapDM(wrap, d).catch(() => null); 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 === 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)) return; seenWraps.add(wrap.id); if (await openInboxWrap(wrap)) 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(); 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); else if (complete && ++p.tries >= PENDING_TRIES) pendingWraps.delete(id); } } 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; const pks = myPubkeys(); if (!pks.length) return; dmStarted = true; // 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) }); } 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. async function ensureDmRelayList() { if (!wallet.nostr || !wallet.nostr.sk) 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 rumor = makeDMRumor(id.pubkey, peer, text); 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(); } } 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) || node; }; // ---- 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; ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; render(); fetchFullProfile(pk); notesFor(pk); } // ---- profile notes: latest public posts & replies ----------------------- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; 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) .slice(0, 20); if (fresh.length || !c.notes.length) { c.notes = fresh; persistPage('notes', pk, fresh.map(slimNote)); } })().catch(() => {}).finally(() => { c.status = 'ready'; if (ui.profilePk === pk) render(); }); return c; } // 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 { out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' }, part.length > 64 ? part.slice(0, 61) + '…' : part)); } } else if (/^nostr:npub1/i.test(part)) { const mpk = parseNostrPubkey(part.slice(6)); if (mpk) out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(mpk); } }, '@' + displayName(mpk))); else out.push(part); } 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. 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; } 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 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(); if (!hook('zapNpub', pk, npub, eventId, n)) hook('lnZapNpub', pk, npub, eventId, n); } }, 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')); return h('div', { class: 'row', style: 'gap:10px;align-items:flex-start;padding:10px 0' + (open ? ';cursor:pointer' : '') + (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: open ? (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', { style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))), canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); } }, '⚡') : 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(); })().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 }; render(); } // Publish a kind-1 reply to the focused note (NIP-10 markers), addressed to // the conversation's own relays plus ours, and shown optimistically. async function publishReply(c, s, text) { const id = await identity(); if (!id) throw new Error(t('msgNoIdentity')); 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]), ], }; 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 }); return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' }, c.root ? row(c.root) : h('div', { class: 'small faint', style: 'padding:10px 0' }, c.status === 'loading' ? '…' : t('threadRootMissing')), ...c.replies.flatMap((ev) => [noteSep(), row(ev)]), c.status === 'loading' ? h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })) : !c.replies.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')) : null), h('div', { class: 'card row', style: 'gap:8px;align-items:center' }, 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.closest('.card').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) { toast(e.message); } s.sending = false; render(); }, }, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back'))); } async function publishProfileFields(fields, opts = {}) { const id = await identity(); if (!id) throw new Error(t('msgNoIdentity')); 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]]) { 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')))); const full = fullProfiles.get(pk); // Your own profile IS the editor — and it renders IMMEDIATELY, seeded // from the local name/picture cache, so the page never reflows when the // published kind 0 arrives. saveProfile re-fetches the newest kind 0 // before merging, so a stale seed can't clobber anything. const myAddr = mine ? hook('namesAddress') : null; if (mine && !ui.profEdit) { 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 || '', }; ui.profEditFilled = full !== undefined; } // 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 || ''; } 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], onInput: (ev) => { ui.profEdit[key] = ev.target.value; }, })); // 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; 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' }, avatar(pk, 'chat-avatar profile-avatar', false), h('div', { class: 'col grow', style: 'min-width:0;gap:2px' }, h('div', { class: 'chat-title' }, 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 showAbout && !ui.profEdit ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap' }, about.slice(0, 1000)) : null, // your own npub stays out of the editor — it means nothing to most // people, and the account settings still show it to those who care mine ? 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, 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(); }, })), h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile }, ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')), logoutBtn()) : mine ? logoutBtn() // the editor renders above once the kind 0 loads : 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); ui.profilePk = null; ui.chatOpen = false; ui.tab = 'send'; render(); hook('matchSendText', npubStr); } }, t('profPay')))), // Their latest public notes, zappable in place. (() => { 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', style: 'gap:0;padding:2px 14px;max-height:440px;overflow-y:auto' }, ...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), ]))); })(), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.pubProf = null; ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('back')), mine ? logoutPop() : 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(false), 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'; 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; // until the guestbook fold lands, last session's settled count beats // a placeholder that flips a moment later const live = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0; const memberCount = 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', 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; 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); 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' }, 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), counts.size ? h('div', { class: 'chat-reacts' }, [...counts.entries()].map(([emoji, n]) => h('span', { class: 'chat-react' }, emoji, n > 1 ? ' ' + n : ''))) : 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' }, h('div', { class: 'row between chat-head' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, backBtn(() => { ui.msgView = 'home'; 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; 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, 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:')); } // "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 ---------------------------------------------------------- function dmView() { startDMs(); const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const msgs = [...(threads.get(peer)?.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(); return h('div', { class: 'card col chat-card' }, h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; 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' + (m.mine ? ' me' : '') }, m.rumor.content), 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'))])), composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer)); } // ---- feature ------------------------------------------------------------ function messagesTab() { if (ui.msgView === 'room') return roomView(); if (ui.msgView === 'dm') return dmView(); return homeView(); } return { id: 'messages', // 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() { identity().then((id) => { if (!id) return; const entry = { name: null, picture: null, t: Date.now() }; profiles.set(id.pubkey, entry); persistProfile(id.pubkey, entry); scheduleRepaint(); }).catch(() => {}); }, 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 and read state // is device-local. Paint it from the first frame instead of popping it // in when the fetch returns. const s = st(); if (!Object.keys(s.read || {}).length && !threads.size && !rooms.size) 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.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); } return null; } if (ui.zapSetup) return zapSetupScreen(); 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 }; 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 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 subscribe up front too, not just when chat opens — the // header's unread dot can't report a room nobody is listening to. for (const jm of communities()) { try { ensureRoom(jm); } 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(); // the next account must decrypt wraps this one couldn't clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; listsSynced = false; }, }; }