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": {
"detectViewportFitCoverChanges": true,
"initialViewportFitCover": true,
"offsetForKeyboardInsetBug": true
"offsetForKeyboardInsetBug": false
},
"Keyboard": {
"resize": "none"
"resize": "native"
},
"SplashScreen": {
"launchShowDuration": 900,
"launchAutoHide": true,
"backgroundColor": "#16294F",
"showSpinner": false,
"iosSpinnerStyle": "large"
}
}
}
+31 -27
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);--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%;}
/* ---- Top bar ---- */
@@ -1069,11 +1069,9 @@
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;}
/* 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;}
/* Composer clears the home indicator; when the keyboard opens, @capacitor/keyboard resize:native
shrinks the WebView so this bottom edge ends up right on top of the keyboard — no JS needed. */
body.chat-open .content .panel{bottom:var(--sab);}
/* 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);}}
@@ -1112,7 +1110,7 @@
</head>
<body>
<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.
//
// 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.
(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.
// Keyboard is handled NATIVELY by @capacitor/keyboard (resize:native resizes the WebView so the composer
// sits on the keyboard) — no JS height juggling here. We only keep the thread pinned to newest on show.
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'); });
}
if(KB && KB.addListener){ KB.addListener('keyboardDidShow', function(){ var b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; }); }
}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>
@@ -1392,6 +1386,7 @@ let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, onlin
let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
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)
// 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.
@@ -2638,18 +2633,24 @@ 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: 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 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(!keepScroll){
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;
const pending=[...box.querySelectorAll('img')].filter(im=>!im.complete);
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'; };
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
@@ -2679,6 +2680,7 @@ function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(dr
async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
_convoRevealed=false; // #7: this open hasn't revealed yet
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
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
// #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.
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 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'; }