From 096683385a193303c161a151642321df774aad73 Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 8 Jul 2026 13:20:19 +0530 Subject: [PATCH] fix: chat-open scroll regression, SFU screen reliability, unanswered calls, selected-chat border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/calls.js | 24 ++++++++++++++++++++---- server/public/home.html | 23 ++++++++++++++--------- server/signaling.js | 2 +- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/server/calls.js b/server/calls.js index bdd6785..20b88c0 100644 --- a/server/calls.js +++ b/server/calls.js @@ -95,10 +95,18 @@ function startDmCall(me, otherId, teamId) { let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); 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. 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 + // #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. const mid = A.id(); 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); roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); 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 - // "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 { - 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' }); 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 (_) {} }); @@ -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 (_) {} }); } +// 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. function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); } @@ -152,4 +168,4 @@ function declineDmCall(room, byUser) { 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 }; diff --git a/server/public/home.html b/server/public/home.html index 8485e09..51d7f3e 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -171,8 +171,7 @@ .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:hover{background:#f3f6fb;} - .chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);} - .chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);} + .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 */ .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 .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 .big{font-size:2.4rem;margin-bottom:.4rem;} /* 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.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;} @@ -827,7 +826,7 @@ - @@ -1884,7 +1883,11 @@ function renderThread(keepScroll){ if(!THREAD.length){ box.innerHTML='
No messages yet β€” say hello πŸ‘‹
'; return; } let html=''; for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='
'+pEsc(dayLabel(m.created_at))+'
'; _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 } function appendBubble(m){ @@ -2717,9 +2720,11 @@ function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) retu async function sfuConnect(){ const LK=await sfuLoadLib(); SFU.lib=LK; 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 - // see tile visibility to pause/resume β€” keep all subscribed tracks flowing. dynacast stays on. - const room=new LK.Room({ adaptiveStream:false, dynacast:true }); SFU.room=room; + // adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so + // LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a + // 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.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant)); await room.connect(SFU.url, tk.token); @@ -3210,7 +3215,7 @@ async function onMeetMsg(e){ refreshMeetPanel(); updateHostControls(); 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'){ 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); } diff --git a/server/signaling.js b/server/signaling.js index d549ee6..97e1c42 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -87,7 +87,7 @@ function handle(ws, m, req) { // …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 })); } 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 break; }