diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:36:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:36:17 +0200 |
| commit | d6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8 (patch) | |
| tree | f0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub/src | |
| parent | 799d87999c8324564dce5159191532e008dd93d2 (diff) | |
| parent | 32855a95e11032302f8d24036f6f2dd44b829368 (diff) | |
| download | meshbay-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.js | 99 |
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); }, []); |