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 hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
|
||||
const ackChannel = useChatStore((s) => s.ackChannel);
|
||||
const saveScrollPosition = useChatStore((s) => s.saveScrollPosition);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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<string>(channelId);
|
||||
const visibleMsgIdRef = useRef<string | null>(null);
|
||||
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden no-scrollbar"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<div className="py-4">
|
||||
<LoadingSpinner size={24} />
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full overflow-y-auto overflow-x-hidden no-scrollbar"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<div className="py-4">
|
||||
<LoadingSpinner size={24} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
||||
|
||||
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
|
||||
{messages.map((msg, i) => {
|
||||
const prevMsg = messages[i - 1];
|
||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="flex items-center px-5 my-2 select-none pointer-events-none">
|
||||
<div className="flex-1 h-[1px] bg-border-hard" />
|
||||
<span className="px-[14px] text-[11px] font-bold text-txt-tertiary leading-tight">
|
||||
{formatDateDivider(msg.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 h-[1px] bg-border-hard" />
|
||||
</div>
|
||||
)}
|
||||
<Message
|
||||
message={msg}
|
||||
isCompact={!isFirstInGroup}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
||||
|
||||
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
|
||||
{messages.map((msg, i) => {
|
||||
const prevMsg = messages[i - 1];
|
||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="flex items-center px-5 my-2 select-none pointer-events-none">
|
||||
<div className="flex-1 h-[1px] bg-border-hard" />
|
||||
<span className="px-[14px] text-[11px] font-bold text-txt-tertiary leading-tight">
|
||||
{formatDateDivider(msg.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 h-[1px] bg-border-hard" />
|
||||
</div>
|
||||
)}
|
||||
<Message
|
||||
message={msg}
|
||||
isCompact={!isFirstInGroup}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div ref={bottomRef} />
|
||||
{!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>;
|
||||
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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user