feat: persist scroll position per channel with Jump to Present button

- Save the top-visible message ID on channel leave, restore via
  scrollIntoView on return (immune to lazy-loaded image reflow)
- Add floating glass-bubble "Jump to Present" button when scrolled
  5000px+ from bottom
- Clear stale scroll anchors when user returns to bottom
- Evict scroll positions alongside channel cache eviction
This commit is contained in:
Jannis Braun
2026-03-17 01:46:25 +01:00
parent 42e765b877
commit e5f89d4c3f
2 changed files with 132 additions and 48 deletions
@@ -49,6 +49,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const isLoading = useChatStore((s) => s.isLoading); const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true); const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const ackChannel = useChatStore((s) => s.ackChannel); const ackChannel = useChatStore((s) => s.ackChannel);
const saveScrollPosition = useChatStore((s) => s.saveScrollPosition);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
@@ -56,6 +57,8 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0); const prevMessagesLength = useRef(0);
const prevChannelIdRef = useRef<string>(channelId);
const visibleMsgIdRef = useRef<string | null>(null);
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>(); const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY // 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); return () => clearTimeout(ackTimerRef.current);
}, [channelId, messages.length, lastMessageId, isNearBottom, ackChannel]); }, [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(() => { useEffect(() => {
prevMessagesLength.current = 0; const prevId = prevChannelIdRef.current;
setIsNearBottom(true); prevChannelIdRef.current = channelId;
isNearBottomRef.current = true;
}, [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(() => { useEffect(() => {
const prev = prevMessagesLength.current; const prev = prevMessagesLength.current;
prevMessagesLength.current = messages.length; prevMessagesLength.current = messages.length;
@@ -96,18 +124,30 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
if (messages.length === 0) return; if (messages.length === 0) return;
if (prev === 0) { 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(() => { requestAnimationFrame(() => {
const container = containerRef.current; const container = containerRef.current;
if (container) { if (!container) return;
container.scrollTop = container.scrollHeight; 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) { } else if (messages.length > prev && isNearBottom) {
// New messages arrived while near bottom — smooth scroll // New messages arrived while near bottom — smooth scroll
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
} }
}, [messages.length, isNearBottom]); }, [messages.length, isNearBottom, channelId]);
// Auto-scroll when content height grows (embeds/images loading) while near bottom // Auto-scroll when content height grows (embeds/images loading) while near bottom
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
@@ -178,10 +218,24 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
// Check if near bottom // Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100; const nearBottom = distanceFromBottom < 5000;
setIsNearBottom(nearBottom); setIsNearBottom(nearBottom);
isNearBottomRef.current = 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 // Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) { if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true); setIsLoadingMore(true);
@@ -214,9 +268,10 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
} }
return ( return (
<div className="flex-1 relative min-h-0">
<div <div
ref={containerRef} ref={containerRef}
className="flex-1 overflow-y-auto overflow-x-hidden no-scrollbar" className="h-full overflow-y-auto overflow-x-hidden no-scrollbar"
onScroll={handleScroll} onScroll={handleScroll}
> >
{isLoadingMore && ( {isLoadingMore && (
@@ -256,6 +311,19 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
{!isNearBottom && messages.length > 0 && (
<button
onClick={() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' })}
className="absolute bottom-20 left-1/2 -translate-x-1/2 z-[120] glass-bubble px-4 py-2 flex items-center gap-2 rounded-full text-txt-secondary hover:text-txt-primary transition-all animate-fade-in cursor-pointer"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
</svg>
<span className="text-[13px] font-medium">Jump to Present</span>
</button>
)}
</div>
); );
} }
+16
View File
@@ -32,7 +32,9 @@ interface ChatState {
unreadChannels: Set<string>; unreadChannels: Set<string>;
realtimeMessageEvents: RealtimeMessageEvent[]; realtimeMessageEvents: RealtimeMessageEvent[];
channelAccessTimes: Map<string, number>; channelAccessTimes: Map<string, number>;
scrollPositions: Map<string, string>;
setCurrentChannel: (channelId: string | null) => void; setCurrentChannel: (channelId: string | null) => void;
saveScrollPosition: (channelId: string, messageId: string) => void;
setReplyTo: (message: MessageWithUser | null) => void; setReplyTo: (message: MessageWithUser | null) => void;
loadMessages: (channelId: string, force?: boolean) => Promise<void>; loadMessages: (channelId: string, force?: boolean) => Promise<void>;
clearAllMessages: () => void; clearAllMessages: () => void;
@@ -83,6 +85,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
unreadChannels: new Set(), unreadChannels: new Set(),
realtimeMessageEvents: [], realtimeMessageEvents: [],
channelAccessTimes: new Map(), 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) => { setCurrentChannel: (channelId) => {
set((state) => { set((state) => {
@@ -94,6 +105,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Evict stale channels if we have too many cached // Evict stale channels if we have too many cached
let newMessages = state.messages; let newMessages = state.messages;
let newHasMore = state.hasMore; let newHasMore = state.hasMore;
let newScrollPositions = state.scrollPositions;
if (state.messages.size > MAX_CACHED_CHANNELS) { if (state.messages.size > MAX_CACHED_CHANNELS) {
const entries = [...newAccessTimes.entries()] const entries = [...newAccessTimes.entries()]
.filter(([id]) => id !== channelId) .filter(([id]) => id !== channelId)
@@ -103,10 +115,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (evictIds.size > 0) { if (evictIds.size > 0) {
newMessages = new Map(state.messages); newMessages = new Map(state.messages);
newHasMore = new Map(state.hasMore); newHasMore = new Map(state.hasMore);
newScrollPositions = new Map(state.scrollPositions);
for (const id of evictIds) { for (const id of evictIds) {
newMessages.delete(id); newMessages.delete(id);
newHasMore.delete(id); newHasMore.delete(id);
newAccessTimes.delete(id); newAccessTimes.delete(id);
newScrollPositions.delete(id);
} }
} }
} }
@@ -116,6 +130,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
channelAccessTimes: newAccessTimes, channelAccessTimes: newAccessTimes,
messages: newMessages, messages: newMessages,
hasMore: newHasMore, hasMore: newHasMore,
scrollPositions: newScrollPositions,
}; };
}); });
}, },
@@ -129,6 +144,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
unreadChannels: new Set(), unreadChannels: new Set(),
realtimeMessageEvents: [], realtimeMessageEvents: [],
channelAccessTimes: new Map(), channelAccessTimes: new Map(),
scrollPositions: new Map(),
currentChannelId: null, currentChannelId: null,
replyTo: null, replyTo: null,
}), }),