fix(calls): send native push for incoming calls + replay on reconnect

Calls only notified over the chat WebSocket (CHAT.pushToUser), so a CLOSED app
(no live socket) never rang — unlike messages, which also call PUSH.sendToUser.
Add PUSH.sendToUser for both DM (startDmCall -> callee) and group (startGroupCall
-> other members) so APNs/FCM/WebPush alerts a closed device.

To make the alert actionable, add CALLS.replayActiveCalls(userId, ws), invoked
from the chat-hello handler: when a socket (re)connects, re-send any dm-call /
group-call the user is currently being rung into (the original events fire once at
call start and are missed by an app that was closed). Opening the app from the push
then re-surfaces the invite so they can answer within the ring window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 22:13:52 +05:30
parent 1920258fd6
commit a0b3936799
2 changed files with 36 additions and 1 deletions
+33 -1
View File
@@ -5,6 +5,7 @@ const path = require('path');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const PUSH = require('./push'); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
@@ -71,6 +72,9 @@ async function startGroupCall(group, teamId, user) {
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
// Native push to the OTHER members so a closed app (no live WS) is notified of the group call. The
// broadcast() above only reaches connected sockets. Best-effort; sendToUser never throws.
try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendToUser(mid, { title: gName, body: '📞 ' + call.startedByName + ' started a group call', kind: 'group', id: group, room, tag: 'call:' + room, data: { kind: 'group', id: group, room } }); } } catch (_) {}
return { room, active: true };
}
@@ -115,6 +119,10 @@ async function startDmCall(me, otherId, teamId) {
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
// Native push so a CLOSED app (no live WS) still rings — the CHAT.pushToUser events above only reach a
// connected socket. Best-effort. On reconnect the callee's app re-shows this invite (replayActiveCalls),
// so tapping the banner into the app lets them answer while the caller is still ringing.
try { PUSH.sendToUser(otherId, { title: byName, body: '📞 Incoming call', kind: 'dm', id: me.id, room, tag: 'call:' + room, icon: me.avatar_url || undefined, data: { kind: 'dm', id: me.id, room } }); } catch (_) {}
return { room, active: true };
}
@@ -142,6 +150,30 @@ function markDmAnswered(room, userId) {
const call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
}
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
// original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses
// them. This makes the call PUSH actionable: tapping the banner opens the app, the socket connects, and
// the invite re-appears so they can answer (while the caller is still within the ring window). Sends only
// to the freshly-connected socket. Best-effort; never throws.
async function replayActiveCalls(userId, ws) {
if (!userId || !ws || ws.readyState !== 1) return;
try {
for (const [, call] of dmCalls) {
if (call.answered) continue;
if (call.users.includes(userId) && call.startedBy !== userId) {
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
}
}
for (const [group, call] of groupCalls) {
if (call.startedBy === userId) continue;
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
if (!member) continue;
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
}
} catch (_) {}
}
// Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
@@ -168,4 +200,4 @@ async function declineDmCall(room, byUser) {
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
+3
View File
@@ -92,6 +92,9 @@ async function handle(ws, m, req) {
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
// Re-ring any call this user is currently being called into (missed while their app was closed) —
// makes the call push actionable: opening the app resurfaces the invite so they can answer.
try { require('./calls').replayActiveCalls(u.id, ws); } catch (_) {}
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.