fix: chat-open scroll regression, SFU screen reliability, unanswered calls, selected-chat border

- REGRESSION (More#1): removed overflow-anchor:none (it stopped the view from staying
  at the bottom as images settle) + renderThread re-asserts scroll-to-bottom after late
  content, unless a 'New messages' divider is shown → chats open at the latest message.
- #9: dynacast off — a screen-share layer was being paused unless a camera track was
  also flowing (blank / only-with-camera / slow). Now every published track flows.
- More#7/#9: 1:1 calls track 'answered'; unanswered calls auto-end after ~40s (caller
  no longer stuck ringing) and post 'Missed call' instead of a duration; answered calls
  show duration from the answer time. meeting-ended reason 'unanswered' → 'No answer'.
- More#6: selected chat now has a thin yellow (brand) boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 13:20:19 +05:30
parent f6962a0b4a
commit 096683385a
3 changed files with 35 additions and 14 deletions
+20 -4
View File
@@ -95,10 +95,18 @@ function startDmCall(me, otherId, teamId) {
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const byName = me.name || me.email; const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId }; const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
// Log to history (both participants) so the call shows under Past meetings with its transcript. // Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
// so the caller isn't stuck "ringing" forever.
call.ringTimer = setTimeout(() => {
if (call.answered) return;
const peers = meetingRooms.get(room);
if (peers) { for (const [, p] of peers) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended', reason: 'unanswered' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
endDmCallByRoom(room);
}, 40000);
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name. // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id(); const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' }); R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
@@ -115,10 +123,11 @@ function endDmCallByRoom(room, silent) {
const call = dmCalls.get(key); const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return; if (!call) return;
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline. // Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
if (!silent) try { if (!silent) try {
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt); const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
@@ -126,6 +135,13 @@ function endDmCallByRoom(room, silent) {
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
} }
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
// message shows a real duration (from answer) and the unanswered timeout stands down.
function markDmAnswered(room, userId) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
}
// Called from signaling when any mesh room empties. // Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); } function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
@@ -152,4 +168,4 @@ function declineDmCall(room, byUser) {
return { ok: true }; return { ok: true };
} }
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey }; module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
+14 -9
View File
@@ -171,8 +171,7 @@
.perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;} .perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;} .chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;} .chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);} .chat-row.active{background:var(--blue-soft);box-shadow:inset 0 0 0 1.5px var(--brand);} /* #6: thin yellow boundary around the selected chat */
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;} .avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;}
.avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;} .avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;} .avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;}
@@ -291,7 +290,7 @@
.convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;} .convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
.convo-body .big{font-size:2.4rem;margin-bottom:.4rem;} .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
/* message thread */ /* message thread */
.convo-msgs{flex:1;overflow-y:auto;overflow-anchor:none;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} /* overflow-anchor:none so prepending older history doesn't fight our manual scroll anchor */ .convo-msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);}
.bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);} .bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);}
.bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;} .bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;}
.bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;} .bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;}
@@ -827,7 +826,7 @@
<body> <body>
<script src="/icons.js?v=5"></script> <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 src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-08-batch55';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-08-batch56';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 // 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) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -1884,7 +1883,11 @@ function renderThread(keepScroll){
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; } if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
let html=''; let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); } for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
box.innerHTML=html; twemojify(box); if(!keepScroll) box.scrollTop=box.scrollHeight; // keepScroll: prepending older history — don't jump to bottom box.innerHTML=html; twemojify(box);
if(!keepScroll){ box.scrollTop=box.scrollHeight; // open/refresh → newest at the bottom; re-assert after late content (images/twemoji) settles, unless we jumped to a "New messages" divider
const pin=()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; };
requestAnimationFrame(pin); setTimeout(pin,150); setTimeout(pin,400);
}
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
} }
function appendBubble(m){ function appendBubble(m){
@@ -2717,9 +2720,11 @@ function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) retu
async function sfuConnect(){ async function sfuConnect(){
const LK=await sfuLoadLib(); SFU.lib=LK; const LK=await sfuLoadLib(); SFU.lib=LK;
const tk=await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential const tk=await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential
// adaptiveStream off: we attach media via manual srcObject (not track.attach), so LiveKit can't // adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so
// see tile visibility to pause/resume — keep all subscribed tracks flowing. dynacast stays on. // LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a
const room=new LK.Room({ adaptiveStream:false, dynacast:true }); SFU.room=room; // camera track was also flowing → the screen showed blank / only when the camera was on, and was
// slow to appear (#9). Off = every published track always flows at full quality.
const room=new LK.Room({ adaptiveStream:false, dynacast:false }); SFU.room=room;
room.on(LK.RoomEvent.TrackSubscribed, (track,pub,participant)=>sfuAttach(pub,track,participant)); room.on(LK.RoomEvent.TrackSubscribed, (track,pub,participant)=>sfuAttach(pub,track,participant));
room.on(LK.RoomEvent.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant)); room.on(LK.RoomEvent.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant));
await room.connect(SFU.url, tk.token); await room.connect(SFU.url, tk.token);
@@ -3210,7 +3215,7 @@ async function onMeetMsg(e){
refreshMeetPanel(); updateHostControls(); refreshMeetPanel(); updateHostControls();
return; return;
} }
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered
if(m.type==='meeting-peer-joined'){ if(m.type==='meeting-peer-joined'){
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile 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); } 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); }
+1 -1
View File
@@ -87,7 +87,7 @@ function handle(ws, m, req) {
// …and tell existing peers a newcomer arrived. // …and tell existing peers a newcomer arrived.
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); } for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null }); peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
if (ws._meetingUserId) CHAT.broadcastPresence(ws._meetingUserId); // now in a call → update contacts live if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); } // #9: callee joined → mark 1:1 answered
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
break; break;
} }