fix(mobile): parallax slide-back, swipe images left/right, glue thread while images load

- #4: on back, reveal the chat list UNDERNEATH with a parallax slide-in while the conversation slides
  off to the right (slide the whole .content, list forced visible beneath) — matches the Teams push/pop.
- #8: swipe left/right in the image viewer flips to the prev/next image (not just swipe-down to close).
- #7: re-pin to the bottom as each image finishes loading (when near bottom), so late images don't
  reflow/'dance' the thread.
Build marker -> 2026-07-17-batch94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:07:42 +05:30
parent e29810ea2a
commit f2e2e8b08a
+20 -9
View File
@@ -1073,11 +1073,13 @@
/* Native-style push/pop: the conversation slides in from the right when opened and slides back out
when you go back, so navigation has a clear direction (like Teams/WhatsApp). */
@keyframes bzConvoIn{from{transform:translateX(100%);}to{transform:translateX(0);}}
@keyframes bzListIn{from{transform:translateX(-24%);}to{transform:translateX(0);}}
body.chat-open:not(.chat-closing) .content .panel{animation:bzConvoIn .22s cubic-bezier(.2,.7,.3,1);}
body.chat-closing .content{display:block!important;}
/* Close: the outgoing conversation is slid out via an inline transform in showWelcome() — more
reliable across WebKit than swapping @keyframes on the same element mid-flight. */
body.chat-closing .content .panel{will-change:transform;}
/* Close (native push/pop, like Teams): reveal the chat LIST underneath with a parallax slide-in while
the conversation slides off to the right. The conversation (.content) is slid by an inline transform
in showWelcome(); the list (.chatcol) is force-shown beneath it for the duration of the animation. */
body.chat-closing .content{display:block!important;z-index:2;box-shadow:-10px 0 26px rgba(15,23,42,.16);will-change:transform;}
body.chat-closing .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListIn .2s cubic-bezier(.4,0,.7,.4);}
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
@@ -1104,7 +1106,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-17-batch93';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-17-batch94';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
@@ -2567,7 +2569,11 @@ function openLightbox(src){
let dsy=0, dsx=0, dDrag=false;
img.addEventListener('touchstart',(e)=>{ if(z<=1 && e.touches.length===1){ dDrag=true; dsy=e.touches[0].clientY; dsx=e.touches[0].clientX; } },{passive:true});
img.addEventListener('touchmove',(e)=>{ if(!dDrag||z>1||e.touches.length!==1) return; const dy=e.touches[0].clientY-dsy, dx=e.touches[0].clientX-dsx; if(dy>0 && dy>Math.abs(dx)){ img.style.transition='none'; img.style.transform='translateY('+dy+'px)'; ov.style.background='rgba(0,0,0,'+Math.max(.15,0.92-dy/500)+')'; } },{passive:true});
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0; if(dy>100 && z<=1){ close(); } else { img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); } },{passive:true});
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0, dx=t?(t.clientX-dsx):0;
if(z<=1 && dy>100 && dy>Math.abs(dx)){ close(); return; } // #8: swipe down = dismiss
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ show(dx<0?idx+1:idx-1); } // #8: swipe left/right = prev/next image
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200);
},{passive:true});
const onKey=(e)=>{
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
@@ -2621,6 +2627,9 @@ function renderThread(keepScroll){
const pin=()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; };
requestAnimationFrame(()=>{ pin(); box.style.visibility='visible'; });
setTimeout(pin,150); setTimeout(pin,400);
// As each image finishes loading, re-glue to the bottom (only while the user is still near it) so
// late-loading images can't visibly reflow / "dance" the thread.
box.querySelectorAll('img').forEach(im=>{ if(!im.complete) im.addEventListener('load', ()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep') && (b.scrollHeight-b.scrollTop-b.clientHeight)<400) b.scrollTop=b.scrollHeight; }, {once:true}); });
}
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
}
@@ -4631,10 +4640,12 @@ function showWelcome(){
// Mobile: slide the outgoing conversation off to the right before swapping back to the chat list, so
// "back" has a clear push/pop direction (like Teams/WhatsApp). Desktop just closes instantly.
if(document.body.classList.contains('chat-open') && !document.body.classList.contains('chat-closing') && window.matchMedia('(max-width:760px)').matches){
const panel=document.querySelector('.content .panel[data-panel="chat"]');
const content=document.querySelector('.shell > .content');
// Render the list NOW so it's ready underneath before we start sliding the conversation away.
renderChats(searchVal());
document.body.classList.add('chat-closing');
if(panel){ panel.style.transition='transform .2s cubic-bezier(.4,0,.7,.4)'; requestAnimationFrame(()=>{ panel.style.transform='translateX(100%)'; }); }
setTimeout(()=>{ if(panel){ panel.style.transition=''; panel.style.transform=''; } finish(); }, 210);
if(content){ content.style.transition='transform .21s cubic-bezier(.4,0,.7,.4)'; requestAnimationFrame(()=>{ content.style.transform='translateX(100%)'; }); }
setTimeout(()=>{ if(content){ content.style.transition=''; content.style.transform=''; } finish(); }, 215);
} else finish();
}
railBtns.forEach(btn=>{ btn.onclick=()=>{