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:
+49
-10
@@ -1027,8 +1027,10 @@
|
|||||||
/* #3: kill any horizontal drift on phones (long content, over-wide children, safe-area math) — the
|
/* #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. */
|
page must never pan left/right. Clip every scroll surface and let long words/URLs wrap. */
|
||||||
html,body{overflow-x:hidden;}
|
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;}
|
/* Stop the page from widening WITHOUT clipping overlays (profile/bell menus, reaction popups): only
|
||||||
.chat-main,.msg,.msg-body,.bubble{min-width:0;max-width:100%;overflow-wrap:anywhere;}
|
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
|
/* #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
|
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. */
|
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
|
/* 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). */
|
when you go back, so navigation has a clear direction (like Teams/WhatsApp). */
|
||||||
@keyframes bzConvoIn{from{transform:translateX(100%);}to{transform:translateX(0);}}
|
@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-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{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 */
|
/* Bigger touch targets */
|
||||||
.chat-row{padding:.7rem .85rem;}
|
.chat-row{padding:.7rem .85rem;}
|
||||||
.convo-head{padding:.7rem .85rem;}
|
.convo-head{padding:.7rem .85rem;}
|
||||||
@@ -1101,7 +1104,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<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.
|
// 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
|
||||||
@@ -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 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); };
|
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)=>{
|
const onKey=(e)=>{
|
||||||
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
|
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
|
||||||
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
|
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
|
||||||
@@ -2606,9 +2615,12 @@ function renderThread(keepScroll){
|
|||||||
let html='';
|
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); }
|
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);
|
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; };
|
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
|
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(){
|
function showWelcome(){
|
||||||
const finish=()=>{ selected=null; document.body.classList.remove('chat-open','chat-closing'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); };
|
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
|
// Mobile: slide the outgoing conversation off to the right before swapping back to the chat list, so
|
||||||
// clear push/pop direction. Desktop just closes instantly.
|
// "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){
|
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');
|
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();
|
} else finish();
|
||||||
}
|
}
|
||||||
railBtns.forEach(btn=>{ btn.onclick=()=>{
|
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
|
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
|
||||||
}, {passive:true});
|
}, {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.
|
// 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'); }
|
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;
|
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
|
||||||
|
|||||||
Reference in New Issue
Block a user