mobile: kill slide end-hitch (compositor teardown), fix reopen-at-latest + native download hang (batch124)

Slide: the 40-128ms hitch was a compositor RE-RASTER at transitionend, not JS
(longtasks:[], renderMs 2-6ms in the device log). Removed contain:layout paint /
isolation:isolate from the .content slide layer and the filter:brightness parallax
on .chatcol (both force a full re-raster on teardown); defer the layer demotion two
frames past transitionend so it lands off the motion path. Also replaced the fragile
length+lastId slide-end skip (it almost never held once a live message grew the cache,
so the full innerHTML rebuild kept running at slide-end) with an exact per-message diff.

Reopen-at-latest: loadOlder wrote the GROWN thread (100+ msgs) into THREAD_CACHE, so
re-opening re-rendered all of it and stranded you mid-history as images shifted the
(pre-load) bottom past the 1200px pin guard. Open now renders the latest PAGE only;
added _forcePinOpen to glue to the newest through late image loads until the user scrolls.

Download hang: <a download> navigated the whole WKWebView away to the raw file (no back,
app frozen). Native-only capture-phase interceptor opens downloads in the system browser.
Lightbox close/download buttons: solid dark chips so they're visible over bright images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 00:31:57 +05:30
parent 433703cfeb
commit 91b3e5c522
+35 -15
View File
@@ -345,10 +345,10 @@
.lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);}
/* new #2: on iOS these sat UNDER the Dynamic Island / notch and couldn't be tapped. Push them below
the safe area (and clear of the right-side inset in landscape). */
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:calc(18px + env(safe-area-inset-top,0px));border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;z-index:2;}
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:calc(18px + env(safe-area-inset-top,0px));border:1px solid rgba(255,255,255,.55);background:rgba(0,0,0,.58);color:#fff;width:46px;height:46px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;z-index:2;box-shadow:0 2px 12px rgba(0,0,0,.55);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);} /* solid dark chip + border so the ✕ / download stay clearly visible over bright images */
.lightbox .lb-close{right:calc(18px + env(safe-area-inset-right,0px));}
.lightbox .lb-dl{right:calc(74px + env(safe-area-inset-right,0px));}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(0,0,0,.78);}
.lightbox img{max-width:82vw;}
/* #3 zoom: transform-based, anchored at the cursor; arrows hide while zoomed so panning isn't hijacked */
.lightbox img{transition:transform .08s linear;transform-origin:center center;will-change:transform;}
@@ -1105,10 +1105,10 @@
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 .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;} /* NB: NO contain/isolation — those force a full re-raster when the layer is torn down at transitionend (the 40-128ms end-of-slide hitch the device log showed). will-change alone promotes to a GPU layer; demoting it is cheap. */
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);}
body.chat-opening.chat-anim .chatcol{transform:translate3d(-22%,0,0);transition:transform .3s cubic-bezier(.32,.72,0,1);} /* translate-only parallax — animating filter:brightness re-rasters the list every frame AND again on teardown; the dim isn't worth the jank. */
/* 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;}
@@ -1140,7 +1140,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-19-batch123';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-19-batch124';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.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -1444,6 +1444,7 @@ let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[];
let _convoRevealed=false; // #7: gate the open-time hide/reveal so the cache→network double-render doesn't flash/dance
let _forcePinOpen=false; // true from chat-open until the user's first scroll gesture: keep gluing to the newest even as late images grow the thread past the usual 1200px "don't yank" guard (else you land mid-history)
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
// a no-op on desktop. onRefresh is an async function.
@@ -2585,6 +2586,19 @@ function openInvitePicker(room){
ov.querySelector('#invClose').onclick=()=>ov.remove();
ov.querySelector('#invBtn').onclick=async()=>{ const ids=[...ov.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room, userIds:ids }); ov.remove(); toast('Invited '+r.invited+(r.invited===1?' person':' people')); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Native app (iOS/Android WKWebView): an <a href="/files/.." download> would NAVIGATE the single WebView
// away from the SPA to the raw file — there's no back button, so the app looks frozen and must be force-quit
// (exactly the "download breaks everything, must close and reopen" bug). Intercept every download link and
// hand the URL to the SYSTEM browser instead: it downloads/previews natively and leaves the app untouched.
// Desktop (Electron) and web keep their normal <a download> behaviour, so this is native-only.
(function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return;
document.addEventListener('click', function(e){
var a=e.target&&e.target.closest&&e.target.closest('a[download]'); if(!a) return;
var href=a.getAttribute('href'); if(!href) return;
e.preventDefault(); e.stopPropagation();
try{ window.open(href, '_blank'); }catch(_){ try{ location.href=href; }catch(__){} } // Capacitor routes _blank to the OS browser (UIApplication.open) → no SPA navigation
}, true); // capture phase so it wins over the lightbox / bubble click handlers
})();
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){
if(document.getElementById('lightbox')) return;
@@ -2778,8 +2792,10 @@ function armOpenSlide(){
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(); };
flushSlideQueue(); // pins / any reconcile run first, while still on the promoted layer (cheap)
// Demote the GPU layer OFF the motion path: 2 frames after the slide ends the screen is already static,
// so the layer-teardown raster no longer collides with the animation's final frames (that WAS the hitch).
requestAnimationFrame(()=>requestAnimationFrame(()=>{ if(window.__slideTok===token) b.classList.remove('chat-opening','chat-anim'); })); };
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); };
if(c) c.addEventListener('transitionend', _slideEndHandler);
requestAnimationFrame(()=>requestAnimationFrame(()=>{
@@ -2793,6 +2809,7 @@ 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)
_convoRevealed=false; // #7: this open hasn't revealed yet
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
@@ -2881,13 +2898,15 @@ async function openConvo(kind,id){
if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
});
if(box) box.addEventListener('scroll', onMsgsScroll);
if(box){ const _rel=()=>{ _forcePinOpen=false; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back (stop force-gluing to the newest)
if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[];
// Synchronous render from cache (within the click's activation → paints immediately, even
// when opened from a notification; an async-only render would defer the paint until a click).
const ckey=kind+':'+id;
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
let _renderedOpen=null; // the exact array we painted from cache, to diff against the network result below
if(THREAD_CACHE.has(ckey)){ const _c=THREAD_CACHE.get(ckey); THREAD = _c.length>PAGE ? _c.slice(-PAGE) : _c.slice(); _hasMoreOlder = _c.length>PAGE; _renderedOpen=THREAD; renderThread(); } // open at the LATEST page only: a short DOM pins to the newest reliably even as images load. Re-opening a thread that was scrolled up no longer re-renders 100+ msgs and strands you mid-history — older pages back in on scroll-up.
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.
@@ -2908,15 +2927,16 @@ async function openConvo(kind,id){
const inp0=document.getElementById('msgInput'); if(inp0 && !isMobileUA()) inp0.focus(); // #2: don't auto-open the phone keyboard on chat open
return;
}
const _sig=(a)=>((a&&a.length)||0)+'|'+(((a&&a[a.length-1])||{}).id||'')+'|'+(((a&&a[a.length-1])||{}).edited_at||'');
const _prevSig = _convoRevealed ? _sig(THREAD) : null; // signature of what the cache render already shows (before we overwrite)
// Did the network result EXACTLY match what the cache render already painted? Compare per-message by
// id + edited/deleted (NOT the old length+lastId signature the cache count drifts every time a live
// message arrives, so that check almost never held and the full innerHTML rebuild ran at slide-end =
// the 40-128ms hitch). An exact match → skip the rebuild entirely and just stay pinned.
const _same = _renderedOpen && _renderedOpen.length===msgs.length && _renderedOpen.every((m,i)=>m.id===msgs[i].id && (m.edited_at||0)===(msgs[i].edited_at||0) && !!m.deleted===!!msgs[i].deleted);
THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice());
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
// If the network result matches what the cache render already painted, DON'T rebuild the DOM at slide
// end (that innerHTML+paint was the ~60-130ms hitch the device log showed). Just keep it pinned.
if(_prevSig!==null && _sig(msgs)===_prevSig){
afterOpenSlide(()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep') && (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; });
if(_same){
afterOpenSlide(()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; }); // identical to the cache render → no rebuild, just glue to the newest
} else {
afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id) renderThread(); }); // content changed → re-render, but only after the slide
}
@@ -2937,7 +2957,7 @@ async function openConvo(kind,id){
// #7: land on the newest message AND stay there while late content (images/avatars in long threads)
// 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; };
const _pinNewest=()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // _forcePinOpen: on a fresh open, force the newest into view no matter how far late images push it, until the user scrolls
// 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(()=>{