fix: notification-open in-place (#2/#7/#19) + polish (#9 scrollbar, #16 call scrollbar, #17 ringback)

- #2/#7/#19: openFromNotif opened chats via a full-page reload (location.assign),
  which was slow AND dropped live state — the sidebar hadn't reloaded so the header
  showed 'Conversation' with no DP, and an incoming call's Join/invite popup was lost.
  Now it opens the chat IN-PLACE (selectChat) since the app is already running;
  full-page nav only as a fallback when the app isn't initialised.
- #9: slimmer 6px scrollbar; explicitly hide all webkit stepper-arrow variants.
- #16: 1:1/small call no longer shows a stray scrollbar — meet-grid centers tiles
  and clips overflow (call UI fits the viewport).
- #17: caller now hears a gentle ringback while waiting; stops on answer, on any
  call exit, and auto-stops after 45s so it never rings forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:54:28 +05:30
parent 16065315a7
commit c672f7b65f
+27 -7
View File
@@ -139,10 +139,10 @@
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
::-webkit-scrollbar{width:9px;height:9px;}
::-webkit-scrollbar-button{display:none!important;width:0;height:0;}
::-webkit-scrollbar{width:6px;height:6px;}
::-webkit-scrollbar-button,::-webkit-scrollbar-button:start:decrement,::-webkit-scrollbar-button:end:increment,::-webkit-scrollbar-button:vertical:start,::-webkit-scrollbar-button:vertical:end,::-webkit-scrollbar-button:horizontal:start,::-webkit-scrollbar-button:horizontal:end{display:none!important;width:0!important;height:0!important;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:#c7d0dd;border-radius:9px;border:2px solid transparent;background-clip:content-box;}
::-webkit-scrollbar-thumb{background:#c7d0dd;border-radius:9px;border:1px solid transparent;background-clip:content-box;}
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
::-webkit-scrollbar-corner{background:transparent;}
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
@@ -342,7 +342,7 @@
.ap-x:hover{color:var(--red);background:#fee2e2;}
/* meetings */
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
.meet-grid{flex:1;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:start;overflow:auto;background:#0b1220;}
.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.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;}
@@ -790,7 +790,7 @@
<body>
<script src="/icons.js?v=4"></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-batch47';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-07-batch48';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>
@@ -1604,7 +1604,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); } }
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 */ } }
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
@@ -2153,7 +2153,19 @@ async function unsubscribePush(){
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function openFromNotif(kind,id){ try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){} try{ window.focus(); }catch(_){} location.assign('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')); }
function openFromNotif(kind,id){
try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){}
try{ window.focus(); }catch(_){}
// The app is already running (the notification came from it) → open the chat IN-PLACE. A full-page
// reload was slow (#19) and dropped live state: the sidebar hadn't reloaded so the header showed
// "Conversation" with no DP (#7), and an incoming call's Join/invite popup was lost (#2).
if(window.ME && ME.id && typeof selectChat==='function'){
if(id){ try{ switchTab('chat'); }catch(_){} try{ selectChat(kind||'dm', id); }catch(_){} }
return;
}
// Fallback: app not initialised yet → navigate (boot reads openKind/openId and opens it).
location.assign(id ? ('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')) : '/home');
}
// Draw the conversation's avatar (DM = sender pic, group = group DP; else colored initials) to
// a round PNG data URL for the desktop toast image.
function notifAvatarDataUrl(kind,id,fallbackName){
@@ -2233,6 +2245,12 @@ function ringOnce(){
}
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } }
// #17 Outgoing ringback — the CALLER hears a gentle periodic ring while waiting for the other side to
// pick up. Stops as soon as a peer joins, or the call ends.
let _ringbackTimer=null, _ringbackStop=null;
function ringbackOnce(){ try{ _audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)(); if(_audioCtx.state==='suspended') _audioCtx.resume(); const t=_audioCtx.currentTime; ringTone(t, 440, 1.1, 0.11); ringTone(t+0.16, 480, 1.1, 0.11); }catch(_){} }
function startRingback(){ if(_ringbackTimer) return; ringbackOnce(); _ringbackTimer=setInterval(ringbackOnce, 3000); _ringbackStop=setTimeout(stopRingback, 45000); /* never ring forever if unanswered/declined */ }
function stopRingback(){ if(_ringbackTimer){ clearInterval(_ringbackTimer); _ringbackTimer=null; } if(_ringbackStop){ clearTimeout(_ringbackStop); _ringbackStop=null; } }
function onChatMessage(m){
const isGroupMsg=!!m.conversation_id;
const kind=isGroupMsg?'group':'dm';
@@ -3012,6 +3030,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
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)
@@ -3086,6 +3105,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
const isDm=!!(meetReturn && meetReturn.kind==='dm');
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
const next=meetPeers.keys().next().value; // first remaining participant