fix(calls): callee tile while ringing (#7), multi-device decline no longer kills active call (#8), SFU screen-attach retry (#9)
- #7: outgoing 1:1 call shows the person you're calling (DP + name + 'Ringing…') instead of only your own tile; removed when they answer or the call ends. - #8: declineDmCall now ignores the decline if that user has ALREADY accepted on another device (they're in the room) — declining the ringing invite on a 2nd device no longer tears down the active call. - #9: sfuAttach retries (bounded) when the uid→peerId map lags the LiveKit track, so a shared screen isn't silently dropped due to a race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,10 @@ function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room);
|
||||
function declineDmCall(room, byUser) {
|
||||
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
||||
const call = dmCalls.get(key); if (!call) return { ok: false };
|
||||
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
|
||||
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
|
||||
const inRoom = meetingRooms.get(room);
|
||||
if (inRoom) { for (const [, p] of inRoom) { if (p.ws && p.ws._meetingUserId === byUser.id) return { ok: true, alreadyJoined: true }; } }
|
||||
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
||||
try {
|
||||
const mid = A.id();
|
||||
|
||||
+24
-6
@@ -349,6 +349,9 @@
|
||||
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
|
||||
.meet-grid{flex:1;min-height:0;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:center;overflow:hidden;background:#0b1220;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */
|
||||
.meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;}
|
||||
.meet-tile.waiting{border-color:#334155;}
|
||||
.meet-tile.waiting .ringing{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.8rem;color:#cbd5e1;background:rgba(0,0,0,.35);padding:.35rem;animation:ringPulse 1.4s ease-in-out infinite;}
|
||||
@keyframes ringPulse{0%,100%{opacity:.6;}50%{opacity:1;}}
|
||||
.meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);}
|
||||
.meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;}
|
||||
.meet-tile.sharing{grid-column:span 2;border-color:#2563eb;}
|
||||
@@ -823,7 +826,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=5"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-07-batch53';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-07-batch54';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -1717,7 +1720,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){ 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}; 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
|
||||
@@ -2719,8 +2722,12 @@ function sfuRebuild(pid){
|
||||
const vid=p.screen||p.cam; if(vid) st.addTrack(vid); if(p.audio) st.addTrack(p.audio);
|
||||
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
|
||||
}
|
||||
function sfuAttach(pub, track, participant){
|
||||
const pid=peerIdForUid(participant.identity); if(!pid) return;
|
||||
function sfuAttach(pub, track, participant, _try){
|
||||
const pid=peerIdForUid(participant.identity);
|
||||
if(!pid){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
|
||||
if((_try||0)<10){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); }
|
||||
return;
|
||||
}
|
||||
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
|
||||
if(track.kind==='audio') p.audio=mt;
|
||||
else if(pub.source===SFU.lib.Track.Source.ScreenShare){ p.screen=mt; meetSharers.add(pid); }
|
||||
@@ -2984,6 +2991,16 @@ function addTile(id, stream, label, muted){
|
||||
}
|
||||
function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } }
|
||||
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); }
|
||||
// #7: on an outgoing 1:1 call, show the person you're calling (DP + name + "Ringing…") so it's clear
|
||||
// who the call is to, instead of only your own tile. Removed as soon as they answer.
|
||||
let _dmCallWaiting=null;
|
||||
function addWaitingTile(name, avatar){
|
||||
const grid=document.getElementById('meetGrid'); if(!grid || document.getElementById('meet-tile-__waiting')) return;
|
||||
const tile=document.createElement('div'); tile.className='meet-tile novid waiting'; tile.id='meet-tile-__waiting';
|
||||
tile.innerHTML='<div class="meet-av" style="background:'+avColor(name||'?')+'">'+pEsc(initials(name||'?'))+(avatar?'<img src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div><span class="nm">'+pEsc(name||'')+'</span><div class="ringing">'+ic('phone',13)+' Ringing…</div>';
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); }
|
||||
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
|
||||
function meetWatchStream(id, stream){
|
||||
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
|
||||
@@ -3166,6 +3183,7 @@ async function onMeetMsg(e){
|
||||
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
|
||||
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
|
||||
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); }
|
||||
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){ const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||||
@@ -3176,7 +3194,7 @@ async function onMeetMsg(e){
|
||||
}
|
||||
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended
|
||||
if(m.type==='meeting-peer-joined'){
|
||||
stopRingback(); // #17: they answered → stop the outgoing ring
|
||||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile
|
||||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
|
||||
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
||||
if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer)
|
||||
@@ -3251,7 +3269,7 @@ let meetLeaving=false;
|
||||
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||
function leaveMeeting(forced){
|
||||
if(meetLeaving) return; meetLeaving=true;
|
||||
stopRingback(); // #17: any call exit stops the outgoing ring
|
||||
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){
|
||||
const next=meetPeers.keys().next().value; // first remaining participant
|
||||
|
||||
Reference in New Issue
Block a user