From a0b39367994252eb439774a7918924e252616c63 Mon Sep 17 00:00:00 2001 From: sravan Date: Mon, 27 Jul 2026 22:13:52 +0530 Subject: [PATCH] fix(calls): send native push for incoming calls + replay on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/calls.js | 34 +++++++++++++++++++++++++++++++++- server/signaling.js | 3 +++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/calls.js b/server/calls.js index f6b709c..7540b84 100644 --- a/server/calls.js +++ b/server/calls.js @@ -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 }; diff --git a/server/signaling.js b/server/signaling.js index 3959387..9bfd044 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -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.