From aaebbd5d51e0e149c36975122dd0ae59e6c011bb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 09:27:09 -0700 Subject: Call history: each call is a row in its conversation — Outgoing / Incoming video call · 4:12, Missed, Declined, No answer, Cancelled, Busy — a tap calls back. Kept like a message and sent to our own inbox (kind 1405, gift-wrapped to us alone) so our other devices list it; a call logged by two of our devices shows once, by its call-id. A call that rang while the app was shut and went unanswered is a missed call when it next opens. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/calls.js | 7 +++- src/features/messages.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++-- src/i18n.js | 11 ++++++ src/style.css | 7 ++++ 4 files changed, 114 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/calls.js b/src/calls.js index 98d0a46..827d605 100644 --- a/src/calls.js +++ b/src/calls.js @@ -66,6 +66,7 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp // media(type, facing) → MediaStream: microphone, and camera for 'video' // mayRing(pk) whether this person may ring us (followed, or a conversation) // changed() the call's state moved: draw it +// ended(record) a call is over: { id, peer, type, dir, reason, connected, seconds, at } for its log // trickle() false for a signer that's slow or asks each time (a // bunker, Amber): the candidates go inside the offer or // answer instead of one signed message each @@ -93,6 +94,10 @@ export function createCalls(env) { stopMedia(c); c.state = 'ended'; c.reason = reason; c.endedAt = Date.now(); changed(); + try { + if (env.ended) env.ended({ id: c.id, peer: c.peer, type: c.type, dir: c.dir, reason, at: c.offerAt || c.at, connected: !!c.connectedAt, + seconds: c.connectedAt ? Math.round((c.endedAt - c.connectedAt) / 1000) : 0 }); + } catch {} clearTimeout(endTimer); endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); } @@ -245,7 +250,7 @@ export function createCalls(env) { if (!(await env.mayRing(from))) return; clearTimeout(endTimer); const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice'; - const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' }; + const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); changed(); return; diff --git a/src/features/messages.js b/src/features/messages.js index 9d03a86..cde11dc 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -72,6 +72,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st // 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 CALL_LOG = 1405; // a call, logged in its conversation (see logCall) const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos']; // A view the URL asks to open (a notification tap): read once, then taken @@ -3110,6 +3111,10 @@ export function messagesFeature(ctx) { } else if (got.rumor.kind === 444 && !isMe(got.author)) { // a White Noise invitation: the group itself says who is asking whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); + } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { + // a call, as one of our devices logged it, under the person it was with + const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; + if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } @@ -3404,6 +3409,10 @@ export function messagesFeature(ctx) { if (rumor.kind === 15) { row.k = 15; row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else if (rumor.kind === CALL_LOG) { + // a call: its who, how and how long are all in its tags + row.k = CALL_LOG; + row.tg = rumor.tags || []; } else { // a message's custom emoji ride along, or they'd be text after a reload const em = [...emojiTagMap(rumor.tags)]; @@ -9097,6 +9106,7 @@ export function messagesFeature(ctx) { media: callMedia, mayRing: async (pk) => mayRing(pk), changed: () => { callRing(); render(); }, + ended: (rec) => { logCall(rec).catch(() => {}); }, // a key of our own signs each candidate at once; a remote signer gets // them all inside the offer or answer instead trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, @@ -9111,6 +9121,76 @@ export function messagesFeature(ctx) { if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } } } + // The call log: each call a row in its conversation ("Outgoing video call + // 4:12", "Missed voice call"), kept like a message and sent to our own + // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC + // discussion uses for logs, which other clients pass over) so our other + // devices list it too. A call rung on two of our devices is logged by + // both; the conversation shows it once, by its call-id. + const callStatus = (rec) => rec.connected ? 'answered' + : rec.reason === 'missed' ? 'missed' + : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' + : rec.reason === 'no-answer' ? 'no-answer' + : rec.reason === 'busy' ? 'busy' + : rec.reason === 'hung-up' ? 'cancelled' + : 'failed'; + const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 }; + function callLogText(dir, type, status, seconds) { + const video = type === 'video'; + const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn') + : status === 'missed' ? 'callLogMissed' + : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined') + : status === 'no-answer' ? 'callLogNoAnswer' + : status === 'cancelled' ? 'callLogCancelled' + : status === 'busy' ? 'callLogBusy' : 'callLogFailed'; + const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') }); + return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what; + } + async function logCall(rec) { + if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it + // (a missed call found as the app opens may come before its keys do) + let id = await identity(); + for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); } + if (!id) return; + const status = callStatus(rec); + const rumor = rumorWithId({ + kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000), + content: callLogText(rec.dir, rec.type, status, rec.seconds), + tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status], + ['duration', String(rec.seconds || 0)], ['alt', 'Call log']], + }); + noteDM(rec.peer, rumor, true); + persistDms(); + // to our other devices — not through Amber, a prompt per call for a log line + if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return; + const toSelf = await wrapDM(id.signer, id.pubkey, rumor); + publishOn(DM_RELAYS, toSelf); + toOwnInbox(toSelf); + } + // A conversation's rows with each call once: its most telling log (one + // device answered it while another only saw it ring) + function foldCallLogs(msgs) { + const best = new Map(); + for (const m of msgs) { + if (m.rumor.kind !== CALL_LOG) continue; + const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id; + const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0; + const cur = best.get(cid); + if (!cur || st2(m) > st2(cur)) best.set(cid, m); + } + return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m); + } + function callLogRow(m, peer) { + const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1]; + const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out'; + const missed = status === 'missed' || (dir === 'in' && status === 'declined'); + return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' }, + h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) }, + callIcon(type === 'video' ? 'video' : 'phone'), + h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)), + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))); + } + // the call offer a push brought (sw-nwc.js keeps it): rung now, once async function pendingCall() { try { @@ -9120,7 +9200,14 @@ export function messagesFeature(ctx) { if (!r) return; await cache.delete('/__pending-call'); const { offer, at } = await r.json(); - if (offer && Date.now() - at < RING_S * 1000) await callsNow().handle(offer, { maxAge: RING_S }); + if (!offer) return; + if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; } + // rang while the app was shut, and nobody came: a missed call in the conversation + if (mayRing(offer.pubkey)) { + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } } catch {} } function startCall(peer, type) { @@ -16352,7 +16439,7 @@ export function messagesFeature(ctx) { 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); + const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); const choice = routeChoice(peer); @@ -16418,7 +16505,7 @@ export function messagesFeature(ctx) { }, }, ...(msgs.length - ? msgs.map((m) => + ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) : h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-line' }, h('div', { diff --git a/src/i18n.js b/src/i18n.js index 80e8146..0189616 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -519,6 +519,17 @@ const STR = { callDeclinedElsewhere: 'Declined on another device', callUnsupported: 'This browser can’t make calls.', notifCall: 'Calls', + callLogVoice: 'voice call', + callLogVideo: 'video call', + callLogOut: 'Outgoing {what}', + callLogIn: 'Incoming {what}', + callLogMissed: 'Missed {what}', + callLogDeclined: 'Declined {what}', + callLogTheyDeclined: 'Outgoing {what} · Declined', + callLogNoAnswer: 'Outgoing {what} · No answer', + callLogCancelled: 'Cancelled {what}', + callLogBusy: 'Outgoing {what} · Busy', + callLogFailed: '{what} · Couldn’t connect', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', diff --git a/src/style.css b/src/style.css index e1b47c1..dff1c50 100644 --- a/src/style.css +++ b/src/style.css @@ -1941,3 +1941,10 @@ body::after { padding: 8px 16px; border-radius: 999px; border: 0; background: #1fa45a; color: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, .3); } .call-min-status { font-variant-numeric: tabular-nums; opacity: .9; } .call-min .call-audio { display: none; } +/* a call in its conversation: a row of its own, centred, a tap calls back */ +.chat-row.call-log { justify-content: center; margin: 6px 0; } +.call-log-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; + border: 1px solid var(--line); background: var(--surface2); color: inherit; font-size: 13px; cursor: pointer; } +.call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; } +.call-log-pill svg { width: 16px; height: 16px; } +.chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); } -- cgit v1.2.3