// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
// Concord, membership travels by invite. coinos ships with one built-in
// community (below) so every user starts somewhere; beyond it users join by
// invite link, by direct invite (giftwrapped to their npub), or by founding
// their own community in-app.
//
// Identity: the session's nostr login when a signer is live, else the
// wallet's NIP-06 key. Community traffic encrypts under stream keys the app
// holds (signers only sign seals); DMs seal to the peer, which is what the
// widened signer adapters (encryptTo/decryptFrom) exist for. The DM inbox
// listens for both identities and decrypts with whichever keys are present.
import {
subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays,
npubOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread,
} from '../nostr.js';
import {
channelKey, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js';
import { saveInbox } from '../dm-inbox.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { t } from '../i18n.js';
// The coinos community's join material lives in ../community.js — shared
// with the public read-only chat page so the two can never drift.
import { COMMUNITY, EPOCH } from '../community.js';
const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
// NIP-89 client attribution on PUBLIC notes (posts + thread replies) — other
// clients render it as "via coinos". Name-only: the fuller form appends a
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
const CLIENT_TAG = ['client', 'coinos'];
export function messagesFeature(ctx) {
const { h, ui, render, wallet, toast, hook } = ctx;
// ---- persisted state ----------------------------------------------------
const st = () => {
const s = wallet.loadFeatureState('messages', {});
s.joined ||= {}; // { [cid]: { [pubkey]: true } }
s.cache ||= {}; // { [channelId]: [msgs] }
s.communities ||= []; // join material beyond the built-in
s.invites ||= {}; // { [cid]: { sk, token, url } } — minted links
s.dms ||= {}; // { [peerPk]: [{ id, from, text, t }] }
s.declined ||= {}; // direct-invite rumor ids dismissed
s.tombstones ||= {}; // { [cid]: removed_at ms } — left communities (CORD-02 §8)
s.read ||= {}; // { ['dm:'+pk | 'ch:'+id]: created_at } — last message we've seen
s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in)
s.drafts ||= {}; // { ['dm:'+pk | 'ch:'+id]: text } — half-typed messages
s.zaps ||= {}; // { [eventId]: { s: sats, m: 1 } } — last known zap tallies
for (const c of s.communities) c.added_at ||= Date.now();
// pre-multi-community shape: joined was { [pubkey]: true } for coinos
for (const k of Object.keys(s.joined))
if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; }
return s;
};
const save = (s) => wallet.saveFeatureState('messages', s);
const communities = () => [COMMUNITY, ...st().communities];
// ---- unread -------------------------------------------------------------
// A conversation is unread when its newest message from someone else is
// newer than the last one we looked at. The watermark is the rumor's own
// created_at, not a local clock, so it means the same thing on every device
// the state syncs to. Our own messages never count — sending isn't reading,
// but you've obviously seen what you wrote.
const dmRead = (pk) => 'dm:' + pk;
const chRead = (id) => 'ch:' + id;
// The list screen has a watermark of its own: standing on it answers the
// header dot ("someone's waiting" — you've now been shown who), while each
// row keeps its own dot until that conversation is actually opened.
const HOME_READ = 'home';
const newestFrom = (entries, theirs) => {
let ts = 0;
for (const m of entries) if (theirs(m)) ts = Math.max(ts, m.rumor.created_at || 0);
return ts;
};
function markRead(key, ts) {
const s = st();
if (!ts || (s.read[key] || 0) >= ts) return;
s.read[key] = ts;
bumpMsgRev();
save(s);
// read markers ride their own sync domain (registered below) — nudging
// the cache is what gets the watermark onto the relays for other devices
try { wallet.saveCache(); } catch {}
}
// One tap, every dot: advance each conversation's watermark to its newest
// foreign message — DMs and community channels alike.
function markAllRead() {
const s = st();
const my = myPubkeys();
let changed = false;
const bump = (key, ts) => { if (ts && (s.read[key] || 0) < ts) { s.read[key] = ts; changed = true; } };
for (const [pk, msgs] of threads) bump(dmRead(pk), newestFrom(msgs.values(), (m) => !m.mine));
for (const room of rooms.values())
for (const [id, msgs] of room.byChannel)
bump(chRead(id), newestFrom(msgs.values(), (m) => !my.includes(m.author)));
if (changed) { bumpMsgRev(); save(s); try { wallet.saveCache(); } catch {} }
render();
}
const anyUnread = () => {
for (const [pk, msgs] of threads) if (dmUnread(pk, msgs)) return true;
for (const room of rooms.values()) if (roomUnread(room)) return true;
return false;
};
// Read state syncs across devices: each watermark only ever grows, so a
// per-key max is a clean commutative merge — a thread read anywhere is
// read everywhere.
if (wallet.registerCacheExtension && !wallet._msgReadSync) {
wallet._msgReadSync = true;
wallet.registerCacheExtension({
domain: 'msgread',
mergeAlways: true,
save: () => {
const read = wallet.loadFeatureState('messages', {}).read || {};
return Object.keys(read).length ? { msgRead: read } : {};
},
load: (d) => {
if (!d.msgRead) return;
const s = wallet.loadFeatureState('messages', {});
s.read ||= {};
let changed = false;
for (const [k, v] of Object.entries(d.msgRead)) {
if (typeof v === 'number' && (s.read[k] || 0) < v) { s.read[k] = v; changed = true; }
}
if (changed) { bumpMsgRev(); wallet.saveFeatureState('messages', s); }
},
});
}
// Per-conversation dots for the list screen: newer than when we last had
// that thread (or any channel of that room) open.
const dmUnread = (pk, msgs) =>
newestFrom(msgs.values(), (m) => !m.mine) > (st().read[dmRead(pk)] || 0);
function roomUnread(room) {
const s = st();
const my = myPubkeys();
for (const [id, msgs] of room.byChannel)
if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true;
return false;
}
// How many conversations are waiting on us — the header only draws a dot, but
// a count keeps the door open for a number later. Floored by the list-screen
// watermark: once you've seen the list, the header stops repeating it.
//
// Memoized on a revision counter: the header asks on EVERY render, and the
// walk touches every cached message in every thread and room — a phone
// paid that for each toast and repaint. Message arrivals and read-marks
// bump the revision; everything else reuses the answer.
let msgRev = 0;
const bumpMsgRev = () => { msgRev++; };
let _unreadMemo = { rev: -1, val: 0 };
function unreadCount() {
if (_unreadMemo.rev === msgRev) return _unreadMemo.val;
_unreadMemo = { rev: msgRev, val: unreadCountNow() };
return _unreadMemo.val;
}
function unreadCountNow() {
const s = st();
const my = myPubkeys();
const seen = s.read[HOME_READ] || 0;
let n = 0;
for (const [pk, msgs] of threads)
if (newestFrom(msgs.values(), (m) => !m.mine) > Math.max(seen, s.read[dmRead(pk)] || 0)) n++;
for (const room of rooms.values())
for (const [id, msgs] of room.byChannel)
if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > Math.max(seen, s.read[chRead(id)] || 0)) n++;
return n;
}
const communityById = (cid) => communities().find((c) => c.community_id === cid);
// ---- identity -----------------------------------------------------------
// Who we speak as. If the user logged in with a nostr account we speak as
// that account or not at all — falling back to the wallet's own key would
// sign under a pubkey that isn't the one their avatar and profile show,
// which reads to everyone else as a different person entirely.
async function identity() {
const id = hook('nostrLoginIdentity');
if (id) {
const signer = id.signer || (await hook('nostrLoginResume'));
return signer ? { pubkey: id.pubkey, signer } : null;
}
if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk };
return null;
}
const myPubkeys = () => {
const pks = [];
const id = hook('nostrLoginIdentity');
if (id) pks.push(id.pubkey);
if (wallet.nostr && wallet.nostr.pk && !pks.includes(wallet.nostr.pk)) pks.push(wallet.nostr.pk);
return pks;
};
const isMe = (pk) => myPubkeys().includes(pk);
// The ONE nostr identity this wallet uses for DMs: the login identity when
// signed in (extension/bunker/passkey/Google), else the seed-derived key. A
// wallet has two keys (seed + login), but only this one should receive and
// advertise DMs — advertising/polling the seed key as a SECOND inbox is what
// produced duplicate welcome DMs and split a person's messages across two
// identities. isMe/dmDecryptors stay tolerant of both so already-received
// messages still thread and align; we just stop treating the seed key as a
// live second inbox. (One key here, but kept as a list for the '#p' filters.)
const dmSubKeys = () => {
const id = hook('nostrLoginIdentity');
const pk = (id && id.pubkey) || (wallet.nostr && wallet.nostr.pk) || null;
return pk ? [pk] : [];
};
// "No identity" while soft-locked means the keys left with the lock — say
// that, not "signer disconnected" (which reads as a nostr-login problem).
const noIdToast = () => {
// A missing signer gets the reconnect screen, not a dead-end toast —
// logging out and back in was the workaround nobody should need.
if (!wallet.watchOnly && hook('nostrReconnectPrompt')) return;
toast(t(wallet.watchOnly ? 'msgLockedChat' : 'msgNoIdentity'));
};
// Thrown by the publish paths after noIdToast already did the talking
// (opened the reconnect screen, or toasted): callers must not toast it
// again — a pasted-nsec login that lost its signer on reload used to get
// the toast INSTEAD of the screen with the nsec field this way.
class NoIdentity extends Error { constructor() { super(t('msgNoIdentity')); this.silent = true; } }
const requireIdentity = async () => {
const id = await identity();
if (!id) { noIdToast(); throw new NoIdentity(); }
return id;
};
// ---- shared runtime -----------------------------------------------------
const rooms = new Map(); // cid -> room runtime
const threads = new Map(); // peerPk -> Map(rumorId -> { rumor, mine })
// DM reactions, keyed by the reacted message's id — no thread needed, so a
// reaction that arrives before its message still lands. Ephemeral like the
// community ones: the wrap backlog rebuilds them on reload.
const dmReacts = new Map(); // rumorId -> Map(authorPk -> emoji)
const pendingDirect = new Map(); // rumor id -> { bundle, from }
const profiles = new Map(); // pubkey -> profile | null while loading
const seenWraps = new Set();
// Wraps this device already opened (or gave up on), remembered ACROSS
// boots: relays re-serve their last ~400 wraps on every subscribe, and a
// remote-signer wallet paid two NIP-46 round trips (sign + verify each)
// per wrap to rediscover DMs it already had — ~4s of a phone's boot.
// Keyed by an id prefix (64 bits is plenty for "skip this one").
const WRAPS_MAX = 1500;
const wrapKey = (id) => String(id).slice(0, 16);
let wrapLog = null, wrapsTimer = 0;
function loadSeenWraps() {
if (wrapLog) return;
wrapLog = (st().wraps || []).slice(-WRAPS_MAX);
for (const k of wrapLog) seenWraps.add(k);
}
function rememberWrap(id) {
const k = wrapKey(id);
if (!wrapLog) wrapLog = [];
if (!seenWraps.has(k)) wrapLog.push(k);
seenWraps.add(k);
clearTimeout(wrapsTimer);
wrapsTimer = setTimeout(() => {
try { const s = st(); s.wraps = wrapLog.slice(-WRAPS_MAX); wrapLog = s.wraps; save(s); } catch {}
}, 1500);
}
let dmStarted = false;
let allUnsubs = [];
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
repaintTimer = setTimeout(() => {
repaintTimer = null;
if (ui.screen === 'wallet') render();
}, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
// away instead of flashing the punk fallback; entries refresh in the
// background once a day.
const PROFILE_TTL = 24 * 3600_000;
let profilesWarmed = false;
function warmProfiles() {
if (profilesWarmed) return;
profilesWarmed = true;
const cached = wallet.loadFeatureState('profiles', {});
for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p);
}
function persistProfile(pk, p) {
// A faceless answer is not a fact worth writing down — persisting
// {name:null, picture:null} rows only evicts real faces from the cap and
// spreads a cold-relay miss across sessions.
if (!p || (!p.name && !p.picture)) return;
const s = wallet.loadFeatureState('profiles', {});
s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now(),
...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen faces give theirs up first. The row itself stays —
// that face just paints the way it used to.
if (JSON.stringify(s).length > THUMB_BUDGET) {
const oldestFirst = Object.keys(s).filter((k) => s[k].thumb).sort((a, b) => (s[a].t || 0) - (s[b].t || 0));
for (const k of oldestFirst) {
delete s[k].thumb; delete s[k].thumbFor;
if (JSON.stringify(s).length <= THUMB_BUDGET) break;
}
}
const keys = Object.keys(s);
if (keys.length > 150) {
// the user's own faces never churn out — their avatar going punk over
// a busy community room is the one eviction people actually notice
const keep = new Set([(hook('nostrLoginIdentity') || {}).pubkey,
wallet.nostrPubkey && wallet.nostrPubkey()].filter(Boolean));
const evictable = keys.filter((k) => !keep.has(k));
for (const k of evictable.sort((a, b) => (s[a].t || 0) - (s[b].t || 0)).slice(0, keys.length - 150)) delete s[k];
}
wallet.saveFeatureState('profiles', s);
}
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
// the legacy coinos.io copies 502 for a third of the set — so painting
// those from the local files means no network, no broken faces, and the
// right size for the circle being drawn.
// Absolute (what the wizard publishes) or the bare relative path an older
// build wrote into a few profiles.
const PUNK_PIC_RE = /^(?:https?:\/\/(?:[a-z0-9-]+\.)*coinos\.io\/)?punks\/(\d{1,2})\.webp$/i;
const localPunk = (url, big) => {
const m = PUNK_PIC_RE.exec(url || '');
return m ? (big ? `punks/${m[1]}.webp` : `punks-sm/${m[1]}.webp`) : null;
};
// A refreshed profile whose picture hasn't changed keeps the thumbnail we
// already made of it — otherwise every relay refresh throws the local copy
// away and the original gets downloaded all over again.
const keepThumb = (pk, entry) => {
const prev = profiles.get(pk);
if (!prev || !entry || !entry.picture) return entry;
if (prev.thumb && prev.thumbFor === entry.picture) { entry.thumb = prev.thumb; entry.thumbFor = prev.thumbFor; }
if (prev.thumbFail === entry.picture) {
entry.thumbFail = prev.thumbFail;
entry.thumbFailAt = prev.thumbFailAt || 0;
entry.thumbFails = prev.thumbFails || 1;
entry.thumbFailVersion = prev.thumbFailVersion || 0;
}
return entry;
};
// Pull the picture bytes into the HTTP cache the moment we learn the URL —
// an avatar div then paints instantly instead of holding its quiet circle
// while the image downloads.
const preloadPicture = (p) => { try { if (p && p.picture) new Image().src = p.picture; } catch {} };
// A profile picture is whatever its owner uploaded, and that is very often
// the full-size original: among the faces this wallet had cached, one was a
// 5MB JPEG and another 3.9MB — megabytes fetched and decoded to paint a
// 30px circle, which is the second of blank circles you see after a
// refresh. So the first time we see a picture we downscale it ONCE and keep
// the thumbnail beside the profile. Every later boot paints the face from
// localStorage in the first frame, with no network at all.
//
// Reading the pixels needs CORS (a tainted canvas can't be exported). Most
// avatar hosts allow it; the ones that don't are remembered as failures and
// keep today's behaviour rather than being retried every boot.
const THUMB_PX = 96; // 30px circles at 3x; the 64px profile avatar
// layers the original over it anyway
const THUMB_MAX = 9000; // chars of data URL for one face
const THUMB_BUDGET = 180_000; // ...and for all of them together
const THUMB_SLOW = 20_000; // a host that won't answer must not hold a slot
const THUMB_RETRY = 6 * 3600_000; // ...and must not be written off for good
const THUMB_RETRY_MAX = 14 * 24 * 3600_000; // a host that never works, backed off
// Older crossOrigin attempts could fail on a cached no-CORS response.
// Those failures must not suppress the corrected fetch/blob path.
const THUMB_VERSION = 1;
const thumbing = new Set();
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (p.thumbFor === p.picture) return;
if (localPunk(p.picture)) return; // our own art, already the right size on disk
// A failed attempt is usually the network, not the host: these images sit
// on CDNs that answer in 700ms one minute and time out the next. So every
// failure is tried again — backing off each time, so a host that truly
// won't have us is asked about twice a month rather than every boot.
if (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === p.picture
&& Date.now() - (p.thumbFailAt || 0) < Math.min(THUMB_RETRY * 2 ** ((p.thumbFails || 1) - 1), THUMB_RETRY_MAX)) return;
if (thumbing.has(pk) || thumbing.size >= 3) return; // a few at a time
// Making the thumbnail costs one more fetch of the original today to
// save every fetch after it — but not on a connection someone is
// nursing. Data Saver keeps today's behaviour.
try { if (navigator.connection && navigator.connection.saveData) return; } catch {}
thumbing.add(pk);
const url = p.picture;
const done = (patch) => {
thumbing.delete(pk);
const entry = { ...(profiles.get(pk) || p), ...patch };
if (patch.thumb) {
delete entry.thumbFail; delete entry.thumbFailAt;
delete entry.thumbFails; delete entry.thumbFailVersion;
}
profiles.set(pk, entry);
persistProfile(pk, entry);
if (patch.thumb) scheduleRepaint();
};
const failed = () => done({ thumbFail: url, thumbFailAt: Date.now(), thumbFailVersion: THUMB_VERSION,
thumbFails: (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === url ? p.thumbFails || 0 : 0) + 1 });
// Fetch the bytes rather than pointing a crossOrigin at the URL.
// The avatar itself is painted as a plain background-image, so the
// picture is already in the HTTP cache as a no-CORS entry — and a
// crossOrigin for the same URL reuses that entry and fails the CORS
// check, which is how a host that allows us perfectly well (verified: the
// same fetch succeeds) came to be written off. Drawing from a blob also
// keeps the canvas untainted, so nothing here depends on the element's
// CORS bookkeeping at all.
(async () => {
let bmp = null;
try {
const res = await fetch(url, { mode: 'cors', cache: 'reload', signal: AbortSignal.timeout(THUMB_SLOW) });
if (!res.ok) throw new Error('http ' + res.status);
bmp = await createImageBitmap(await res.blob());
// centre-crop to a square, the way background-size:cover paints it
const side = Math.min(bmp.width, bmp.height);
if (!side) throw new Error('empty');
const c = document.createElement('canvas');
c.width = c.height = THUMB_PX;
c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2,
side, side, 0, 0, THUMB_PX, THUMB_PX);
let data = c.toDataURL('image/webp', 0.75);
if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder
if (data.length > THUMB_MAX) throw new Error('too big');
done({ thumb: data, thumbFor: url });
} catch { failed(); } finally { try { bmp && bmp.close(); } catch {} }
})();
}
// A profile that came back EMPTY is usually a cold boot's 5s query racing
// relays that were still dialing — not proof there's no kind 0. Trusting it
// for the full TTL once dressed a real account in a punk and an npub for a
// whole day; an empty answer is retried in minutes instead.
const EMPTY_RETRY = 10 * 60_000;
function profileOf(pk) {
warmProfiles();
const cur = profiles.get(pk);
if (cur !== undefined && (cur === null
|| Date.now() - (cur.t || 0) < (!cur.name && !cur.picture ? EMPTY_RETRY : PROFILE_TTL))) return cur;
profiles.set(pk, cur || null); // null = loading, no fallback art yet
fetchNostrProfile(pk).then((p) => {
// An empty answer on a cold boot (relays still dialing, the 5s query
// came back with nothing) must never clobber a remembered face with a
// punk — keep the stale fields and just refresh the clock, so the
// known avatar holds while the full-profile fetch does its rounds.
const prev = profiles.get(pk);
const entry = keepThumb(pk, p ? { ...p, t: Date.now() } : { ...(prev || {}), t: Date.now() });
profiles.set(pk, entry);
persistProfile(pk, entry);
preloadPicture(entry);
scheduleRepaint();
}).catch(() => {
const prev = profiles.get(pk);
profiles.set(pk, { ...(prev || {}), t: Date.now() });
});
return cur || null;
}
const displayName = (pk) => {
const p = profileOf(pk);
if (p && p.name) return p.name;
const npub = npubOf(pk);
return npub ? npub.slice(0, 12) : pk.slice(0, 12);
};
// Community wraps decrypt with real secp256k1 work (NIP-44), and a relay
// backlog delivers hundreds at once. Decrypting them inline in the
// subscription callbacks blocked the main thread in one burst on boot — the
// stutter felt when dragging the balance carousel right after a refresh.
// Queue the decrypt work and run it time-sliced: ~8ms of work, then yield so
// input and paint get a turn. Chat catches up a few frames later; the wallet
// stays responsive. (Same pattern the DM inbox drain already uses.)
// Open one community wrap off the main thread when the crypto worker is
// up (the decrypt + seal verify per wrap is the remaining boot secp256k1
// the profiler pinned); the time-sliced queue below stays as the fallback,
// so a dead worker only changes where the crypto runs, never whether chat
// catches up. The stream conversation key is group material every member
// derives — no secret crosses the thread boundary.
function openWrapBg(wrap, stream, cb) {
const job = openWrapsOffthread([wrap], stream.convKey);
if (!job) { enqueueRoomWork(() => cb(openWrap(wrap, stream))); return; }
job.then((r) => {
if (r) cb(r[0]);
else enqueueRoomWork(() => cb(openWrap(wrap, stream))); // worker died mid-job
});
}
const roomWork = [];
let roomWorking = false;
function enqueueRoomWork(fn) {
roomWork.push(fn);
if (roomWorking) return;
roomWorking = true;
(async () => {
try {
let t0 = performance.now();
while (roomWork.length) {
try { roomWork.shift()(); } catch {}
if (performance.now() - t0 > 8) { await new Promise((r) => setTimeout(r)); t0 = performance.now(); }
}
} finally { roomWorking = false; }
})();
}
// ---- community rooms ----------------------------------------------------
// subscribe:false builds the room from LOCAL CACHE only — no relay
// subscription, so none of the gift-wrap verify/decrypt (real secp256k1)
// runs. That's the boot default: the home screen and its unread dot read
// the last-known cached messages, and the heavy live backfill waits until
// the user actually opens Chat (homeView/room view pass subscribe:true).
function ensureRoom(jm, { subscribe = true } = {}) {
let room = rooms.get(jm.community_id);
if (room) { if (subscribe) subscribeRoom(room); return room; }
const root = hexToBytes(jm.community_root);
room = {
jm,
control: controlKey(root, jm.community_id, jm.root_epoch || EPOCH),
guestbook: guestbookKey(root, jm.community_id, jm.root_epoch || EPOCH),
chStream: (id) => channelKey(root, id, EPOCH),
folded: null,
controlEntries: [],
guestEntries: [],
members: new Map(),
byChannel: new Map(),
edits: new Map(),
deletes: new Set(),
reactions: new Map(),
presence: new Map(), // pubkey -> ms of their last beat (this session only)
typing: new Map(), // pubkey -> { ch, t }
subbed: new Set(),
subscribed: false,
relays: jm.relays && jm.relays.length ? jm.relays : DM_RELAYS,
};
rooms.set(jm.community_id, room);
const refold = () => { room.folded = foldControl(room.controlEntries, { ownerHex: jm.owner, cid: jm.community_id }); };
const refoldGuestbook = () => {
room.members = foldGuestbook(room.guestEntries, { nowMs: Date.now(), banned: room.folded?.banned });
for (const [, msgs] of room.byChannel)
for (const { rumor, author } of msgs.values()) {
const tms = eventMs(rumor);
if (tms) observeAuthor(room.members, author, tms);
}
};
// The replay of stored wraps arrives one event at a time, and folding
// after every wrap paints each intermediate state: a superseded ban-list
// version blinks members' messages out until the newest version lands,
// and the member count creeps upward as joins stream in. Fold once per
// burst instead — a short quiet gap ends the burst — so the page goes
// from cache straight to the settled state.
let foldTimer = 0;
room.scheduleFold = () => {
clearTimeout(foldTimer);
foldTimer = setTimeout(() => {
refold();
refoldGuestbook();
// remember the settled count — next session's list paints it
// immediately instead of "encrypted" flipping to a number
const s = st();
(s.memberCounts ||= {})[jm.community_id] =
[...room.members.values()].filter((m) => m.state === 'join').length;
save(s);
scheduleRepaint();
}, 250);
};
// warm from local cache so the page (and the unread dot) paint before —
// and without needing — any relay subscription
const cached = st().cache;
for (const c of jm.channels || [])
for (const m of cached[c.id] || []) {
const msgs = room.byChannel.get(c.id) || room.byChannel.set(c.id, new Map()).get(c.id);
if (!msgs.has(m.rumor.id)) msgs.set(m.rumor.id, m);
}
bumpMsgRev();
if (subscribe) subscribeRoom(room);
return room;
}
// Open the relay subscriptions for a room — the heavy path (gift-wrap
// verify + decrypt). Deferred until Chat is opened; idempotent.
function subscribeRoom(room) {
if (room.subscribed) return;
room.subscribed = true;
const scheduleFold = room.scheduleFold;
allUnsubs.push(
subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
openWrapBg(wrap, room.control, (opened) => {
if (!opened || opened.rumor.kind !== 3308) return;
room.controlEntries.push(opened);
scheduleFold();
});
}),
subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
openWrapBg(wrap, room.guestbook, (opened) => {
if (!opened) return;
room.guestEntries.push(opened);
scheduleFold();
});
})
);
for (const c of room.jm.channels || []) subChannel(room, c.id);
}
function subChannel(room, id) {
if (room.subbed.has(id)) return;
room.subbed.add(id);
// derive the stream key once per channel, not once per wrap — groupKey
// does an hkdf + ECDH each call, real curve work on a 200-wrap backfill
const stream = room.chStream(id);
allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [stream.pk], limit: 200 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
openWrapBg(wrap, stream, (opened) => {
if (!opened) return;
onChat(room, id, opened);
});
}));
}
function onChat(room, channelId, { rumor, author }) {
const tag = (k) => rumor.tags?.find((x) => x[0] === k);
// CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it
if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return;
if (room.folded && room.folded.banned.has(author)) return;
if (rumor.kind === PRESENCE || rumor.kind === TYPING) {
// Dated by the beat itself, never by arrival. Relays hand back stored
// beats when we subscribe, and treating those as "just now" would light
// up the whole member list as online. Clamped to now so a fast clock
// can't hold someone online forever.
const ts = Math.min(Date.now(), eventMs(rumor) || rumor.created_at * 1000);
if (ts > (room.presence.get(author) || 0)) room.presence.set(author, ts);
if (rumor.kind === TYPING && Date.now() - ts < TYPING_MS) {
room.typing.set(author, { ch: channelId, t: ts });
setTimeout(scheduleRepaint, TYPING_MS + 100); // clear itself when it lapses
}
scheduleRepaint();
return;
}
if (rumor.kind === 9) {
const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId);
msgs.set(rumor.id, { rumor, author });
bumpMsgRev();
room.typing.delete(author); // the message itself ends the "typing…"
} else if (rumor.kind === 5) {
for (const e of rumor.tags.filter((x) => x[0] === 'e')) {
const m = room.byChannel.get(channelId)?.get(e[1]);
if (!m || m.author === author) room.deletes.add(e[1]);
}
} else if (rumor.kind === 3302) {
const target = tag('e')?.[1];
if (target) {
const cur = room.edits.get(target);
if (!cur || eventMs(rumor) > eventMs(cur.rumor)) room.edits.set(target, { rumor, author });
}
} else if (rumor.kind === 7) {
const target = tag('e')?.[1];
if (target) {
const r = room.reactions.get(target) || room.reactions.set(target, new Map()).get(target);
r.set(author, rumor.content);
}
}
const tms = eventMs(rumor);
if (tms) observeAuthor(room.members, author, tms);
scheduleRepaint();
}
const roomChannels = (room) => {
if (room.folded && room.folded.channels.size)
return [...room.folded.channels.entries()].map(([id, c]) => ({ id, name: c.name }));
return room.jm.channels || [];
};
function persistCache(room) {
const s = st();
for (const [chId, msgs] of room.byChannel) {
// Pending entries stay out of the cache: unconfirmed means the relay
// never took it, and a reload would resurrect it dimmed forever.
s.cache[chId] = [...msgs.values()]
.filter((m) => !room.deletes.has(m.rumor.id) && !m.pending)
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor))
.slice(-CACHE_MAX);
}
save(s);
}
async function ensureJoined(room, id) {
const s = st();
const j = (s.joined[room.jm.community_id] ||= {});
if (j[id.pubkey]) return;
const { created_at, ms } = msTags(Date.now());
const tags = [ms];
if (room.jm.invitedBy) tags.push(['invite', room.jm.invitedBy, room.jm.inviteLabel || '']);
const wrap = await wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'join', tags, created_at }, id.signer, room.guestbook);
publishOn(room.relays, wrap);
j[id.pubkey] = true;
save(s);
}
// ---- drafts -------------------------------------------------------------
// Half-typed text survives navigation and reloads: one draft per
// conversation ('dm:' / 'ch:'), kept in feature state.
// The session map is authoritative while the app runs; persistence rides a
// debounce so typing doesn't hammer storage.
const sessionDrafts = new Map();
let draftPersist = 0;
const POST_DRAFT = 'post'; // the profile page's new-post composer
const draftFor = (key) =>
(sessionDrafts.has(key) ? sessionDrafts.get(key) : (st().drafts || {})[key] || '');
function setDraft(key, text) {
sessionDrafts.set(key, text);
clearTimeout(draftPersist);
draftPersist = setTimeout(() => {
const s = st();
s.drafts ||= {};
for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
save(s);
}, 800);
}
// The morph never rewrites a focused field's value (it would fight the user
// mid-keystroke), and the composer is focused at the moment you send — so
// clearing the draft alone leaves the sent text sitting in the input.
// Clear the live element too.
function clearDraft(key) {
setDraft(key, '');
const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
}
async function sendMessage(room, chId) {
const text = draftFor('ch:' + chId).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
// The rumor id is a plain hash, so the message can be on screen before
// any signing, encryption or network runs. The pending flag never shows —
// it only keeps unconfirmed messages out of the cache and marks what to
// withdraw if signing fails.
const { created_at, ms } = msTags(Date.now());
// an armed reply context e-tags the quoted message (rendered as a quote
// by replyQuote on every device that has the original)
const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
const rumor = rumorWithId({
kind: 9, pubkey: id.pubkey, content: text,
tags: [['channel', chId], ['epoch', String(EPOCH)], ...(replyTo ? [['e', replyTo]] : []), ms], created_at,
});
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
msgs.set(rumor.id, entry);
clearDraft('ch:' + chId);
ui.msgStick = true;
render();
try {
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
seenWraps.add(wrap.id); // our own echo has nothing to add
const ok = await publishOn(room.relays, wrap);
if (!ok) { toast(t('msgSendFailed')); return; }
delete entry.pending;
ensureJoined(room, id).catch(() => {});
persistCache(room);
} catch (e) {
// Signing or wrapping failed — the message never existed on the wire,
// so it leaves the screen rather than sit there looking sent.
msgs.delete(rumor.id);
toast(e.message || String(e));
render();
}
}
// A reaction is a kind-7 rumor on the channel stream — same envelope as a
// message, content = the emoji. One reaction per author per message (the
// fold keeps the latest), so picking a different emoji replaces yours;
// tapping an existing chip joins that vote. Optimistic like sendMessage:
// the chip appears immediately, the wire catches up.
const REACT_EMOJIS = ['👍', '❤️', '😂', '🔥', '🎉', '🙏'];
async function sendReaction(room, chId, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
ui.msgSheet = null;
const { created_at, ms } = msTags(Date.now());
const rumor = rumorWithId({
kind: 7, pubkey: id.pubkey, content: emoji,
tags: [['channel', chId], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at,
});
const r = room.reactions.get(m.rumor.id) || room.reactions.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
r.set(id.pubkey, emoji);
render();
try {
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
seenWraps.add(wrap.id); // our own echo has nothing to add
const ok = await publishOn(room.relays, wrap);
if (!ok) toast(t('msgSendFailed'));
} catch (e) {
// never went out — put back whatever stood before
if (prev) r.set(id.pubkey, prev); else r.delete(id.pubkey);
toast(e.message || String(e));
render();
}
}
// The displayed text of a message after its edit fold, trimmed to one line.
function msgSnippet(room, m, max = 90) {
const edit = room.edits.get(m.rumor.id);
const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
const one = String(text || '').replace(/\s+/g, ' ').trim();
return one.length > max ? one.slice(0, max - 1) + '…' : one;
}
// A kind-9 that e-tags another message is a REPLY — render the quoted
// context above its text, Telegram-style. Quotes of deleted (or not-yet-
// loaded) messages stay silent rather than resurrect them.
function replyQuote(room, chId, m) {
const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
if (!replyId || room.deletes.has(replyId)) return null;
const src = room.byChannel.get(chId)?.get(replyId);
if (!src) return null;
return h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.author)),
h('span', { class: 'chat-quote-text' }, msgSnippet(room, src)));
}
// Telegram-style message action sheet: quick reactions on top, then the
// actions this message supports. Opened by a tap on the bubble.
function messageSheet(room, chId) {
const m = room.byChannel.get(chId)?.get(ui.msgSheet);
if (!m) { ui.msgSheet = null; return null; }
const my = myPubkeys();
const mine = my.includes(m.author);
const reacts = room.reactions.get(m.rumor.id);
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
const close = () => { ui.msgSheet = null; render(); };
const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
h('div', { class: 'msg-sheet-emojis' },
REACT_EMOJIS.map((e2) => h('button', {
class: e2 === myReact ? 'on' : '',
onClick: () => { close(); sendReaction(room, chId, m, e2); },
}, e2))),
item('↩', t('msgReply'), () => {
ui.msgReplyTo = m.rumor.id;
close();
setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
}),
!mine && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null,
item('⧉', t('copy'), async () => {
try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {}
close();
}),
mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
}
// The composer's "replying to" context bar, with its way out.
function replyBar(room, chId) {
const m = ui.msgReplyTo && room.byChannel.get(chId)?.get(ui.msgReplyTo);
if (!m) { ui.msgReplyTo = null; return null; }
return h('div', { class: 'reply-bar' },
h('div', { class: 'col grow', style: 'gap:1px;min-width:0' },
h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.author)),
h('span', { class: 'small muted chat-quote-text' }, msgSnippet(room, m))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×'));
}
async function deleteMessage(room, chId, m) {
const id = await identity();
if (!id || id.pubkey !== m.author) return;
const { created_at, ms } = msTags(Date.now());
const rumor = {
kind: 5, pubkey: id.pubkey, content: '',
tags: [['channel', chId], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at,
};
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
room.deletes.add(m.rumor.id);
render();
publishOn(room.relays, wrap);
persistCache(room);
}
// ---- founding a community (CORD-02 genesis) -----------------------------
async function createCommunity(name) {
const id = await identity();
if (!id) { noIdToast(); return; }
name = name.trim().slice(0, 64);
if (!name) return;
const ownerSalt = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const rootBytes = crypto.getRandomValues(new Uint8Array(32));
const cid = communityId(id.pubkey, ownerSalt);
const generalId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const control = controlKey(rootBytes, cid, 0);
const guestbook = guestbookKey(rootBytes, cid, 0);
const now = Date.now();
const relays = DM_RELAYS;
const meta = { ...makeEdition({ vsk: 0, eid: cid, version: 1, content: JSON.stringify({ name, relays }) }, now), pubkey: id.pubkey };
const general = { ...makeEdition({ vsk: 2, eid: generalId, version: 1, content: JSON.stringify({ name: 'general', private: false }) }, now + 1), pubkey: id.pubkey };
const { created_at, ms } = msTags(now + 2);
const join = { kind: 3306, pubkey: id.pubkey, content: 'join', tags: [ms], created_at };
const events = [
await wrapRumor(meta, id.signer, control, { plaintext: true }),
await wrapRumor(general, id.signer, control, { plaintext: true }),
await wrapRumor(join, id.signer, guestbook),
];
for (const e of events) await publishOn(relays, e);
const jm = {
community_id: cid, owner: id.pubkey, owner_salt: ownerSalt,
community_root: bytesToHex(rootBytes), root_epoch: 0,
channels: [{ id: generalId, name: 'general' }], relays, name,
added_at: Date.now(),
};
const s = st();
s.communities.push(jm);
(s.joined[cid] ||= {})[id.pubkey] = true;
save(s);
publishLists();
registerPush().catch(() => {});
ensureRoom(jm);
ui.msgView = 'room';
ui.msgCommunity = cid;
ui.msgChannel = null;
ui.msgNewName = '';
ui.msgHomePanel = null;
render();
}
// A new channel is one owner-signed ChannelMetadata edition (CORD-03 §2);
// the control-plane fold picks it up and every member's switcher grows.
async function createChannel(room, name) {
name = (name || '').trim().slice(0, 64);
if (!name) return;
const id = await identity();
if (!id || id.pubkey !== room.jm.owner) { toast(t('msgOwnerOnly')); return; }
const chId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const edition = {
...makeEdition({ vsk: 2, eid: chId, version: 1, content: JSON.stringify({ name: name.replace(/^#/, ''), private: false }) }, Date.now()),
pubkey: id.pubkey,
};
const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true });
const ok = await publishOn(room.relays, wrap);
if (!ok) { toast(t('msgSendFailed')); return; }
ui.msgNewChannel = '';
ui.msgChannelPanel = false; // the field has done its job — put it away
toast('#' + name.replace(/^#/, ''));
}
// ---- invites ------------------------------------------------------------
function bundleFor(room, creatorPk) {
return {
community_id: room.jm.community_id, owner: room.jm.owner, owner_salt: room.jm.owner_salt,
community_root: room.jm.community_root, root_epoch: room.jm.root_epoch || 0,
channels: roomChannels(room).map((c) => ({ id: c.id, name: c.name })),
relays: room.relays, name: (room.folded?.metadata?.name) || room.jm.name,
creator_npub: creatorPk,
};
}
async function mintInviteLink(room) {
const id = await identity();
if (!id) { noIdToast(); return null; }
const s = st();
const existing = s.invites[room.jm.community_id];
if (existing) return existing.url;
const linkSk = generateSecretKey();
const token = crypto.getRandomValues(new Uint8Array(16));
const evt = makeInviteBundleEvent(linkSk, bundleFor(room, id.pubkey), token);
const ok = await publishOn(room.relays, evt);
if (!ok) { toast(t('msgSendFailed')); return null; }
const url = makeInviteLink(APP_BASE, getPublicKey(linkSk), room.relays, token);
s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url, created_at: Math.floor(Date.now() / 1000) };
save(s);
publishLists();
return url;
}
async function sendDirectInvite(room, input) {
const peer = parseNostrPubkey(input);
if (!peer) { toast(t('msgBadNpub')); return; }
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
const rumor = {
kind: 3313, pubkey: id.pubkey,
content: JSON.stringify(bundleFor(room, id.pubkey)),
tags: [], created_at: Math.floor(Date.now() / 1000),
};
const wrap = await wrapDM(id.signer, peer, rumor, [['k', '3313']]);
const inbox = (await fetchInboxRelays(peer)).slice(0, 4);
const ok = await publishOn([...new Set([...inbox, ...DM_RELAYS])], wrap);
toast(ok ? t('msgInviteSent') : t('msgSendFailed'));
}
function acceptBundle(b, { invitedBy, inviteLabel } = {}) {
const s = st();
if (!communityById(b.community_id)) {
const jm = {
community_id: b.community_id, owner: b.owner, owner_salt: b.owner_salt,
community_root: b.community_root, root_epoch: b.root_epoch || 0,
channels: (b.channels || []).map((c) => ({ id: c.id, name: c.name })),
relays: (b.relays || []).slice(0, 5), name: b.name || 'community',
invitedBy, inviteLabel, added_at: Date.now(),
};
s.communities.push(jm);
save(s);
publishLists();
}
const room = ensureRoom(communityById(b.community_id));
identity().then((id) => id && ensureJoined(room, id)).catch(() => {});
registerPush().catch(() => {});
ui.msgView = 'room';
ui.msgCommunity = b.community_id;
ui.msgChannel = null;
ui.msgHomePanel = null;
pendingLink = null;
render();
}
// A link invite being previewed (pasted or arrived via /invite/#…).
let pendingLink = null; // { parsed, state: 'loading'|'ready'|'error', bundle?, error? }
async function loadLinkInvite(parsed) {
pendingLink = { parsed, state: 'loading' };
scheduleRepaint();
const relays = [...new Set([...(parsed.relays || []), ...DM_RELAYS])];
const evts = await queryOn(relays, { kinds: [33301], authors: [parsed.signerPk] }, 4000);
const newest = evts.sort((a, b) => b.created_at - a.created_at)[0];
const b = newest && openInviteBundle(newest, parsed.token);
if (!b) pendingLink = { parsed, state: 'error', error: t('msgInviteNotFound') };
else if (b.revoked) pendingLink = { parsed, state: 'error', error: t('msgInviteRevoked') };
else if (b.expired) pendingLink = { parsed, state: 'error', error: t('msgInviteExpired') };
else pendingLink = { parsed, state: 'ready', bundle: b };
scheduleRepaint();
}
function joinFromText(text) {
const parsed = parseInviteLink(text);
if (!parsed) { toast(t('msgBadInvite')); return; }
ui.msgJoinText = '';
ui.msgHomePanel = null;
loadLinkInvite(parsed);
}
// An invite link opened in the browser lands here before the wallet exists.
const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null;
if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} }
// coinos.io/ parity: a claimed name (or a raw npub / hex key) as
// the whole URL path deep-links to that profile. Real files never reach the
// app — the server tries them first — and gift/invite links carry their own
// multi-segment paths, which this single-segment shape can't match. The
// path is consumed immediately so a reload lands on the wallet as usual.
const urlProfile = (() => {
if (typeof location === 'undefined' || urlInvite) return null;
const m = location.pathname.match(/^\/([A-Za-z0-9._-]{1,64})\/?$/);
// reserved app routes are never usernames — /chat is the public
// community page (app.js routes it), and eating it here rewrote the URL
// to / before that route ever saw it
if (m && ['chat'].includes(m[1])) return null;
return m ? m[1] : null;
})();
if (urlProfile) { try { history.replaceState(null, '', '/'); } catch {} }
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
// the unlock screen (ui.pubProf gates that surface) — nostr profiles are
// public data, and Back lands on the app's own front door.
if (urlProfile) {
// Claim the first paint synchronously: these are set before the boot
// render, so the visitor sees a profile shell from the first frame —
// never a flash of the front door while the registrar lookup runs.
ui.pubProf = true;
ui.pubProfPending = urlProfile;
(async () => {
await new Promise((r) => setTimeout(r, 0)); // never render mid-boot
let pk = parseNostrPubkey(urlProfile);
if (!pk && /^[a-z0-9._-]{1,30}$/i.test(urlProfile)) {
// same registrar + domain rule the names feature uses
const domain = getNetwork() === 'mutinynet' ? 'staging.coinos.io' : 'coinos.io';
const name = urlProfile.toLowerCase();
try {
const j = await fetch(`https://names.coinos.io/.well-known/nostr.json?name=${encodeURIComponent(name)}&domain=${domain}`)
.then((r) => r.json());
pk = (j.names || {})[name] || null;
} catch {}
// The username is real display material: seed the light cache (stale,
// t:0, so the kind 0 still gets fetched) and the page paints the name
// immediately instead of an npub prefix. `loading` keeps the avatar a
// quiet circle meanwhile — punk art must mean "has no picture", never
// "picture not here yet", or the wrong face flashes before the real
// one loads.
if (pk && /^[0-9a-f]{64}$/.test(pk)) {
warmProfiles();
if (!profiles.has(pk)) profiles.set(pk, { name, addr: `${name}@${domain}`, t: 0, loading: true });
}
}
if (pk && /^[0-9a-f]{64}$/.test(pk)) {
// hold the pinned nav key across this render: the profile replaces
// the shell in place, with no page-slide re-animation
ui.pubProfHold = true;
ui.pubProfPending = null;
if (ui.screen === 'wallet') ui.pubProf = null; // wallet chrome owns it
openProfile(pk);
ui.pubProfHold = null;
} else {
ui.pubProfPending = null;
ui.pubProf = null; // unknown name: fall through to the front door
render();
}
})();
}
// ---- push notifications -------------------------------------------------
// The nwcpush notifier watches relays for what it can see without keys:
// kind-1059 wraps p-tagged at us (DMs, direct invites), zap receipts
// (payments), and wraps authored by our communities' channel keys (chat).
// It pushes a typed nudge; the service worker shows a generic notification
// unless a window is visible. Payment pushes also arrive server-to-server
// from the names registrar when a lightning-address receive settles.
const b64ToBytes = (b64) => {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
};
// What the notifier is allowed to wake us for. Communities are opt-in and
// off by default: a busy room would otherwise buzz a phone all day for
// conversations that aren't addressed to anyone in particular.
function pushWatch() {
const s = st();
const on = s.notify;
const authors = [];
for (const jm of communities()) {
if (!on[jm.community_id]) continue;
const root = hexToBytes(jm.community_root);
for (const c of (jm.channels || []).slice(0, 8)) authors.push(channelKey(root, c.id, EPOCH).pk);
}
// per-category opt-outs travel with the registration so the notifier
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const reasons = { payment: s.reasons?.payment !== false, dm: s.reasons?.dm !== false };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
// staging. The notifier now delivers a payment only to registrations on
// the payment's own network.
return { ptags: myPubkeys(), authors, reasons, net: getNetwork() };
}
const roomNotify = (cid) => !!st().notify[cid];
async function toggleRoomNotify(cid) {
const s = st();
if (s.notify[cid]) delete s.notify[cid]; else s.notify[cid] = true;
save(s);
render();
// The server keeps the watch list, so the change only lands once we
// re-register. Asking for permission is fair here — they just opted in.
const ok = await registerPush({ interactive: !!s.notify[cid] });
if (!ok && s.notify[cid]) {
const s2 = st();
delete s2.notify[cid];
save(s2);
toast(t('msgPushFailed'));
render();
}
}
// Settings → Notifications: the master switch for this device plus the
// per-category choices the registration carries to the notifier.
function notifyCard() {
const supported = typeof Notification !== 'undefined'
&& typeof navigator !== 'undefined' && 'serviceWorker' in navigator
&& typeof window !== 'undefined' && 'PushManager' in window;
const perm = supported ? Notification.permission : 'unsupported';
const s = st();
const on = (k) => s.reasons?.[k] !== false;
const flip = async (k) => {
const s2 = st();
s2.reasons = { ...(s2.reasons || {}), [k]: !on(k) };
save(s2); render();
const ok = await registerPush({ interactive: true });
if (!ok) toast(t('msgPushFailed'));
};
const rowT = (label, k) => h('div', { class: 'row between' },
h('span', { class: 'small' }, label),
h('button', { class: 'btn-sm', onClick: () => flip(k) }, on(k) ? t('nwcOn') : t('nwcOff')));
return h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('notifTitle')),
!supported
? h('div', { class: 'small faint' }, t('nwcNoPush'))
: perm === 'denied'
? h('div', { class: 'notice err' }, t('nwcNotifBlocked'))
: perm !== 'granted' || !s.push
? h('button', { class: 'btn-primary btn-block', onClick: async () => {
const ok = await registerPush({ interactive: true });
toast(ok ? t('notifEnabled') : t('msgPushFailed'));
render();
} }, t('notifEnable'))
: h('div', { class: 'small faint' }, t('notifOnDevice')),
rowT(t('notifPayRecv'), 'payment'),
rowT(t('notifDm'), 'dm'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
// Hand the service worker what it needs to tell a friend's DM from a
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
let inboxAt = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
if (!id) return;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
const follows = newest
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
if (p && p.name) names[pk] = p.name;
}
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
follows, known, names, hasList: !!newest, updated: Date.now(),
});
} catch { inboxAt = 0; }
}
async function registerPush({ interactive = false } = {}) {
try {
if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false;
if (Notification.permission !== 'granted') {
if (!interactive) return false;
if ((await Notification.requestPermission()) !== 'granted') return false;
}
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
const wantKey = b64ToBytes(publicKey);
let sub = await reg.pushManager.getSubscription();
// A subscription made against a DIFFERENT VAPID key can never receive a
// push — the notifier's sends fail with a permanent 403 and it keeps
// reusing the dead sub, so the device goes silent forever (this is what
// stranded notifications after the key last rotated). Re-subscribe when
// the existing sub's server key doesn't match the current one.
const keyMatches = (s) => {
try {
const cur = new Uint8Array(s.options.applicationServerKey || []);
return cur.length === wantKey.length && cur.every((b, i) => b === wantKey[i]);
} catch { return false; }
};
if (sub && !keyMatches(sub)) { try { await sub.unsubscribe(); } catch {} sub = null; }
if (!sub) {
sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: wantKey });
}
const r = await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON(), notify: pushWatch() }),
});
if (!r.ok) return false;
const s = st();
if (!s.push) { s.push = true; save(s); }
return true;
} catch {
return false;
}
}
// ---- device sync: Community List (13302) + Invite List (13303) ----------
// Self-encrypted replaceables (CORD-02 §8 / CORD-05 §4) so memberships and
// minted link keys follow the user to any device or client. Published under
// every identity we can encrypt for: the wallet key is the cross-device
// constant (same seed on every device), the login npub makes the list
// readable by other Concord clients serving the same identity.
async function selfCryptors() {
const out = [];
if (wallet.nostr && wallet.nostr.sk && wallet.nostr.ck) out.push({
pk: wallet.nostr.pk,
sign: async (e) => finalizeEvent(e, wallet.nostr.sk),
enc: async (txt) => nip44.encrypt(txt, wallet.nostr.ck),
dec: async (ct) => nip44.decrypt(ct, wallet.nostr.ck),
});
const login = hook('nostrLoginIdentity');
if (login && login.signer && login.signer.encryptSelf) out.push({
pk: login.pubkey,
sign: (e) => login.signer.signEvent(e),
enc: (txt) => login.signer.encryptSelf(txt),
dec: (ct) => login.signer.decryptSelf(ct),
});
return out;
}
// Join material subset (never the icon, never link fields). We only run
// epoch 0 today, so seed and current coincide.
const jmSubset = (jm) => ({
community_id: jm.community_id, owner: jm.owner, owner_salt: jm.owner_salt,
community_root: jm.community_root, root_epoch: jm.root_epoch || 0,
channels: (jm.channels || []).map((c) => ({ id: c.id, name: c.name })),
relays: jm.relays, name: jm.name,
});
const buildCommunityList = (s) => JSON.stringify({
entries: s.communities.slice(0, 50).map((jm) => ({
community_id: jm.community_id, seed: jmSubset(jm), current: jmSubset(jm), added_at: jm.added_at || 0,
})),
tombstones: Object.entries(s.tombstones).map(([community_id, removed_at]) => ({ community_id, removed_at })),
});
const buildInviteList = (s) => JSON.stringify({
entries: Object.entries(s.invites).map(([community_id, inv]) => ({
token: inv.token, signer_sk: inv.sk, community_id, url: inv.url, created_at: inv.created_at || 0,
})),
tombstones: [],
});
function mergeCommunityList(doc) {
const s = st();
let changed = false;
for (const tb of doc.tombstones || []) {
if ((tb.removed_at || 0) > (s.tombstones[tb.community_id] || 0)) {
s.tombstones[tb.community_id] = tb.removed_at;
changed = true;
}
}
for (const e of (doc.entries || []).slice(0, 50)) {
const jm = e.current || e.seed;
if (!jm || jm.community_id !== e.community_id) continue;
if (jm.community_id === COMMUNITY.community_id) continue; // built-in
if ((e.added_at || 0) <= (s.tombstones[e.community_id] || 0)) continue; // tombstone wins
if (s.communities.some((c) => c.community_id === e.community_id)) continue;
if (communityId(jm.owner, jm.owner_salt) !== jm.community_id) continue; // self-certify before adopting keys
if (!/^[0-9a-f]{64}$/.test(jm.community_root || '')) continue;
s.communities.push({ ...jmSubset(jm), added_at: e.added_at || Date.now() });
changed = true;
}
// a tombstone newer than an entry's added_at removes it locally too
const keep = s.communities.filter((c) => (s.tombstones[c.community_id] || 0) <= (c.added_at || 0));
if (keep.length !== s.communities.length) { s.communities = keep; changed = true; }
if (changed) save(s);
return changed;
}
function mergeInviteList(doc) {
const s = st();
let changed = false;
for (const e of doc.entries || []) {
if (!e.token || !e.community_id || !e.signer_sk) continue;
if (!s.invites[e.community_id]) {
s.invites[e.community_id] = { sk: e.signer_sk, token: e.token, url: e.url, created_at: e.created_at };
changed = true;
}
}
if (changed) save(s);
return changed;
}
async function publishLists() {
const ids = await selfCryptors();
const s = st();
const docs = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
for (const id of ids)
for (const [kind, doc] of docs) {
try {
const evt = await id.sign({
kind, content: await id.enc(doc), tags: [], created_at: Math.floor(Date.now() / 1000),
});
publishOn(DM_RELAYS, evt);
} catch {}
}
}
let listsSynced = false;
async function syncLists() {
if (listsSynced) return;
const ids = await selfCryptors();
if (!ids.length) return;
listsSynced = true;
const evs = await queryOn(DM_RELAYS, { kinds: [13302, 13303], authors: ids.map((i) => i.pk) }, 3500);
let changed = false;
const remoteDocs = new Set();
for (const kind of [13302, 13303])
for (const id of ids) {
const newest = evs.filter((e) => e.kind === kind && e.pubkey === id.pk).sort((a, b) => b.created_at - a.created_at)[0];
if (!newest) continue;
try {
const raw = await id.dec(newest.content);
remoteDocs.add(kind + ':' + raw);
const doc = JSON.parse(raw);
if (kind === 13302) changed = mergeCommunityList(doc) || changed;
else changed = mergeInviteList(doc) || changed;
} catch {}
}
if (changed) {
// cache-only here too — subscription waits for the user to open Chat
for (const jm of communities()) ensureRoom(jm, { subscribe: ui.chatOpen });
scheduleRepaint();
}
// republish when any identity's copy is missing or stale
const s = st();
const current = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
const anyMissing = ids.length * 2 > remoteDocs.size
|| current.some(([kind, doc]) => !remoteDocs.has(kind + ':' + doc));
if (anyMissing && (s.communities.length || Object.keys(s.invites).length || Object.keys(s.tombstones).length))
publishLists();
}
async function leaveCommunity(room) {
const cid = room.jm.community_id;
const id = await identity();
const s = st();
s.tombstones[cid] = Date.now();
s.communities = s.communities.filter((c) => c.community_id !== cid);
delete s.joined[cid];
save(s);
if (id) {
const { created_at, ms } = msTags(Date.now());
wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'leave', tags: [ms], created_at }, id.signer, room.guestbook)
.then((w) => publishOn(room.relays, w)).catch(() => {});
}
rooms.delete(cid);
publishLists();
ui.msgView = 'home';
ui.msgLeaveArm = false;
ui.msgInvitePanel = false;
render();
}
// ---- DMs ----------------------------------------------------------------
const threadOf = (peer) => threads.get(peer) || threads.set(peer, new Map()).get(peer);
function noteDM(peer, rumor, mine) {
if (!peer || !rumor.id) return;
threadOf(peer).set(rumor.id, { rumor, mine });
bumpMsgRev();
scheduleRepaint();
}
// Wraps nothing could open are NOT consumed. The usual reason is the
// remote signer losing the race with the relay backfill on a fresh load
// (the login signer resumes lazily, the stored wraps arrive first), or a
// single bunker round-trip failing — and dropping the wrap there was how
// whole stretches of DM history quietly went missing, differently on every
// device. Keep them and retry as the decryptor set improves; a wrap that
// never opens (a stranger's wrap that merely p-tags us) falls out after a
// few full-strength passes.
const pendingWraps = new Map(); // wrap.id -> { wrap, tries }
const PENDING_MAX = 800;
// Full-strength attempts before giving a wrap up. Kept low on purpose: a
// bunker signer pays two relay round-trips per attempt, and a wrap that a
// complete decryptor set failed to open twice is essentially never ours.
const PENDING_TRIES = 3;
let drainTimer = 0;
let draining = false;
function dmDecryptors() {
const out = [];
if (wallet.nostr && wallet.nostr.sk) out.push(wallet.nostr.sk);
const login = hook('nostrLoginIdentity');
if (login && login.signer && login.signer.decryptFrom) out.push(login.signer);
return out;
}
// A "full-strength" attempt has every key this wallet expects: retries
// only count against a wrap once the login signer (if one is linked) has
// actually had its chance — the wallet key alone can never open a wrap
// sealed to the login npub.
function decryptorsComplete() {
const login = hook('nostrLoginIdentity');
return !login || !!(login.signer && login.signer.decryptFrom);
}
// Raw-key unwraps (seed-derived / pasted nsec — the common case) run in
// the crypto worker: two ECDH ops + a seal verify per wrap is the boot
// secp256k1 that used to run here. Remote signers can't export their key,
// so their decryptFrom stays on the main thread, exactly as before.
async function unwrapDMAny(wrap, d) {
if (d instanceof Uint8Array) {
const job = unwrapDMsOffthread([wrap], d);
if (job) {
const r = await job;
if (r) return r[0]; // null here means "not ours", not "worker failed"
}
}
return unwrapDM(wrap, d).catch(() => null);
}
async function openInboxWrap(wrap) {
for (const d of dmDecryptors()) {
const got = await unwrapDMAny(wrap, d);
if (!got) continue;
if (got.rumor.kind === 14) {
// unwrapDM judges "mine" against the key that DECRYPTED, but this
// wallet can hold several identities (wallet key + nostr login). A
// sent-copy authored by any of them must thread under the RECIPIENT,
// or every welcome DM the registrar sends lands in a self-thread.
const mine = isMe(got.author);
const to = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
const peer = mine ? (to || got.peer || got.author) : got.author;
noteDM(peer, got.rumor, mine);
persistDms();
} else if (got.rumor.kind === 7) {
// a DM reaction (ours echoed back, or the peer's — 0xchat's shape)
const target = got.rumor.tags?.find((x) => x[0] === 'e')?.[1];
if (target) {
(dmReacts.get(target) || dmReacts.set(target, new Map()).get(target)).set(got.author, got.rumor.content);
scheduleRepaint();
}
} else if (got.rumor.kind === 3313 && !isMe(got.author)) {
try {
const b = openDirectBundle(got.rumor.content);
if (b && !st().declined[got.rumor.id] && !communityById(b.community_id))
pendingDirect.set(got.rumor.id, { bundle: b, from: got.author, rid: got.rumor.id });
scheduleRepaint();
} catch {}
}
return true;
}
return false;
}
async function handleInboxWrap(wrap) {
if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) return;
seenWraps.add(wrap.id);
if (await openInboxWrap(wrap)) { rememberWrap(wrap.id); return; }
if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value);
pendingWraps.set(wrap.id, { wrap, tries: decryptorsComplete() ? 1 : 0 });
scheduleDrain(8000);
}
function scheduleDrain(ms) {
if (!pendingWraps.size) return;
clearTimeout(drainTimer);
drainTimer = setTimeout(() => { drainPendingWraps().catch(() => {}); }, ms);
}
async function drainPendingWraps() {
if (draining || !pendingWraps.size) return;
draining = true;
try {
// Wraps sealed to the login npub can't open without its signer. Wake it
// up ourselves — the boot-time resume is a single attempt, and if it
// loses to cold-start churn nothing else asks until the user happens to
// touch a screen that calls identity(). Resume single-flights and
// rate-limits itself, so asking here is cheap.
if (!decryptorsComplete()) await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
if (!dmDecryptors().length) return;
const complete = decryptorsComplete();
// Each openInboxWrap decrypts a gift wrap — NIP-44 ECDH, real
// secp256k1 work. A backlog decrypted in one tight loop blocks the main
// thread for the whole burst (the boot-time jank that made a carousel
// drag lurch). Cap each synchronous run to a frame's budget and yield so
// input and paint get a turn; the backfill just takes a few extra frames.
let chunkStart = performance.now();
for (const [id, p] of [...pendingWraps]) {
// A remote signer can die mid-pass; counting the remaining wraps as
// full-strength failures would evict messages that were never really
// tried. Stop here — the slow retry picks the rest up.
if (complete && !decryptorsComplete()) break;
if (await openInboxWrap(p.wrap)) { pendingWraps.delete(id); rememberWrap(id); }
else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); }
if (performance.now() - chunkStart > 8) {
await new Promise((r) => setTimeout(r));
chunkStart = performance.now();
}
}
} finally { draining = false; }
// still holding wraps: the signer may connect later — keep a slow retry
if (pendingWraps.size) scheduleDrain(decryptorsComplete() ? 30_000 : 60_000);
}
function openDirectBundle(json) {
const b = JSON.parse(json);
if (communityId(b.owner, b.owner_salt) !== b.community_id) return null;
if (!/^[0-9a-f]{64}$/.test(b.community_root || '')) return null;
if (!Array.isArray(b.channels) || b.channels.length > 256) return null;
if (b.expires_at && Date.now() > b.expires_at) return null;
return b;
}
function startDMs() {
if (dmStarted) return;
// Poll/advertise only the single DM identity (see dmSubKeys) — not the seed
// key as a phantom second inbox.
const pks = dmSubKeys();
if (!pks.length) return;
dmStarted = true;
loadSeenWraps();
// warm threads from the local cache. A self-thread of our own messages is
// the residue of the old sent-copy misthreading (each welcome DM landed
// under our own key) — sweep it rather than resurrect it; the cached
// rows carry no tags, so they cannot be re-threaded to their recipients.
const s = st();
let swept = false;
for (const [peer, list] of Object.entries(s.dms)) {
if (isMe(peer) && list.every((m) => isMe(m.from))) {
delete s.dms[peer];
swept = true;
continue;
}
for (const m of list)
threadOf(peer).set(m.id, { rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14 }, mine: isMe(m.from) });
}
bumpMsgRev();
if (swept) save(s);
allUnsubs.push(subscribeOn(DM_RELAYS, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
handleInboxWrap(wrap).catch(() => {});
}));
// Senders deliver to the relays our kind-10050 advertises — a list other
// clients may have published with relays beyond our defaults. Read those
// too, or a compliant sender's DM lands somewhere we never look (a reply
// went only to damus/primal and no device ever showed it).
(async () => {
try {
const lists = await Promise.all(pks.map((pk) => fetchInboxRelays(pk).catch(() => [])));
const extras = [...new Set(lists.flat().map((r) => String(r || '').trim().replace(/\/$/, '')))]
.filter((r) => /^wss:\/\//i.test(r) && !DM_RELAYS.includes(r))
.slice(0, 3);
if (extras.length && dmStarted)
allUnsubs.push(subscribeOn(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
handleInboxWrap(wrap).catch(() => {});
}));
} catch {}
})();
ensureDmRelayList().catch(() => {});
}
// Publish a kind 10050 DM-relay list for the wallet key if none exists, so
// other NIP-17 clients can find our inbox. Never touch a login npub's list
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
if (existing.length) return;
const evt = finalizeEvent({
kind: 10050,
content: '',
tags: DM_RELAYS.map((r) => ['relay', r]),
created_at: Math.floor(Date.now() / 1000),
}, wallet.nostr.sk);
publishOn(DM_RELAYS, evt);
}
async function sendDM(peer) {
const text = draftFor('dm:' + peer).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
// Same optimistic shape as channel sends: the rumor is synchronous and on
// screen at once. Wrapping the same rumor keeps the id, so the sent-copy
// echo folds into this entry instead of duplicating it.
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
const rumor = makeDMRumor(id.pubkey, peer, text, replyTo ? [['e', replyTo]] : []);
const entry = { rumor, mine: true, pending: true };
threadOf(peer).set(rumor.id, entry);
clearDraft('dm:' + peer);
ui.msgStick = true;
render();
try {
// Sequential on purpose — a remote signer is happier signing one at a time.
const toPeer = await wrapDM(id.signer, peer, rumor);
const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
// Our own relays take the wrap immediately; the peer's declared inbox
// (a lookup that can take seconds) is chased off the critical path.
// Publishing the same wrap twice is harmless — relays dedupe on id.
const ok = await publishOn(DM_RELAYS, toPeer);
publishOn(DM_RELAYS, toSelf);
fetchInboxRelays(peer).then((inbox) => {
const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r));
if (extra.length) publishOn(extra, toPeer);
}).catch(() => {});
if (!ok) { toast(t('msgSendFailed')); return; }
delete entry.pending;
persistDms();
} catch (e) {
threadOf(peer).delete(rumor.id);
toast(e.message || String(e));
render();
}
}
// React to a DM: the same kind-7-in-a-gift-wrap shape 0xchat uses, sent to
// the peer and to our own inbox (the echo is what other devices fold in).
// Optimistic like sendDM; one reaction per author, latest wins.
async function sendDmReaction(peer, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
ui.msgSheet = null;
const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji);
const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
r.set(id.pubkey, emoji);
render();
try {
const toPeer = await wrapDM(id.signer, peer, rumor);
const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
seenWraps.add(toPeer.id); seenWraps.add(toSelf.id);
const ok = await publishOn(DM_RELAYS, toPeer);
publishOn(DM_RELAYS, toSelf);
fetchInboxRelays(peer).then((inbox) => {
const extra = inbox.slice(0, 4).filter((x) => !DM_RELAYS.includes(x));
if (extra.length) publishOn(extra, toPeer);
}).catch(() => {});
if (!ok) toast(t('msgSendFailed'));
} catch (e) {
if (prev) r.set(id.pubkey, prev); else r.delete(id.pubkey);
toast(e.message || String(e));
render();
}
}
function persistDms() {
const s = st();
const byRecent = [...threads.entries()]
.map(([peer, m]) => [peer, [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)])
.sort((a, b) => (b[1].at(-1)?.rumor.created_at || 0) - (a[1].at(-1)?.rumor.created_at || 0))
.slice(0, 30);
s.dms = {};
for (const [peer, list] of byRecent)
s.dms[peer] = list.filter((m) => !m.pending).slice(-CACHE_MAX)
.map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }));
save(s);
}
// ---- views --------------------------------------------------------------
const timeLabel = (tms) => {
const d = new Date(tms);
const today = new Date().toDateString() === d.toDateString();
return today
? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
// Which bytes a face paints from, in order of preference:
// our own punk art, straight off disk at the size being drawn;
// the thumbnail we keep locally, which needs no network at all;
// the original, when we have nothing else yet.
// The 64px profile avatar layers the original OVER the local copy: CSS
// paints the first layer that has arrived, so the face is there instantly
// and sharpens when the full-size one lands.
function avatarBg(p, big) {
const punk = localPunk(p.picture, big);
if (punk) return `url(${JSON.stringify(punk)})`;
const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null;
if (!local) return `url(${JSON.stringify(p.picture)})`;
return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
}
const avatar = (pk, cls = 'chat-avatar', clickable = true) => {
const p = profileOf(pk);
// Someone we've never cached used to get an empty circle until a relay
// answered — seconds of blankness on every boot, and for anyone whose
// kind 0 carries no picture (or none at all, which never gets cached)
// that repeated forever. The punk is derived from the pubkey alone, so
// it can be drawn in the first frame: it's already the right answer for
// everyone without a picture, and for a coinos user who kept the default
// it IS their published picture. A real photo replaces it when it lands.
//
// `loading` is different and still gets the quiet circle: a name lookup
// is in flight for that specific person, so a picture is expected and
// punk art must not flash in front of it.
const node = p && p.loading && !p.picture
? h('div', { class: cls + ' fallback loading' })
: p === null
? fallbackAvatar(h, pk, null, cls)
: p.picture
// A background paints synchronously from cache; a fresh decodes
// async, so recreating one per render made avatars visibly flash.
//
// The thumbnail we keep locally is the whole picture at this size, so
// a 30px circle never asks the network for the original again. The
// 64px profile avatar layers them: CSS paints the first layer that
// has arrived, so the thumbnail shows instantly and the full-size
// original takes over on top when it lands.
? h('div', { class: cls + ' ava-img', style: 'background-image:' + avatarBg(p, cls.includes('profile-avatar')) })
: fallbackAvatar(h, pk, p.name, cls);
// A face we keep painting is one worth keeping a thumbnail of.
if (p && p.picture) makeThumb(pk, p);
if (clickable) {
node.classList.add('clickable');
node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); });
}
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
// The hats feature is a deferred chunk — until it lands, replay last
// session's boot stamps (hat art + geometry it persisted) so a hat sits on
// the head from the FIRST frame instead of popping in a beat later. Once
// hatsReady answers, the stamps stand down: a null from wrapAvatar then
// genuinely means "bare head".
let _hatStamps = null;
function hatStamp(pk, node) {
if (hook('hatsReady')) return null;
if (_hatStamps === null) {
try { _hatStamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { _hatStamps = {}; }
}
const s = _hatStamps[pk];
if (!s || !s.a) return null;
return h('span', { class: 'hat-wrap' }, node, h('span', { class: 'ava-hat', style: s.s, html: s.a }));
}
// ---- profiles: view + own kind-0 editor ---------------------------------
const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at }
const fullFetched = new Set(); // fetched from relays this session
// The profile-page cache SURVIVES refreshes: bios and recent posts serve
// instantly from storage while a background fetch freshens them. Bounded:
// the last handful of viewed/warmed pages, notes trimmed to what the rows
// render.
const PROF_PAGE_CACHE = 'profPages';
const pageCache = () => {
try {
const c = wallet.loadFeatureState(PROF_PAGE_CACHE, {}) || {};
return { full: c.full || {}, notes: c.notes || {} };
} catch { return { full: {}, notes: {} }; }
};
function persistPage(kind, pk, value) {
try {
const c = pageCache();
c[kind][pk] = { t: Date.now(), v: value };
for (const k of ['full', 'notes']) {
const keys = Object.keys(c[k]).sort((a, b) => c[k][b].t - c[k][a].t);
for (const drop of keys.slice(10)) delete c[k][drop];
}
wallet.saveFeatureState(PROF_PAGE_CACHE, c);
} catch {}
}
const slimNote = (ev) => ({
id: ev.id, pubkey: ev.pubkey, kind: 1, created_at: ev.created_at,
content: String(ev.content || '').slice(0, 3000),
tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'),
});
function fetchFullProfile(pk) {
if (!fullProfiles.has(pk)) {
const cached = pageCache().full[pk];
if (cached) fullProfiles.set(pk, cached.v);
}
if (fullFetched.has(pk)) return;
fullFetched.add(pk);
queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3500).then((evs) => {
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
let m = {};
try { m = newest ? JSON.parse(newest.content) : {}; } catch {}
fullProfiles.set(pk, m);
persistPage('full', pk, m);
// A full fetch is the freshest word on this profile — stamp it into the
// light cache (and persist) so a picture changed elsewhere replaces the
// stale one everywhere within a session, not after the 24h TTL. The
// repaint covers the header/chat avatars, not just an open profile page.
const entry = {
name: m.display_name || m.name || null, picture: m.picture || null,
nip05: m.nip05 || null, lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null,
t: Date.now(),
};
keepThumb(pk, entry);
profiles.set(pk, entry);
persistProfile(pk, entry);
preloadPicture(entry);
if (ui.profilePk === pk) render();
else scheduleRepaint();
}).catch(() => { if (!fullProfiles.has(pk)) fullProfiles.set(pk, {}); });
}
// Warm a profile PAGE (full kind-0 + latest notes + relay list) before
// anyone taps it, so the page opens complete instead of behind spinners.
// Queued with a small concurrency cap: likely tap-targets only, never a
// whole room's member list at once.
const prefetched = new Set();
const prefetchQueue = [];
let prefetching = 0;
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
prefetched.add(pk);
prefetchQueue.push(pk);
drainPrefetch();
}
function drainPrefetch() {
while (prefetching < 2 && prefetchQueue.length) {
const pk = prefetchQueue.shift();
prefetching++;
Promise.allSettled([
Promise.resolve(fetchFullProfile(pk)),
Promise.resolve(notesFor(pk)),
]).finally(() => { prefetching--; drainPrefetch(); });
}
}
function openProfile(pk) {
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
// router otherwise keeps a thread above the profile, which is the order
// for the other direction (a note row tapped on a profile page).
ui.profOverThread = !!ui.noteThread;
ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null;
render();
fetchFullProfile(pk);
notesFor(pk);
}
// ---- profile notes: latest public posts & replies -----------------------
const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
// that e-tag it — NIP-57 kind 9735 (the recipient's LNURL server signs one
// once the invoice is paid) and coinos' own Ark zap receipt (kind 9737,
// the zapper publishes it beside the mailbox delivery). Summed per id,
// deduped by receipt, painted as a little bolt + sats. A chat message is a
// rumor with an id like any event, so a zap aimed at it e-tags that id —
// the receipt reveals nothing but the id itself.
const ZAP_KINDS = [9735, 9737];
const zapTotals = new Map(); // id -> { sats, seen: Set, mine }
const zapAsked = new Set();
let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = [];
const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])];
const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
function receiptSats(ev) {
if (ev.kind === 9737) {
const net = tagOf(ev, 'network');
if (net && net !== getNetwork()) return 0;
return Math.max(0, parseInt(tagOf(ev, 'amount'), 10) || 0);
}
const b11 = tagOf(ev, 'bolt11');
if (b11) { try { const d = decodeBolt11(b11); if (d && d.amountSat) return d.amountSat; } catch {} }
// no readable invoice: the zap request inside says what was asked for
try {
const req = JSON.parse(tagOf(ev, 'description') || 'null');
return Math.floor((parseInt(tagOf(req, 'amount'), 10) || 0) / 1000);
} catch { return 0; }
}
function zapperOf(ev) {
if (ev.kind === 9737) return ev.pubkey;
if (tagOf(ev, 'P')) return tagOf(ev, 'P');
try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; }
}
function noteReceipt(ev) {
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
const from = zapperOf(ev);
let changed = false;
for (const x of ev.tags) {
if (x[0] !== 'e' || !x[1]) continue;
const cur = zapTotals.get(x[1]) || zapTotals.set(x[1], { sats: 0, seen: new Set(), mine: false }).get(x[1]);
if (cur.seen.has(ev.id)) continue;
cur.seen.add(ev.id);
cur.sats += sats;
// our own zap's receipt: the real thing has landed, so the optimistic
// amount the chip has been carrying since the tap steps aside
if (from && my.includes(from)) { cur.mine = true; voidPending(x[1], ev.created_at * 1000); }
changed = true;
}
if (changed) { saveZapTotals(); scheduleRepaint(); }
}
// Ask once per id (batched per paint), and keep ONE live subscription on
// the ids most recently on screen, so a zap landing while you watch —
// yours included — shows up without a reload.
function watchZaps(ids) {
let fresh = false;
for (const id of ids) if (id && !zapAsked.has(id)) { zapAsked.add(id); zapQueue.add(id); fresh = true; }
if (!fresh || zapTimer) return;
zapTimer = setTimeout(() => {
zapTimer = null;
const batch = [...zapQueue]; zapQueue = new Set();
const relays = zapRelays();
for (let i = 0; i < batch.length; i += 150) {
const slice = batch.slice(i, i + 150);
queryOn(relays, { kinds: ZAP_KINDS, '#e': slice }, 4000)
.then((evs) => {
evs.forEach(noteReceipt);
// the relays have spoken for these ids: their live tally is the
// truth now, remembered or not (a zap that was deleted has to be
// able to disappear)
for (const id of slice) zapSeeds().delete(id);
saveZapTotals();
scheduleRepaint();
}).catch(() => {});
}
zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200);
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
zapLiveUnsub = subscribeOn(relays, { kinds: ZAP_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteReceipt);
}, 250);
}
// Tallies are asked of the relays again on every boot, and that takes
// seconds — long enough that a room full of zapped messages paints bare and
// fills in afterwards. So the totals themselves are remembered (just the
// numbers, never the receipts) and painted straight away; the moment the
// relays answer for a message, their word replaces it.
const ZAP_REMEMBER = 400; // tallies kept between sessions
let zapSeed = null;
function zapSeeds() {
if (!zapSeed) {
zapSeed = new Map();
try {
for (const [id, v] of Object.entries(st().zaps || {})) if (v && v.s) zapSeed.set(id, { sats: v.s, mine: !!v.m });
} catch {}
}
return zapSeed;
}
let zapSaveT = null;
function saveZapTotals() {
clearTimeout(zapSaveT);
zapSaveT = setTimeout(() => {
try {
const s2 = st();
// Update what's recently been on screen, and KEEP the rest: opening a
// DM thread must not forget what the community chat had learned.
const out = { ...(s2.zaps || {}) };
for (const id of zapRecent) {
const z = zapTotals.get(id) || zapSeeds().get(id);
if (z && z.sats) out[id] = z.mine ? { s: z.sats, m: 1 } : { s: z.sats };
else delete out[id]; // the relays answered, and it's zero
}
let n = Object.keys(out).length;
if (n > ZAP_REMEMBER) {
const recent = new Set(zapRecent);
for (const k of Object.keys(out)) { // insertion order: oldest first
if (n <= ZAP_REMEMBER) break;
if (!recent.has(k)) { delete out[k]; n--; }
}
}
s2.zaps = out;
save(s2);
} catch {}
}, 1500);
}
// After OUR zap the receipt trails the payment by seconds — ask again.
function recheckZap(id) {
for (const ms of [4000, 12000, 30000]) setTimeout(() => { zapAsked.delete(id); watchZaps([id]); }, ms);
}
const fmtSats = (n) => n < 1000 ? String(n)
: n < 10000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
: n < 1e6 ? Math.round(n / 1000) + 'k'
: (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
const BOLT_SVG = '';
// A tapped zap takes a few seconds to pay, and its public receipt trails
// the payment by seconds more — long enough that the tap looked ignored.
// So the chip appears the instant you tap, carrying your amount and
// pulsing while the payment is in flight; it goes solid when the payment
// lands and hands over to the real receipt when that arrives. A failure
// takes it straight back off.
const ZAP_FLIGHT_MS = 45_000; // nothing reported back: assume it's gone
const ZAP_PAID_MS = 5 * 60_000; // paid, but the receipt never showed
// id -> { sats, at, state }, where state is:
// flying — tapped, payment in the air: counted on top, chip pulses
// paid — the flow reported success: counted on top, chip solid
// void — one of OUR receipts has been counted into the real total for
// this id, so the optimistic amount must not be added again.
// Kept rather than deleted, because the receipt usually beats
// the flow's own report: an ark zap publishes that receipt
// itself, and it returns over the live subscription before the
// send call resolves. Deleting would let the later settle
// resurrect the amount — which is how a 21-sat zap displayed 42.
const zapPending = new Map();
const pendTtl = (p) => (p.state === 'flying' ? ZAP_FLIGHT_MS : ZAP_PAID_MS);
function pendingOf(id) {
const p = zapPending.get(id);
if (!p) return null;
if (Date.now() - p.at > pendTtl(p)) { zapPending.delete(id); return null; }
return p;
}
function markZapPending(id, sats) {
if (!id || !sats) return;
zapPending.set(id, { sats, at: Date.now(), state: 'flying' });
scheduleRepaint();
// repaint when the in-flight chip would expire, so a zap nobody ever
// reported on doesn't pulse forever
setTimeout(scheduleRepaint, ZAP_FLIGHT_MS + 200);
}
// A zap flow reporting back: paid (keep the amount, stop pulsing) or
// failed (the chip was never real — take it off).
function settleZap(id, ok, sats) {
if (!id) return;
const p = pendingOf(id);
if (!ok) {
if (p && p.state !== 'void') { zapPending.delete(id); scheduleRepaint(); }
return;
}
if (p && p.state === 'void') return; // the receipt is already carrying it
zapPending.set(id, { sats: sats || (p && p.sats) || 0, at: Date.now(), state: 'paid' });
scheduleRepaint();
}
// One of our own receipts has been counted for this id: the real total
// speaks for the zap now, so the optimistic amount stands down.
function voidPending(id, evMs) {
const p = zapPending.get(id);
// ...unless it's the receipt of an OLDER zap of ours arriving (the first
// paint fetches every receipt a message has). That one is already in the
// total and says nothing about the zap currently in the air.
if (p && p.state !== 'void' && evMs && evMs < p.at - 120_000) return;
zapPending.set(id, { sats: 0, at: Date.now(), state: 'void' });
}
// The chip: a little bolt + the sats total. Absent until the first receipt
// — or until you zap it yourself, which is its own kind of receipt.
function zapChip(id, { onClick, cls = '' } = {}) {
const z = zapTotals.get(id) || zapSeeds().get(id);
const p = pendingOf(id);
const optimistic = p && p.state !== 'void' ? p.sats : 0;
const sats = (z ? z.sats : 0) + optimistic;
if (!sats) return null;
const flying = !!p && p.state === 'flying';
return h('span', {
class: 'zap-tally' + ((z && z.mine) || optimistic ? ' on' : '') + (flying ? ' flying' : '')
+ (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''),
title: flying ? t('zapSending') : t('zapTallyTitle', { n: sats.toLocaleString() }),
onClick: onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined,
}, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(sats));
}
// Zap a chat message or DM: same one-tap flow as a post (ark first,
// Lightning fallback, the amount remembered from the first time).
function zapMessage(pk, id) {
zapNote(pk, { id });
recheckZap(id);
}
const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] }
// NIP-65: where this author actually writes. Their notes and threads live
// there first — our default relays are just the common ground.
const relayListCache = new Map(); // pk -> Promise
function relaysOf(pk) {
let p = relayListCache.get(pk);
if (p) return p;
// the feed's batched fetch has probably already asked for this one
const known = relayListsNow().get(pk);
if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; }
p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500)
.then((evs) => {
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
if (!newest) return [];
return [...new Set(newest.tags
.filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1]))
.map((x) => x[1].replace(/\/$/, '')))].slice(0, 4);
})
.catch(() => []);
relayListCache.set(pk, p);
return p;
}
const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk))])];
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) return c;
// stored posts paint the page instantly; the relay fetch below freshens
const stored = pageCache().notes[pk];
c = stored
? { status: 'ready', notes: stored.v }
: { status: 'loading', notes: [] };
notesCache.set(pk, c);
(async () => {
const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500);
const seen = new Set();
const fresh = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at);
if (fresh.length || !c.notes.length) {
c.notes = fresh;
persistPage('notes', pk, fresh.slice(0, 20).map(slimNote)); // cache stays bounded
}
})().catch(() => {}).finally(() => {
c.status = 'ready';
if (ui.profilePk === pk) render();
});
return c;
}
// Infinite scroll: pull the next page of OLDER notes (until = oldest seen)
// when the profile page nears its bottom. Guarded so scroll spam costs
// nothing: one fetch in flight, and a short answer marks the end.
// Registered at CONSTRUCTION, not init(): the public no-wallet profile
// (a /username deep link over the unlock screen) scrolls too, and init
// only runs once a wallet opens.
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
if (!ui.profilePk && !(ui.chatOpen && ui.msgView === 'feed')) return;
if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
}
async function loadOlderNotes(pk) {
const c = notesCache.get(pk);
if (!c || c.status !== 'ready' || c.loadingMore || c.end) return;
const oldest = c.notes[c.notes.length - 1];
if (!oldest) { c.end = true; return; }
c.loadingMore = true;
render();
try {
const evs = await queryOn(await notesRelays(pk),
{ kinds: [1], authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
const seen = new Set(c.notes.map((e) => e.id));
const older = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at);
if (older.length) c.notes = [...c.notes, ...older];
else c.end = true; // the relays have nothing older
} catch {} finally {
c.loadingMore = false;
if (ui.profilePk === pk) render();
}
}
// ---- follows: the kind-3 contact list -------------------------------------
// Who you follow. Kept locally so the Follow button and the feed are right
// in the first frame, and refreshed from relays behind that. A contact list
// is a shared document — other clients keep relay hints in its content and
// petnames in its tags — so publishing preserves everything we didn't write
// and only ever adds or removes one p tag.
const FOLLOWS = 'follows';
let follows = null; // { set: Set, tags, content, at }
let followsAt = 0; // when we last asked the relays
let followsPub = false; // a publish is in flight
const mePk = () => (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostr && wallet.nostr.pk) || null;
const pTags = (tags) => (tags || []).filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || ''));
function followsNow() {
if (!follows) {
let s = null;
try { s = wallet.loadFeatureState(FOLLOWS, null); } catch {}
const tags = (s && s.tags) || [];
follows = { set: new Set(pTags(tags).map((x) => x[1])), tags, content: (s && s.c) || '', at: (s && s.at) || 0 };
}
return follows;
}
function saveFollows(f) {
follows = f;
try { wallet.saveFeatureState(FOLLOWS, { tags: f.tags, c: f.content, at: f.at }); } catch {}
}
const isFollowing = (pk) => followsNow().set.has(pk);
// Fetch the newest list from the relays. Older than what we hold is
// ignored: a relay that missed our last publish must not un-follow people.
async function syncFollows({ force = false } = {}) {
const me = mePk();
if (!me) return followsNow();
if (!force && Date.now() - followsAt < 10 * 60_000) return followsNow();
followsAt = Date.now();
try {
const evs = await queryOn(zapRelays(), { kinds: [3], authors: [me] }, 5000);
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
const cur = followsNow();
if (newest && newest.created_at > cur.at) {
saveFollows({
set: new Set(pTags(newest.tags).map((x) => x[1])),
tags: newest.tags || [], content: newest.content || '', at: newest.created_at,
});
feedAuthorsChanged();
scheduleRepaint();
}
} catch { followsAt = 0; }
return followsNow();
}
// Follow or unfollow, on the freshest list the relays will give us — the
// local copy alone would quietly drop anyone another client added since.
// The button flips immediately and goes back if the publish fails.
async function toggleFollow(pk) {
if (!pk || followsPub) return;
const id = await requireIdentity();
if (pk === id.pubkey) return;
followsPub = true;
const before = followsNow();
// paint the answer now, decide the real list a beat later
saveFollows({ ...before, set: new Set(before.set.has(pk) ? [...before.set].filter((x) => x !== pk) : [...before.set, pk]) });
render();
try {
const base = await syncFollows({ force: true });
const had = base.set.has(pk);
const tags = had
? base.tags.filter((x) => !(x[0] === 'p' && x[1] === pk))
: [...base.tags, ['p', pk]];
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
const partial = { kind: 3, content: base.content || '', created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
saveFollows({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
feedAuthorsChanged();
syncInbox({ force: true }).catch(() => {}); // the SW's friend filter reads this list
toast(had ? t('feedUnfollowed') : t('feedFollowed'));
} catch (e) {
saveFollows(before);
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
} finally {
followsPub = false;
render();
}
}
// ---- outbox: where each person actually publishes -------------------------
// NIP-65. Asking our own three relays for everyone's posts only ever finds
// the people who happen to use them — the rest are invisible, and it looks
// like they stopped posting. So each author's own WRITE relays are read
// instead. Lists are fetched in batches and kept for a week; the authors are
// then covered greedily, so a feed of hundreds of people is a handful of
// sockets that between them reach everyone rather than one socket each.
const RELAY_LISTS = 'relayLists';
const RELAY_LIST_TTL = 7 * 24 * 3600_000;
const OUTBOX_MAX = 10; // sockets we'll open for one pass
const PER_AUTHOR = 3; // write relays taken from any one list
let relayLists = null; // pk -> { r: [url], t }
function relayListsNow() {
if (!relayLists) {
relayLists = new Map();
try {
for (const [pk, v] of Object.entries(wallet.loadFeatureState(RELAY_LISTS, {}) || {}))
if (v && Date.now() - (v.t || 0) < RELAY_LIST_TTL) relayLists.set(pk, v);
} catch {}
}
return relayLists;
}
function saveRelayLists() {
try {
const out = {};
// the people we follow are the ones worth remembering; a page visit
// shouldn't push them out
const keep = new Set([...followsNow().set]);
let spare = 200 - keep.size;
for (const [pk, v] of relayListsNow()) {
if (keep.has(pk)) out[pk] = v;
else if (spare-- > 0) out[pk] = v;
}
wallet.saveFeatureState(RELAY_LISTS, out);
} catch {}
}
const writeRelaysIn = (ev) => [...new Set((ev.tags || [])
.filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//i.test(x[1]))
.map((x) => String(x[1]).replace(/\/+$/, '')))].slice(0, PER_AUTHOR);
// One REQ per hundred authors, against our own relays — a relay list is
// small, widely mirrored, and worth having before anything else is asked.
// Authors with no list at all are remembered as such, so we don't ask again
// every time the feed refreshes.
async function fetchRelayLists(pks) {
const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk));
if (!want.length) return;
for (let i = 0; i < want.length; i += 100) {
const batch = want.slice(i, i + 100);
const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []);
const newest = new Map();
for (const ev of evs || []) {
const cur = newest.get(ev.pubkey);
if (!cur || ev.created_at > cur.created_at) newest.set(ev.pubkey, ev);
}
for (const pk of batch) {
const ev = newest.get(pk);
relayListsNow().set(pk, { r: ev ? writeRelaysIn(ev) : [], t: Date.now() });
}
}
saveRelayLists();
}
// The fewest relays that between them carry every author: take the relay
// covering the most people not yet covered, repeat.
//
// Our own relays are then asked about EVERYONE as well, not just the people
// with no list. Measured over eight busy accounts, each source holds posts
// the other misses — reading only the author's own relays found 7 to 40
// notes ours had never seen, and ours held plenty theirs didn't. The feed
// wants the union, so the outbox adds reach rather than replacing it.
function outboxPlan(authors) {
const lists = relayListsNow();
const byRelay = new Map();
const orphans = [];
for (const pk of authors) {
const r = (lists.get(pk) || {}).r || [];
if (!r.length) { orphans.push(pk); continue; }
for (const url of r) {
if (!byRelay.has(url)) byRelay.set(url, new Set());
byRelay.get(url).add(pk);
}
}
const orphaned = new Set(orphans);
const left = new Set(authors.filter((pk) => !orphaned.has(pk))); // the rest are on our relays below
const plan = [];
while (left.size && plan.length < OUTBOX_MAX) {
let best = null, bestN = 0;
for (const [url, set] of byRelay) {
let n = 0;
for (const pk of set) if (left.has(pk)) n++;
if (n > bestN) { bestN = n; best = url; }
}
if (!best) break;
const take = [...byRelay.get(best)].filter((pk) => left.has(pk));
plan.push({ relays: [best], authors: take });
for (const pk of take) left.delete(pk);
byRelay.delete(best);
}
plan.push({ relays: zapRelays(), authors });
return plan;
}
// ---- the feed: posts from the people you follow ---------------------------
// Their kind-1 notes, newest first, replies left out — a reply belongs to
// its thread, and a timeline of half-conversations reads like eavesdropping.
// The last screenful is kept locally so the feed opens with posts in it
// rather than a spinner, exactly like the profile pages do.
const FEED_CACHE = 'feedNotes';
const FEED_LIMIT = 80;
const FEED_KEEP = 200; // in memory
const FEED_STORE = 50; // ...and on disk
let feed = null; // { status, notes, end, loadingMore }
let feedUnsubs = [];
let feedAt = 0;
const isReply = (ev) => (ev.tags || []).some((x) => x[0] === 'e');
const feedAuthors = () => [...followsNow().set].slice(0, 500);
function feedNow() {
if (!feed) {
let stored = [];
try { stored = wallet.loadFeatureState(FEED_CACHE, []) || []; } catch {}
feed = { status: stored.length ? 'ready' : 'loading', notes: stored };
refreshFeed();
}
return feed;
}
// A follow list that changed means a feed built from the wrong authors.
function feedAuthorsChanged() {
if (!feed) return;
feedAt = 0;
refreshFeed();
}
function mergeFeed(evs) {
const c = feedNow();
const seen = new Set(c.notes.map((e) => e.id));
const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !seen.has(e.id) && seen.add(e.id));
if (!add.length) return false;
c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {}
return true;
}
// One pass over the plan: each relay is asked only for the authors it
// actually carries. The slowest relay doesn't hold up the rest — every
// answer merges as it lands.
async function feedPass(extra = {}) {
const authors = feedAuthors();
if (!authors.length) return false;
await fetchRelayLists(authors);
const plan = outboxPlan(authors);
let got = false;
await Promise.all(plan.map(async ({ relays, authors: a }) => {
const evs = await queryOn(relays, { kinds: [1], authors: a, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
if (mergeFeed(evs)) { got = true; scheduleRepaint(); }
}));
return got;
}
async function refreshFeed() {
if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; }
if (Date.now() - feedAt < 30_000) return;
feedAt = Date.now();
try { await feedPass(); } catch {} finally {
if (feed) feed.status = 'ready';
scheduleRepaint();
}
watchFeed();
}
// While the feed is what's on screen, new posts arrive by themselves — on
// the same relays the pass above reads, one subscription each.
function watchFeed() {
stopFeedWatch();
if (!feedAuthors().length || !ui.chatOpen || ui.msgView !== 'feed') return;
const since = Math.floor(Date.now() / 1000) - 60;
for (const { relays, authors } of outboxPlan(feedAuthors()))
feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors, since },
(ev) => { if (mergeFeed([ev])) scheduleRepaint(); }));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
feedUnsubs = [];
}
async function loadOlderFeed() {
const c = feedNow();
if (c.status !== 'ready' || c.loadingMore || c.end) return;
const oldest = c.notes[c.notes.length - 1];
if (!oldest || !feedAuthors().length) { c.end = true; return; }
c.loadingMore = true;
render();
try {
if (!await feedPass({ until: oldest.created_at - 1 })) c.end = true;
} catch {} finally {
c.loadingMore = false;
render();
}
}
// Note content, safely: text stays text nodes — relay content must never
// reach innerHTML. URLs become links (image URLs inline), npub mentions a
// clickable @name, other nostr: refs a dim stub.
const NOTE_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi;
function noteBody(text) {
const out = [];
for (const part of String(text || '').split(NOTE_SPLIT)) {
if (!part) continue;
if (/^https?:\/\//i.test(part)) {
if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) {
out.push(h('img', { src: part, class: 'note-img', loading: 'lazy',
onError: (e) => { e.target.style.display = 'none'; } }));
} else if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(part)) {
// metadata-only preload: the poster frame paints, nothing streams
// until the viewer presses play
out.push(h('video', { src: part, class: 'note-video', controls: true,
preload: 'metadata', playsinline: true,
onError: (e) => { e.target.style.display = 'none'; } }));
} else {
out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' },
part.length > 64 ? part.slice(0, 61) + '…' : part));
}
} else if (/^nostr:(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk)));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:(note|nevent)1/i.test(part)) {
// a referenced note opens right here as a thread — fetched by id
// (with the reference's relay hints) when tapped
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'event') out.push(h('a', {
href: '#', onClick: (e) => { e.preventDefault(); openNoteRef(ref); },
}, t('noteRefLink')));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:/i.test(part)) {
out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else out.push(part);
}
return out;
}
// Zap a specific note. With a default amount configured this is ONE TAP:
// the zap fires instantly (ark first, Lightning fallback) and reports by
// toast, no form, no leaving the page. Without one, a small setup screen
// asks once and remembers.
// Whether a ⚡ makes sense for this author from this wallet: not
// ourselves, and an instant path (Ark) or a Lightning fallback exists.
const canZapPk = (pk) => !!pk && !isMe(pk) && !!(hook('arkReady') || hook('canLnZap'));
function zapNote(pk, ev) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21' }; render(); return; }
markZapPending(ev.id, def); // the chip answers the tap; the flow reports back
if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
// no instant path in this build — the classic form flow
settleZap(ev.id, false);
ui.profilePk = null;
ui.chatOpen = false;
ui.tab = 'send';
render();
if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id);
}
}
// First ⚡ tap ever: pick the amount one time, then every zap is one tap.
function zapSetupScreen() {
const s = ui.zapSetup;
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, '⚡ ' + t('zapSetupTitle')),
h('div', { class: 'small muted' }, t('zapSetupDesc')),
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '1', value: s.amount, onInput: (e) => { s.amount = e.target.value; } }),
h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, 'sats')),
h('button', { class: 'btn-primary btn-block', onClick: () => {
const n = parseInt(s.amount, 10);
if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return; }
ctx.setZapDefaultSat(n);
const { pk, npub, eventId } = s;
ui.zapSetup = null;
render();
markZapPending(eventId, n);
if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) settleZap(eventId, false);
recheckZap(eventId);
} }, t('zapSetupSave'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back')));
}
// One post as a feed row (avatar · name · time · body), twitter/jumble
// style: rows share a scrollable container and are split by hairlines
// rather than floating in their own cards. Tapping a row opens its thread.
function noteRow(pk, ev, name, { open = true, focus = false } = {}) {
prefetchProfilePage(pk); // rows are tap-targets: have the page warm
const isReply = ev.tags.some((x) => x[0] === 'e');
const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap'));
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
const pending = !!ev.pending;
const openable = open && !pending;
if (!pending) watchZaps([ev.id]);
return h('div', {
class: 'row',
style: 'gap:10px;align-items:flex-start;padding:10px 0'
+ (openable ? ';cursor:pointer' : '')
+ (pending ? ';opacity:.55' : '')
+ (focus ? ';background:var(--accent-soft,rgba(128,128,128,.08));border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px' : ''),
// closest('button') guard: on touch, a ⚡ tap must never double as a
// row tap even if propagation quirks let the click reach us
onClick: openable ? (e) => { if (e.target && e.target.closest && e.target.closest('button')) return; openNoteThread(ev); } : undefined,
},
// the avatar is its own tap-target (profile), even inside an openable
// row — its handler stops propagation, so the row still opens the thread
avatar(pk),
h('div', { class: 'col grow', style: 'min-width:0;gap:3px' },
h('div', { class: 'row between', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
// the name, like the avatar, is its own tap-target (profile)
style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer',
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
(isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)),
zapChip(ev.id, { onClick: canZap ? () => { zapNote(pk, ev); recheckZap(ev.id); } : null })),
pending ? null : h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' },
// Reply on every post: opens (or re-targets) its thread and puts
// the cursor in the reply box — no hunting for the row tap.
h('button', {
class: 'btn-sm', title: t('msgReply'), onClick: (e) => {
e.stopPropagation();
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
render();
} else openNoteThread(ev);
if (ui.noteThread) ui.noteThread.refocus = true; // survives the box relocating when the thread loads
setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120);
},
}, '↩'),
canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); recheckZap(ev.id); } }, '⚡') : null)),
h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content))));
}
// ---- thread view: a note in its conversation ----------------------------
const threadCache = new Map(); // root id -> { status, root, replies }
const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' });
function rootIdOf(ev) {
const es = ev.tags.filter((x) => x[0] === 'e');
const marked = es.find((x) => x[3] === 'root');
return (marked || es[0] || [])[1] || ev.id;
}
function threadFor(seed) {
const rootId = rootIdOf(seed);
let c = threadCache.get(rootId);
if (c) return c;
c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: [] };
threadCache.set(rootId, c);
(async () => {
// the conversation's home relays are the root author's (NIP-10 outbox);
// the seed's author is the best guess until the root is known
const relays = await notesRelays((c.root || seed).pubkey);
const [roots, replies] = await Promise.all([
c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000),
queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
]);
if (!c.root) c.root = (roots || [])[0] || null;
const seen = new Set([rootId]);
c.replies = (replies || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => a.created_at - b.created_at);
c.status = 'ready';
if (ui.noteThread && ui.noteThread.rootId === rootId) {
render();
// The inline reply box may have MOVED on this render (it slots under
// the focused note once that note exists) — a focus taken before the
// load finished died with the old position. Re-take it if the reply
// button asked for it.
if (ui.noteThread.refocus) {
ui.noteThread.refocus = false;
setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 30);
}
}
})().catch(() => {
c.status = 'ready';
if (ui.noteThread && ui.noteThread.rootId === rootId) render();
});
return c;
}
function openNoteThread(ev) {
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
}
// A nostr:nevent / nostr:note reference: the thread loader needs the real
// event (its tags name the root, its author names the home relays), so
// fetch it by id — the reference's relay hints first — then open.
async function openNoteRef(ref) {
toast(t('noteRefLoading'));
const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])];
const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []);
const ev = (evs || [])[0];
if (ev) openNoteThread(ev);
else toast(t('noteRefNotFound'));
}
// Publish a kind-1 reply to the focused note (NIP-10 markers), addressed to
// the conversation's own relays plus ours, and shown optimistically.
// A new top-level kind-1 note, published to the identity's own relays —
// the profile page's compose button. OPTIMISTIC: a provisional copy sits
// at the top of the feed before any signing or relay round-trip (a remote
// signer alone can take seconds), swapped for the signed event on success
// and withdrawn on failure.
async function publishPost(text, media = []) {
const id = await requireIdentity();
const temp = {
id: 'pending:' + Math.random().toString(36).slice(2),
pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
tags: [], pending: true,
};
const caches = [...new Set([id.pubkey, ui.profilePk].filter(Boolean))]
.map((pk) => notesCache.get(pk)).filter(Boolean);
for (const c of caches) c.notes = [temp, ...c.notes];
// your own post belongs at the top of your own feed too, before any
// relay has heard of it
if (feed) { feed.notes = [temp, ...feed.notes]; }
render();
try {
// NIP-92: what we uploaded, described, so clients needn't sniff the URL
const imeta = media.filter((m) => m && m.url)
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, CLIENT_TAG] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
if (!ok) throw new Error(t('msgSendFailed'));
for (const c of caches) c.notes = c.notes.map((e) => (e.id === temp.id ? evt : e));
if (feed) feed.notes = feed.notes.map((e) => (e.id === temp.id ? evt : e));
render();
return evt;
} catch (e) {
for (const c of caches) c.notes = c.notes.filter((e) => e.id !== temp.id);
if (feed) feed.notes = feed.notes.filter((e) => e.id !== temp.id);
render();
throw e;
}
}
async function publishReply(c, s, text) {
const id = await requireIdentity();
const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
const rootId = c.rootId;
const pTags = [...new Set([
target.pubkey,
...(c.root ? [c.root.pubkey] : []),
...target.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]),
])].filter((pk) => pk !== id.pubkey).slice(0, 8);
const partial = {
kind: 1,
content: text,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', rootId, '', 'root'],
...(target.id !== rootId ? [['e', target.id, '', 'reply']] : []),
...pTags.map((pk) => ['p', pk]),
CLIENT_TAG,
],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
if (!ok) throw new Error(t('msgSendFailed'));
c.replies = [...c.replies, evt];
return evt;
}
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
const row = (ev) => noteRow(ev.pubkey, ev, displayName(ev.pubkey), { open: false, focus: ev.id === s.focusId && ev.id !== c.rootId });
// The reply box sits INLINE, right under the note it answers — it used
// to live at the bottom of the thread, where nobody scrolled to find it.
const replyBox = () => h('div', { class: 'row', style: 'gap:8px;align-items:center;padding:2px 0 10px' },
h('input', {
type: 'text', class: 'grow thread-reply-input', placeholder: t('threadReplyHint'),
value: s.draft || '',
onInput: (e) => { s.draft = e.target.value; },
onKeydown: (e) => { if (e.key === 'Enter') e.target.parentElement.querySelector('.thread-reply-send')?.click(); },
}),
h('button', {
class: 'btn-primary thread-reply-send', disabled: !!s.sending,
onClick: async () => {
const text = (s.draft || '').trim();
if (!text) return;
s.sending = true; render();
try {
await publishReply(c, s, text);
s.draft = '';
// the input may still be focused, and the morph won't touch a
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
},
}, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend')));
// rows with the reply box slotted under the focused note (the root when
// nothing narrower is focused; appended at the end if the focused note
// hasn't loaded yet, so the box never disappears entirely)
const kids = [];
let boxPlaced = false;
const place = (ev) => {
if (boxPlaced) return;
if (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)) {
kids.push(replyBox());
boxPlaced = true;
}
};
if (c.root) { kids.push(row(c.root)); place(c.root); }
else {
kids.push(h('div', { class: 'small faint', style: 'padding:10px 0' },
c.status === 'loading' ? '…' : t('threadRootMissing')));
}
for (const ev of c.replies) { kids.push(noteSep(), row(ev)); place(ev); }
if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })));
else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
if (!boxPlaced) kids.push(replyBox());
return h('div', { class: 'col', style: 'gap:16px' },
// full header: search/chat/settings stay reachable mid-thread (only
// the public no-wallet surface drops the action row)
ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' }, ...kids),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back')));
}
async function publishProfileFields(fields, opts = {}) {
const id = await requireIdentity();
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
let base = {};
try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
// a name and a punk for profiles that have none — it must never RENAME
// (or re-face) an identity that already has one.
const fill = new Set(opts.fillOnly || []);
const taken = (k) => (k === 'name' ? !!(base.name || base.display_name) : !!base[k]);
for (const [k, v] of Object.entries(fields || {})) {
if (!v) continue;
if (fill.has(k) && taken(k)) continue;
if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue;
merged[k] = v;
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
}
async function saveProfile() {
const id = await identity();
if (!id) { noIdToast(); return; }
const e = ui.profEdit;
e.migrationName = null;
ui.profSaving = true;
render();
try {
// merge over the newest published kind 0 so unknown fields round-trip
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
let base = {};
try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
let addr = oldAddr;
const want = (e.uname || '').trim().toLowerCase();
if (oldAddr && want && want !== oldAddr.split('@')[0]) {
if (!/^[a-z0-9][a-z0-9._-]{0,29}$/.test(want)) throw new Error(t('profUnameInvalid'));
await hook('namesClaimName', want, { quietProfile: true });
addr = want + '@' + oldAddr.split('@')[1];
}
const merged = { ...base };
for (const [k, v] of [['name', e.name], ['about', e.about], ['picture', e.picture], ['banner', e.banner]]) {
if (v.trim()) merged[k] = v.trim();
else delete merged[k];
}
// The address doubles as the lightning address and NIP-05. Fill them
// when empty; move them when they pointed at our (old) address. A
// deliberately foreign lud16/nip05 is the user's business.
if (addr) {
for (const k of ['lud16', 'nip05'])
if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
}
if (merged.name) merged.display_name = merged.name;
const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
ui.profEdit = null; ui.profEditFilled = false;
toast(t('profSaved'));
} catch (err) {
if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
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());
// History can outlive an account switch; only restore our own editor.
if (!mine && ui.profEdit) { ui.profEdit = null; ui.profEditFilled = false; }
const logoutBtn = () => h('button', {
class: 'btn-block', style: 'color:var(--red,#c0392b);display:flex;align-items:center;justify-content:center;gap:8px',
onClick: () => { ui.logoutConfirm = true; render(); },
},
h('span', { style: 'display:flex', html: '' }),
t('logout'));
// One Log out button; the popup offers the two exits — leave (wallets
// stay saved, one unlock away) or leave and take everything with you.
// The full wipe still routes through the Delete-all warning after this.
// Two stages in one popup: the plain logout choices, then — if "forget
// all data" is picked — the delete-everything warning right here. The old
// flow detoured to the Accounts page for that confirmation, which read
// as broken navigation (and left people thinking data was wiped when
// only a confirm screen had opened).
const logoutPop = () => !ui.logoutConfirm ? null : h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) { ui.logoutConfirm = null; render(); } },
},
ui.logoutConfirm === 'forget'
? h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('logoutForget') + '?'),
h('div', { class: 'notice err small' }, t('clearAllWarn')),
h('button', {
class: 'btn-block', style: 'color:var(--red,#c0392b)',
onClick: () => { ui.logoutConfirm = null; ctx.logoutForget(); },
}, t('clearAll')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('back')))
: h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('logout') + '?'),
h('p', { class: 'small muted', style: 'margin:0' }, t('logoutPopBlurb')),
h('button', { class: 'btn-primary btn-block', onClick: () => {
ui.logoutConfirm = null;
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
ctx.logout && ctx.logout();
} }, t('logout')),
ctx.logoutForget ? h('button', {
class: 'btn-block', style: 'color:var(--red,#c0392b)',
onClick: () => { ui.logoutConfirm = 'forget'; render(); },
}, t('logoutForget')) : null,
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back'))));
// The identity switcher: every account signed in on this device as an
// avatar + name row (tap = switch, nothing logged out), and the door to
// add a new one.
const switchPop = () => {
if (!ui.switchIdPop) return null;
const close = () => { ui.switchIdPop = null; render(); };
const others = (ctx.identities ? ctx.identities() : []).filter((x) => !x.active);
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('switchIdentityTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('switchIdentityDesc')),
h('div', { class: 'col', style: 'gap:0' },
others.map((x) => h('div', {
class: 'row gap6 clickable', style: 'align-items:center;padding:8px 0;border-bottom:1px solid var(--line)',
onClick: () => { ui.switchIdPop = null; ctx.switchIdentity(x.id); },
},
x.pk ? avatar(x.pk, 'chat-avatar', false) : h('div', { class: 'chat-avatar fallback' }),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'chat-name', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.pk ? displayName(x.pk) : x.label),
h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
(x.pk ? x.label : t('identityNoKey'))
+ (x.network && x.network !== 'mainnet' ? ' · ' + x.network : '')
+ (x.watch ? ' · ' + t('watchOnlyTag') : '')))))),
h('button', { class: 'btn-primary btn-block', onClick: () => { ui.switchIdPop = null; ctx.signInAnother(); } }, t('signInNew')),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
};
const full = fullProfiles.get(pk);
// Your own profile reads like anyone else's — npub, bio, latest posts —
// with the editor behind an explicit Edit button (it used to BE the
// page, which made looking at your own profile feel like a form). The
// seed comes from the local name/picture cache so the form opens
// instantly; saveProfile re-fetches the newest kind 0 before merging,
// so a stale seed can't clobber anything.
const myAddr = mine ? hook('namesAddress') : null;
const openEditor = () => {
const p = (full === undefined ? profileOf(pk) : full) || {};
ui.profEdit = {
uname: myAddr ? myAddr.split('@')[0] : '',
name: (full || {}).display_name || p.name || '',
about: p.about || '',
picture: p.picture || '',
banner: (full || {}).banner || '',
};
ui.profEditFilled = full !== undefined;
render();
};
// Once the real kind 0 lands, top up fields still sitting empty — never
// ones the user (or the cache) already filled.
if (mine && ui.profEdit && !ui.profEditFilled && full !== undefined) {
ui.profEditFilled = true;
const e = ui.profEdit;
if (!e.name && !e.touched?.name) e.name = full.display_name || full.name || '';
for (const key of ['about', 'picture', 'banner'])
if (!e[key] && !e.touched?.[key]) e[key] = full[key] || '';
}
const name = displayName(pk);
const npub = npubOf(pk) || pk;
const field = (label, key, ph = '', multi = false) => h('label', { class: 'field' },
h('span', { class: 'lab' }, label),
h(multi ? 'textarea' : 'input', {
...(multi ? { rows: '4', style: 'font-family:var(--sans);min-height:72px' } : { type: 'text' }),
placeholder: ph, value: ui.profEdit[key],
// render per keystroke so the page-top preview (name/picture/banner)
// tracks the draft live — the morph never rewrites a focused field,
// so this can't fight the typing
onInput: (ev) => {
ui.profEdit[key] = ev.target.value;
(ui.profEdit.touched ||= {})[key] = true;
render();
},
}));
// A lightning address worth showing: not an npub-shaped machine address
// (npub1…@some.relay duplicates the npub below) and not the same string
// as the nip05 already on screen.
// The address must not wait for the kind 0: our own claimed name is
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
// an about of "~" or a lone character is noise, not a bio
const about = full && typeof full.about === 'string' ? full.about.trim() : '';
const showAbout = about.length > 1;
// The cover photo: nostr's standard kind-0 `banner`. Legacy coinos.io
// wore these proudly — migrated accounts bring theirs along, and anyone
// can set one in the editor below. WHILE EDITING, the draft is the
// truth: an uploaded (or pasted) picture/banner previews immediately at
// the top of the page instead of waiting for Save — and clearing a field
// previews the removal too.
const draft = mine && ui.profEdit ? ui.profEdit : null;
const urlish = (s) => (typeof s === 'string' && /^https?:\/\//i.test(s.trim()) ? s.trim() : null);
const bannerUrl = draft ? urlish(draft.banner)
: (full && urlish(full.banner)) || null;
const draftPic = draft && urlish(draft.picture);
return h('div', { class: 'col', style: 'gap:16px' },
// full header on profiles too — losing the search button here made
// finding the NEXT person a trek back home
ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col', style: 'gap:12px' },
bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(bannerUrl)})` }) : null,
h('div', { class: 'row gap6', style: 'align-items:center' },
draftPic
? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` })
: avatar(pk, 'chat-avatar profile-avatar', false),
h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
h('div', { class: 'chat-title' }, draft && draft.name.trim() ? draft.name.trim() : name),
nip05 ? h('div', { class: 'muted small break' }, nip05) : null,
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
// @names, other nostr: refs truncate to stubs, URLs linkify — and
// overflow-wrap catches any remaining unbreakable token (a raw npub
// in a bio was punching clean through the card).
showAbout && !ui.profEdit
? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap;overflow-wrap:anywhere' },
...noteBody(about.slice(0, 1000)))
: null,
// the npub, tap to copy — on every profile including your own; it
// only steps aside while the (long) edit form is open
ui.profEdit ? null : h('button', {
class: 'addr-box break npub-box', title: t('copy'),
style: 'font-size:11px;cursor:pointer;text-align:left;width:100%',
onClick: async () => { try { await navigator.clipboard.writeText(npub); toast(t('copied')); } catch {} },
}, npub),
ui.profEdit
? h('div', { class: 'col', style: 'gap:8px' },
// the username IS the payment address and NIP-05 — the frozen
// @domain suffix is there so people make that connection
myAddr ? h('label', { class: 'field' },
h('span', { class: 'lab' }, t('profUsername')),
h('div', { class: 'row', style: 'align-items:center;gap:0' },
h('input', {
type: 'text', style: 'flex:1;min-width:0',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
value: ui.profEdit.uname,
onInput: (ev) => {
ui.profEdit.uname = ev.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, '');
ev.target.value = ui.profEdit.uname;
ui.profEdit.migrationName = null;
render();
},
}),
h('span', { class: 'muted', style: 'white-space:nowrap;padding:0 8px' }, '@' + myAddr.split('@')[1]))) : null,
// Offered only for a submitted name confirmed as legacy-only.
ui.profEdit.migrationName && ui.profEdit.migrationName === ui.profEdit.uname
&& myAddr && myAddr.split('@')[1] === 'coinos.io' ? h('button', {
type: 'button', class: 'linklike small', style: 'align-self:flex-start',
onClick: () => {
location.href = `https://coinos.io/migrate?to=${encodeURIComponent(myAddr)}&back=${encodeURIComponent(location.origin + '/')}`;
},
}, t('onbHaveCoinos')) : null,
field(t('profDisplayName'), 'name'),
field(t('profAbout'), 'about', '', true),
field(t('profPicture'), 'picture', 'https://…'),
// or skip the URL entirely: pick a photo, we upload and fill it
h('div', { class: 'row gap6' },
h('button', {
class: 'btn-sm', type: 'button', disabled: !!ui.profUploading,
onClick: () => document.getElementById('prof-pic-file')?.click(),
}, ui.profUploading ? h('span', { class: 'spinner sm' }) : t('profUploadPic')),
h('input', {
id: 'prof-pic-file', type: 'file', accept: 'image/*', style: 'display:none',
onChange: async (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f || !ctx.uploadImage) return;
ui.profUploading = true; render();
try { if (ui.profEdit) ui.profEdit.picture = await ctx.uploadImage(f); }
catch (err) { toast(err.message); }
ui.profUploading = false; render();
},
})),
field(t('profBanner'), 'banner', 'https://…'),
h('div', { class: 'row gap6' },
h('button', {
class: 'btn-sm', type: 'button', disabled: !!ui.profUploading,
onClick: () => document.getElementById('prof-banner-file')?.click(),
}, ui.profUploading ? h('span', { class: 'spinner sm' }) : t('profUploadBanner')),
h('input', {
id: 'prof-banner-file', type: 'file', accept: 'image/*', style: 'display:none',
onChange: async (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f || !ctx.uploadImage) return;
ui.profUploading = true; render();
try { if (ui.profEdit) ui.profEdit.banner = await ctx.uploadImage(f); }
catch (err) { toast(err.message); }
ui.profUploading = false; render();
},
})),
h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile },
ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel')))
: mine
? h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'row gap6 wrap' },
h('button', { class: 'btn-primary grow', onClick: openEditor }, t('profEdit')),
h('button', { class: 'grow', onClick: () => { ui.profCompose = ui.profCompose == null ? draftFor(POST_DRAFT) : null; render(); } }, t('profNewPost'))),
// The post draft rides the same persisted draft store as DMs
// and channels: a reload (say, to reconnect a signer) brings
// the half-written post back, composer open. Posting clears
// it; Cancel just closes the composer and keeps the text.
postComposer(),
// the non-destructive way out: add another identity (Nostr,
// passkey, Google) next to this one and switch between them
// on the Accounts screen — logout stays for actually leaving
ctx.signInAnother ? h('button', {
class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px',
onClick: () => {
// other identities on this device → pick from a list;
// none yet → straight to the sign-in doors
const others = ctx.identities ? ctx.identities().filter((x) => !x.active) : [];
if (others.length) { ui.switchIdPop = true; render(); } else ctx.signInAnother();
},
},
h('span', { style: 'display:flex', html: '' }),
t('signInAnother')) : null,
logoutBtn())
: ui.pubProf
? null // no wallet open: messaging and paying both need one
: h('div', { class: 'row gap6 wrap' },
h('button', { class: 'btn-primary grow', onClick: () => {
const peer = pk;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'dm';
ui.msgPeer = peer;
ui.msgStick = true;
render();
} }, t('msgDmsTitle')),
h('button', { class: 'grow', onClick: () => {
const npubStr = npubOf(pk);
// leave the whole messaging surface, not just the profile —
// a lingering search or thread would win the screen router
// and the Send tab would never appear
ui.profilePk = null;
ui.profOverThread = false;
ui.noteThread = null;
ui.userSearch = null;
ui.chatOpen = false;
ui.tab = 'send';
render();
hook('matchSendText', npubStr);
} }, t('profPay')),
// Follow: their posts join your feed, and the service worker
// learns to treat their DMs as a friend's rather than a
// stranger's.
h('button', {
class: (isFollowing(pk) ? 'btn-ghost ' : '') + 'grow', disabled: followsPub,
onClick: () => toggleFollow(pk),
}, isFollowing(pk) ? t('feedFollowing') : t('feedFollow')))),
// Their public notes: the PAGE scrolls (no inner scrollbox), older
// pages stream in as you near the bottom (the init() scroll listener →
// loadOlderNotes), and on phones the feed goes full-bleed — edge to
// edge, no card walls (see .notes-feed).
(() => {
const c = notesFor(pk);
// while loading: an invisible copy of the empty-state line holds the
// height, so the page doesn't jump when the answer lands (no spinner)
if (c.status === 'loading')
return h('div', { class: 'small faint', style: 'text-align:center;visibility:hidden' }, t('profNotesNone'));
if (!c.notes.length)
return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone'));
return h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted', style: 'padding:0 2px' }, t('profNotesTitle')),
h('div', { class: 'card col notes-feed', style: 'gap:0' },
...c.notes.flatMap((ev, i) => [
i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null,
noteRow(pk, ev, name),
])),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
: null);
})(),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.profOverThread = false; ui.pubProf = null; ui.profEdit = null; ui.profEditFilled = false; ui.profCompose = null; render(); } }, t('back')),
mine ? logoutPop() : null,
mine ? switchPop() : null);
}
const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹');
// ---- the post composer ----------------------------------------------------
// Shared by the profile page and the feed. A picture is uploaded the moment
// it's picked and its URL appended to the text — which is how every nostr
// client reads media — with a NIP-92 imeta tag published beside it for the
// ones that would rather have the metadata than sniff the URL.
const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || '');
async function attachMedia(file) {
if (!file || !ctx.uploadImage) return;
ui.postUploading = true; render();
try {
const url = await ctx.uploadImage(file);
(ui.postMedia ||= []).push({ url, m: file.type || '' });
const cur = composeText().replace(/\s+$/, '');
const next = (cur ? cur + '\n' : '') + url;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
const CLIP = '';
function postComposer() {
if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null;
const text = composeText();
return h('div', { class: 'col', style: 'gap:8px' },
h('textarea', {
rows: '3', placeholder: t('profComposePh'),
style: 'font-family:var(--sans);min-height:64px',
value: text,
onInput: (ev) => { ui.profCompose = ev.target.value; setDraft(POST_DRAFT, ev.target.value); },
}),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
const body = composeText().trim();
if (!body) return;
const media = (ui.postMedia || []).filter((m) => body.includes(m.url));
// the post is on screen instantly (publishPost is optimistic) —
// close the composer now; a failure reopens it with the text intact
ui.profCompose = null;
ui.postMedia = [];
setDraft(POST_DRAFT, '');
try { await publishPost(body, media); toast(t('profPosted')); }
catch (e) {
ui.profCompose = body;
ui.postMedia = media;
setDraft(POST_DRAFT, body);
if (!e.silent) toast(e.message || String(e));
render();
}
} }, t('profPostBtn')),
ctx.uploadImage ? h('button', {
class: 'btn-sm', title: t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
h('button', { class: 'btn-ghost', onClick: () => { ui.profCompose = null; render(); } }, t('cancel'))));
}
// ---- the feed view --------------------------------------------------------
function feedView() {
const c = feedNow();
const authors = feedAuthors();
const rows = c.notes.flatMap((ev, i) => [
i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null,
noteRow(ev.pubkey, ev, displayName(ev.pubkey)),
]);
// the chat shell draws the brand header; this is just the page under it
return h('div', { class: 'card col chat-page', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => { ui.msgView = 'home'; stopFeedWatch(); render(); }),
h('h3', { style: 'margin:0' }, t('feedTitle')),
h('button', {
class: 'btn-sm', style: 'margin-left:auto',
onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); },
}, t('profNewPost'))),
postComposer(),
!authors.length
? h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoFollows')),
h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')))
: c.status === 'loading' && !c.notes.length
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
: !c.notes.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' }))
: null);
}
// ---- user search: the header magnifier ----------------------------------
// Same engine the Send form and DMs use (registrar names + Primal cache +
// NIP-50 relays); tapping a result opens their profile, and profile-Back
// lands here again since the search state survives.
const userSearcher = makeSearcher((q, rows) => {
if (ui.userSearch && ui.userSearch.q === q) { ui.userSearch.rows = rows; render(); }
for (const r of rows || []) if (r.pk) prefetchProfilePage(r.pk);
});
function userSearchScreen() {
const s = ui.userSearch;
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('searchUsers')),
h('input', {
type: 'text', class: 'user-search-input', placeholder: t('searchUsersHint'), value: s.q,
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); },
}),
s.rows && s.rows.length
? h('div', { class: 'list' }, resultRows(h, s.rows, (r) => {
if (r.pk) { openProfile(r.pk); render(); }
}, (pk, node) => hook('wrapAvatar', pk, node)))
: s.rows && s.q.trim().length >= 2
? h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchNoResults'))
: h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchUsersEmpty'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.userSearch = null; render(); } }, t('back')));
}
// ---- presence & typing --------------------------------------------------
// Both ride the channel stream as 21059 wraps, so they stay inside the
// community's encryption — who's around is members-only, not a public
// status broadcast. 21059 sits in NIP-01's ephemeral range, but measured
// against the relays we actually use (relay.coinos.io, nos.lol) neither
// drops them, so they carry a NIP-40 expiration and relay.coinos.io sweeps
// the kind on a timer; nothing here may assume the relay forgets.
// Presence only exists while someone is watching a room. A member nobody
// saw beat is dated by their last message instead, the way an offline
// contact still shows a last-seen.
const PRESENCE = 20100;
const TYPING = 20101;
const BEAT_MS = 60_000; // how often we announce ourselves
const ONLINE_MS = 100_000; // a beat older than this is no longer "online"
const TYPING_MS = 7_000; // how long a typing ping stands
const TYPE_THROTTLE = 5_000; // and how rarely we send one
const lastPing = new Map(); // channelId + kind -> ms
async function ping(room, chId, kind) {
const key = chId + ':' + kind;
const gap = kind === PRESENCE ? BEAT_MS : TYPE_THROTTLE;
if (Date.now() - (lastPing.get(key) || 0) < gap) return;
lastPing.set(key, Date.now());
try {
const id = await identity();
if (!id) return;
const { created_at, ms } = msTags(Date.now());
const rumor = {
kind, pubkey: id.pubkey, content: '',
tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at,
};
// NIP-40: a beat is worthless once it's stale, so relays that honour
// expiration may drop it instead of keeping it forever.
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId),
{ ephemeral: true, expiration: created_at + 300 });
await publishOn(room.relays, wrap);
} catch { lastPing.delete(key); }
}
// Keep beating while a room is on screen — otherwise everyone would look
// online for exactly one render and then go dark.
let beatTimer = null;
function keepBeating() {
if (beatTimer) return;
beatTimer = setInterval(() => {
if (!ui.chatOpen || ui.msgView !== 'room') return;
const jm = communityById(ui.msgCommunity) || COMMUNITY;
const room = rooms.get(jm.community_id);
if (!room) return;
const chans = roomChannels(room);
const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0];
if (ch) ping(room, ch.id, PRESENCE);
scheduleRepaint(); // let "online" lapse into "last seen" on its own
}, 30_000);
}
// When we last had any sign of someone: a beat, or failing that the newest
// thing they said anywhere in the community.
function lastSeenMs(room, pk) {
let ts = room.presence.get(pk) || 0;
for (const msgs of room.byChannel.values())
for (const m of msgs.values())
if (m.author === pk) ts = Math.max(ts, eventMs(m.rumor) || 0);
return ts;
}
const isOnline = (room, pk) => Date.now() - (room.presence.get(pk) || 0) < ONLINE_MS;
function typingNow(room, chId) {
const my = myPubkeys();
const out = [];
for (const [pk, ts] of room.typing)
if (ts.ch === chId && Date.now() - ts.t < TYPING_MS && !my.includes(pk)) out.push(pk);
return out;
}
// Telegram's wording, and its kindness: exact minutes while it's fresh,
// then a vague "recently" rather than advertising how long someone's been away.
function seenLabel(room, pk) {
if (isOnline(room, pk)) return t('msgOnline');
const ts = lastSeenMs(room, pk);
if (!ts) return t('msgSeenRecently');
const mins = Math.floor((Date.now() - ts) / 60_000);
if (mins < 1) return t('msgSeenJustNow');
if (mins < 60) return t('msgSeenMins', { n: mins });
const hrs = Math.floor(mins / 60);
if (hrs < 24) return t('msgSeenHours', { n: hrs });
const days = Math.floor(hrs / 24);
if (days <= 7) return t('msgSeenDays', { n: days });
return t('msgSeenRecently');
}
// Recipient search for New message: debounced, sequenced, cached upstream.
const dmSearch = { rows: null, busy: false };
const dmSearcher = makeSearcher((q, rows) => {
dmSearch.rows = rows;
dmSearch.busy = false;
scheduleRepaint();
});
const dmSearcherUpdate = dmSearcher.update.bind(dmSearcher);
dmSearcher.update = (q) => {
const willSearch = q && q.trim().length >= 2;
if (willSearch) { dmSearch.busy = dmSearch.rows === null || !dmSearch.rows.length; }
dmSearcherUpdate(q);
};
const stickToBottom = () => {
queueMicrotask(() => {
const log = document.querySelector('.chat-log');
if (log && ui.msgStick !== false) log.scrollTop = log.scrollHeight;
});
};
// The chat card is sized to the viewport, so the mobile keyboard shrinks it:
// the composer stays on screen, but the log keeps its old scroll offset and
// the newest messages vanish under the fold. Follow the bottom edge through
// every viewport change (keyboard, rotation, browser chrome) — unless the
// user has deliberately scrolled up, which msgStick already remembers.
const onViewportResize = () => { if (ui.chatOpen) stickToBottom(); };
// A remote signer that a reload dropped: say so where the typing happens,
// rather than letting someone write a message and only then be told. The
// identity itself is still known — it's the ability to sign as it that's
// missing — so this offers to fetch it back rather than to log in again.
function signerNotice() {
if (wallet.watchOnly) {
return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' },
h('span', { class: 'small muted grow' }, '\u{1F512} ' + t('msgLockedChat')),
h('button', { class: 'btn-sm', onClick: () => { ui.justLocked = false; ui.screen = 'vault'; render(); } }, t('unlock')));
}
const id = hook('nostrLoginIdentity');
if (!id || id.signer) return null;
return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' },
h('span', { class: 'small muted grow' }, t('msgSignerOff')),
h('button', {
class: 'btn-sm', disabled: !!ui.msgReconnecting,
onClick: async () => {
ui.msgReconnecting = true; render();
const s = await hook('nostrLoginResume');
ui.msgReconnecting = false;
// Success needs no announcement — the notice itself goes away.
if (!s) { ui.chatOpen = false; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = 'nostr'; }
render();
},
}, ui.msgReconnecting ? h('span', { class: 'spinner sm' }) : t('msgReconnect')));
}
// Enter sends; Shift+Enter (the textarea's native edit) and Ctrl+J insert
// a newline. Height follows the text as it's typed — imperatively, since
// typing doesn't re-render — with rows as the re-render fallback.
const growComposer = (el) => {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
};
const composer = (placeholder, onSend, onType, draftKey) =>
h('div', { class: 'col', style: 'gap:6px' },
signerNotice(),
h('div', { class: 'chat-compose' },
h('textarea', {
class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
value: draftFor(draftKey), maxlength: '2000',
onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); if (onType && e.target.value) onType(); },
onKeydown: (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
const el = e.target;
const { selectionStart: s0, selectionEnd: s1, value } = el;
el.value = value.slice(0, s0) + '\n' + value.slice(s1);
el.selectionStart = el.selectionEnd = s0 + 1;
setDraft(draftKey, el.value);
growComposer(el);
}
},
// Focus summons the keyboard; some browsers resize only after its
// animation, so re-stick once now and once when it has settled.
onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); },
}),
h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend'))));
// ---- home ---------------------------------------------------------------
function homeView() {
startDMs();
// Threads you've since replied to should stop being strangers to the
// worker; throttled inside, so this is cheap on every render.
syncInbox().catch(() => {});
for (const jm of communities()) ensureRoom(jm);
for (const peer of threads.keys()) prefetchProfilePage(peer);
const dmRows = [...threads.entries()]
.map(([peer, m]) => {
const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1);
return { peer, last, unread: dmUnread(peer, m) };
})
.filter((x) => x.last)
.sort((a, b) => b.last.rumor.created_at - a.last.rumor.created_at);
// Being here answers the header dot — including anything that lands while
// the list is open, since arrivals repaint it and the rows below say who.
{
const my = myPubkeys();
let newest = 0;
for (const msgs of threads.values())
newest = Math.max(newest, newestFrom(msgs.values(), (m) => !m.mine));
for (const room of rooms.values())
for (const msgs of room.byChannel.values())
newest = Math.max(newest, newestFrom(msgs.values(), (m) => !my.includes(m.author)));
markRead(HOME_READ, newest);
}
const kids = [];
// Chat takes the whole screen, so home carries the way back to the wallet.
kids.push(h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => { ui.chatOpen = false; render(); }),
h('h3', { style: 'margin:0' }, t('tabMessages')),
anyUnread()
? h('button', { class: 'linklike small', style: 'margin-left:auto', onClick: markAllRead }, t('msgMarkAllRead'))
: null));
// A locked wallet can't decrypt or sign — say so up top, where the rooms
// that quietly won't open are listed.
const lockedRow = signerNotice();
if (lockedRow && wallet.watchOnly) kids.push(lockedRow);
if (pendingLink) kids.push(linkInviteCard());
for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv));
// offer push once — it covers messages AND payments
if (typeof Notification !== 'undefined' && Notification.permission === 'default' && !st().pushDismissed)
kids.push(h('div', { class: 'notice info col', style: 'gap:8px' },
h('div', {}, t('msgPushOffer')),
h('div', { class: 'row gap6' },
h('button', {
class: 'btn-primary btn-sm',
onClick: async () => { const ok = await registerPush({ interactive: true }); toast(ok ? t('msgPushOn') : t('msgPushFailed')); render(); },
}, t('msgPushEnable')),
h('button', {
class: 'btn-ghost btn-sm',
onClick: () => { const s = st(); s.pushDismissed = true; save(s); render(); },
}, t('msgDismiss')))));
// ---- the feed, above the conversations: it's the thing you read, they're
// the things you answer
kids.push(h('div', { class: 'list' },
h('div', {
class: 'item chat-thread-row',
onClick: () => { ui.msgView = 'feed'; feedNow(); watchFeed(); render(); },
},
h('div', { class: 'chat-avatar fallback' }, '\u2605'),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('span', { class: 'chat-name' }, t('feedTitle')),
h('div', { class: 'muted small' },
followsNow().set.size === 1 ? t('feedFollowing1')
: followsNow().set.size ? t('feedFollowingN', { n: followsNow().set.size })
: t('feedNoFollowsShort'))))));
// ---- DMs
kids.push(h('div', { class: 'row between', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t('msgDmsTitle')),
h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; if (ui.msgHomePanel === 'newdm') warmSearch(); render(); } }, t('msgNewDm'))));
if (ui.msgHomePanel === 'newdm') {
const openThread = (pk) => {
dmSearcher.clear();
ui.msgNewDmTo = '';
ui.msgHomePanel = null;
ui.msgView = 'dm';
ui.msgPeer = pk;
ui.msgStick = true;
render();
};
kids.push(h('div', { class: 'col gap6' },
h('div', { class: 'row gap6' },
h('input', {
class: 'grow', type: 'text', placeholder: t('msgSearchPlaceholder'),
value: ui.msgNewDmTo || '',
onInput: (e) => { ui.msgNewDmTo = e.target.value; dmSearcher.update(e.target.value); },
}),
h('button', {
class: 'btn-sm', onClick: () => {
const pk = parseNostrPubkey(ui.msgNewDmTo);
if (!pk) { toast(t('msgBadNpub')); return; }
openThread(pk);
},
}, t('msgOpen'))),
dmSearch.rows === null ? null
: dmSearch.busy ? h('div', { class: 'row gap6', style: 'align-items:center;padding:4px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgSearching')))
: dmSearch.rows.length
? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node)))
: h('div', { class: 'small muted' }, t('msgNoMatches'))));
}
// A long DM history must not bury the communities below it: past a
// handful, the rest waits behind "show all". (No cap for barely-over —
// a "show 2 more" button costs more than the rows it hides.)
const DM_PREVIEW = 5;
const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW);
kids.push(
dmRows.length
? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) =>
h('div', {
class: 'item chat-thread-row' + (unread ? ' unread' : ''),
onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); },
},
avatar(peer),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row between' },
h('span', { class: 'chat-name' }, displayName(peer)),
h('span', { class: 'chat-time thread-when' },
timeLabel(last.rumor.created_at * 1000),
unread ? h('i', { class: 'thread-dot' }) : null)),
h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + last.rumor.content)))))
: h('div', { class: 'muted small' }, t('msgNoDms')));
if (shownDms.length < dmRows.length)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } },
t('msgShowAllDms', { n: dmRows.length })));
else if (ui.msgAllDms && dmRows.length > DM_PREVIEW + 2)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } },
t('msgShowFewerDms')));
// ---- communities
kids.push(h('div', { class: 'row between mt16', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t('msgCommunitiesTitle')),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'join' ? null : 'join'; render(); } }, t('msgJoin')),
h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'create' ? null : 'create'; render(); } }, t('msgCreate')))));
if (ui.msgHomePanel === 'join')
kids.push(h('div', { class: 'row gap6' },
h('input', {
class: 'grow', type: 'text', placeholder: t('msgInvitePlaceholder'),
value: ui.msgJoinText || '', onInput: (e) => { ui.msgJoinText = e.target.value; },
}),
h('button', { class: 'btn-sm', onClick: () => joinFromText(ui.msgJoinText) }, t('msgJoin'))));
if (ui.msgHomePanel === 'create')
kids.push(h('div', { class: 'row gap6' },
h('input', {
class: 'grow', type: 'text', placeholder: t('msgNamePlaceholder'), maxlength: '64',
value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; },
}),
h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate'))));
kids.push(h('div', { class: 'list' }, communities().map((jm) => {
const room = rooms.get(jm.community_id);
const name = room?.folded?.metadata?.name || jm.name;
// Last session's settled count anchors the number: while the guestbook
// replay is still streaming in (multiple relays, bursts past the fold
// debounce), the live tally climbs through intermediate values — take
// the max so the count doesn't visibly tick upward on every refresh.
// Once the replay settles, the cache is rewritten to the live figure,
// so a genuine departure still shows (one quiet step down, not a climb).
const live = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0;
const memberCount = Math.max(live, (st().memberCounts || {})[jm.community_id] || 0);
const unread = room ? roomUnread(room) : false;
return h('div', {
class: 'item chat-thread-row' + (unread ? ' unread' : ''),
onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); },
},
h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row between' },
h('span', { class: 'chat-name' }, name),
unread ? h('i', { class: 'thread-dot' }) : null),
h('div', { class: 'muted small' },
memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))));
})));
return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids);
}
function linkInviteCard() {
const pl = pendingLink;
return h('div', { class: 'notice info col', style: 'gap:8px' },
pl.state === 'loading' ? h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' }), t('msgInviteLoading'))
: pl.state === 'error' ? h('div', { class: 'row between' },
h('span', {}, pl.error),
h('button', { class: 'linklike', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss')))
: h('div', { class: 'col', style: 'gap:8px' },
h('div', {}, t('msgInviteTo', { name: pl.bundle.name || 'community' })),
h('div', { class: 'muted small' }, t('msgInviteFounder', { npub: (npubOf(pl.bundle.owner) || '').slice(0, 16) + '…' })),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-primary btn-sm', onClick: () => acceptBundle(pl.bundle, { invitedBy: pl.bundle.creator_npub }) }, t('msgJoin')),
h('button', { class: 'btn-ghost btn-sm', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss')))));
}
function directInviteCard(rid, inv) {
return h('div', { class: 'notice info col', style: 'gap:8px' },
h('div', {}, t('msgDirectInvite', { name: inv.bundle.name || 'community', from: displayName(inv.from) })),
h('div', { class: 'row gap6' },
h('button', {
class: 'btn-primary btn-sm',
onClick: () => { pendingDirect.delete(rid); acceptBundle(inv.bundle, { invitedBy: inv.from }); },
}, t('msgJoin')),
h('button', {
class: 'btn-ghost btn-sm',
onClick: () => { const s = st(); s.declined[rid] = 1; save(s); pendingDirect.delete(rid); render(); },
}, t('msgDismiss'))));
}
// ---- room ---------------------------------------------------------------
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
.filter((m) => !room.deletes.has(m.rumor.id))
.filter((m) => !(room.folded && room.folded.banned.has(m.author)))
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor));
markRead(chRead(chId), newestFrom(msgs, (m) => !my.includes(m.author)));
if (!msgs.length)
return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))];
let lastAuthor = null, lastT = 0;
watchZaps(msgs.slice(-150).map((m) => m.rumor.id));
return msgs.map((m) => {
const tms = eventMs(m.rumor);
const mine = my.includes(m.author);
const edit = room.edits.get(m.rumor.id);
const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
const grouped = m.author === lastAuthor && tms - lastT < 5 * 60_000;
lastAuthor = m.author; lastT = tms;
const reacts = room.reactions.get(m.rumor.id);
const counts = new Map();
if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1);
// this wallet's own reaction (any of its identities) — highlighted, and
// choosing another emoji replaces it (one reaction per author, the fold
// keeps the latest)
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
return h(
'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
h('div', { class: 'chat-body' },
grouped ? null : h('div', { class: 'chat-meta' },
h('span', {
class: 'chat-name clickable' + (m.author === room.jm.owner ? ' owner' : ''),
onClick: () => openProfile(m.author),
},
displayName(m.author),
m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable',
// Telegram-style: a tap on the message opens its action sheet
// (quick reactions, reply, copy, delete). Links, images and the
// hover × keep their own clicks; a desktop text-selection drag
// ends in a click too, and must not pop the sheet over the copy.
onClick: (e) => {
if (e.target.closest && e.target.closest('a, button, img')) return;
const sel = window.getSelection && window.getSelection();
if (sel && String(sel).length) return;
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
},
replyQuote(room, chId, m),
...noteBody(text),
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
mine
? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×')
: null,
// reactions tuck inside the bubble, under the text (Telegram-style);
// the zap total leads the row, and tapping it zaps again
((zc) => counts.size || zc
? h('div', { class: 'chat-reacts' },
zc,
[...counts.entries()].map(([emoji, n]) =>
h('span', {
class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''),
title: t('msgReact'),
onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); },
}, emoji, n > 1 ? ' ' + n : '')))
: null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: !mine && canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null }))))
);
});
}
function roomView() {
const jm = communityById(ui.msgCommunity) || COMMUNITY;
const room = ensureRoom(jm);
const chans = roomChannels(room);
const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0];
const name = room.folded?.metadata?.name || jm.name;
const members = [...room.members.entries()].filter(([, m]) => m.state === 'join');
const memberCount = members.length;
const onlineCount = members.filter(([pk]) => isOnline(room, pk)).length;
stickToBottom();
keepBeating();
if (ch) ping(room, ch.id, PRESENCE);
return h('div', { class: 'card col chat-card chat-page' },
h('div', { class: 'row between chat-head' },
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }),
h('div', {
class: 'col clickable', style: 'gap:2px;min-width:0',
onClick: () => { ui.msgMembers = !ui.msgMembers; render(); },
},
h('div', { class: 'chat-title' }, name),
h('div', { class: 'muted small' },
memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'),
onlineCount ? h('span', { class: 'online-count' }, ' · ', t('msgNOnline', { n: onlineCount })) : null))),
h('div', { class: 'row gap6', style: 'align-items:center' },
// One channel needs no label — naming it only asks people to notice a
// choice they don't have. Several become a picker.
chans.length > 1
? h('select', {
class: 'chan-pick',
onChange: (e) => { ui.msgChannel = e.target.value; ui.msgStick = true; ui.msgReplyTo = null; ui.msgSheet = null; subChannel(room, e.target.value); render(); },
}, chans.map((c) => h('option', { value: c.id, selected: c.id === ch?.id }, '#' + c.name)))
: null,
h('button', {
class: 'iconbtn bell' + (roomNotify(jm.community_id) ? ' on' : ''),
title: roomNotify(jm.community_id) ? t('msgNotifyOn') : t('msgNotifyOff'),
onClick: () => toggleRoomNotify(jm.community_id),
html: roomNotify(jm.community_id)
? ''
: '',
}),
h('button', {
class: 'btn-sm', title: t('msgInviteTitle'),
onClick: () => { ui.msgInvitePanel = !ui.msgInvitePanel; render(); },
}, t('msgAddPerson')))),
ui.msgInvitePanel ? invitePanel(room) : null,
ui.msgMembers ? memberPanel(room, members) : null,
h('div', {
class: 'chat-log',
onScroll: (e) => {
const el = e.target;
ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
},
}, ...(ch ? messageRows(room, ch.id) : [])),
ch ? typingLine(room, ch.id) : null,
ch ? replyBar(room, ch.id) : null,
composer(
chans.length > 1 ? t('msgPlaceholder', { channel: ch ? ch.name : '' }) : t('msgPlaceholderPlain'),
() => ch && sendMessage(room, ch.id),
() => ch && ping(room, ch.id, TYPING),
ch ? 'ch:' + ch.id : 'ch:'),
ch && ui.msgSheet ? messageSheet(room, ch.id) : null);
}
// "Alice is typing…" — named up to two, counted beyond that.
function typingLine(room, chId) {
const who = typingNow(room, chId);
if (!who.length) return null;
const label = who.length === 1 ? t('msgTyping', { name: displayName(who[0]) })
: who.length === 2 ? t('msgTyping2', { a: displayName(who[0]), b: displayName(who[1]) })
: t('msgTypingMany', { n: who.length });
return h('div', { class: 'chat-typing' },
h('span', { class: 'typing-dots' }, h('i'), h('i'), h('i')),
h('span', { class: 'small muted' }, label));
}
function memberPanel(room, members) {
const rows = members
.map(([pk]) => ({ pk, online: isOnline(room, pk), seen: lastSeenMs(room, pk) }))
.sort((a, b) => (b.online - a.online) || (b.seen - a.seen));
return h('div', { class: 'col chat-members' },
...rows.map((r) => h('div', {
class: 'item chat-thread-row',
onClick: () => openProfile(r.pk),
},
h('span', { class: 'ava-wrap' + (r.online ? ' online' : '') }, avatar(r.pk)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
h('span', { class: 'chat-name' }, displayName(r.pk)),
r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))));
}
function invitePanel(room) {
const builtin = room.jm.community_id === COMMUNITY.community_id;
return h('div', { class: 'col chat-invite', style: 'gap:8px' },
h('div', { class: 'row gap6' },
h('button', {
class: 'btn-sm', disabled: ui.msgMinting,
onClick: async () => {
ui.msgMinting = true; render();
try {
const url = await mintInviteLink(room);
if (url) { await navigator.clipboard.writeText(url); toast(t('msgLinkCopied')); }
} finally { ui.msgMinting = false; render(); }
},
}, ui.msgMinting ? h('span', { class: 'spinner sm' }) : t('msgCopyInvite')),
h('input', {
class: 'grow', type: 'text', placeholder: t('msgNpubPlaceholder'),
value: ui.msgInviteTo || '', onInput: (e) => { ui.msgInviteTo = e.target.value; },
}),
h('button', {
class: 'btn-sm',
onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); },
}, t('msgSend'))),
// Owners can add channels (an owner-signed control edition), but a naming
// field sitting open is a question nobody asked — it reads as something
// you're meant to fill in right after making a community.
myPubkeys().includes(room.jm.owner)
? (ui.msgChannelPanel
? h('div', { class: 'row gap6' },
h('input', {
class: 'grow', type: 'text', placeholder: t('msgChannelPlaceholder'), maxlength: '64',
value: ui.msgNewChannel || '', onInput: (e) => { ui.msgNewChannel = e.target.value; },
onKeydown: (e) => { if (e.key === 'Enter') createChannel(room, ui.msgNewChannel); },
}),
h('button', { class: 'btn-sm', onClick: () => createChannel(room, ui.msgNewChannel) }, t('msgCreate')),
h('button', { class: 'btn-ghost btn-sm', onClick: () => { ui.msgChannelPanel = false; render(); } }, t('cancel')))
: h('div', { class: 'row' },
h('button', {
class: 'btn-sm',
onClick: () => { ui.msgChannelPanel = true; render(); },
}, t('msgNewChannel'))))
: null,
// leaving discards the keys on this identity — two taps, default community exempt
builtin ? null : h('div', { class: 'row' },
h('button', {
class: 'btn-ghost btn-sm ' + (ui.msgLeaveArm ? 'btn-danger' : ''),
onClick: () => {
if (ui.msgLeaveArm) leaveCommunity(room);
else { ui.msgLeaveArm = true; render(); }
},
}, ui.msgLeaveArm ? t('msgLeaveConfirm') : t('msgLeave'))));
}
// ---- dm thread ----------------------------------------------------------
// The DM flavor of the message action sheet: reactions + Reply + Copy.
// No Delete — a gift-wrapped DM can't be retracted from the peer's relays.
function dmSheet(peer) {
const m = threadOf(peer).get(ui.msgSheet);
if (!m) { ui.msgSheet = null; return null; }
const my = myPubkeys();
const reacts = dmReacts.get(m.rumor.id);
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
const close = () => { ui.msgSheet = null; render(); };
const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
h('div', { class: 'msg-sheet-emojis' },
REACT_EMOJIS.map((e2) => h('button', {
class: e2 === myReact ? 'on' : '',
onClick: () => { close(); sendDmReaction(peer, m, e2); },
}, e2))),
item('↩', t('msgReply'), () => {
ui.msgReplyTo = m.rumor.id;
close();
setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
}),
!m.mine && canZapPk(m.rumor.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null,
item('⧉', t('copy'), async () => {
try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {}
close();
})));
}
function dmView() {
startDMs();
const peer = ui.msgPeer;
if (!peer) { ui.msgView = 'home'; return homeView(); }
const thread = threads.get(peer) || new Map();
const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at);
// Looking at the thread is reading it — including anything that lands while
// it's still open, since every arrival repaints us.
markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine));
stickToBottom();
const my = myPubkeys();
const dmQuote = (m) => {
const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
const src = replyId && thread.get(replyId);
if (!src) return null;
return h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
h('span', { class: 'chat-quote-text' }, String(src.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90)));
};
watchZaps(msgs.slice(-150).map((m) => m.rumor.id));
const dmChips = (m) => {
const reacts = dmReacts.get(m.rumor.id);
const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: !m.mine && canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null });
if ((!reacts || !reacts.size) && !zc) return null;
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
const counts = new Map();
if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1);
return h('div', { class: 'chat-reacts' },
zc,
[...counts.entries()].map(([emoji, n]) => h('span', {
class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''),
onClick: (e) => { e.stopPropagation(); sendDmReaction(peer, m, emoji); },
}, emoji, n > 1 ? ' ' + n : '')));
};
const dmReplyBar = () => {
const m = ui.msgReplyTo && thread.get(ui.msgReplyTo);
if (!m) { ui.msgReplyTo = null; return null; }
return h('div', { class: 'reply-bar' },
h('div', { class: 'col grow', style: 'gap:1px;min-width:0' },
h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.rumor.pubkey)),
h('span', { class: 'small muted chat-quote-text' }, String(m.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×'));
};
return h('div', { class: 'card col chat-card chat-page' },
h('div', { class: 'row chat-head gap6', style: 'align-items:center' },
backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }),
avatar(peer),
h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) },
h('div', { class: 'chat-title' }, displayName(peer)),
h('div', { class: 'muted small' }, t('msgDmEncrypted')))),
h('div', {
class: 'chat-log',
onScroll: (e) => {
const el = e.target;
ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
},
},
...(msgs.length
? msgs.map((m) =>
h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
h('div', { class: 'chat-body' },
h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : ''),
// same tap-for-actions as community bubbles
onClick: (e) => {
if (e.target.closest && e.target.closest('a, button, img')) return;
const sel = window.getSelection && window.getSelection();
if (sel && String(sel).length) return;
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
}, dmQuote(m), ...noteBody(m.rumor.content), dmChips(m)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer),
ui.msgSheet ? dmSheet(peer) : null);
}
// ---- feature ------------------------------------------------------------
function messagesTab() {
if (ui.msgView === 'feed') return feedView();
if (ui.msgView === 'room') return roomView();
if (ui.msgView === 'dm') return dmView();
return homeView();
}
return {
id: 'messages',
// A zap flow reporting how its payment went, so the chip that appeared
// on tap can stop pulsing (paid) or disappear (failed / never fired).
// Also the way a zap sent from a form reaches the tally right away,
// without waiting on its receipt.
zapSettled(eventId, ok, sats) { settleZap(eventId, ok, sats); return true; },
// Chat lives behind a header button and takes over the whole screen —
// no balance card, no tabs; each view carries its own way back.
// The bare avatar node for the app header's identity menu.
// The header rebuilds every render; recreating the each time makes
// the picture visibly flash during boot's render bursts. There is exactly
// one header avatar, so the node itself is reused until the profile
// (or account) actually changes.
// A seed minted seconds ago has no kind-0 anywhere — don't make the
// avatar sit white while a relay lookup confirms that; paint the punk now.
identityGenerated() {
// Synchronous on purpose: identity() can await a signer resume, and
// the header sat avatar-less for that beat. A generated seed's pubkey
// is right here — paint the punk before the next frame.
const pk = (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostr && wallet.nostr.pk);
if (!pk) return;
const entry = { name: null, picture: null, t: Date.now() };
profiles.set(pk, entry);
persistProfile(pk, entry);
scheduleRepaint();
},
headerAvatar(pk) {
// A fresh node every render: the morph keeps the live element (and its
// loaded image) in place when nothing changed, which is exactly what
// the old cached-node trick faked — and reusing one node in two trees
// let the morph strip its children when positions paired it wrong.
return avatar(pk, 'chat-avatar header-ava', false);
},
// Conversations waiting on us, for the header's message button.
unreadMessages() {
// A device with no read watermarks yet (a fresh restore or brand-new
// wallet) will show the dot the moment the community messages land —
// which is always: the default community is never empty, the welcome
// DM is on its way, and read state is device-local. Paint it from the
// first frame instead of popping it in when the fetch returns. NB the
// guard must key on "no MESSAGES landed yet", not "no rooms": init()
// creates the coinos room synchronously before the first paint, so a
// rooms.size check defeated the optimism it was written for.
const s = st();
const anyMsgs = threads.size > 0
|| [...rooms.values()].some((r) => [...r.byChannel.values()].some((m) => m.size));
if (!Object.keys(s.read || {}).length && !anyMsgs) return 1;
return unreadCount();
},
notifySettingsCards() { return [notifyCard()]; },
screenView() {
// A profile deep link mid-resolution holds the frame over EVERY screen
// — an auto-restored wallet would otherwise flash its home page for the
// beat the registrar lookup takes. The shell shows the name straight
// off the URL; no spinner, the empty beat reads calmer.
if (ui.pubProfPending) return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('div', { class: 'chat-avatar profile-avatar fallback loading' }),
h('div', { class: 'chat-title' }, ui.pubProfPending))));
if (ui.screen !== 'wallet') {
// The public (no-wallet) surface: a deep-linked profile, and the
// threads reachable from it, are public nostr content — shown
// without an account. Back falls through to the app's own screens.
if (ui.pubProf) {
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
}
return null;
}
if (ui.zapSetup) return zapSetupScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
if (ui.userSearch && !ui.chatOpen) return userSearchScreen();
if (!ui.chatOpen) return null;
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(true),
messagesTab());
},
// The header magnifier: search anyone on nostr, results open profiles.
userSearchAvailable() { return true; },
openUserSearch() {
ui.chatOpen = false;
ui.profilePk = null;
ui.userSearch = { q: '', rows: null };
warmSearch(); // the field is about to be typed into — open the pipes
return true;
},
// nostr-login just connected or resumed a signer: wraps the backfill
// couldn't open a moment ago can be opened now — drain right away.
nostrSignerLive() { scheduleDrain(0); },
// Anyone (ark's history, other features) can open a profile or render a
// small clickable identity chip.
showProfile(pk) { openProfile(pk); return true; },
// The light profile cache, read-only — lets the onboarding wizard skip
// asks (like the avatar picker) that a loaded identity already answered.
cachedProfile(pk) { return profileOf(pk); },
// Start fetching a profile (and its picture bytes) NOW — the login flow
// calls this the moment it knows the identity, so the avatar is already
// in cache by the time the home screen first paints.
warmProfile(pk) { try { prefetchProfilePage(pk); profileOf(pk); } catch {} return true; },
// Publish (merge) kind-0 fields for the current identity — the onboarding
// wizard sets name + picture through this.
publishProfile(fields, opts = {}) { return publishProfileFields(fields, opts); },
// A migrated coinos.io account brings its face with it: fetch the legacy
// avatar and cover art and fill our kind 0's EMPTY slots — fill, never
// overwrite, so an existing nostr identity keeps its own look. The light
// cache is pre-warmed so the wizard's punk-picker skip sees the picture
// before the publish round-trips.
async adoptLegacyProfile(username) {
try {
const u = await fetch(`https://coinos.io/api/users/${encodeURIComponent(username)}`).then((r) => r.json());
if (!u) return true;
const hosted = (v) => `https://coinos.io/api/public/${v}.webp`;
const picture = u.profile ? hosted(u.profile)
: typeof u.picture === 'string' && /^https?:\/\//i.test(u.picture) ? u.picture : null;
const banner = !u.banner ? null
: /^https?:\/\//i.test(String(u.banner)) ? String(u.banner) : hosted(u.banner);
if (!picture && !banner) return true;
const pk = myPubkeys()[0];
if (pk && picture) {
const cur = profiles.get(pk);
if (!(cur && cur.picture)) {
profiles.set(pk, { ...(cur || {}), picture, t: Date.now() });
preloadPicture({ picture });
scheduleRepaint();
}
}
await publishProfileFields({ picture, banner }, { fillOnly: ['picture', 'banner'] });
} catch {}
return true;
},
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
addressRenamed(oldAddr, newAddr) {
const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
}).catch(() => {});
},
profileChip(pk, size) {
const big = size === 'lg';
return h('span', {
class: 'zap-chip' + (big ? ' lg' : ''),
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, avatar(pk, 'chat-avatar ' + (big ? 'chip-lg' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
init() {
// your own profile is the likeliest first tap — warm it early
setTimeout(() => {
try { const me = ctx.shownPubkey && ctx.shownPubkey(); if (me) prefetchProfilePage(me); } catch {}
}, 2500);
if (urlInvite && !pendingLink) {
loadLinkInvite(urlInvite);
setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0);
}
ui.pubProf = null; // a wallet is open now — its chrome owns the profile
window.addEventListener('resize', onViewportResize);
window.visualViewport?.addEventListener('resize', onViewportResize);
allUnsubs.push(() => {
window.removeEventListener('resize', onViewportResize);
window.visualViewport?.removeEventListener('resize', onViewportResize);
});
startDMs();
// Communities are built from CACHE ONLY at boot (subscribe:false) — the
// unread dot reads the last-known messages, but none of the gift-wrap
// verify/decrypt runs until the user opens Chat (homeView subscribes
// them). This keeps the home screen (and the balance carousel) off the
// hook for the ~500ms of secp256k1 a full community backfill costs.
for (const jm of communities()) { try { ensureRoom(jm, { subscribe: false }); } catch {} }
syncLists().catch(() => {});
syncInbox().catch(() => {});
syncFollows().catch(() => {}); // the Follow button should be right on first paint
registerPush().catch(() => {}); // silent refresh when permission already granted
},
stop() {
for (const u of allUnsubs) { try { u(); } catch {} }
allUnsubs = [];
rooms.clear();
threads.clear();
pendingDirect.clear();
seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't
clearTimeout(drainTimer);
pendingWraps.clear();
dmStarted = false;
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
stopFeedWatch();
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null;
zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next
},
};
}