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:
+27
-7
@@ -139,10 +139,10 @@
|
|||||||
|
|
||||||
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
|
/* 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. */
|
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
|
||||||
::-webkit-scrollbar{width:9px;height:9px;}
|
::-webkit-scrollbar{width:6px;height:6px;}
|
||||||
::-webkit-scrollbar-button{display:none!important;width:0;height:0;}
|
::-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-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-thumb:hover{background:#aab6c8;}
|
||||||
::-webkit-scrollbar-corner{background:transparent;}
|
::-webkit-scrollbar-corner{background:transparent;}
|
||||||
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
.ap-x:hover{color:var(--red);background:#fee2e2;}
|
.ap-x:hover{color:var(--red);background:#fee2e2;}
|
||||||
/* meetings */
|
/* meetings */
|
||||||
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
|
.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{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.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 .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>
|
<body>
|
||||||
<script src="/icons.js?v=4"></script>
|
<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 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
|
// 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>
|
||||||
@@ -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(); } }
|
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.
|
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||||
async function startOrJoinDmCall(otherId){
|
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'); }
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// a round PNG data URL for the desktop toast image.
|
||||||
function notifAvatarDataUrl(kind,id,fallbackName){
|
function notifAvatarDataUrl(kind,id,fallbackName){
|
||||||
@@ -2233,6 +2245,12 @@ function ringOnce(){
|
|||||||
}
|
}
|
||||||
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
|
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; } }
|
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){
|
function onChatMessage(m){
|
||||||
const isGroupMsg=!!m.conversation_id;
|
const isGroupMsg=!!m.conversation_id;
|
||||||
const kind=isGroupMsg?'group':'dm';
|
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-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended
|
||||||
if(m.type==='meeting-peer-joined'){
|
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); }
|
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)
|
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)
|
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).
|
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||||
function leaveMeeting(forced){
|
function leaveMeeting(forced){
|
||||||
if(meetLeaving) return; meetLeaving=true;
|
if(meetLeaving) return; meetLeaving=true;
|
||||||
|
stopRingback(); // #17: any call exit stops the outgoing ring
|
||||||
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||||||
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||||||
const next=meetPeers.keys().next().value; // first remaining participant
|
const next=meetPeers.keys().next().value; // first remaining participant
|
||||||
|
|||||||
Reference in New Issue
Block a user