diff --git a/server/public/home.html b/server/public/home.html
index 1253053..162bc12 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -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); /* Outgoing stays on the WebView (no CallKit) so the mic works. */ } }
+ try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); return; } /* native: CallKit + native LiveKit, no WebView join */ meetReturn={kind:'group',id:group}; 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?'Join':''); }
@@ -2562,7 +2562,7 @@ function onGroupCall(d){
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. Outgoing stays on the WebView (no CallKit) so the mic works. */ } }
+ try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); return; } /* native: CallKit + native LiveKit, no WebView join */ _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 */ } }
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
@@ -3793,34 +3793,34 @@ async function setupNativeCall(){
// 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).
+ // Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
+ // (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
+ // the call was answered (native media bypasses our WS, so the server can't learn it otherwise) and clear
+ // the in-app invite. Track answered rooms so end vs decline is signalled correctly.
+ const _nativeAnswered = new Set();
NC.addListener('answerCall', (d)=>{ try{
if(!d||!d.room) return;
+ _nativeAnswered.add(d.room);
dismissCallInvite(d.room);
- meetReturn = (d.kind==='group') ? {kind:'group', id:d.groupId} : {kind:'dm', id:d.callerId};
- switchTab('meeting'); enterMeeting(d.room);
+ postJSON('/api/calls/answered',{ room:d.room }).catch(()=>{});
}catch(e){ console.warn('[callkit] answer failed', e); } });
- // User declined/ended on the CallKit screen.
+ // Ended/declined on the CallKit screen (or the plugin ended it on remote hang-up). Tell the server: END an
+ // answered call, DECLINE one that was still ringing.
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
+ const room=d&&d.room; if(!room) return;
+ dismissCallInvite(room);
+ if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
+ else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
}catch(_){} });
- // After answer, CallKit releases the mic + audio session back to us (an active CallKit call reserves the
- // mic → dead mic + earpiece). Force the loudspeaker (CallKit used the earpiece), then RE-ACQUIRE the mic
- // (it was captured dead while CallKit held it). Retry the mic toggle a few times in case the WebView is
- // still joining the room when the handoff fires.
- NC.addListener('callHandoff', async ()=>{
- try{ const ar=nativeAudioRoute(); if(ar) await ar.setSpeaker({on:true}); }catch(_){}
- for(let i=0;i<5;i++){
- try{ const lp=(typeof SFU!=='undefined'&&SFU.room&&SFU.room.localParticipant)||null; if(lp && typeof sfuSetMic==='function'){ await sfuSetMic(false); await sfuSetMic(true); break; } }catch(_){}
- await new Promise(r=>setTimeout(r,700));
- }
- });
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(_){} }
+// Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the
+// CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity).
+async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
+ const NC=nativeCallPlugin(); if(!NC||!uuid) return;
+ let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
+ try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }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(_){} }
diff --git a/server/routes.js b/server/routes.js
index aae271d..55fd644 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -1074,6 +1074,26 @@ route('POST', '/api/calls/decline', async (req, res) => {
json(res, 200, await CALLS.declineDmCall(String(room), u));
});
+// --- Native-call lifecycle. NATIVE (CallKit + LiveKit) calls carry media over LiveKit, NOT our mesh/WS, so
+// the server can't learn from a room emptying that a native call was answered/ended. The app signals it
+// explicitly. (WebView/mesh calls keep using the WS room lifecycle — these are additive no-ops there.) ---
+route('POST', '/api/calls/answered', async (req, res) => {
+ const u = await currentUser(req);
+ if (!u) return json(res, 401, { error: 'unauthorized' });
+ const { room } = await readBody(req);
+ if (!room) return json(res, 400, { error: 'room required' });
+ try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {}
+ json(res, 200, { ok: true });
+});
+route('POST', '/api/calls/end', async (req, res) => {
+ const u = await currentUser(req);
+ if (!u) return json(res, 401, { error: 'unauthorized' });
+ const { room } = await readBody(req);
+ if (!room) return json(res, 400, { error: 'room required' });
+ try { await CALLS.endCallByRoom(String(room)); } catch (_) {}
+ json(res, 200, { ok: true });
+});
+
// Toggle "only admins can add/remove members" (any admin).
route('POST', '/api/groups/admin-only', async (req, res) => {
const u = await currentUser(req);