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
},
"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,
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). */
: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%;}
/* ---- Top bar ---- */
@@ -1069,17 +1069,23 @@
directly above the keyboard (no nav sandwiched between the text box and the keyboard). The panel
then only needs to clear the home indicator. */
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
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);}
/* 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-open:not(.chat-closing):not(.chat-dragging) .content .panel{animation:bzConvoIn .22s cubic-bezier(.2,.7,.3,1);}
/* Close / interactive back (native push/pop, like Teams): reveal the chat LIST underneath with a
parallax slide-in while the conversation slides/drags off to the right. The conversation (.content)
is moved by an inline transform (showWelcome() for a tap, or the edge-drag gesture); the list
(.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-dragging .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;}
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
@@ -1106,7 +1112,7 @@
</head>
<body>
<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.
//
// 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.
(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);
// 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(_){} })();
</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>
@@ -2568,7 +2582,10 @@ function openLightbox(src){
// 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('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;
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
@@ -2621,15 +2638,19 @@ 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){ // #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.
if(!keepScroll){ // #7: keep the thread HIDDEN until its images have loaded (or a short timeout), then
// 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;
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}); });
const pending=[...box.querySelectorAll('img')].filter(im=>!im.complete);
let revealed=false;
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'; };
if(!pending.length){ requestAnimationFrame(reveal); }
else {
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
}
@@ -4676,18 +4697,38 @@ function 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
// left-edge SWIPE-back: a drag that begins at the very left edge and moves clearly rightward closes the
// topmost layer (popup → in-conversation search → open chat) via the same bzcBack() used for the OS back.
// left-edge back gesture. When a conversation is the top layer, the drag is INTERACTIVE — the whole
// 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(){
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)=>{
if(e.touches.length!==1){ edge=false; return; }
const t=e.touches[0]; edge = t.clientX<=24; sx=t.clientX; sy=t.clientY; // only arm when starting at the left edge
mode='';
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});
window.addEventListener('touchend',(e)=>{
if(!edge) return; edge=false;
const t=e.changedTouches&&e.changedTouches[0]; if(!t) return;
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
const t=e.changedTouches&&e.changedTouches[0]; const dx=t?(t.clientX-sx):0, dy=t?(t.clientY-sy):0;
if(mode==='convo' && content){
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});
})();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble