mobile: stop the open-pin schedule the instant the user scrolls (batch131)

Bug: for ~3.4s after opening a chat, the "keep newest in view as images load" pin schedule
would yank you back to the bottom if you tried to scroll up for older history — so scrolling
up snapped back to latest, and only worked once the schedule expired (~5s). Fix: the user's
first scroll gesture now sets _openScrolled, which cancels the pending pin timers and short-
circuits _pinNewest (so the late image-load pins stop too). loadOlder/anchor then works
immediately, no yank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 08:21:34 +05:30
parent 2a60c01268
commit 150a074578
+6 -5
View File
@@ -1146,7 +1146,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-19-batch130';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-19-batch131';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
@@ -2850,6 +2850,7 @@ async function openConvo(kind,id){
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 _convoRevealed=false; // #7: this open hasn't revealed yet
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below) _forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
let _openScrolled=false, _openPinTimers=[]; // the moment the user scrolls we STOP the open-pin schedule so it can't yank them back off older history they're trying to reach
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);
@@ -2938,7 +2939,7 @@ async function openConvo(kind,id){
if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions'); if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
}); });
if(box) box.addEventListener('scroll', onMsgsScroll); if(box) box.addEventListener('scroll', onMsgsScroll);
if(box){ const _rel=()=>{ _forcePinOpen=false; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back (stop force-gluing to the newest) if(box){ const _rel=()=>{ _forcePinOpen=false; _openScrolled=true; _openPinTimers.forEach(clearTimeout); _openPinTimers.length=0; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back: stop force-gluing to the newest AND cancel the pending open-pins so scrolling up for older history isn't yanked back down
if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; }; const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[]; composeMentions=new Map(); convoMembers=[];
@@ -3000,11 +3001,11 @@ async function openConvo(kind,id){
// #7: land on the newest message AND stay there while late content (images/avatars in long threads) // #7: land on the newest message AND stay there while late content (images/avatars in long threads)
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but // keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down. // only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
const _pinNewest=()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // _forcePinOpen: on a fresh open, force the newest into view no matter how far late images push it, until the user scrolls const _pinNewest=()=>{ if(_openScrolled) return; if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // stops the instant the user scrolls (_openScrolled); until then _forcePinOpen forces the newest into view as late images load
// Re-glue to the newest as late images/day-separators grow the thread. Spread over time (the compositor fade // Re-glue to the newest as late images/day-separators grow the thread. Spread over time (the compositor fade
// is unaffected by these main-thread pins), so "open at latest" holds while images finish loading. // is unaffected by these main-thread pins), so "open at latest" holds while images finish loading.
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d)); if(!_openScrolled) _openPinTimers=[0,120,320,600,1000,1600,2400,3400].map((d)=>setTimeout(_pinNewest, d)); // cancellable: the user's first scroll clears these so an early scroll-up for older history isn't yanked back
{ const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } } { const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } } // _pinNewest self-guards on _openScrolled, so these late image-load pins also stop once the user scrolls
} }
// ---------- @mentions (group chat) ---------- // ---------- @mentions (group chat) ----------
let mentionItems=[], mentionIdx=0, mentionStart=-1; let mentionItems=[], mentionIdx=0, mentionStart=-1;