fix(mobile): un-clip popups, swipe-to-reply, swipe-down-close image, no msg dance, slide-back

Addresses reported iOS observations (web-side, no rebuild):
- #1: the overflow-x:hidden I added to stop sideways-scroll was clipping the profile/bell popup menus;
  now only the chat-list scroller is clipped and long content wraps instead of widening the page.
- #9: swipe a message bubble right to reply (WhatsApp/Teams style).
- #8: swipe an opened image down to dismiss the lightbox.
- #7: hide the thread for one frame on open so messages don't visibly scroll/'dance' into place.
- #4: slide the outgoing conversation off-screen on back (inline transform, reliable across WebKit).
Build marker -> 2026-07-17-batch93.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:34:15 +05:30
parent 13b0f910ab
commit e29810ea2a
+49 -10
View File
@@ -1027,8 +1027,10 @@
/* #3: kill any horizontal drift on phones (long content, over-wide children, safe-area math) — the
page must never pan left/right. Clip every scroll surface and let long words/URLs wrap. */
html,body{overflow-x:hidden;}
.shell,.content,.chatcol,.panel,.convo,.convo-msgs,.chatlist,.side-head,.side-search-row,.chat-row{overflow-x:hidden;max-width:100%;min-width:0;}
.chat-main,.msg,.msg-body,.bubble{min-width:0;max-width:100%;overflow-wrap:anywhere;}
/* Stop the page from widening WITHOUT clipping overlays (profile/bell menus, reaction popups): only
cap the chat-list scroller and let long words/URLs wrap instead of pushing width. */
.chatlist{overflow-x:hidden;}
.chat-main,.msg,.msg-body,.bubble,.chat-sub,.chat-preview{min-width:0;max-width:100%;overflow-wrap:anywhere;}
/* #1/#5: the top safe-area inset (notch / Dynamic Island) is applied PER SURFACE — the chat-list
column and each content panel — rather than on the whole shell, so nothing hides under the island
and there's no double-padding. Bottom inset is handled by the fixed nav + panel bottom below. */
@@ -1071,10 +1073,11 @@
/* 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 bzConvoOut{from{transform:translateX(0);}to{transform:translateX(100%);}}
body.chat-open:not(.chat-closing) .content .panel{animation:bzConvoIn .22s cubic-bezier(.2,.7,.3,1);}
body.chat-closing .content{display:block!important;}
body.chat-closing .content .panel{animation:bzConvoOut .2s cubic-bezier(.4,0,.7,.4) forwards;will-change:transform;}
/* 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;}
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
@@ -1101,7 +1104,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-17-batch92';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-17-batch93';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
@@ -2559,6 +2562,12 @@ function openLightbox(src){
const show=(i)=>{ if(!multi) return; idx=((i%gallery.length)+gallery.length)%gallery.length; const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); };
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
// #8: swipe DOWN (when not zoomed) to dismiss, like native photo viewers — drag follows the finger and
// fades the backdrop; release past a threshold closes, otherwise it springs back.
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});
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); }
@@ -2606,9 +2615,12 @@ function renderThread(keepScroll){
let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
box.innerHTML=html; twemojify(box);
if(!keepScroll){ box.scrollTop=box.scrollHeight; // open/refresh → newest at the bottom; re-assert after late content (images/twemoji) settles, unless we jumped to a "New messages" divider
if(!keepScroll){ // #7: jump to the newest message with the list HIDDEN for one frame, so the user never
// sees it scroll/"dance" into place. Reveal only once it's pinned at the bottom.
box.style.visibility='hidden'; box.scrollTop=box.scrollHeight;
const pin=()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; };
requestAnimationFrame(pin); setTimeout(pin,150); setTimeout(pin,400);
requestAnimationFrame(()=>{ pin(); box.style.visibility='visible'; });
setTimeout(pin,150); setTimeout(pin,400);
}
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
}
@@ -4616,11 +4628,13 @@ function switchTab(tab){
}
function showWelcome(){
const finish=()=>{ selected=null; document.body.classList.remove('chat-open','chat-closing'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); };
// Mobile: play the slide-out (bzConvoOut) before swapping back to the chat list, so "back" has a
// clear push/pop direction. Desktop just closes instantly.
// 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"]');
document.body.classList.add('chat-closing');
setTimeout(finish, 200);
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);
} else finish();
}
railBtns.forEach(btn=>{ btn.onclick=()=>{
@@ -4665,6 +4679,31 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
}, {passive:true});
})();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
// follows the finger a little; releasing past the threshold opens the reply composer for that message.
(function(){
let bx=0, by=0, bEl=null, on=false;
document.addEventListener('touchstart',(e)=>{
if(e.touches.length!==1){ on=false; return; }
const t=e.touches[0];
const b=e.target && e.target.closest && e.target.closest('#msgs .bubble');
if(!b || b.classList.contains('deleted') || t.clientX<32){ on=false; bEl=null; return; } // >32px so it can't clash with edge-swipe-back
on=true; bEl=b; bx=t.clientX; by=t.clientY;
}, {passive:true});
document.addEventListener('touchmove',(e)=>{
if(!on||!bEl||e.touches.length!==1) return;
const t=e.touches[0], dx=t.clientX-bx, dy=t.clientY-by;
if(dx>0 && Math.abs(dx)>Math.abs(dy)){ bEl.style.transition='none'; bEl.style.transform='translateX('+Math.min(dx,72)+'px)'; bEl.style.opacity=String(Math.max(.65,1-dx/300)); }
else if(Math.abs(dy)>Math.abs(dx)){ on=false; if(bEl){ bEl.style.transform=''; bEl.style.opacity=''; } bEl=null; } // it's a vertical scroll — let go
}, {passive:true});
document.addEventListener('touchend',(e)=>{
if(!on||!bEl){ on=false; return; }
const t=e.changedTouches&&e.changedTouches[0], dx=t?(t.clientX-bx):0, dy=t?(t.clientY-by):0;
const el=bEl; on=false; bEl=null;
el.style.transition='transform .18s, opacity .18s'; el.style.transform=''; el.style.opacity='';
if(dx>52 && Math.abs(dy)<46){ const id=el.getAttribute('data-id'); const m=(typeof THREAD!=='undefined'&&THREAD||[]).find(x=>String(x.id)===String(id)); if(m) setReply(m); }
}, {passive:true});
})();
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;