diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 09:48:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 09:48:13 -0700 |
| commit | 5869fa2ce351efa485ae6547d618a216b09a47c1 (patch) | |
| tree | 6894739087f320bd48c18c58fd5ca6fe8d8d6781 | |
| parent | aaebbd5d51e0e149c36975122dd0ae59e6c011bb (diff) | |
| download | coinosv3-5869fa2ce351efa485ae6547d618a216b09a47c1.tar.gz coinosv3-5869fa2ce351efa485ae6547d618a216b09a47c1.zip | |
A call from someone you don't follow and haven't written to no longer vanishes: it doesn't ring, but it's a missed call in your conversation with them, saying it didn't ring and why, a tap away from calling back (with the app shut, the worker notes it for the app, without a notification). A muted person's still leaves nothing. A logged call no longer counts as having written to someone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
| -rw-r--r-- | src/calls.js | 12 | ||||
| -rw-r--r-- | src/dm-inbox.js | 9 | ||||
| -rw-r--r-- | src/features/messages.js | 40 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 3 | ||||
| -rw-r--r-- | src/sw-nwc.js | 11 | ||||
| -rw-r--r-- | tools/call-test.js | 13 | ||||
| -rw-r--r-- | tools/call-wake-test.js | 11 |
8 files changed, 84 insertions, 17 deletions
diff --git a/src/calls.js b/src/calls.js index 827d605..66d6f16 100644 --- a/src/calls.js +++ b/src/calls.js @@ -64,7 +64,8 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp // send(peerPk, signed) wrap and publish to them (the app picks the relays) // iceServers() → RTCIceServer[] (TURN from coinos, STUN at least) // media(type, facing) → MediaStream: microphone, and camera for 'video' -// mayRing(pk) whether this person may ring us (followed, or a conversation) +// mayRing(pk) 'ring' (someone followed, or written to), 'quiet' (anyone else: +// no ring, but the call is told of, as missed), or null (muted: nothing) // 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 @@ -247,7 +248,14 @@ export function createCalls(env) { if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); } return; } - if (!(await env.mayRing(from))) return; + const may = await env.mayRing(from); + if (!may) return; + if (may === 'quiet') { + // not someone who rings us: no ring, but not nothing either — it's + // logged as a call that didn't ring + try { if (env.ended) env.ended({ id, peer: from, type: tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice', dir: 'in', reason: 'unrung', connected: false, seconds: 0, at: ev.created_at * 1000 }); } catch {} + 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(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; diff --git a/src/dm-inbox.js b/src/dm-inbox.js index 1a026c1..58fc72d 100644 --- a/src/dm-inbox.js +++ b/src/dm-inbox.js @@ -122,7 +122,8 @@ export function shouldNotifyDm(verdict) { // it's for, and worth ringing only as a fresh offer from someone this // account follows or talks to, and hasn't muted. Returns { offer, name, // type } to ring with, or null for anything else (a candidate, a stranger, -// a stale one, a wrap we hold no key for). +// a stale one, a wrap we hold no key for); `quiet` for a caller who +// may not ring us (not followed, not written to). export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) { for (const rec of inboxes) { if (!rec || !rec.sk) continue; @@ -133,9 +134,11 @@ export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / const from = ev.pubkey; if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null; if ((rec.muted || []).includes(from)) return null; - if (!(rec.follows || []).includes(from) && !(rec.known || []).includes(from)) return null; const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; - return { offer: ev, name: (rec.names || {})[from] || null, type }; + // someone not followed nor written to doesn't ring — `quiet`: the app + // lists it as a call that didn't ring, next time it opens + const quiet = !(rec.follows || []).includes(from) && !(rec.rings || rec.known || []).includes(from); + return { offer: ev, name: (rec.names || {})[from] || null, type, quiet }; } return null; } diff --git a/src/features/messages.js b/src/features/messages.js index cde11dc..54c9cfe 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2507,6 +2507,9 @@ export function messagesFeature(ctx) { ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; + // who may ring us when the app is shut: those we've written to (the + // follows ride separately); `known` stays the DM filter's wider list + const rings = known.filter((pk) => wroteTo(pk)); const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); @@ -2515,7 +2518,7 @@ export function messagesFeature(ctx) { await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, - follows, known, names, hasList: !!newest, updated: Date.now(), + follows, known, rings, names, hasList: !!newest, updated: Date.now(), muted: [...mutesNow().set], // no buzz from a muted sender either }); } catch { inboxAt = 0; } @@ -9091,8 +9094,18 @@ export function messagesFeature(ctx) { return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); } // who may ring: someone you follow, or someone you've written to - const mayRing = (pk) => !hiddenPk(pk) && (followsNow().set.has(pk) - || [...(threads.get(pk) || new Map()).values()].some((m) => m.mine)); + // who may ring: someone you follow, or someone you've written to. Anyone + // else's call doesn't ring but is told of, as a missed call that didn't + // ring (a wife's call must not vanish because you never followed her); + // a muted person's leaves nothing. + const mayRing = (pk) => hiddenPk(pk) ? null + : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet'; + // a message of ours in the conversation — not a call logged there (a + // stranger's unrung call would otherwise let their next one ring) + function wroteTo(pk) { + for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true; + return false; + } function callsNow() { if (calls) return calls; calls = createCalls({ @@ -9128,7 +9141,7 @@ export function messagesFeature(ctx) { // 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 === 'missed' || rec.reason === 'unrung' ? 'missed' : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' : rec.reason === 'no-answer' ? 'no-answer' : rec.reason === 'busy' ? 'busy' @@ -9157,7 +9170,7 @@ export function messagesFeature(ctx) { 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']], + ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']], }); noteDM(rec.peer, rumor, true); persistDms(); @@ -9184,11 +9197,15 @@ export function messagesFeature(ctx) { 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'); + // a call that didn't ring (someone you don't follow), saying so — and, + // while that's still so, how to have them ring next time + const unrung = tag('rang') === 'no'; 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)))); + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))), + unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null); } // the call offer a push brought (sw-nwc.js keeps it): rung now, once @@ -9196,6 +9213,17 @@ export function messagesFeature(ctx) { try { if (typeof caches === 'undefined') return; const cache = await caches.open('coinos-call'); + // calls the worker saw from people who don't ring us: logged, unrung + const quiet = await cache.match('/__quiet-calls'); + if (quiet) { + await cache.delete('/__quiet-calls'); + for (const offer of (await quiet.json()) || []) { + if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } const r = await cache.match('/__pending-call'); if (!r) return; await cache.delete('/__pending-call'); diff --git a/src/i18n.js b/src/i18n.js index 0189616..b7432b0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -530,6 +530,8 @@ const STR = { callLogCancelled: 'Cancelled {what}', callLogBusy: 'Outgoing {what} · Busy', callLogFailed: '{what} · Couldn’t connect', + callLogUnrung: 'This didn’t ring: calls ring from people you follow or have written to.', + callLogUnrungWas: 'This didn’t ring: you didn’t follow them then.', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', diff --git a/src/style.css b/src/style.css index dff1c50..235cb4b 100644 --- a/src/style.css +++ b/src/style.css @@ -1942,7 +1942,8 @@ body::after { .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; } +.chat-row.call-log { justify-content: center; margin: 6px 0; flex-direction: column; align-items: center; gap: 3px; } +.call-log-why { text-align: center; max-width: 320px; } .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; } diff --git a/src/sw-nwc.js b/src/sw-nwc.js index b6af6b8..f9d383e 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -35,6 +35,17 @@ self.addEventListener('push', (e) => { if (r.ok) ring = await classifyCall(await r.json(), await allInboxes()); } catch (err) { console.warn('[sw-call] could not open:', err && err.message); } if (!ring) return; + if (ring.quiet) { + // no ring for them; a note for the app, which lists it as a missed call + try { + const cache = await caches.open(CALL_CACHE); + const had = await cache.match('/__quiet-calls'); + const list = had ? await had.json() : []; + list.push(ring.offer); + await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } })); + } catch (_) {} + return; + } try { const cache = await caches.open(CALL_CACHE); await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } })); diff --git a/tools/call-test.js b/tools/call-test.js index 2958187..7e9a99c 100644 --- a/tools/call-test.js +++ b/tools/call-test.js @@ -2,8 +2,8 @@ // between them. The caller's 📞 / 🎥 in their conversation rings the other // over whatever page they're on; Answer connects them — each hears and sees // the other — mute and camera-off reach the other side, hang-up ends it on -// both. A call can be declined, and a stranger (neither followed nor written -// to) doesn't ring at all. Everything on the wire is an ephemeral gift wrap. +// both. A call can be declined, and a stranger's (neither followed nor +// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap. // Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/) import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; @@ -162,6 +162,15 @@ try { await sleep(5000); check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); await C.page.evaluate(() => document.querySelector('.call-hang')?.click()); + await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000); + await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000); + await sleep(4000); + check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await C.page.evaluate(() => document.querySelector('.call-hang')?.click()); + await openDm(B, C.pk); + check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000), + JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText)))); check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000)); for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | ')); } catch (e) { console.error(e); ok = false; } finally { diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js index e14935a..54c42ee 100644 --- a/tools/call-wake-test.js +++ b/tools/call-wake-test.js @@ -26,10 +26,11 @@ console.log('[the worker\'s check]'); const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }]; const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to); const r = await classifyCall(offer(alice), box); - check('a fresh offer from someone followed rings, named, video', !!r && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al); - check('...a stranger\'s doesn\'t', (await classifyCall(offer(eve), box)) === null); + check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al); + check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true); check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null); - check('...but someone written to does', !!(await classifyCall(offer(eve), [{ ...box[0], known: [E] }]))); + check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false); + check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true); check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null); check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null); check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null); @@ -176,6 +177,10 @@ try { await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); await B.waitText('receive'); check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' }); + await B.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); + check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000)); console.log('\n[rung while closed, and nobody came]'); await B.page.goto('about:blank'); |
