diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 21:04:09 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 21:04:09 -0700 |
| commit | dc8f4b841ff57f4ef74597c2f80b6f90b352db13 (patch) | |
| tree | 43119ca9e4b207f53509d9a6c6eafc26b9be6a71 /src/features/messages.js | |
| parent | d59d667d3e4c6edb42b8a42fd120a1bdd96a58db (diff) | |
| download | coinosv3-dc8f4b841ff57f4ef74597c2f80b6f90b352db13.tar.gz coinosv3-dc8f4b841ff57f4ef74597c2f80b6f90b352db13.zip | |
Messages in the header, and a room you can tell is alive
The header carries a message button again, left of the wallet selector,
with a red dot when a conversation is waiting. Unread is a per-thread
watermark on the newest message from someone else, kept in the rumor's
own created_at so it means the same thing on every device it syncs to.
Rooms subscribe at startup now, or the dot could not speak for a
community nobody was listening to.
Communities no longer buzz a phone by default: push registration only
carries the stream keys of communities you have opted in, per community,
from a bell in the room header. DMs and payments are unchanged.
Presence and typing ride the channel stream as 21059 wraps, so who is
around stays inside the community's encryption rather than becoming a
public status. Measured against relay.coinos.io and nos.lol, neither
actually drops NIP-01 ephemeral kinds, so beats carry a NIP-40
expiration and relay.coinos.io sweeps kind 21059 hourly — and presence
is dated by the beat, never by arrival, or a replayed beat would light
up the whole member list.
The member list follows Telegram: online, then last seen in minutes and
hours, then a vague 'recently' rather than advertising how long someone
has been gone.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 246 |
1 files changed, 239 insertions, 7 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index d93a863..70886d3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -61,6 +61,8 @@ export function messagesFeature(ctx) { 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) 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)) @@ -70,6 +72,43 @@ export function messagesFeature(ctx) { 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; + + const newestFrom = (entries, theirs) => { + let ts = 0; + for (const m of entries) if (theirs(m)) ts = Math.max(ts, m.rumor.created_at || 0); + return ts; + }; + + function markRead(key, ts) { + const s = st(); + if (!ts || (s.read[key] || 0) >= ts) return; + s.read[key] = ts; + save(s); + } + + // How many conversations are waiting on us — the header only draws a dot, but + // a count keeps the door open for a number later. + function unreadCount() { + const s = st(); + const my = myPubkeys(); + let n = 0; + for (const [pk, msgs] of threads) + if (newestFrom(msgs.values(), (m) => !m.mine) > (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)) > (s.read[chRead(id)] || 0)) n++; + return n; + } const communityById = (cid) => communities().find((c) => c.community_id === cid); // ---- identity ----------------------------------------------------------- @@ -170,6 +209,8 @@ export function messagesFeature(ctx) { edits: new Map(), deletes: new Set(), reactions: new Map(), + presence: new Map(), // pubkey -> ms of their last beat (this session only) + typing: new Map(), // pubkey -> { ch, t } subbed: new Set(), relays: jm.relays && jm.relays.length ? jm.relays : DM_RELAYS, }; @@ -219,7 +260,7 @@ export function messagesFeature(ctx) { function subChannel(room, id) { if (room.subbed.has(id)) return; room.subbed.add(id); - allUnsubs.push(subscribeOn(room.relays, { kinds: [1059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { + allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); const opened = openWrap(wrap, room.chStream(id)); @@ -233,9 +274,24 @@ export function messagesFeature(ctx) { // CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return; if (room.folded && room.folded.banned.has(author)) return; + if (rumor.kind === PRESENCE || rumor.kind === TYPING) { + // Dated by the beat itself, never by arrival. Relays hand back stored + // beats when we subscribe, and treating those as "just now" would light + // up the whole member list as online. Clamped to now so a fast clock + // can't hold someone online forever. + const ts = Math.min(Date.now(), eventMs(rumor) || rumor.created_at * 1000); + if (ts > (room.presence.get(author) || 0)) room.presence.set(author, ts); + if (rumor.kind === TYPING && Date.now() - ts < TYPING_MS) { + room.typing.set(author, { ch: channelId, t: ts }); + setTimeout(scheduleRepaint, TYPING_MS + 100); // clear itself when it lapses + } + scheduleRepaint(); + return; + } if (rumor.kind === 9) { const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId); msgs.set(rumor.id, { rumor, author }); + room.typing.delete(author); // the message itself ends the "typing…" } else if (rumor.kind === 5) { for (const e of rumor.tags.filter((x) => x[0] === 'e')) { const m = room.byChannel.get(channelId)?.get(e[1]); @@ -519,15 +575,38 @@ export function messagesFeature(ctx) { 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 on = st().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); } return { ptags: myPubkeys(), authors }; } + 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(); + } + } + async function registerPush({ interactive = false } = {}) { try { if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false; @@ -996,6 +1075,100 @@ export function messagesFeature(ctx) { const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); + // ---- 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) => { @@ -1017,12 +1190,12 @@ export function messagesFeature(ctx) { }); }; - const composer = (placeholder, onSend) => + const composer = (placeholder, onSend, onType) => h('div', { class: 'chat-compose' }, h('input', { class: 'grow', type: 'text', id: 'msg-draft', placeholder, value: ui.msgDraft || '', maxlength: '2000', - onInput: (e) => { ui.msgDraft = e.target.value; }, + onInput: (e) => { ui.msgDraft = e.target.value; if (onType && e.target.value) onType(); }, onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } }, }), h('button', { class: 'btn-primary btn-sm', disabled: ui.msgSending, onClick: onSend }, @@ -1191,6 +1364,7 @@ export function messagesFeature(ctx) { .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; @@ -1237,17 +1411,25 @@ export function messagesFeature(ctx) { const chans = roomChannels(room); const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0]; const name = room.folded?.metadata?.name || jm.name; - const memberCount = [...room.members.values()].filter((m) => m.state === 'join').length; + const members = [...room.members.entries()].filter(([, m]) => m.state === 'join'); + const memberCount = members.length; + const onlineCount = members.filter(([pk]) => isOnline(room, pk)).length; stickToBottom(); + keepBeating(); + if (ch) ping(room, ch.id, PRESENCE); return h('div', { class: 'card col chat-card' }, h('div', { class: 'row between chat-head' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, backBtn(() => { ui.msgView = 'home'; render(); }), - h('div', { class: 'col', style: 'gap:2px;min-width:0' }, + 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')))), + 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' }, chans.length > 1 ? h('div', { class: 'seg' }, chans.map((c) => @@ -1257,10 +1439,19 @@ export function messagesFeature(ctx) { }, '#' + c.name))) : h('div', { class: 'tag' }, '#' + (ch ? ch.name : '')), 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) + ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0" fill="none"/></svg>' + : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.7 21a2 2 0 0 1-3.4 0"/><path d="M18 8a6 6 0 0 0-9.3-5"/><path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h15"/><path d="m2 2 20 20"/></svg>', + }), + h('button', { class: 'iconbtn', title: t('msgInviteTitle'), onClick: () => { ui.msgInvitePanel = !ui.msgInvitePanel; render(); }, }, '+'))), ui.msgInvitePanel ? invitePanel(room) : null, + ui.msgMembers ? memberPanel(room, members) : null, h('div', { class: 'chat-log', onScroll: (e) => { @@ -1268,7 +1459,40 @@ export function messagesFeature(ctx) { ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; }, }, ...(ch ? messageRows(room, ch.id) : [])), - composer(t('msgPlaceholder', { channel: ch ? ch.name : '' }), () => ch && sendMessage(room, ch.id))); + ch ? typingLine(room, ch.id) : null, + composer( + t('msgPlaceholder', { channel: ch ? ch.name : '' }), + () => ch && sendMessage(room, ch.id), + () => ch && ping(room, ch.id, TYPING))); + } + + // "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) { @@ -1320,6 +1544,9 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const msgs = [...(threads.get(peer)?.values() || [])].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + // Looking at the thread is reading it — including anything that lands while + // it's still open, since every arrival repaints us. + markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine)); stickToBottom(); return h('div', { class: 'card col chat-card' }, h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, @@ -1359,6 +1586,8 @@ export function messagesFeature(ctx) { // no balance card, no tabs; each view carries its own way back. // The bare avatar node for the app header's identity menu. headerAvatar(pk) { return avatar(pk, 'chat-avatar header-ava', false); }, + // Conversations waiting on us, for the header's message button. + unreadMessages() { return unreadCount(); }, screenView() { if (ui.screen !== 'wallet') return null; if (ui.profilePk) return profileScreen(); @@ -1406,6 +1635,9 @@ export function messagesFeature(ctx) { setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } startDMs(); + // Communities subscribe up front too, not just when chat opens — the + // header's unread dot can't report a room nobody is listening to. + for (const jm of communities()) { try { ensureRoom(jm); } catch {} } syncLists().catch(() => {}); registerPush().catch(() => {}); // silent refresh when permission already granted }, |
