eleven / ocho Public

web: land at the newest message when a notification is tapped (#101)

While the app is backgrounded, WebKit suspends rendering, so the smooth
scroll that accompanies each live-appended message never runs and the
feed silently falls behind. The native didAttach re-pin then failed two
ways: its one-shot scrollTop write got clamped by WebKit's resume-time
layout (the same quirk pinFeedToBottom's rAF loop already works around),
and its instantaneous "near bottom" check misread a parked backlog as
the user having scrolled up. A tap on a notification for the thread
already on screen never re-pinned at all (no onAppear).

Track where the user's last real scroll left them (feedAtBottom — content
growth fires no scroll event, so a parked backlog can't flip it), have
native didAttach delegate to the page's settle-loop pin gated on that
flag, re-pin on scenePhase → .active for the already-open case, and let
catchUpActiveMessages re-pin when the flag is set even with nothing new
to append (the same bug in the backgrounded PWA).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 94bfafcc5e91ef6d959b0e90cf218ae14208266a parent 8281d21
3 files changed, +50 −16
  • changelog/2026-07-02-notification-tap-scroll.md +1 −0
  • ios/LChat/Views/ChatView.swift +14 −8
  • web/static/chat.js +35 −8
diff --git a/changelog/2026-07-02-notification-tap-scroll.md b/changelog/2026-07-02-notification-tap-scroll.md
new file mode 100644
index 0000000..a1f3b77
--- /dev/null
+++ b/changelog/2026-07-02-notification-tap-scroll.md
@@ -0,0 +1 @@
+Fixed: tapping a notification now lands you on the newest message. Before, the chat could open scrolled a few messages short of the bottom, especially on iPhone.
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 622d049..26baf13 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -30,6 +30,7 @@ struct ChatView: View {
@State private var attachError: String?
@State private var fullFrame: CGRect = .zero // the full-bleed webview, global coords
@State private var safeFrame: CGRect = .zero // the safe region between the bars
+ @Environment(\.scenePhase) private var scenePhase
let instance: SavedInstance
let thread: Thread
@@ -106,6 +107,12 @@ struct ChatView: View {
bridge.didAttach() // a cached feed may need re-pinning to its newest message
if isMac { composerFocused = true }
}
+ // A notification tap can foreground the app with this chat already on top —
+ // no onAppear fires, but the feed was parked all the same and sits shy of
+ // the newest message until it's re-pinned.
+ .onChange(of: scenePhase) { _, phase in
+ if phase == .active { bridge.didAttach() }
+ }
#if targetEnvironment(macCatalyst)
.background(MacWindowTitle(title: thread.title))
#endif
@@ -359,15 +366,14 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
// Called when this thread's (possibly cached) webview lands on screen. A parked
// page keeps receiving messages, but with rendering suspended its smooth-scroll
- // pinning can't run — so the saved offset sits a bubble or two shy of the newest
- // message. Re-pin unless the user had deliberately scrolled up into history.
+ // pinning can't run — so the saved offset sits shy of the newest message. The
+ // page's didAttach owns the re-pin: it re-asserts across a settle window (a
+ // one-shot scrollTop write from here got clamped by WebKit's resume-time
+ // layout) and knows whether the user had deliberately scrolled up into
+ // history (a live gap check here misreads a parked backlog as that).
func didAttach() {
- webView?.evaluateJavaScript("""
- (function(){
- var f = document.getElementById('feed');
- if (f && f.scrollHeight - f.scrollTop - f.clientHeight < f.clientHeight) f.scrollTop = f.scrollHeight;
- })()
- """)
+ webView?.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach()")
}
// Whether the web composer currently has a photo staged. Drives the native send
diff --git a/web/static/chat.js b/web/static/chat.js
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);
}