perf(mobile): compositor 'render-then-arm' chat-open slide + robust load-older anchor; remove diagnostics
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend (afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide. Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke when prepended images loaded and shifted content) and re-anchor as those images load. Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes). Build marker -> 2026-07-18-batch121. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+66
-50
@@ -1101,8 +1101,14 @@
|
|||||||
/* width:100vw makes the sliding pane a DEFINITE width regardless of any ancestor's transient 0-width
|
/* width:100vw makes the sliding pane a DEFINITE width regardless of any ancestor's transient 0-width
|
||||||
during the animation frame — so bubbles never collapse, the composer measures at the right width,
|
during the animation frame — so bubbles never collapse, the composer measures at the right width,
|
||||||
and translateX(100%) travels a real screen width (a true slide, not a no-travel "pop"). */
|
and translateX(100%) travels a real screen width (a true slide, not a no-travel "pop"). */
|
||||||
body.chat-opening .content{display:block!important;width:100vw;max-width:100vw;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;animation:bzContentIn .3s cubic-bezier(.32,.72,0,1);}
|
/* Compositor-only push (workflow-verified). Phase 1 (chat-opening): the pane is pinned OFF-SCREEN on
|
||||||
body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListParallaxOut .3s cubic-bezier(.32,.72,0,1);}
|
its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim,
|
||||||
|
added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions
|
||||||
|
transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */
|
||||||
|
body.chat-opening .content{display:block!important;width:100vw;max-width:100vw;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);transform:translate3d(100%,0,0);will-change:transform;backface-visibility:hidden;-webkit-backface-visibility:hidden;isolation:isolate;contain:layout paint;}
|
||||||
|
body.chat-opening.chat-anim .content{transform:translate3d(0,0,0);transition:transform .3s cubic-bezier(.32,.72,0,1);}
|
||||||
|
body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;transform:translate3d(0,0,0);will-change:transform;backface-visibility:hidden;}
|
||||||
|
body.chat-opening.chat-anim .chatcol{transform:translate3d(-22%,0,0);filter:brightness(.97);transition:transform .3s cubic-bezier(.32,.72,0,1),filter .3s cubic-bezier(.32,.72,0,1);}
|
||||||
/* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right
|
/* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right
|
||||||
(.content moved by an inline transform in showWelcome()/the edge-drag gesture). */
|
(.content moved by an inline transform in showWelcome()/the edge-drag gesture). */
|
||||||
body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;}
|
body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;}
|
||||||
@@ -1134,7 +1140,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<script src="/icons.js?v=6"></script>
|
||||||
<script>window.__BUILD='2026-07-18-batch120';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-18-batch121';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||||
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
||||||
//
|
//
|
||||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||||
@@ -1159,43 +1165,12 @@ function twemojify(_el){ /* native emoji: nothing to do */ }</script>
|
|||||||
window.__bzResetKb=function(){ setKb(0); }; // called on composer blur as a safety net (#1: keyboard closed but lift stayed, leaving empty space after reply-cancel)
|
window.__bzResetKb=function(){ setKb(0); }; // called on composer blur as a safety net (#1: keyboard closed but lift stayed, leaving empty space after reply-cancel)
|
||||||
var KB=C.Plugins&&C.Plugins.Keyboard;
|
var KB=C.Plugins&&C.Plugins.Keyboard;
|
||||||
if(KB && KB.addListener){
|
if(KB && KB.addListener){
|
||||||
KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); if(window.bzDbg) bzDbg('kbShow',{raw:raw, applied:Math.round(kb)}); });
|
KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); });
|
||||||
KB.addListener('keyboardWillHide', function(){ setKb(0); });
|
KB.addListener('keyboardWillHide', function(){ setKb(0); });
|
||||||
KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
|
KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
|
||||||
}
|
}
|
||||||
}catch(_){} })();
|
}catch(_){} })();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
|
||||||
// TEMP diagnostics → server log (/api/dbg): reports the app's REAL runtime state so mobile-only bugs can
|
|
||||||
// be diagnosed from `docker logs` instead of screenshots. Remove once the mobile issues are settled.
|
|
||||||
(function(){
|
|
||||||
function findOverflow(){
|
|
||||||
try{ var dw=document.documentElement.clientWidth, out=[], els=document.querySelectorAll('body *');
|
|
||||||
for(var i=0;i<els.length;i++){ var el=els[i]; var rct=el.getBoundingClientRect();
|
|
||||||
if(rct.width>0 && rct.right>dw+1){ out.push((el.tagName||'')+'.'+String(el.className||'').split(' ').filter(Boolean).slice(0,2).join('.')+' r='+Math.round(rct.right)+' w='+Math.round(rct.width)); if(out.length>=12) break; } }
|
|
||||||
return out;
|
|
||||||
}catch(e){ return ['err:'+(e&&e.message)]; }
|
|
||||||
}
|
|
||||||
window.bzDbg=function(tag, extra){
|
|
||||||
try{ var vv=window.visualViewport||{};
|
|
||||||
var p={ tag:tag, build:window.__BUILD, iw:window.innerWidth, ih:window.innerHeight,
|
|
||||||
cw:document.documentElement.clientWidth, ch:document.documentElement.clientHeight,
|
|
||||||
sw:document.documentElement.scrollWidth, dpr:window.devicePixelRatio,
|
|
||||||
vvW:Math.round(vv.width||0), vvH:Math.round(vv.height||0), vvTop:Math.round(vv.offsetTop||0),
|
|
||||||
ovf:(document.documentElement.scrollWidth>document.documentElement.clientWidth+1) };
|
|
||||||
if(extra) for(var k in extra) p[k]=extra[k];
|
|
||||||
fetch('/api/dbg',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}).catch(function(){});
|
|
||||||
}catch(e){}
|
|
||||||
};
|
|
||||||
window.__bzFindOverflow=findOverflow;
|
|
||||||
window.addEventListener('load', function(){ setTimeout(function(){ bzDbg('load',{overflow:findOverflow()}); }, 900); });
|
|
||||||
if(window.visualViewport){ var t; window.visualViewport.addEventListener('resize', function(){ clearTimeout(t); t=setTimeout(function(){
|
|
||||||
var vv=window.visualViewport; var comp=document.querySelector('.composer'); var cr=comp?comp.getBoundingClientRect():null;
|
|
||||||
var kbTop=Math.round(vv.height+vv.offsetTop); // bottom of the visible area = top of the keyboard
|
|
||||||
bzDbg('kb',{ curKb:getComputedStyle(document.documentElement).getPropertyValue('--kb').trim(), kbOpen:document.body.classList.contains('kb-open'), compBottom:cr?Math.round(cr.bottom):-1, kbTop:kbTop, coveredBy:cr?Math.round(cr.bottom-kbTop):null });
|
|
||||||
}, 250); }); }
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
|
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
@@ -1507,7 +1482,16 @@ async function loadOlder(){
|
|||||||
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
|
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
|
||||||
_loadingOlder=true;
|
_loadingOlder=true;
|
||||||
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
||||||
const box=document.getElementById('msgs'); const prevH=box?box.scrollHeight:0, prevTop=box?box.scrollTop:0;
|
const box=document.getElementById('msgs');
|
||||||
|
const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s);
|
||||||
|
// Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load),
|
||||||
|
// keep this exact message at the same viewport position, so scrolling up never jumps. (The old
|
||||||
|
// scrollHeight math jumped because unloaded prepended images weren't counted, then shifted on load.)
|
||||||
|
const anchorId = THREAD[0] && THREAD[0].id;
|
||||||
|
const _boxTop = box ? box.getBoundingClientRect().top : 0;
|
||||||
|
const _aEl = (box&&anchorId) ? box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]') : null;
|
||||||
|
const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0;
|
||||||
|
const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } };
|
||||||
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
||||||
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
|
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
|
||||||
if(Array.isArray(older) && older.length){
|
if(Array.isArray(older) && older.length){
|
||||||
@@ -1515,7 +1499,8 @@ async function loadOlder(){
|
|||||||
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
||||||
if(older.length<PAGE) _hasMoreOlder=false;
|
if(older.length<PAGE) _hasMoreOlder=false;
|
||||||
renderThread(true); // keep scroll (don't jump to bottom)
|
renderThread(true); // keep scroll (don't jump to bottom)
|
||||||
if(box) box.scrollTop = box.scrollHeight - prevH + prevTop; // anchor on the same message
|
_restore(); // put the anchor message back at its exact viewport position
|
||||||
|
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
|
||||||
} else { _hasMoreOlder=false; }
|
} else { _hasMoreOlder=false; }
|
||||||
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
|
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
|
||||||
}
|
}
|
||||||
@@ -2733,6 +2718,7 @@ function renderThread(keepScroll){
|
|||||||
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){
|
||||||
|
if(document.body.classList.contains('chat-opening')){ afterOpenSlide(()=>appendBubble(m)); return; } // don't write into the compositing layer mid-slide; append the instant it ends
|
||||||
if(rendered.has(m.id)) return; rendered.add(m.id);
|
if(rendered.has(m.id)) return; rendered.add(m.id);
|
||||||
const box=document.getElementById('msgs'); if(!box) return;
|
const box=document.getElementById('msgs'); if(!box) return;
|
||||||
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
|
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
|
||||||
@@ -2754,6 +2740,34 @@ let _openUnread=0; // set by selectChat (the unread count before it's reset) so
|
|||||||
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
|
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
|
||||||
function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } }
|
function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } }
|
||||||
function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} }
|
function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} }
|
||||||
|
// Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous
|
||||||
|
// render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a
|
||||||
|
// double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML
|
||||||
|
// work (network re-render + pin schedule + live appends) is queued via afterOpenSlide and flushed only at
|
||||||
|
// transitionend. A token guards rapid chat-to-chat re-taps; a single stored handler avoids listener leaks.
|
||||||
|
let _slideQueue=[], _slideEndHandler=null;
|
||||||
|
function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); }
|
||||||
|
function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } }
|
||||||
|
function armOpenSlide(){
|
||||||
|
const b=document.body, c=document.querySelector('.shell > .content');
|
||||||
|
const token=(window.__slideTok=(window.__slideTok||0)+1);
|
||||||
|
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||||||
|
clearTimeout(window.__chatOpenT);
|
||||||
|
b.classList.remove('chat-anim');
|
||||||
|
b.classList.add('chat-opening'); // pane -> translate3d(100%), off-screen, own GPU layer
|
||||||
|
const done=()=>{ if(window.__slideTok!==token) return;
|
||||||
|
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||||||
|
clearTimeout(window.__chatOpenT);
|
||||||
|
b.classList.remove('chat-opening','chat-anim');
|
||||||
|
flushSlideQueue(); };
|
||||||
|
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); };
|
||||||
|
if(c) c.addEventListener('transitionend', _slideEndHandler);
|
||||||
|
requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
||||||
|
if(window.__slideTok!==token) return;
|
||||||
|
b.classList.add('chat-anim'); // compositor now runs transform 100%->0
|
||||||
|
window.__chatOpenT=setTimeout(done, 360); // fallback if transitionend is missed (300ms + margin)
|
||||||
|
}));
|
||||||
|
}
|
||||||
async function openConvo(kind,id){
|
async function openConvo(kind,id){
|
||||||
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
|
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
|
||||||
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
|
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
|
||||||
@@ -2854,6 +2868,9 @@ async function openConvo(kind,id){
|
|||||||
const ckey=kind+':'+id;
|
const ckey=kind+':'+id;
|
||||||
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
|
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
|
||||||
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
|
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
|
||||||
|
// Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now,
|
||||||
|
// so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only.
|
||||||
|
if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); }
|
||||||
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
|
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
|
||||||
if(!selected||selected.kind!==kind||selected.id!==id) return;
|
if(!selected||selected.kind!==kind||selected.id!==id) return;
|
||||||
wireMentions();
|
wireMentions();
|
||||||
@@ -2873,7 +2890,7 @@ async function openConvo(kind,id){
|
|||||||
THREAD=msgs;
|
THREAD=msgs;
|
||||||
THREAD_CACHE.set(ckey, THREAD.slice());
|
THREAD_CACHE.set(ckey, THREAD.slice());
|
||||||
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
|
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
|
||||||
renderThread();
|
afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id) renderThread(); }); // defer the network re-render past the slide (no 40-bubble innerHTML into the compositing layer mid-transform)
|
||||||
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
|
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
|
||||||
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
|
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
|
||||||
// and show the "jump to newest" arrow so the user can return to the bottom.
|
// and show the "jump to newest" arrow so the user can return to the bottom.
|
||||||
@@ -2892,15 +2909,12 @@ async function openConvo(kind,id){
|
|||||||
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
|
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
|
||||||
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
|
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
|
||||||
const _pinNewest=()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if((b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; };
|
const _pinNewest=()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if((b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; };
|
||||||
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d));
|
// Pins run AFTER the slide (no scrollTop writes into the compositing layer mid-transform). They re-glue
|
||||||
const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); }
|
// to the newest message as late images/day-separators grow the thread — this keeps "open at latest".
|
||||||
setTimeout(()=>{ try{ const b=document.getElementById('msgs'); if(b && window.bzDbg){ let tall={h:0,c:''}; b.querySelectorAll('.bubble,img,.att-img').forEach(el=>{ const h=el.getBoundingClientRect().height; if(h>tall.h) tall={h:Math.round(h), c:(el.className||el.tagName||'').toString().slice(0,36)}; }); bzDbg('convoOpen',{count:THREAD.length, scrollTop:Math.round(b.scrollTop), scrollH:Math.round(b.scrollHeight), clientH:Math.round(b.clientHeight), atBottom:(b.scrollHeight-b.scrollTop-b.clientHeight)<8, newsep:!!b.querySelector('.new-sep'), tallH:tall.h, tallC:tall.c}); } }catch(_){} }, 3600);
|
afterOpenSlide(()=>{
|
||||||
// Composer + transition diagnostics.
|
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d));
|
||||||
setTimeout(()=>{ try{ if(!window.bzDbg) return; var comp=document.querySelector('.composer'); var ta=document.getElementById('msgInput'); var content=document.querySelector('.shell > .content'); var cs=content?getComputedStyle(content):{};
|
const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); }
|
||||||
bzDbg('diag',{ compH: comp?Math.round(comp.getBoundingClientRect().height):-1, taH: ta?Math.round(ta.getBoundingClientRect().height):-1, taScrollH: ta?ta.scrollHeight:-1, taRows: ta?ta.rows:-1, taValLen: ta?(ta.value||'').length:-1, taNL: ta?(ta.value||'').split('\n').length:-1, taFs: ta?getComputedStyle(ta).fontSize:'', taLh: ta?getComputedStyle(ta).lineHeight:'', taInlineH: ta?(ta.style.height||''):'', chatOpening:document.body.classList.contains('chat-opening'), contentAnim: cs.animationName+' '+cs.animationDuration });
|
});
|
||||||
}catch(_){} }, 160);
|
|
||||||
// Watch for the transient scrollHeight balloon; report what's tallest AND its tallest child + text.
|
|
||||||
if(window.bzDbg){ let _spk=0; const _watch=setInterval(()=>{ const b=document.getElementById('msgs'); if(!(selected&&selected.kind===kind&&selected.id===id)||!b){ clearInterval(_watch); return; } const per=b.scrollHeight/Math.max(1,THREAD.length); if(per>200 && _spk<2){ _spk++; let tall={h:0}; b.querySelectorAll('.bubble').forEach(el=>{ const h=el.getBoundingClientRect().height; if(h>tall.h){ let tc={h:0,d:''}; el.querySelectorAll('*').forEach(ch=>{ const chh=ch.getBoundingClientRect().height; if(chh>tc.h) tc={h:Math.round(chh), d:ch.tagName+'.'+(ch.className||'').toString().slice(0,16)}; }); tall={h:Math.round(h), c:(el.className||'').slice(0,18), txt:(el.textContent||'').trim().slice(0,44), kids:el.childElementCount, tallKid:tc.d+'='+tc.h}; } }); bzDbg('spike',{scrollH:Math.round(b.scrollHeight), per:Math.round(per), tallH:tall.h, tallC:tall.c, txt:tall.txt, kids:tall.kids, tallKid:tall.tallKid}); } }, 200); setTimeout(()=>clearInterval(_watch), 6000); }
|
|
||||||
}
|
}
|
||||||
// ---------- @mentions (group chat) ----------
|
// ---------- @mentions (group chat) ----------
|
||||||
let mentionItems=[], mentionIdx=0, mentionStart=-1;
|
let mentionItems=[], mentionIdx=0, mentionStart=-1;
|
||||||
@@ -3074,10 +3088,12 @@ async function selectChat(kind,id){
|
|||||||
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
|
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
|
||||||
selected={kind,id};
|
selected={kind,id};
|
||||||
document.body.classList.add('chat-open'); // mobile: show the conversation pane
|
document.body.classList.add('chat-open'); // mobile: show the conversation pane
|
||||||
// Smooth native-style push: reveal the list underneath with a parallax while the conversation slides in.
|
// DON'T start the slide here — openConvo builds the shell + renders the thread FIRST, then arms the
|
||||||
if(window.matchMedia('(max-width:760px)').matches){ document.body.classList.add('chat-opening'); clearTimeout(window.__chatOpenT); window.__chatOpenT=setTimeout(()=>document.body.classList.remove('chat-opening'), 340); }
|
// compositor slide (armOpenSlide) once the pane is laid out & painted off-screen, so the transform runs
|
||||||
|
// with the main thread idle (smooth). Only for a fresh push on mobile.
|
||||||
|
window.__freshOpen = window.matchMedia('(max-width:760px)').matches;
|
||||||
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
|
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
|
||||||
renderChats(searchVal());
|
renderChats(searchVal()); // lay out the LIST (the parallax layer) BEFORE the slide is armed
|
||||||
updateRailUnread();
|
updateRailUnread();
|
||||||
await openConvo(kind,id);
|
await openConvo(kind,id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1004,13 +1004,6 @@ try {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD }));
|
route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD }));
|
||||||
|
|
||||||
// TEMP mobile debug sink: the app POSTs its real runtime state here so we can diagnose device-only issues
|
|
||||||
// from the server log (docker logs bizgaze-support) without screenshots. Remove once mobile bugs settle.
|
|
||||||
route('POST', '/api/dbg', async (req, res) => {
|
|
||||||
try { const b = await readBody(req); console.log('[MDBG]', JSON.stringify(b)); } catch (_) {}
|
|
||||||
json(res, 200, { ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
|
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
|
||||||
// The room-membership/host authorization already happens over the meeting WebSocket; this only
|
// The room-membership/host authorization already happens over the meeting WebSocket; this only
|
||||||
// hands the client a media-plane credential scoped to that room and its own identity.
|
// hands the client a media-plane credential scoped to that room and its own identity.
|
||||||
|
|||||||
Reference in New Issue
Block a user