| index 38c496a..3c43f5f 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -512,6 +512,17 @@ function installNativeBridge() { |
| })); |
| return items.length ? { start: tok.start, items } : null; |
| }, |
| + // Called when this thread's (possibly cached) webview lands on screen — |
| + // a notification tap, foregrounding the app, or returning from another |
| + // chat. A parked page keeps receiving messages, but with rendering |
| + // suspended their smooth-scroll pinning never runs, so the feed resumes |
| + // shy of the newest message. Re-pin via the settle loop — a one-shot |
| + // scrollTop write here gets clamped by WebKit's resume-time layout — |
| + // unless the user had deliberately scrolled up into history. |
| + didAttach() { |
| + const feed = document.getElementById("feed"); |
| + if (feed && feedAtBottom) pinFeedToBottom(feed, openSeq); |
| + }, |
| }; |
| } |
| |
| @@ -957,27 +968,26 @@ function renderApp() { |
| // below it — autogrowing textarea, :emoji/@mention typeahead, reply/edit |
| // banners, the keyboard inset — sits in normal flow, so any of it growing |
| // shrinks the feed *without firing a scroll event*, leaving the chat wedged |
| - // not-quite-at-bottom while you type. `stickBottom` is refreshed on scroll, |
| + // not-quite-at-bottom while you type. `feedAtBottom` is refreshed on scroll, |
| // so at resize time it reflects where the user was before the change; the |
| // re-pin fires its own scroll event, which keeps the flag true. |
| if (feedForBlur) { |
| - let stickBottom = true; // feeds open pinned to the latest message |
| feedForBlur.addEventListener( |
| "scroll", |
| () => { |
| - stickBottom = feedForBlur.scrollHeight - feedForBlur.scrollTop - feedForBlur.clientHeight < 80; |
| + feedAtBottom = feedForBlur.scrollHeight - feedForBlur.scrollTop - feedForBlur.clientHeight < 80; |
| }, |
| { passive: true }, |
| ); |
| new ResizeObserver(() => { |
| - if (!stickBottom) return; |
| + if (!feedAtBottom) return; |
| feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| // WebKit restores the pre-resize scroll position *after* observers run, |
| // undoing the line above (Chrome doesn't). Re-assert on the next frame, |
| // once its restoration has happened — unconditionally: the was-at-bottom |
| // decision was made above, and the restoration's own scroll event may |
| // have already pushed the gap past the threshold (e.g. the 216px |
| - // typeahead), flipping stickBottom false. A user can't scroll within |
| + // typeahead), flipping feedAtBottom false. A user can't scroll within |
| // the same frame, so this never fights a real scroll-away. |
| requestAnimationFrame(() => { |
| feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| @@ -1400,6 +1410,15 @@ function refreshAvatars() { |
| |
| let openSeq = 0; // bumped each openThread so a slow history load bails if you switch away |
| |
| +// Whether the user's last real scroll left the feed at (or within 80px of) the |
| +// bottom. Content growth fires no scroll event, so after messages arrive while |
| +// rendering is suspended (parked native webview, hidden PWA tab) this still says |
| +// "was at the bottom" — where an instantaneous gap check would misread the grown |
| +// gap as "scrolled up into history". Maintained by the feed scroll listener |
| +// (renderApp); every deliberate pin sets it true. Feeds open pinned, so it |
| +// starts true. |
| +let feedAtBottom = true; |
| + |
| // Per-thread rendered-feed cache. Switching chats used to clear #feed and |
| // re-fetch every time, which flashed empty for the network round-trip and reset |
| // the scroll. Instead we keep each opened thread's message nodes alive: when you |
| @@ -1527,6 +1546,10 @@ async function openThread(id) { |
| if (cached.nodes) feed.appendChild(cached.nodes); |
| cached.nodes = null; |
| feed.scrollTop = cached.scrollTop != null ? cached.scrollTop : feed.scrollHeight; |
| + // Judge at-bottom from the restored offset right away — the scroll event the |
| + // assignment fires is async, so without this the *previous* thread's flag |
| + // could leak into this thread's catch-up pin decision below. |
| + feedAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| if (!isCoarse) composerText.focus(); |
| catchUpActiveMessages(); // background: append anything that arrived while it was away |
| return; |
| @@ -1564,6 +1587,7 @@ async function openThread(id) { |
| // user scrolls up, or if they switch threads. |
| function pinFeedToBottom(feed, gen) { |
| if (!feed) return; |
| + feedAtBottom = true; // a deliberate pin IS "at the bottom", even if no scroll event fires |
| feed.scrollTop = feed.scrollHeight; |
| let active = true; |
| const onScroll = () => { |
| @@ -2937,9 +2961,12 @@ async function catchUpActiveMessages() { |
| await appendMessage(id, m, false); |
| added++; |
| } |
| - // Only jump to the newest when we actually appended something, so a plain |
| - // reopen/refocus doesn't yank someone out of the history they were reading. |
| - if (added && id === activeId && feed) pinFeedToBottom(feed, openSeq); |
| + // Jump to the newest when we appended something — and also when the user |
| + // was last at the bottom: a message that live-appended while rendering was |
| + // suspended (hidden tab, parked webview) never finished its smooth scroll, |
| + // leaving the feed shy of the newest with nothing left to append. Someone |
| + // reading history is never yanked down (feedAtBottom is false for them). |
| + if ((added || feedAtBottom) && id === activeId && feed) pinFeedToBottom(feed, openSeq); |
| } catch (e) { |
| console.error("catch up messages", e); |
| } |