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:
2026-07-19 00:48:34 +05:30
parent 91b3e5c522
commit ea04556596
2 changed files with 50 additions and 15 deletions
+2
View File
@@ -15,8 +15,10 @@
"@capacitor/app": "^7.0.0", "@capacitor/app": "^7.0.0",
"@capacitor/camera": "^7.0.0", "@capacitor/camera": "^7.0.0",
"@capacitor/core": "^7.0.0", "@capacitor/core": "^7.0.0",
"@capacitor/filesystem": "^7.0.0",
"@capacitor/ios": "^7.0.0", "@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0", "@capacitor/keyboard": "^7.0.0",
"@capacitor/share": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0", "@capacitor/push-notifications": "^7.0.0",
"@capacitor/splash-screen": "^7.0.0", "@capacitor/splash-screen": "^7.0.0",
"@capacitor/status-bar": "^7.0.0" "@capacitor/status-bar": "^7.0.0"
+48 -15
View File
@@ -1079,6 +1079,11 @@
body.chat-open .chatcol{display:none;} body.chat-open .chatcol{display:none;}
.content{display:none;} .content{display:none;}
body.chat-open .content, .shell:not(.is-chat) .content{display:block;} 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;} .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 */ .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 /* #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, 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;} /* 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.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);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. */ 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> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <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. // 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
@@ -2586,18 +2591,35 @@ 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 // Native app (iOS/Android WKWebView): an <a href="/files/.." download> NAVIGATES the single WebView away to
// away from the SPA to the raw file — there's no back button, so the app looks frozen and must be force-quit // the raw file — no back button the app looks frozen (must force-quit). Routing it to Safari fails too,
// (exactly the "download breaks everything, must close and reopen" bug). Intercept every download link and // because Safari doesn't carry the app's login cookie ("unauthorized"). Correct fix: fetch the file IN the
// hand the URL to the SYSTEM browser instead: it downloads/previews natively and leaves the app untouched. // WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs
// Desktop (Electron) and web keep their normal <a download> behaviour, so this is native-only. // 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; (function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return;
document.addEventListener('click', function(e){ document.addEventListener('click', function(e){
var a=e.target&&e.target.closest&&e.target.closest('a[download]'); if(!a) return; var a=e.target&&e.target.closest&&e.target.closest('a[download]'); if(!a) return;
var href=a.getAttribute('href'); if(!href) return; var href=a.getAttribute('href'); if(!href) return;
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
try{ window.open(href, '_blank'); }catch(_){ try{ location.href=href; }catch(__){} } // Capacitor routes _blank to the OS browser (UIApplication.open) → no SPA navigation var P=C.Plugins;
}, true); // capture phase so it wins over the lightbox / bubble click handlers 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. // In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){ function openLightbox(src){
@@ -2769,6 +2791,18 @@ function appendBubble(m){
if(atBottom || m.from===ME.id){ box.scrollTop=box.scrollHeight; } 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 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 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. // 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; } function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
@@ -2792,10 +2826,9 @@ 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);
flushSlideQueue(); // pins / any reconcile run first, while still on the promoted layer (cheap) 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
// Demote the GPU layer OFF the motion path: 2 frames after the slide ends the screen is already static, flushSlideQueue(); // chat-opening now OFF → queued appendBubble()s run inline instead of re-deferring into a dead queue
// 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(()=>{
@@ -2938,7 +2971,7 @@ async function openConvo(kind,id){
if(_same){ 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 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 { } 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 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,