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:
+20
-4
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user