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='