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:
+35
-15
@@ -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);}
|
.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
|
/* 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). */
|
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-close{right:calc(18px + env(safe-area-inset-right,0px));}
|
||||||
.lightbox .lb-dl{right:calc(74px + 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;}
|
.lightbox img{max-width:82vw;}
|
||||||
/* #3 zoom: transform-based, anchored at the cursor; arrows hide while zoomed so panning isn't hijacked */
|
/* #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;}
|
.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,
|
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
|
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. */
|
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.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 .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
|
/* 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;}
|
||||||
@@ -1140,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-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.
|
// 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
|
||||||
@@ -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 convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
|
||||||
let THREAD=[];
|
let THREAD=[];
|
||||||
let _convoRevealed=false; // #7: gate the open-time hide/reveal so the cache→network double-render doesn't flash/dance
|
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)
|
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
|
// 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.
|
// 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('#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'); } };
|
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.
|
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
|
||||||
function openLightbox(src){
|
function openLightbox(src){
|
||||||
if(document.getElementById('lightbox')) return;
|
if(document.getElementById('lightbox')) return;
|
||||||
@@ -2778,8 +2792,10 @@ function armOpenSlide(){
|
|||||||
const done=()=>{ if(window.__slideTok!==token) return;
|
const done=()=>{ if(window.__slideTok!==token) return;
|
||||||
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||||||
clearTimeout(window.__chatOpenT);
|
clearTimeout(window.__chatOpenT);
|
||||||
b.classList.remove('chat-opening','chat-anim');
|
flushSlideQueue(); // pins / any reconcile run first, while still on the promoted layer (cheap)
|
||||||
flushSlideQueue(); };
|
// 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(); };
|
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); };
|
||||||
if(c) c.addEventListener('transitionend', _slideEndHandler);
|
if(c) c.addEventListener('transitionend', _slideEndHandler);
|
||||||
requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
||||||
@@ -2793,6 +2809,7 @@ 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)
|
||||||
_convoRevealed=false; // #7: this open hasn't revealed yet
|
_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');
|
convoIsGroup=(kind==='group');
|
||||||
const el=document.getElementById('chatPanel'); el.classList.remove('center');
|
const el=document.getElementById('chatPanel'); el.classList.remove('center');
|
||||||
el.innerHTML=convoShellHTML(it);
|
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(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
|
||||||
});
|
});
|
||||||
if(box) box.addEventListener('scroll', onMsgsScroll);
|
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
|
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; };
|
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
|
||||||
composeMentions=new Map(); convoMembers=[];
|
composeMentions=new Map(); convoMembers=[];
|
||||||
// Synchronous render from cache (within the click's activation → paints immediately, even
|
// 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).
|
// when opened from a notification; an async-only render would defer the paint until a click).
|
||||||
const ckey=kind+':'+id;
|
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
|
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,
|
// 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.
|
// 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
|
const inp0=document.getElementById('msgInput'); if(inp0 && !isMobileUA()) inp0.focus(); // #2: don't auto-open the phone keyboard on chat open
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const _sig=(a)=>((a&&a.length)||0)+'|'+(((a&&a[a.length-1])||{}).id||'')+'|'+(((a&&a[a.length-1])||{}).edited_at||'');
|
// Did the network result EXACTLY match what the cache render already painted? Compare per-message by
|
||||||
const _prevSig = _convoRevealed ? _sig(THREAD) : null; // signature of what the cache render already shows (before we overwrite)
|
// 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=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
|
||||||
// If the network result matches what the cache render already painted, DON'T rebuild the DOM at slide
|
if(_same){
|
||||||
// end (that innerHTML+paint was the ~60-130ms hitch the device log showed). Just keep it pinned.
|
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
|
||||||
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; });
|
|
||||||
} else {
|
} else {
|
||||||
afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id) renderThread(); }); // content changed → re-render, but only after the slide
|
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)
|
// #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
|
// 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(_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
|
// 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".
|
// to the newest message as late images/day-separators grow the thread — this keeps "open at latest".
|
||||||
afterOpenSlide(()=>{
|
afterOpenSlide(()=>{
|
||||||
|
|||||||
Reference in New Issue
Block a user