feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge
The native call feature (iOS). Backward-compatible: without the plugin (current builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older builds behave exactly as before. Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route): - PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen ring, works when the app is force-killed). - CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on didActivate so the WebView's WebRTC audio rides a call-priority session (background). - Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same active-call background-audio context. - NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps; ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit). Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup + WebAudio ring are suppressed (the system rings instead); outgoing calls are reported to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call uuid through the dm-call/group-call WS events + start responses so both sides can match the CallKit call. Needs a Codemagic build to compile the plugin; first on-device iteration expected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-11
@@ -63,7 +63,7 @@ async function postSystem(group, teamId, text) {
|
||||
|
||||
async function startGroupCall(group, teamId, user) {
|
||||
const existing = groupCalls.get(group);
|
||||
if (existing) return { room: existing.room, active: true, already: true };
|
||||
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||
@@ -72,11 +72,11 @@ async function startGroupCall(group, teamId, user) {
|
||||
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 });
|
||||
broadcast(group, { type: 'group-call', group, active: true, room, uuid: call.uuid, by: user.id, startedByName: call.startedByName, groupName: gName });
|
||||
// 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 };
|
||||
return { room, uuid: call.uuid, active: true };
|
||||
}
|
||||
|
||||
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
||||
@@ -88,7 +88,7 @@ async function endGroupCallByRoom(room) {
|
||||
if (call) {
|
||||
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
||||
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||
broadcast(group, { type: 'group-call', group, active: false, room });
|
||||
broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ async function endGroupCallByRoom(room) {
|
||||
async function startDmCall(me, otherId, teamId) {
|
||||
const key = pairKey(me.id, otherId);
|
||||
const existing = dmCalls.get(key);
|
||||
if (existing) return { room: existing.room, active: true, already: true };
|
||||
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const byName = me.name || me.email;
|
||||
@@ -118,13 +118,13 @@ async function startDmCall(me, otherId, teamId) {
|
||||
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
||||
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||
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 (_) {}
|
||||
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, uuid: call.uuid, with: me.id, by: me.id, byName }); } catch (_) {}
|
||||
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, uuid: call.uuid, with: otherId, by: me.id, byName }); } 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 };
|
||||
return { room, uuid: call.uuid, active: true };
|
||||
}
|
||||
|
||||
async function endDmCallByRoom(room, silent) {
|
||||
@@ -141,7 +141,7 @@ async function endDmCallByRoom(room, silent) {
|
||||
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
||||
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||
} catch (_) {}
|
||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
|
||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} });
|
||||
}
|
||||
|
||||
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
||||
@@ -162,7 +162,7 @@ async function replayActiveCalls(userId, ws) {
|
||||
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 (_) {}
|
||||
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
|
||||
}
|
||||
}
|
||||
for (const [group, call] of groupCalls) {
|
||||
@@ -170,7 +170,7 @@ async function replayActiveCalls(userId, ws) {
|
||||
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 (_) {}
|
||||
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
+45
-7
@@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){
|
||||
}
|
||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||
async function startOrJoinGroupCall(group){
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; const _g=rowFor('group',group); if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', true); switchTab('meeting'); enterMeeting(r.room); } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||||
@@ -2554,14 +2554,15 @@ function onGroupCall(d){
|
||||
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
|
||||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
|
||||
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
|
||||
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
|
||||
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
||||
renderChats(searchVal());
|
||||
}
|
||||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
||||
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||
async function startOrJoinDmCall(otherId){
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||
@@ -2579,8 +2580,10 @@ function onDmCall(d){
|
||||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
|
||||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
|
||||
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||||
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
|
||||
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
|
||||
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
||||
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
||||
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
||||
renderChats(searchVal());
|
||||
}
|
||||
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
|
||||
@@ -3195,7 +3198,7 @@ async function openConvo(kind,id){
|
||||
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
|
||||
// is opened from a call notification, the original transient invite popup may already have closed —
|
||||
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
|
||||
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){
|
||||
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup
|
||||
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
|
||||
}
|
||||
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
|
||||
@@ -3772,6 +3775,40 @@ async function unsubscribePush(){
|
||||
pushActive=false; console.log('[push] unsubscribed (logout)');
|
||||
}catch(_){}
|
||||
}
|
||||
// ---------- Native calling (iOS CallKit + PushKit VoIP) ----------
|
||||
// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and
|
||||
// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On
|
||||
// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup
|
||||
// (onDmCall / the group invite check nativeCallOn()).
|
||||
let _callkitReady=false;
|
||||
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
|
||||
function nativeCallOn(){ return _callkitReady; }
|
||||
async function setupNativeCall(){
|
||||
const NC=nativeCallPlugin(); if(!NC) return;
|
||||
_callkitReady=true;
|
||||
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
|
||||
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
|
||||
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){}
|
||||
// User answered on the CallKit screen → join that call's room (CallKit already foregrounded the app).
|
||||
NC.addListener('answerCall', (d)=>{ try{
|
||||
if(!d||!d.room) return;
|
||||
dismissCallInvite(d.room);
|
||||
meetReturn = (d.kind==='group') ? {kind:'group', id:d.groupId} : {kind:'dm', id:d.callerId};
|
||||
switchTab('meeting'); enterMeeting(d.room);
|
||||
}catch(e){ console.warn('[callkit] answer failed', e); } });
|
||||
// User declined/ended on the CallKit screen.
|
||||
NC.addListener('endCall', (d)=>{ try{
|
||||
const room=d&&d.room;
|
||||
if(room && (typeof meetRoom!=='undefined') && meetRoom===room){ leaveMeeting(); } // already in it → leave
|
||||
else if(room){ postJSON('/api/calls/decline',{room}).catch(()=>{}); dismissCallInvite(room); } // ringing, not joined → decline
|
||||
}catch(_){} });
|
||||
console.log('[callkit] native calling ready');
|
||||
}
|
||||
// Register an OUTGOING call with CallKit so it's a system call too (→ background audio for outgoing calls).
|
||||
function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo }); }catch(_){} }
|
||||
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||||
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||||
|
||||
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
||||
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
||||
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
|
||||
@@ -5661,6 +5698,7 @@ window.addEventListener('message',(e)=>{
|
||||
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
|
||||
wireUpdateBanner(); // #3: desktop update-progress banner
|
||||
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
||||
setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave
|
||||
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
||||
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
||||
// Fast-path: when opened from a notification, show the chat immediately (only needs the
|
||||
|
||||
Reference in New Issue
Block a user