aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 09:48:13 -0700
committerAdam Soltys <[email protected]>2026-10-18 09:48:13 -0700
commit5869fa2ce351efa485ae6547d618a216b09a47c1 (patch)
tree6894739087f320bd48c18c58fd5ca6fe8d8d6781
parentaaebbd5d51e0e149c36975122dd0ae59e6c011bb (diff)
downloadcoinosv3-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.js12
-rw-r--r--src/dm-inbox.js9
-rw-r--r--src/features/messages.js40
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css3
-rw-r--r--src/sw-nwc.js11
-rw-r--r--tools/call-test.js13
-rw-r--r--tools/call-wake-test.js11
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');