feat(calls): server foundation for native CallKit/VoIP calling (phase A)
First slice of the native call feature. Backward-compatible: with no VoIP tokens registered yet it behaves exactly like today's banner push. - push.js: sendApnsVoip() sends a PushKit VoIP push (apns-push-type 'voip', topic <bundle>.voip, reusing the same .p8) to wake a killed app for CallKit; and sendCallNotification() which PREFERS a VoIP push when the user has an 'ios-voip' token, else falls back to the normal alert/banner push (Android/web/pre-CallKit iOS). - routes.js: /api/devices now accepts platform 'ios-voip' (the PushKit token, stored alongside the normal alert token in device_tokens). - calls.js: each call now carries a stable crypto.randomUUID() (CallKit needs a UUID to report + later cancel the call); DM and group call notifications route through PUSH.sendCallNotification instead of the raw banner push. Next: the native-call Capacitor plugin (PushKit + CallKit + LiveKit iOS SDK). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
-9
@@ -2,6 +2,7 @@
|
||||
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
const CHAT = require('./chat');
|
||||
@@ -65,16 +66,16 @@ async function startGroupCall(group, teamId, user) {
|
||||
if (existing) return { room: existing.room, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
||||
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
||||
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 (_) {}
|
||||
// Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available,
|
||||
// else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws.
|
||||
try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendCallNotification(mid, { callUUID: call.uuid, room, kind: 'group', groupId: group, groupName: gName, callerId: user.id, callerName: call.startedByName, title: gName, body: '📞 ' + call.startedByName + ' started a group call', hasVideo: true }); } } catch (_) {}
|
||||
return { room, active: true };
|
||||
}
|
||||
|
||||
@@ -99,7 +100,7 @@ async function startDmCall(me, otherId, teamId) {
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const byName = me.name || me.email;
|
||||
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
||||
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
||||
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
||||
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
||||
@@ -119,10 +120,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 (_) {}
|
||||
// Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token,
|
||||
// else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On
|
||||
// reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way.
|
||||
try { PUSH.sendCallNotification(otherId, { callUUID: call.uuid, room, kind: 'dm', callerId: me.id, callerName: byName, title: byName, body: '📞 Incoming call', hasVideo: false }); } catch (_) {}
|
||||
return { room, active: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user