feat(calls): native-call lifecycle endpoints + WebView steps aside (inc 1)
Complete the WebView/server side of native LiveKit calls: - routes.js: /api/calls/answered (markDmAnswered) + /api/calls/end (endCallByRoom) so the server learns a NATIVE call was answered/ended (native media runs over LiveKit, bypassing our mesh/WS lifecycle). Additive no-ops for WebView/mesh calls. - home.html: for native calls the WebView no longer joins the room (one connection per identity — the plugin holds it). answerCall -> POST /api/calls/answered + clear invite; endCall -> /api/calls/end (answered) or /api/calls/decline (still ringing). Outgoing DM/group calls fetch a LiveKit token and hand it to NativeCall.reportOutgoingCall instead of enterMeeting. Removed the old callHandoff mic-repush. Server deploys now; the plugin (native LiveKit) needs a Codemagic build. Still gated by CALLKIT_ENABLED=0 — flip to 1 only after the build is installed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+22
-22
@@ -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?'<span>Join</span>':''); }
|
||||
@@ -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(_){} }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user