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.