aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 23:36:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 23:36:17 +0200
commitd6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8 (patch)
treef0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub/src
parent799d87999c8324564dce5159191532e008dd93d2 (diff)
parent32855a95e11032302f8d24036f6f2dd44b829368 (diff)
downloadmeshbay-d6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8.tar.gz
Merge branch 'fix/chat-scroll-up'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js99
1 files changed, 81 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 1850c74..3bc110f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -216,6 +216,12 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// them but before the browser paints.
const anchorRef = useRef(null);
const atBottomRef = useRef(true);
+ // Landing on the newest message is an *arrival* behaviour: it belongs to
+ // opening the group and to coming back to the Chat tab, and it has to survive
+ // the thumbnails and link-preview cards that keep growing the list for a
+ // second or two afterwards. It ends the moment the reader asks to move, and
+ // after that nothing may move the view for them.
+ const arrivingRef = useRef(true);
useEffect(() => {
if (status !== 'connected') return;
@@ -230,7 +236,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
setMessages(msgs);
requestAnimationFrame(() => {
const l = listRef.current;
- if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; }
+ // Not if the reader already moved: a slow node means this page
+ // lands seconds after the panel opened, and by then they may be
+ // reading somewhere else entirely.
+ if (l && arrivingRef.current) {
+ l.scrollTop = l.scrollHeight;
+ atBottomRef.current = true;
+ }
});
})
.catch(() => { loadedRef.current = false; });
@@ -284,27 +296,52 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
}, [messages, hasMore]);
- // Keep the view pinned to the newest message while the reader is at the
- // bottom, through everything that grows the content *after* the initial
- // paint: attachment thumbnails and link-preview cards fetched over the
- // network, late layout, and the panel resizing itself with `fit()` below.
- // Without this, opening the Chat tab reliably lands a screen or two above
- // the last message — the layout effect above ran when the list was still
- // short. Does nothing once the reader scrolls up (`atBottomRef` is false),
- // so it never fights reading back or the "load older" anchor.
+ // Hold the arrival at the newest message through everything that grows the
+ // content *after* the initial paint: attachment thumbnails and link-preview
+ // cards fetched over the network, late layout, and the panel resizing itself
+ // with `fit()` below. Without this, opening the Chat tab reliably lands a
+ // screen or two above the last message — the layout effect above ran when
+ // the list was still short.
+ //
+ // Bounded by `arrivingRef`, not by `atBottomRef` alone. The at-bottom test
+ // has a 40px tolerance and is fed by an event delivered a frame late, so on
+ // its own it let a resize storm hold the reader against the end of the
+ // conversation with no way back up it. Once the reader has asked to move,
+ // this stops observing entirely.
useEffect(() => {
const list = listRef.current;
- if (!list || typeof ResizeObserver === 'undefined') return;
+ if (!list || typeof ResizeObserver === 'undefined' || !arrivingRef.current) return;
+ // Declared before `stick` closes over it: a `const` further down would be
+ // in its temporal dead zone here, which is the hook-ordering trap this
+ // codebase has already paid for once.
+ let ro = null;
const stick = () => {
+ if (!arrivingRef.current) { if (ro) ro.disconnect(); return; }
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
};
- const ro = new ResizeObserver(stick);
+ ro = new ResizeObserver(stick);
ro.observe(list);
for (const child of list.children) ro.observe(child);
stick();
return () => ro.disconnect();
}, [messages]);
+ // The reader taking hold of the scroll ends the arrival, and it has to be
+ // recorded here rather than in `onScroll`: a `scroll` event is delivered at
+ // the next rendering step, so anything that pins in between undoes the
+ // movement before the handler that would have stopped it ever runs. These
+ // fire with the gesture itself.
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ const release = () => { arrivingRef.current = false; };
+ const events = ['wheel', 'touchmove', 'pointerdown', 'keydown'];
+ for (const name of events) list.addEventListener(name, release, { passive: true });
+ return () => {
+ for (const name of events) list.removeEventListener(name, release, { passive: true });
+ };
+ }, []);
+
// Entering the Chat tab should leave the cursor in the composer, ready to
// type — the panel mounts fresh on every tab switch, so a mount effect is
// the tab-entry hook. `preventScroll` because the layout effects above are
@@ -332,7 +369,6 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// Document-relative, so a page that happens to be scrolled does not skew
// the result — the answer must be the same either way.
const top = el.getBoundingClientRect().top + window.scrollY;
- el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`;
// What sits *below* the panel is not knowable from up here — today it is
// `.main`'s 24px bottom padding against this 16px gap, which left the
// document 8px taller than the window and a scrollbar on the chat tab at
@@ -340,10 +376,28 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// change to the page break it again, the leftover is measured and taken
// off. Self-correcting: anything added under the panel is absorbed the
// same way.
+ //
+ // Remembered on the element rather than re-derived on every call, because
+ // this runs on `resize` and so can *cause* the event it listens for.
+ // Setting the naive height, reading the overflow back and subtracting it
+ // means the document alternately does and does not overflow the window: a
+ // page scrollbar appears and vanishes with it, `visualViewport` fires
+ // `resize` at every pass, and fit() re-enters itself for the life of the
+ // panel. Measured 2026-09-01 on a page nobody was touching: 240 firings
+ // in two seconds, against 2 for a bare document. Each one re-pinned the
+ // list to the bottom, so every attempt to scroll up was undone inside the
+ // same frame — before the `scroll` event that would have recorded it was
+ // even delivered, which is why the reader could not move and the "jump to
+ // latest" button never appeared. Converged, this writes nothing.
+ const below = el._chatFitBelow || CHAT_BOTTOM_GAP;
+ const target = Math.max(CHAT_MIN_HEIGHT, vh - top - below);
+ if (Math.abs(target - el.getBoundingClientRect().height) > 0.5) {
+ el.style.height = `${target}px`;
+ }
const over = document.documentElement.scrollHeight - vh;
if (over > 0) {
- el.style.height =
- `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
+ el._chatFitBelow = below + over;
+ el.style.height = `${Math.max(CHAT_MIN_HEIGHT, target - over)}px`;
}
};
const fitAndPin = () => {
@@ -351,13 +405,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const l = listRef.current;
if (l && atBottomRef.current) l.scrollTop = l.scrollHeight;
};
+ // A real viewport change can also mean the page below the panel reflowed,
+ // so the learnt leftover is dropped and measured again. Not on
+ // `visualViewport`: an Android keyboard changes the viewport, not the
+ // layout under the panel, and re-learning there would put the oscillation
+ // back on the one platform that fires that event constantly.
+ const refit = () => { el._chatFitBelow = 0; fitAndPin(); };
fitAndPin();
- window.addEventListener('resize', fitAndPin);
- window.addEventListener('orientationchange', fitAndPin);
+ window.addEventListener('resize', refit);
+ window.addEventListener('orientationchange', refit);
window.visualViewport?.addEventListener('resize', fitAndPin);
return () => {
- window.removeEventListener('resize', fitAndPin);
- window.removeEventListener('orientationchange', fitAndPin);
+ window.removeEventListener('resize', refit);
+ window.removeEventListener('orientationchange', refit);
window.visualViewport?.removeEventListener('resize', fitAndPin);
};
}, []);
@@ -366,6 +426,9 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const el = e.target;
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
atBottomRef.current = bottom;
+ // Belt and braces for a scroll no gesture listener saw — a scrollbar
+ // dragged from outside the list, a "find in page" jump, assistive tech.
+ if (!bottom) arrivingRef.current = false;
setAtBottom(bottom);
if (bottom) setUnreadFrom(null);
}, []);