fix(mobile): JS-driven keyboard inset, interactive edge-back, finger-follow image swipe, no-dance

- #3 keyboard (was B: chat didn't move, keyboard covered composer): native resize wasn't moving our
  fixed layout, so drive it from JS — the Keyboard plugin reports its height, we expose --kb and lift the
  composer to sit exactly on the keyboard; keyboard config -> resize:none so native won't double-adjust.
- #4: the left-edge back is now INTERACTIVE — the conversation follows the finger with the list revealed
  underneath (parallax), commit past 1/3 width else spring back (like Teams). Falls back to release-based
  bzcBack for popups/search.
- #8: the image now follows the finger sideways (and down) during the swipe, not just on release.
- #7: hold the thread hidden until its images load (max 700ms) then reveal at the bottom — no dance.
Build marker -> 2026-07-17-batch95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:11:54 +05:30
parent f2e2e8b08a
commit 3393e44691
2 changed files with 67 additions and 26 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"offsetForKeyboardInsetBug": true "offsetForKeyboardInsetBug": true
}, },
"Keyboard": { "Keyboard": {
"resize": "native" "resize": "none"
} }
} }
} }
+66 -25
View File
@@ -26,7 +26,7 @@
/* Safe-area insets. The native app enables edge-to-edge via the @capacitor-community/safe-area plugin, /* Safe-area insets. The native app enables edge-to-edge via the @capacitor-community/safe-area plugin,
which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home
indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */ indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */
:root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);} :root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);--kb:0px;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;} body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;}
/* ---- Top bar ---- */ /* ---- Top bar ---- */
@@ -1069,17 +1069,23 @@
directly above the keyboard (no nav sandwiched between the text box and the keyboard). The panel directly above the keyboard (no nav sandwiched between the text box and the keyboard). The panel
then only needs to clear the home indicator. */ then only needs to clear the home indicator. */
body.chat-open .rail{display:none;} body.chat-open .rail{display:none;}
body.chat-open .content .panel{bottom:var(--sab);} /* Composer sits on the home indicator normally, and rises to sit exactly on the keyboard when it's
open (--kb is set by the Keyboard plugin listener). When the keyboard is up there's no home
indicator, so --sab collapses to 0. */
body.chat-open .content .panel{bottom:calc(var(--sab) + var(--kb));transition:bottom .22s ease;}
body.kb-open{--sab:0px;}
/* 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 bzListIn{from{transform:translateX(-24%);}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-open:not(.chat-closing):not(.chat-dragging) .content .panel{animation:bzConvoIn .22s cubic-bezier(.2,.7,.3,1);}
/* Close (native push/pop, like Teams): reveal the chat LIST underneath with a parallax slide-in while /* Close / interactive back (native push/pop, like Teams): reveal the chat LIST underneath with a
the conversation slides off to the right. The conversation (.content) is slid by an inline transform parallax slide-in while the conversation slides/drags off to the right. The conversation (.content)
in showWelcome(); the list (.chatcol) is force-shown beneath it for the duration of the animation. */ is moved by an inline transform (showWelcome() for a tap, or the edge-drag gesture); the list
body.chat-closing .content{display:block!important;z-index:2;box-shadow:-10px 0 26px rgba(15,23,42,.16);will-change:transform;} (.chatcol) is force-shown beneath it for the duration. */
body.chat-closing .content, body.chat-dragging .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);} body.chat-closing .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListIn .2s cubic-bezier(.4,0,.7,.4);}
body.chat-dragging .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;}
/* 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;}
@@ -1106,7 +1112,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-batch94';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-17-batch95';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
@@ -1121,6 +1127,14 @@ function twemojify(_el){ /* native emoji: nothing to do */ }</script>
// juggling here — we only tag <html> so CSS can make small native-only tweaks if needed. // juggling here — we only tag <html> so CSS can make small native-only tweaks if needed.
(function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return; (function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return;
var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p); var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p);
// Keyboard: the native resize doesn't move our fixed layout, so we drive it ourselves. The Keyboard
// plugin reports its height — we expose it as --kb and lift the composer above it (see CSS), then keep
// the thread pinned to the newest message.
var KB=C.Plugins&&C.Plugins.Keyboard;
if(KB && KB.addListener){
KB.addListener('keyboardWillShow', function(info){ var h=(info&&info.keyboardHeight)||0; r.style.setProperty('--kb', h+'px'); document.body.classList.add('kb-open'); var b=document.getElementById('msgs'); if(b) requestAnimationFrame(function(){ b.scrollTop=b.scrollHeight; }); });
KB.addListener('keyboardWillHide', function(){ r.style.setProperty('--kb','0px'); document.body.classList.remove('kb-open'); });
}
}catch(_){} })(); }catch(_){} })();
</script> </script>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div> <div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
@@ -2568,7 +2582,10 @@ function openLightbox(src){
// fades the backdrop; release past a threshold closes, otherwise it springs back. // fades the backdrop; release past a threshold closes, otherwise it springs back.
let dsy=0, dsx=0, dDrag=false; 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('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('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)+')'; } // #8: follow finger down to dismiss
else if(multi && Math.abs(dx)>Math.abs(dy)){ img.style.transition='none'; img.style.transform='translateX('+dx+'px)'; } // #8: follow finger sideways to flip
},{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; 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 && 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 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
@@ -2621,15 +2638,19 @@ 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){ // #7: jump to the newest message with the list HIDDEN for one frame, so the user never if(!keepScroll){ // #7: keep the thread HIDDEN until its images have loaded (or a short timeout), then
// sees it scroll/"dance" into place. Reveal only once it's pinned at the bottom. // jump to the newest message and reveal — so the user never sees it reflow / "dance" into place.
box.style.visibility='hidden'; box.scrollTop=box.scrollHeight; 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 pending=[...box.querySelectorAll('img')].filter(im=>!im.complete);
requestAnimationFrame(()=>{ pin(); box.style.visibility='visible'; }); let revealed=false;
setTimeout(pin,150); setTimeout(pin,400); const reveal=()=>{ if(revealed) return; revealed=true; const b=document.getElementById('msgs'); if(!b) return; if(!b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; b.style.visibility='visible'; };
// As each image finishes loading, re-glue to the bottom (only while the user is still near it) so if(!pending.length){ requestAnimationFrame(reveal); }
// late-loading images can't visibly reflow / "dance" the thread. else {
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}); }); let left=pending.length;
const one=()=>{ if(--left<=0) reveal(); };
pending.forEach(im=>{ const h=()=>{ im.removeEventListener('load',h); im.removeEventListener('error',h); one(); }; im.addEventListener('load',h); im.addEventListener('error',h); });
setTimeout(reveal, 700); // never wait longer than this for slow images
}
} }
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
} }
@@ -4676,18 +4697,38 @@ function bzcBack(){
try{ history.pushState(null,'',location.href); }catch(_){} try{ history.pushState(null,'',location.href); }catch(_){}
window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } }); window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } });
// #4: iOS/Android have no on-screen back button in a full-screen webview, so give the app a native-feeling // #4: iOS/Android have no on-screen back button in a full-screen webview, so give the app a native-feeling
// left-edge SWIPE-back: a drag that begins at the very left edge and moves clearly rightward closes the // left-edge back gesture. When a conversation is the top layer, the drag is INTERACTIVE — the whole
// topmost layer (popup → in-conversation search → open chat) via the same bzcBack() used for the OS back. // conversation follows the finger and the chat list is revealed underneath (like Teams); releasing past a
// third of the width commits the back, otherwise it springs shut. For other layers (popup / in-conversation
// search) it falls back to a release-triggered bzcBack().
(function(){ (function(){
let sx=0, sy=0, edge=false; let sx=0, sy=0, mode='', content=null, w=0, decided=false;
const convoIsTop=()=> window.matchMedia('(max-width:760px)').matches
&& document.body.classList.contains('chat-open')
&& !document.querySelector('.modal-ov') && !document.getElementById('lightbox')
&& !(function(){ const sh=document.getElementById('convoSearchHead'); return sh && sh.style.display!=='none'; })();
window.addEventListener('touchstart',(e)=>{ window.addEventListener('touchstart',(e)=>{
if(e.touches.length!==1){ edge=false; return; } mode='';
const t=e.touches[0]; edge = t.clientX<=24; sx=t.clientX; sy=t.clientY; // only arm when starting at the left edge if(e.touches.length!==1) return;
const t=e.touches[0]; if(t.clientX>28) return; // only from the very left edge
sx=t.clientX; sy=t.clientY; decided=false; w=window.innerWidth||360;
if(convoIsTop()){ mode='convo'; content=document.querySelector('.shell > .content'); renderChats(searchVal()); } // list ready underneath
else mode='back';
}, {passive:true});
window.addEventListener('touchmove',(e)=>{
if(mode!=='convo'||!content) return;
const t=e.touches[0], dx=t.clientX-sx, dy=t.clientY-sy;
if(!decided){ if(Math.abs(dx)>10||Math.abs(dy)>10){ decided=true; if(Math.abs(dy)>Math.abs(dx)){ mode=''; return; } document.body.classList.add('chat-dragging'); } }
if(decided && dx>=0){ content.style.transition='none'; content.style.transform='translateX('+dx+'px)'; }
}, {passive:true}); }, {passive:true});
window.addEventListener('touchend',(e)=>{ window.addEventListener('touchend',(e)=>{
if(!edge) return; edge=false; const t=e.changedTouches&&e.changedTouches[0]; const dx=t?(t.clientX-sx):0, dy=t?(t.clientY-sy):0;
const t=e.changedTouches&&e.changedTouches[0]; if(!t) return; if(mode==='convo' && content){
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back content.style.transition='transform .17s ease-out';
if(dx > w*0.32){ content.style.transform='translateX(100%)'; setTimeout(()=>{ content.style.transition=''; content.style.transform=''; document.body.classList.remove('chat-dragging'); selected=null; document.body.classList.remove('chat-open'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); }, 165); }
else { content.style.transform='translateX(0)'; setTimeout(()=>{ content.style.transition=''; content.style.transform=''; document.body.classList.remove('chat-dragging'); }, 165); }
} else if(mode==='back'){ if(dx>60 && Math.abs(dy)<50) bzcBack(); }
mode=''; content=null;
}, {passive:true}); }, {passive:true});
})(); })();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble // #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble