diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json
index 956f0f1..7453b1b 100644
--- a/mobile/capacitor.config.json
+++ b/mobile/capacitor.config.json
@@ -17,7 +17,7 @@
"offsetForKeyboardInsetBug": true
},
"Keyboard": {
- "resize": "native"
+ "resize": "none"
}
}
}
diff --git a/server/public/home.html b/server/public/home.html
index a0fae64..7c39de2 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -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 @@
-
// juggling here — we only tag 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(_){} })();

Biz Connect
Loading…
@@ -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+=''+pEsc(dayLabel(m.created_at))+'
'; _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