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:
2026-07-18 23:49:38 +05:30
parent f3c3e08be4
commit 529bc26cb5
2 changed files with 66 additions and 57 deletions
+64 -48
View File
@@ -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; };
// Pins run AFTER the slide (no scrollTop writes into the compositing layer mid-transform). They re-glue
// to the newest message as late images/day-separators grow the thread — this keeps "open at latest".
afterOpenSlide(()=>{
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d)); [0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d));
const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); }
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); });
// Composer + transition diagnostics.
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):{};
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);
} }
-7
View File
@@ -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.