Native calls: open the REAL meeting window (join the mesh), drop the bespoke overlay

The custom call overlay was wrong — the native call must use the app's actual
meeting UI. Fix: a native call now JOINS the mesh room like any participant, so
the caller/callee tiles, roster, mute state and the whole answer/end lifecycle
run through the existing (tested) meeting code. The only native-specific bit is
meetNative=true → the WebView does NOT open its own SFU media connection (the
plugin already owns this identity's one LiveKit connection); mic/hang-up bridge
to the plugin. This fixes, via existing server code, all the reported bugs:
- "no meeting window" → the real meeting window opens on answer/outgoing.
- "caller stuck Ringing after pickup" → mesh peer-join clears the waiting tile
  and finishMeetingJoin marks the call answered.
- "call still running after the other side hung up" → mesh leave ends the DM
  for both (signaling leaveMeeting); plus an idempotent endDmCallByRoom backup
  kicks a stuck peer when the ending side's WebSocket is down.
- "accept on one device doesn't stop the other" → markDmAnswered (fired on mesh
  join) emits call-taken to the user's other sockets; deliverLocal fans to all.
- "second-device accept wins / collision" → the other device's ring is dismissed
  so it can't double-join the same identity.

home.html: enterMeeting(code, audioOnly, {native, uuid}); skip sfuConnect when
native; toggleMic->plugin; toggleCam blocked (video is the next phase); leave ->
callkitEnd (guarded against the plugin's endCall re-firing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 19:31:19 +05:30
parent 11f72b2592
commit c2b339e284
2 changed files with 44 additions and 63 deletions
+5
View File
@@ -137,6 +137,11 @@ async function endDmCallByRoom(room, silent) {
if (!call) return;
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// A native side that ends the call (POST /api/calls/end) may have a dropped WebSocket, so the OTHER party
// can be left sitting in the mesh meeting "still in the call". Close their window. Idempotent: when this
// runs from the mesh emptying normally, the room is already gone → no-op (so it never double-ends).
const stuck = meetingRooms.get(room);
if (stuck) { for (const [, p] of stuck) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
if (!silent) try {
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
+39 -63
View File
@@ -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){ 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); } }
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ 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); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false, (_r&&_r.avatar)||null); 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 */ } }
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()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ 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
@@ -3803,26 +3803,25 @@ async function setupNativeCall(){
if(!d||!d.room) return;
_nativeAnswered.add(d.room);
dismissCallInvite(d.room);
postJSON('/api/calls/answered',{ room:d.room }).catch(()=>{});
// Show the in-app call screen for the callee (native media has no meeting window of its own).
const isGroup=(d.kind==='group'); const c=(CONTACTS||[]).find(x=>x.id===d.callerId);
ncShowCall({ role:'callee', room:d.room, uuid:d.callUUID, video:!!d.hasVideo,
name: isGroup ? (d.groupName||'Group call') : (d.callerName||(c&&c.name)||'Call'),
avatar: isGroup ? null : (c&&c.avatar)||null, state:'Connecting…' });
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
const isGroup=(d.kind==='group');
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
}catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } });
// The plugin fires callConnected once its LiveKit room connects. For the CALLEE that IS the pickup → mark
// the in-app screen connected + start the timer. (The caller flips on the server's "call-answered".)
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(_ncCall && _ncCall.role==='callee') ncMarkConnected(); });
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); if(_ncCall){ ncSetState('Call failed'); setTimeout(()=>{ if(_ncCall) ncHideCall(); }, 1500); } });
// Keep the in-app mute button in sync when muted from the CallKit system UI.
NC.addListener('setMuted', (e)=>{ if(_ncCall && e && typeof e.muted!=='undefined') ncSetMuted(!!e.muted, false); });
// 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('callConnected', ()=>{ pdbg('nc-connected'); });
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
// Ended on the CallKit system screen / plugin ended on remote hang-up. Leave the meeting window too (guarded
// so our own in-app hang-up, which already called the plugin, doesn't loop). Then tell the server.
NC.addListener('endCall', (d)=>{ try{
const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) });
if(!room) return;
dismissCallInvite(room);
if(_ncCall && _ncCall.room===room) ncHideCall();
if(_ncEnding){ _ncEnding=false; }
else if(meetState==='call' && meetRoom===room){ leaveMeeting(true); }
if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
}catch(_){} });
@@ -3830,57 +3829,24 @@ async function setupNativeCall(){
}
// 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, avatar){
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(_){}
pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) });
ncShowCall({ role:'caller', room, uuid, video:!!hasVideo, name:peerName||'Call', avatar:avatar||null, state:'Calling…' });
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(_){} }
// --- In-app call screen for NATIVE calls. Native media runs over LiveKit (no mesh/WebView meeting window),
// so this is a UI-only overlay: it shows who you're on with + a timer, and drives mute/end through the
// plugin (CallKit stays the source of truth). Shown for both the caller and the callee. ---
let _ncCall=null; // { role, room, uuid, name, avatar, video, muted, connected, t0, timer }
function ncFmt(s){ s=Math.max(0,Math.floor(s)); const m=Math.floor(s/60), ss=String(s%60).padStart(2,'0'); return m+':'+ss; }
function ncEnsureEl(){
let el=document.getElementById('nc-call'); if(el) return el;
el=document.createElement('div'); el.id='nc-call';
el.style.cssText='position:fixed;inset:0;z-index:100000;display:none;flex-direction:column;align-items:center;justify-content:space-between;background:linear-gradient(165deg,#0b1220,#111827 55%,#0b1220);color:#fff;padding:calc(env(safe-area-inset-top,0px) + 52px) 20px calc(env(safe-area-inset-bottom,0px) + 40px);';
el.innerHTML='<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;text-align:center">'
+'<div id="nc-ava" style="width:118px;height:118px;border-radius:50%;background:#374151;display:flex;align-items:center;justify-content:center;font-size:44px;font-weight:600;overflow:hidden"></div>'
+'<div id="nc-name" style="font-size:23px;font-weight:600"></div>'
+'<div id="nc-state" style="font-size:15px;opacity:.72;font-variant-numeric:tabular-nums"></div></div>'
+'<div style="display:flex;gap:30px;align-items:center;justify-content:center">'
+'<button id="nc-mute" style="width:64px;height:64px;border-radius:50%;border:none;background:#374151;color:#fff;font-size:11px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer"></button>'
+'<button id="nc-end" style="width:72px;height:72px;border-radius:50%;border:none;background:#ef4444;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button></div>';
document.body.appendChild(el);
el.querySelector('#nc-end').innerHTML=ic('callEnd',26);
el.querySelector('#nc-end').onclick=()=>{ const u=_ncCall&&_ncCall.uuid; ncHideCall(); if(u) callkitEnd(u); };
el.querySelector('#nc-mute').onclick=()=>{ if(_ncCall) ncSetMuted(!_ncCall.muted, true); };
return el;
}
function ncRenderMute(){ const b=document.getElementById('nc-mute'); if(!b||!_ncCall) return; b.innerHTML=ic(_ncCall.muted?'micOff':'mic',22)+'<span>'+(_ncCall.muted?'Unmute':'Mute')+'</span>'; b.style.background=_ncCall.muted?'#fff':'#374151'; b.style.color=_ncCall.muted?'#111827':'#fff'; }
function ncSetMuted(m, push){ if(!_ncCall) return; _ncCall.muted=!!m; ncRenderMute(); if(push){ const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!!m }); }catch(_){} } } }
function ncSetState(txt){ const s=document.getElementById('nc-state'); if(s) s.textContent=txt||''; }
function ncTick(){ if(!_ncCall||!_ncCall.connected) return; ncSetState(ncFmt((Date.now()-_ncCall.t0)/1000)); }
function ncShowCall(o){
if(!o||!o.room) return; ncEnsureEl();
_ncCall={ role:o.role||'callee', room:o.room, uuid:o.uuid, name:o.name||'Call', avatar:o.avatar||null, video:!!o.video, muted:false, connected:false, t0:0, timer:null };
const el=document.getElementById('nc-call');
el.querySelector('#nc-ava').innerHTML = o.avatar ? ('<img src="'+pEsc(o.avatar)+'" style="width:100%;height:100%;object-fit:cover">') : pEsc(((o.name||'?').trim().charAt(0)||'?').toUpperCase());
el.querySelector('#nc-name').textContent=o.name||'Call';
ncSetState(o.state||'Connecting…'); ncRenderMute();
el.style.display='flex';
}
function ncMarkConnected(){ if(!_ncCall||_ncCall.connected) return; _ncCall.connected=true; _ncCall.t0=Date.now(); if(_ncCall.timer) clearInterval(_ncCall.timer); _ncCall.timer=setInterval(ncTick,1000); ncTick(); }
function ncHideCall(){ const el=document.getElementById('nc-call'); if(el) el.style.display='none'; if(_ncCall&&_ncCall.timer) clearInterval(_ncCall.timer); _ncCall=null; }
// Another of the callee's devices answered — stop ringing here (but never on the device that answered).
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); if(_ncCall && _ncCall.room===d.room) ncHideCall(); }
// The callee picked up — flip the caller's in-app screen from "Calling…" to a running timer.
function onCallAnswered(d){ if(!d) return; if(_ncCall && _ncCall.role==='caller' && (!d.room || _ncCall.room===d.room)) ncMarkConnected(); }
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
// callee tiles, roster, mute state and the whole answer/end lifecycle are the normal meeting code. The only
// difference vs a web call is meetNative=true → the WebView does NOT open its own SFU media connection (that
// would be a 2nd connection for the same identity), and mute/hang-up bridge to the plugin.
// Another of the callee's devices answered — stop ringing here (never on the device that answered).
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); }
// The callee picked up (server signal; the mesh peer-join already clears these on the caller — belt & braces).
function onCallAnswered(d){ if(d&&d.room && meetRoom===d.room){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; } }
// 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
@@ -5327,9 +5293,11 @@ function bzUnlockAudio(){
}
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
async function enterMeeting(code, audioOnly){
let meetNative=false, meetNativeUuid=null, _ncEnding=false; // native call: WebView joins the mesh for UI; the plugin owns media/CallKit
async function enterMeeting(code, audioOnly, opts){
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
meetNative=!!(opts&&opts.native); meetNativeUuid=(opts&&opts.uuid)||null; // native → skip our own SFU media; mute/end bridge to the plugin
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
// we clear ALL open invites, since you can only be in one call at a time.
@@ -5339,7 +5307,7 @@ async function enterMeeting(code, audioOnly){
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
meetLocalStream=new MediaStream();
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
meetMic=meetNative; meetCam=false; meetIsHost=false; meetHostId=null; // native: the plugin connects with the mic LIVE → show unmuted
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
@@ -5363,7 +5331,9 @@ async function onMeetMsg(e){
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isnt active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isnt active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
// for the UI only. Media is native; tell peers our mic is live.
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
@@ -5414,6 +5384,7 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){
if(!meetLocalStream) return;
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){
@@ -5431,6 +5402,7 @@ async function toggleMic(){
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){
if(!meetLocalStream) return;
if(meetNative){ toast('Video isnt available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){
@@ -5454,6 +5426,10 @@ let meetLeaving=false;
// there wrongly announced "Host handed to <the person who just left>" (bug).
function leaveMeeting(forced){
if(meetLeaving) return; meetLeaving=true;
// Native call: also end the CallKit/plugin call. _ncEnding tells the plugin's endCall listener this leave
// originated here, so it doesn't call leaveMeeting again (the plugin ending the call re-fires endCall).
if(meetNative && meetNativeUuid){ _ncEnding=true; callkitEnd(meetNativeUuid); }
meetNative=false; meetNativeUuid=null;
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
const isDm=!!(meetReturn && meetReturn.kind==='dm');
if(!forced && !isDm && meetIsHost && meetPeers.size>0){