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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user