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
+16
View File
@@ -32,7 +32,9 @@ interface ChatState {
unreadChannels: Set<string>;
realtimeMessageEvents: RealtimeMessageEvent[];
channelAccessTimes: Map<string, number>;
scrollPositions: Map<string, string>;
setCurrentChannel: (channelId: string | null) => void;
saveScrollPosition: (channelId: string, messageId: string) => void;
setReplyTo: (message: MessageWithUser | null) => void;
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
clearAllMessages: () => void;
@@ -83,6 +85,15 @@ export const useChatStore = create<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((set, get) => ({
channelAccessTimes: newAccessTimes,
messages: newMessages,
hasMore: newHasMore,
scrollPositions: newScrollPositions,
};
});
},
@@ -129,6 +144,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
unreadChannels: new Set(),
realtimeMessageEvents: [],
channelAccessTimes: new Map(),
scrollPositions: new Map(),
currentChannelId: null,
replyTo: null,
}),