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:
+66
-25
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user