From c2b339e2849be292f6821c6cd5a54224a676e7fa Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 30 Jul 2026 19:31:19 +0530 Subject: [PATCH] Native calls: open the REAL meeting window (join the mesh), drop the bespoke overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/calls.js | 5 ++ server/public/home.html | 102 +++++++++++++++------------------------- 2 files changed, 44 insertions(+), 63 deletions(-) diff --git a/server/calls.js b/server/calls.js index b687daf..0c6bbb7 100644 --- a/server/calls.js +++ b/server/calls.js @@ -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'; diff --git a/server/public/home.html b/server/public/home.html index 0c61a24..bc8d4c8 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){ 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?'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); 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='
' - +'
' - +'
' - +'
' - +'
' - +'' - +'
'; - 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)+''+(_ncCall.muted?'Unmute':'Mute')+''; 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 ? ('') : 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 isn’t 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 isn’t 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 isn’t 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 " (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){