From 3393e446919bfcfe9a4bddea8e175f1f804f4b01 Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 17 Jul 2026 23:11:54 +0530 Subject: [PATCH] fix(mobile): JS-driven keyboard inset, interactive edge-back, finger-follow image swipe, no-dance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- mobile/capacitor.config.json | 2 +- server/public/home.html | 91 ++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 26 deletions(-) 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