diff --git a/server/public/home.html b/server/public/home.html
index eb35cc9..73c88dc 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -1101,8 +1101,14 @@
/* 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,
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);}
- body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListParallaxOut .3s cubic-bezier(.32,.72,0,1);}
+ /* Compositor-only push (workflow-verified). Phase 1 (chat-opening): the pane is pinned OFF-SCREEN on
+ 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
(.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;}
@@ -1134,7 +1140,7 @@
-
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;
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('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
}
}catch(_){} })();
-

Biz Connect
Loading…
@@ -1507,7 +1482,16 @@ async function loadOlder(){
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
_loadingOlder=true;
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(_){}
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
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(older.length!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; }
_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
}
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);
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
@@ -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 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(_){} }
+// 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){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
@@ -2854,6 +2868,9 @@ async function openConvo(kind,id){
const ckey=kind+':'+id;
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
else if(box){ box.innerHTML='
Loading messages… '; } // 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(!selected||selected.kind!==kind||selected.id!==id) return;
wireMentions();
@@ -2873,7 +2890,7 @@ async function openConvo(kind,id){
THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice());
_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
// #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.
@@ -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
// 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; };
- [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}); }); }
- 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); }
+ // 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));
+ const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); }
+ });
}
// ---------- @mentions (group chat) ----------
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
selected={kind,id};
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.
- 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); }
+ // DON'T start the slide here — openConvo builds the shell + renders the thread FIRST, then arms the
+ // 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)
- renderChats(searchVal());
+ renderChats(searchVal()); // lay out the LIST (the parallax layer) BEFORE the slide is armed
updateRailUnread();
await openConvo(kind,id);
}
diff --git a/server/routes.js b/server/routes.js
index 486fb3f..1999fee 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -1004,13 +1004,6 @@ try {
} catch (_) {}
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).
// 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.