fix(mobile): native keyboard (remove JS hack), open at latest + no double-render dance, splash cfg

- #3 keyboard: the overshoot proved resize:native DOES lift the composer; my --kb JS was a second lift.
  Removed the JS hack entirely; keyboard is now purely @capacitor/keyboard resize:native. Config: turn
  OFF safe-area offsetForKeyboardInsetBug so only one mechanism moves the view (needs the build).
- #7: gate the open-time hide/reveal with _convoRevealed so the cache->network double render doesn't
  flash/dance; on mobile open at the LATEST message (skip auto-scroll to first-unread).
- #6: add SplashScreen config (bg #16294F, no spinner) — asset gen already produces the logo.
Build marker -> 2026-07-18-batch97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:17:58 +05:30
parent 763b2996d1
commit cc2a76450f
2 changed files with 40 additions and 29 deletions
+9 -2
View File
@@ -14,10 +14,17 @@
"SafeArea": { "SafeArea": {
"detectViewportFitCoverChanges": true, "detectViewportFitCoverChanges": true,
"initialViewportFitCover": true, "initialViewportFitCover": true,
"offsetForKeyboardInsetBug": true "offsetForKeyboardInsetBug": false
}, },
"Keyboard": { "Keyboard": {
"resize": "none" "resize": "native"
},
"SplashScreen": {
"launchShowDuration": 900,
"launchAutoHide": true,
"backgroundColor": "#16294F",
"showSpinner": false,
"iosSpinnerStyle": "large"
} }
} }
} }
+23 -19
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);--kb:0px;} :root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,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,11 +1069,9 @@
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;}
/* Composer sits on the home indicator normally, and rises to sit exactly on the keyboard when it's /* Composer clears the home indicator; when the keyboard opens, @capacitor/keyboard resize:native
open (--kb is set by the Keyboard plugin listener). When the keyboard is up there's no home shrinks the WebView so this bottom edge ends up right on top of the keyboard — no JS needed. */
indicator, so --sab collapses to 0. */ body.chat-open .content .panel{bottom:var(--sab);}
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);}}
@@ -1112,7 +1110,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-batch96';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-18-batch97';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
@@ -1127,14 +1125,10 @@ 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 // Keyboard is handled NATIVELY by @capacitor/keyboard (resize:native resizes the WebView so the composer
// plugin reports its height — we expose it as --kb and lift the composer above it (see CSS), then keep // sits on the keyboard) — no JS height juggling here. We only keep the thread pinned to newest on show.
// the thread pinned to the newest message.
var KB=C.Plugins&&C.Plugins.Keyboard; var KB=C.Plugins&&C.Plugins.Keyboard;
if(KB && KB.addListener){ if(KB && KB.addListener){ KB.addListener('keyboardDidShow', function(){ var b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; }); }
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>
@@ -1392,6 +1386,7 @@ let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, onlin
let selected=null; // {kind,id} or null = welcome let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels) let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[]; let THREAD=[];
let _convoRevealed=false; // #7: gate the open-time hide/reveal so the cache→network double-render doesn't flash/dance
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately) const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's // Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
// a no-op on desktop. onRefresh is an async function. // a no-op on desktop. onRefresh is an async function.
@@ -2638,12 +2633,17 @@ 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: keep the thread HIDDEN until its images have loaded (or a short timeout), then if(!keepScroll){
// jump to the newest message and reveal — so the user never sees it reflow / "dance" into place. if(_convoRevealed){
// #7: a later render during the SAME open (network arriving after the cache render) — just stay at
// the newest message, no hide/reveal, so there's no second flash/"dance".
box.scrollTop=box.scrollHeight; requestAnimationFrame(()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; });
} else {
// #7: FIRST render of this open — keep the thread hidden until its images have loaded (or a short
// timeout), then jump to the newest message and reveal, so it never visibly reflows/"dances".
box.style.visibility='hidden'; box.scrollTop=box.scrollHeight; box.style.visibility='hidden'; box.scrollTop=box.scrollHeight;
const pending=[...box.querySelectorAll('img')].filter(im=>!im.complete); const pending=[...box.querySelectorAll('img')].filter(im=>!im.complete);
let revealed=false; const reveal=()=>{ if(_convoRevealed) return; _convoRevealed=true; const b=document.getElementById('msgs'); if(!b) return; if(!b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; b.style.visibility='visible'; };
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); } if(!pending.length){ requestAnimationFrame(reveal); }
else { else {
let left=pending.length; let left=pending.length;
@@ -2652,6 +2652,7 @@ function renderThread(keepScroll){
setTimeout(reveal, 700); // never wait longer than this for slow images 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
} }
function appendBubble(m){ function appendBubble(m){
@@ -2679,6 +2680,7 @@ function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(dr
async function openConvo(kind,id){ async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'}; const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3) const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
_convoRevealed=false; // #7: this open hasn't revealed yet
convoIsGroup=(kind==='group'); convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center'); const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it); el.innerHTML=convoShellHTML(it);
@@ -2796,7 +2798,9 @@ async function openConvo(kind,id){
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread, // #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
// and show the "jump to newest" arrow so the user can return to the bottom. // and show the "jump to newest" arrow so the user can return to the bottom.
if(_unreadN>0 && THREAD.length>=_unreadN){ // #7 (mobile): phones open at the LATEST message (no auto-scroll up to the first unread), which is what
// users expect from a chat app; the divider/jump-up is a desktop convenience only.
if(_unreadN>0 && THREAD.length>=_unreadN && !isMobileUA()){
const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id; const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id;
const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]'); const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]');
if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; } if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; }