diff options
Diffstat (limited to 'src/features')
| -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 }, |
