aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/calls.js7
-rw-r--r--src/features/messages.js93
-rw-r--r--src/i18n.js11
-rw-r--r--src/style.css7
4 files changed, 114 insertions, 4 deletions
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)); }