mobile: definitive slide fix (zero teardown) + real native download + reconcile (batch125)
Slide: the residual ~40ms end-of-slide hitch (down from 128ms in b124) was the layer DEMOTE + the deferred full renderThread at transitionend. Two fixes: - Keep .content promoted for the WHOLE time a chat is open (will-change lives on body.chat-open, not on the animating class). The open transition now only changes the transform VALUE 100%->0; when it ends there is nothing to tear down -> no demote raster. - Slide-end no longer rebuilds all 40 nodes: reconcileOpen() appends only the 0-2 genuinely new tail messages (full renderThread only if the page structure diverged). Download: routing to Safari failed auth (no login cookie). Real fix: fetch the file in the WebView (cookie present) and hand the bytes to the OS save/share sheet via Filesystem+Share (added to mobile deps; ships next TestFlight build). Until then, images fall back to the iOS long-press "Save to Photos" instead of breaking the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+48
-15
@@ -1079,6 +1079,11 @@
|
||||
body.chat-open .chatcol{display:none;}
|
||||
.content{display:none;}
|
||||
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
|
||||
/* Keep the conversation pane on its own GPU layer for the WHOLE time it's open (not just while the
|
||||
slide animates). The open transition then only changes the transform VALUE 100%→0; when it ends there
|
||||
is NOTHING to tear down (no contain/box-shadow/will-change toggle) → the ~40ms end-of-slide demote
|
||||
raster is gone. The layer is demoted only when you leave the chat, which has its own close animation. */
|
||||
body.chat-open .content{z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;backface-visibility:hidden;-webkit-backface-visibility:hidden;}
|
||||
.shell:not(.is-chat) .chatcol{display:none;}
|
||||
.content .panel{top:var(--sat);bottom:calc(60px + var(--sab));} /* below the notch; clear the bottom nav + home indicator */
|
||||
/* #2 (mobile keyboard): inside an open conversation, hide the bottom tab bar so the composer sits
|
||||
@@ -1105,7 +1110,7 @@
|
||||
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;} /* 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 .content{display:block!important;transform:translate3d(100%,0,0);} /* ONLY the start transform — display/z-index/box-shadow/will-change all come from the resting body.chat-open rule above, so removing chat-opening/chat-anim at transitionend changes NOTHING (transform 0→none is a no-op, the layer stays promoted). Zero teardown = zero end-of-slide hitch. */
|
||||
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);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. */
|
||||
@@ -1140,7 +1145,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-19-batch124';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-19-batch125';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
|
||||
@@ -2586,18 +2591,35 @@ 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.
|
||||
// Native app (iOS/Android WKWebView): an <a href="/files/.." download> NAVIGATES the single WebView away to
|
||||
// the raw file — no back button → the app looks frozen (must force-quit). Routing it to Safari fails too,
|
||||
// because Safari doesn't carry the app's login cookie ("unauthorized"). Correct fix: fetch the file IN the
|
||||
// WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs
|
||||
// the Filesystem+Share plugins (added to the mobile app; ships in the next TestFlight build). Until then, fall
|
||||
// back to iOS long-press "Save to Photos" for images (works today) so nothing breaks. Desktop/web untouched.
|
||||
async function nativeSaveFile(url, name){
|
||||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem, Sh=P&&P.Share;
|
||||
if(!Fs||!Sh) return false;
|
||||
try{
|
||||
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
|
||||
const blob=await res.blob();
|
||||
const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); });
|
||||
const fname=(name||url.split('/').pop()||'file').replace(/[^\w.\- ]+/g,'_');
|
||||
const w=await Fs.writeFile({ path:fname, data:b64, directory:'CACHE' });
|
||||
await Sh.share({ url:w.uri, title:fname }); // iOS sheet: Save to Files / Save Image / share
|
||||
}catch(e){ toast("Couldn't save the file"); }
|
||||
return true; // handled either way — never fall through to navigation
|
||||
}
|
||||
(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
|
||||
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
|
||||
var P=C.Plugins;
|
||||
if(P&&P.Filesystem&&P.Share){ nativeSaveFile(href, a.getAttribute('download')||''); return; } // real inline save
|
||||
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
|
||||
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
|
||||
}, true); // capture phase so it wins over the lightbox / bubble handlers
|
||||
})();
|
||||
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
|
||||
function openLightbox(src){
|
||||
@@ -2769,6 +2791,18 @@ function appendBubble(m){
|
||||
if(atBottom || m.from===ME.id){ box.scrollTop=box.scrollHeight; }
|
||||
else { const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; } // let them jump when ready
|
||||
}
|
||||
// Slide-end update WITHOUT rebuilding the DOM: the cache render already painted the latest page, and the
|
||||
// network result is almost always that same page ± a couple of brand-new tail messages. Append only those
|
||||
// (0-2 nodes) instead of re-running innerHTML over all 40 — that full rebuild's layout+paint was the residual
|
||||
// slide-end cost. Returns false (→ caller does a full renderThread) only if an already-shown message was
|
||||
// removed/edited/deleted since the cache render, which is rare and not worth a targeted DOM patch.
|
||||
function reconcileOpen(prev){
|
||||
const box=document.getElementById('msgs'); if(!box || !prev || !prev.length) return false;
|
||||
const nextById=new Map(THREAD.map(m=>[m.id,m]));
|
||||
for(const m of prev){ const n=nextById.get(m.id); if(!n || (m.edited_at||0)!==(n.edited_at||0) || !!m.deleted!==!!n.deleted) return false; }
|
||||
for(const m of THREAD){ if(!rendered.has(m.id)) appendBubble(m); } // we're at the bottom on open → appendBubble keeps us pinned to the newest
|
||||
return true;
|
||||
}
|
||||
let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread
|
||||
// Per-conversation drafts: unsent text is kept (across chat switches AND reloads) until sent.
|
||||
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
|
||||
@@ -2792,10 +2826,9 @@ function armOpenSlide(){
|
||||
const done=()=>{ if(window.__slideTok!==token) return;
|
||||
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||||
clearTimeout(window.__chatOpenT);
|
||||
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'); })); };
|
||||
b.classList.remove('chat-opening','chat-anim'); // teardown is now a no-op: the layer stays promoted via body.chat-open, so there's no demote raster to move off the motion path
|
||||
flushSlideQueue(); // chat-opening now OFF → queued appendBubble()s run inline instead of re-deferring into a dead queue
|
||||
};
|
||||
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); };
|
||||
if(c) c.addEventListener('transitionend', _slideEndHandler);
|
||||
requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
||||
@@ -2938,7 +2971,7 @@ async function openConvo(kind,id){
|
||||
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
|
||||
afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id){ if(!reconcileOpen(_renderedOpen)) renderThread(); } }); // content changed → append the new tail cheaply; full re-render only if the page structure diverged
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user