diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index 484ff444..6da89fb0 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -49,6 +49,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess const isLoading = useChatStore((s) => s.isLoading); const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true); const ackChannel = useChatStore((s) => s.ackChannel); + const saveScrollPosition = useChatStore((s) => s.saveScrollPosition); const bottomRef = useRef(null); const containerRef = useRef(null); const contentRef = useRef(null); @@ -56,6 +57,8 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess const isNearBottomRef = useRef(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const prevMessagesLength = useRef(0); + const prevChannelIdRef = useRef(channelId); + const visibleMsgIdRef = useRef(null); const ackTimerRef = useRef>(); // Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY @@ -81,14 +84,39 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess return () => clearTimeout(ackTimerRef.current); }, [channelId, messages.length, lastMessageId, isNearBottom, ackChannel]); - // Reset scroll tracking on channel switch so initial-load scroll fires + // Save scroll anchor (tracked by handleScroll) when leaving a channel, then reset tracking useEffect(() => { - prevMessagesLength.current = 0; - setIsNearBottom(true); - isNearBottomRef.current = true; - }, [channelId]); + const prevId = prevChannelIdRef.current; + prevChannelIdRef.current = channelId; - // Handle scrolling: initial load snaps to bottom, new messages smooth-scroll if near bottom + // Save or clear the old channel's scroll position + if (prevId && prevId !== channelId) { + if (visibleMsgIdRef.current) { + // User was scrolled up — save the anchor message + saveScrollPosition(prevId, visibleMsgIdRef.current); + visibleMsgIdRef.current = null; + } else { + // User was at bottom — clear any stale saved position so we snap to bottom next time + const pos = useChatStore.getState().scrollPositions; + if (pos.has(prevId)) { + const next = new Map(pos); + next.delete(prevId); + useChatStore.setState({ scrollPositions: next }); + } + } + } + + prevMessagesLength.current = 0; + + // If we have a saved position for the incoming channel, don't mark as near-bottom + // — this prevents the ResizeObserver from snapping to bottom before the restore rAF fires + const willRestore = useChatStore.getState().scrollPositions.has(channelId); + setIsNearBottom(!willRestore); + isNearBottomRef.current = !willRestore; + }, [channelId, saveScrollPosition]); + + // Handle scrolling: initial load restores position or snaps to bottom, + // new messages smooth-scroll if near bottom useEffect(() => { const prev = prevMessagesLength.current; prevMessagesLength.current = messages.length; @@ -96,18 +124,30 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess if (messages.length === 0) return; if (prev === 0) { - // Initial load / channel switch — snap to bottom + // Initial load / channel switch — restore to saved message anchor or snap to bottom + const savedMsgId = useChatStore.getState().scrollPositions.get(channelId); requestAnimationFrame(() => { const container = containerRef.current; - if (container) { - container.scrollTop = container.scrollHeight; + if (!container) return; + if (savedMsgId) { + const el = document.getElementById(`msg-${savedMsgId}`); + if (el) { + el.scrollIntoView({ block: 'start' }); + const dist = container.scrollHeight - container.scrollTop - container.clientHeight; + const near = dist < 5000; + setIsNearBottom(near); + isNearBottomRef.current = near; + return; + } } + // No saved anchor or message not in cache — snap to bottom + container.scrollTop = container.scrollHeight; }); } else if (messages.length > prev && isNearBottom) { // New messages arrived while near bottom — smooth scroll bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); } - }, [messages.length, isNearBottom]); + }, [messages.length, isNearBottom, channelId]); // Auto-scroll when content height grows (embeds/images loading) while near bottom const hasMessages = messages.length > 0; @@ -178,10 +218,24 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess // Check if near bottom const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - const nearBottom = distanceFromBottom < 100; + const nearBottom = distanceFromBottom < 5000; setIsNearBottom(nearBottom); isNearBottomRef.current = nearBottom; + // Track top-visible message for scroll position persistence + if (!nearBottom) { + const containerTop = container.getBoundingClientRect().top; + const msgEls = container.querySelectorAll('[id^="msg-"]'); + for (const el of msgEls) { + if (el.getBoundingClientRect().bottom > containerTop) { + visibleMsgIdRef.current = el.id.replace('msg-', ''); + break; + } + } + } else { + visibleMsgIdRef.current = null; + } + // Load more when scrolled to top if (container.scrollTop < 50 && hasMore && !isLoadingMore) { setIsLoadingMore(true); @@ -214,47 +268,61 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess } return ( -
- {isLoadingMore && ( -
- +
+
+ {isLoadingMore && ( +
+ +
+ )} + + {!hasMore && } + +
+ {messages.map((msg, i) => { + const prevMsg = messages[i - 1]; + const showDate = shouldShowDateDivider(prevMsg, msg); + const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg); + + return ( + + {showDate && ( +
+
+ + {formatDateDivider(msg.createdAt)} + +
+
+ )} + + + ); + })}
- )} - {!hasMore && } - -
- {messages.map((msg, i) => { - const prevMsg = messages[i - 1]; - const showDate = shouldShowDateDivider(prevMsg, msg); - const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg); - - return ( - - {showDate && ( -
-
- - {formatDateDivider(msg.createdAt)} - -
-
- )} - - - ); - })} +
-
+ {!isNearBottom && messages.length > 0 && ( + + )}
); } diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 3ee4637a..95686053 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -32,7 +32,9 @@ interface ChatState { unreadChannels: Set; realtimeMessageEvents: RealtimeMessageEvent[]; channelAccessTimes: Map; + scrollPositions: Map; setCurrentChannel: (channelId: string | null) => void; + saveScrollPosition: (channelId: string, messageId: string) => void; setReplyTo: (message: MessageWithUser | null) => void; loadMessages: (channelId: string, force?: boolean) => Promise; clearAllMessages: () => void; @@ -83,6 +85,15 @@ export const useChatStore = create((set, get) => ({ unreadChannels: new Set(), realtimeMessageEvents: [], channelAccessTimes: new Map(), + scrollPositions: new Map(), + + saveScrollPosition: (channelId, messageId) => { + set((state) => { + const newPositions = new Map(state.scrollPositions); + newPositions.set(channelId, messageId); + return { scrollPositions: newPositions }; + }); + }, setCurrentChannel: (channelId) => { set((state) => { @@ -94,6 +105,7 @@ export const useChatStore = create((set, get) => ({ // Evict stale channels if we have too many cached let newMessages = state.messages; let newHasMore = state.hasMore; + let newScrollPositions = state.scrollPositions; if (state.messages.size > MAX_CACHED_CHANNELS) { const entries = [...newAccessTimes.entries()] .filter(([id]) => id !== channelId) @@ -103,10 +115,12 @@ export const useChatStore = create((set, get) => ({ if (evictIds.size > 0) { newMessages = new Map(state.messages); newHasMore = new Map(state.hasMore); + newScrollPositions = new Map(state.scrollPositions); for (const id of evictIds) { newMessages.delete(id); newHasMore.delete(id); newAccessTimes.delete(id); + newScrollPositions.delete(id); } } } @@ -116,6 +130,7 @@ export const useChatStore = create((set, get) => ({ channelAccessTimes: newAccessTimes, messages: newMessages, hasMore: newHasMore, + scrollPositions: newScrollPositions, }; }); }, @@ -129,6 +144,7 @@ export const useChatStore = create((set, get) => ({ unreadChannels: new Set(), realtimeMessageEvents: [], channelAccessTimes: new Map(), + scrollPositions: new Map(), currentChannelId: null, replyTo: null, }),