summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 13:52:01 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 13:52:01 +0200
commitc4813d1aaf1f4fdf3eb9dc07909f324f5745544d (patch)
treecd39bbff663a2ed3db6462c026978142a1bf5cdc /packages/meshbay-hub/src
parentb8130c80035abc2b28d0b2f19347d16a40668559 (diff)
downloadmeshbay-c4813d1aaf1f4fdf3eb9dc07909f324f5745544d.tar.gz
fix(hub): chat scroll-to-bottom, message loading on group switch, connecting spinner
Three regressions fixed in ChatPanel: - Scroll pinning: layout effect now depends on [messages, hasMore] so the "load older" button appearance triggers a re-pin; setHasMore is called before setMessages to avoid an intermediate render without the button; fit() is wrapped in fitAndPin() so panel resizing re-pins the scroll. - Message loading on group switch: fetch effect depends on the status prop instead of transportRef.current?.connected to avoid racing with GroupPage's cleanup; loadedRef resets on fetch failure so a retry works. - Connecting spinner restored in the Chat tab empty state. Adds structural regression tests (test_chat_scroll_bottom.py) that lock the dependency arrays and setState ordering so these invariants break loudly in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js56
1 files changed, 27 insertions, 29 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 f21ec2c..1850c74 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -199,7 +199,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity }) {
+ onPreview, mayUpload = true, onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -218,28 +218,25 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const atBottomRef = useRef(true);
useEffect(() => {
+ if (status !== 'connected') return;
const transport = transportRef.current;
if (!transport || !transport.connected) return;
if (!loadedRef.current) {
loadedRef.current = true;
- // The newest page. This used to be fetchChatHistory(0, 200), which paged
- // forwards from the very first message ever sent, so a busy group opened
- // on its oldest screen and the recent conversation was unreachable.
transport.fetchChatHistory({ limit: CHAT_PAGE })
.then(({ messages: msgs, hasMore: more }) => {
- setMessages(msgs);
setHasMore(more);
+ setMessages(msgs);
+ requestAnimationFrame(() => {
+ const l = listRef.current;
+ if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; }
+ });
})
- .catch(() => {});
+ .catch(() => { loadedRef.current = false; });
}
transport.onChat = (msg) => {
- // A live message has no row id until it is re-read from the node, so it
- // gets a local one. Keys have to be stable and unique or prepending a
- // page makes Preact reuse the wrong bubbles. Computed once and reused
- // below: the unread marker points at a message by id, so generating a
- // second one there would point it at nothing.
const id = msg.id
|| `live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
setMessages(prev => [...prev, {
@@ -250,13 +247,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
timestamp: msg.timestamp || Date.now() / 1000,
thread_id: msg.thread_id,
}]);
- // Somebody wrote while you were reading further up: mark where you were
- // rather than yanking the view down.
if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
};
return () => { transport.onChat = null; };
- }, [transportRef.current?.connected]);
+ }, [status]);
const loadOlder = useCallback(async () => {
const transport = transportRef.current;
@@ -286,14 +281,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
anchorRef.current = null;
return;
}
- // Only follow the conversation if the reader was already at the bottom.
- // Scrolling unconditionally fought every attempt to read back through it.
- //
- // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no
- // height, so aligning it to the bottom of the viewport leaves the list's
- // own padding below it and the bar stops just short of the end.
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
- }, [messages]);
+ }, [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
@@ -357,14 +346,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
`${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
}
};
- fit();
- window.addEventListener('resize', fit);
- window.addEventListener('orientationchange', fit);
- window.visualViewport?.addEventListener('resize', fit);
+ const fitAndPin = () => {
+ fit();
+ const l = listRef.current;
+ if (l && atBottomRef.current) l.scrollTop = l.scrollHeight;
+ };
+ fitAndPin();
+ window.addEventListener('resize', fitAndPin);
+ window.addEventListener('orientationchange', fitAndPin);
+ window.visualViewport?.addEventListener('resize', fitAndPin);
return () => {
- window.removeEventListener('resize', fit);
- window.removeEventListener('orientationchange', fit);
- window.visualViewport?.removeEventListener('resize', fit);
+ window.removeEventListener('resize', fitAndPin);
+ window.removeEventListener('orientationchange', fitAndPin);
+ window.visualViewport?.removeEventListener('resize', fitAndPin);
};
}, []);
@@ -470,7 +464,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
<div class="chat-start">${t('chat.start_of_history')}</div>
`}
${messages.length === 0 && html`
- <div class="chat-empty">${t('chat.empty')}</div>
+ <div class="chat-empty">
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching')
+ ? html`<span class="spinner"></span>${' '}${t('status.connecting_short')}`
+ : t('chat.empty')}
+ </div>
`}
${messages.map((m, i) => {
const isOwn = m.sender_name === username || m.sender_id === username;