Fix scrolling
All checks were successful
Build and Release / build-and-release (push) Successful in 9m44s
All checks were successful
Build and Release / build-and-release (push) Successful in 9m44s
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.23",
|
||||
"type": "module",
|
||||
"main": "src/App.jsx",
|
||||
"dependencies": {
|
||||
|
||||
@@ -32,6 +32,9 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||
|
||||
const SCROLL_DEBUG = true;
|
||||
const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); };
|
||||
|
||||
const metadataCache = new Map();
|
||||
const attachmentCache = new Map();
|
||||
|
||||
@@ -562,6 +565,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const prevFirstMsgIdRef = useRef(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const loadMoreSettlingRef = useRef(false);
|
||||
const loadMoreSettlingTimerRef = useRef(null);
|
||||
const realDistanceFromBottomRef = useRef(0);
|
||||
const userIsScrolledUpRef = useRef(false);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -581,6 +588,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
loadMoreRef.current = loadMore;
|
||||
if (status !== 'LoadingMore') {
|
||||
isLoadingMoreRef.current = false;
|
||||
if (loadMoreSettlingRef.current) {
|
||||
if (loadMoreSettlingTimerRef.current) clearTimeout(loadMoreSettlingTimerRef.current);
|
||||
loadMoreSettlingTimerRef.current = setTimeout(() => {
|
||||
loadMoreSettlingRef.current = false;
|
||||
loadMoreSettlingTimerRef.current = null;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}, [status, loadMore]);
|
||||
|
||||
@@ -652,6 +666,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
prevMessageCountRef.current = newCount;
|
||||
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
||||
|
||||
scrollLog('[SCROLL:decrypt] Phase 1 — setDecryptedMessages from cache', { count: newMessages.length });
|
||||
setDecryptedMessages(newMessages);
|
||||
|
||||
// Phase 2: Batch-decrypt only uncached messages in background
|
||||
@@ -679,12 +694,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
// Still re-render from cache in case optimistic matches were added
|
||||
if (!cancelled) {
|
||||
decryptionDoneRef.current = true;
|
||||
scrollLog('[SCROLL:decrypt] Phase 2 — all cached, setDecryptedMessages');
|
||||
setDecryptedMessages(buildFromCache());
|
||||
|
||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
initialScrollScheduledRef.current = true;
|
||||
const loadId = channelLoadIdRef.current;
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||
scrollLog('[SCROLL:initialLoad] scheduling scroll chain');
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) { scrollLog('[SCROLL:initialLoad] scrollEnd exec'); el.scrollTop = el.scrollHeight; } };
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||
}));
|
||||
@@ -804,6 +821,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// Phase 3: Re-render with newly decrypted content
|
||||
decryptionDoneRef.current = true;
|
||||
scrollLog('[SCROLL:decrypt] Phase 3 — decrypted, setDecryptedMessages', { count: needsDecryption.length });
|
||||
setDecryptedMessages(buildFromCache());
|
||||
|
||||
// After decryption, items may be taller — re-scroll to bottom.
|
||||
@@ -811,7 +829,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
initialScrollScheduledRef.current = true;
|
||||
const loadId = channelLoadIdRef.current;
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||
scrollLog('[SCROLL:initialLoad] scheduling scroll chain (phase 3)');
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) { scrollLog('[SCROLL:initialLoad] scrollEnd exec (phase 3)'); el.scrollTop = el.scrollHeight; } };
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||
}));
|
||||
@@ -863,6 +882,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setEphemeralMessages([]);
|
||||
floodAbortRef.current = true;
|
||||
isLoadingMoreRef.current = false;
|
||||
loadMoreSettlingRef.current = false;
|
||||
userIsScrolledUpRef.current = false;
|
||||
realDistanceFromBottomRef.current = 0;
|
||||
if (loadMoreSettlingTimerRef.current) {
|
||||
clearTimeout(loadMoreSettlingTimerRef.current);
|
||||
loadMoreSettlingTimerRef.current = null;
|
||||
}
|
||||
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||
prevMessageCountRef.current = 0;
|
||||
prevFirstMsgIdRef.current = null;
|
||||
@@ -879,6 +905,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
||||
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
||||
if (idx !== -1 && virtuosoRef.current) {
|
||||
scrollLog('[SCROLL:jumpToMessage]', { jumpToMessageId, idx });
|
||||
isInitialLoadRef.current = false;
|
||||
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||
setTimeout(() => {
|
||||
@@ -993,14 +1020,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setUnreadDividerTimestamp(readState.lastReadTimestamp);
|
||||
}, [readState, channelId]);
|
||||
|
||||
// Ref to avoid decryptedMessages in markChannelAsRead deps (prevents handleAtBottomStateChange churn)
|
||||
const decryptedMessagesRef = useRef(decryptedMessages);
|
||||
decryptedMessagesRef.current = decryptedMessages;
|
||||
|
||||
// Mark channel as read when scrolled to bottom
|
||||
const markChannelAsRead = useCallback(() => {
|
||||
if (!currentUserId || !channelId || !decryptedMessages.length) return;
|
||||
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
|
||||
const msgs = decryptedMessagesRef.current;
|
||||
if (!currentUserId || !channelId || !msgs.length) return;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (!lastMsg?.created_at) return;
|
||||
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
|
||||
setUnreadDividerTimestamp(null);
|
||||
}, [currentUserId, channelId, decryptedMessages, markReadMutation]);
|
||||
}, [currentUserId, channelId, markReadMutation]);
|
||||
|
||||
const markChannelAsReadRef = useRef(markChannelAsRead);
|
||||
markChannelAsReadRef.current = markChannelAsRead;
|
||||
@@ -1016,6 +1048,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
||||
];
|
||||
const scrollToBottom = useCallback((force = false) => {
|
||||
// Guard: when used as an event handler (e.g. img onLoad), the event
|
||||
// object is passed as `force`. Coerce to boolean to ignore it.
|
||||
if (typeof force !== 'boolean') force = false;
|
||||
scrollLog('[SCROLL:scrollToBottom]', { force, initialLoad: isInitialLoadRef.current, userScrolledUp: userIsScrolledUpRef.current });
|
||||
if (isInitialLoadRef.current) {
|
||||
const el = scrollerElRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
@@ -1023,6 +1059,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
}
|
||||
if (force) {
|
||||
// Direct DOM scroll is more reliable than scrollToIndex for user-sent messages
|
||||
// Also reset userIsScrolledUpRef since we're explicitly scrolling to bottom
|
||||
userIsScrolledUpRef.current = false;
|
||||
const snap = () => {
|
||||
const el = scrollerElRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
@@ -1031,7 +1069,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
// Escalating retries for late-sizing content (images, embeds)
|
||||
setTimeout(snap, 50);
|
||||
setTimeout(snap, 150);
|
||||
} else if (virtuosoRef.current) {
|
||||
} else if (virtuosoRef.current && !userIsScrolledUpRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: 'LAST',
|
||||
align: 'end',
|
||||
@@ -1045,6 +1083,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
if (isLoadingMoreRef.current) return;
|
||||
if (statusRef.current === 'CanLoadMore') {
|
||||
isLoadingMoreRef.current = true;
|
||||
loadMoreSettlingRef.current = true;
|
||||
if (loadMoreSettlingTimerRef.current) {
|
||||
clearTimeout(loadMoreSettlingTimerRef.current);
|
||||
loadMoreSettlingTimerRef.current = null;
|
||||
}
|
||||
loadMoreRef.current(50);
|
||||
}
|
||||
}, []);
|
||||
@@ -1052,27 +1095,58 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||
const followOutput = useCallback((isAtBottom) => {
|
||||
console.log('[Virtuoso] followOutput:', { isAtBottom, jumpTo: jumpToMessageIdRef.current, userSent: userSentMessageRef.current });
|
||||
if (jumpToMessageIdRef.current) return false;
|
||||
|
||||
const metrics = {
|
||||
isAtBottom,
|
||||
userScrolledUp: userIsScrolledUpRef.current,
|
||||
realDist: realDistanceFromBottomRef.current,
|
||||
jumpTo: jumpToMessageIdRef.current,
|
||||
userSent: userSentMessageRef.current,
|
||||
initialLoad: isInitialLoadRef.current,
|
||||
settling: loadMoreSettlingRef.current,
|
||||
};
|
||||
|
||||
if (jumpToMessageIdRef.current) {
|
||||
scrollLog('[SCROLL:followOutput] BLOCKED by jumpToMessage', metrics);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user sent a message, ALWAYS scroll to bottom aggressively
|
||||
if (userSentMessageRef.current) {
|
||||
userSentMessageRef.current = false;
|
||||
scrollLog('[SCROLL:followOutput] USER SENT MSG → auto', metrics);
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
|
||||
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
|
||||
if (isInitialLoadRef.current) {
|
||||
scrollLog('[SCROLL:followOutput] BLOCKED by initialLoad', metrics);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use 'smooth' again to see if 'auto' was causing the jump
|
||||
return isAtBottom ? 'smooth' : false;
|
||||
|
||||
// During load-more settling, don't auto-scroll (prevents snap-to-bottom when header changes)
|
||||
if (loadMoreSettlingRef.current) {
|
||||
scrollLog('[SCROLL:followOutput] BLOCKED by settling', metrics);
|
||||
return false;
|
||||
}
|
||||
|
||||
// CORE FIX: If user has scrolled >150px from bottom, never auto-scroll
|
||||
// regardless of what Virtuoso's internal isAtBottom state thinks
|
||||
if (userIsScrolledUpRef.current) {
|
||||
scrollLog('[SCROLL:followOutput] BLOCKED by userIsScrolledUp', metrics);
|
||||
return false;
|
||||
}
|
||||
|
||||
const decision = isAtBottom ? 'smooth' : false;
|
||||
scrollLog('[SCROLL:followOutput] decision:', decision, metrics);
|
||||
return decision;
|
||||
}, []);
|
||||
|
||||
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
||||
const handleAtBottomStateChange = useCallback((atBottom) => {
|
||||
console.log('[Virtuoso] atBottomStateChange:', atBottom);
|
||||
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
|
||||
if (loadMoreSettlingRef.current && atBottom) {
|
||||
return;
|
||||
}
|
||||
isAtBottomRef.current = atBottom;
|
||||
if (atBottom) {
|
||||
markChannelAsRead();
|
||||
@@ -1116,9 +1190,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||
|
||||
// If we were at bottom (approx), force stay at bottom
|
||||
if (previousDistanceFromBottom < 50) {
|
||||
console.log('[LayoutEffect] Sync scroll adjustment', { heightDiff, previousDistanceFromBottom });
|
||||
// If we were at bottom (approx) AND user hasn't scrolled up, force stay at bottom
|
||||
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
|
||||
scrollLog('[SCROLL:layoutEffect]', { heightDiff, previousDistanceFromBottom, userScrolledUp: userIsScrolledUpRef.current, willScroll });
|
||||
if (willScroll) {
|
||||
scroller.scrollTop = scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -1153,14 +1228,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||
|
||||
console.log('[ResizeObserver] Input resize:', {
|
||||
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
|
||||
scrollLog('[SCROLL:resizeObserver]', {
|
||||
newHeight,
|
||||
heightDiff,
|
||||
previousDistanceFromBottom
|
||||
previousDistanceFromBottom,
|
||||
userScrolledUp: userIsScrolledUpRef.current,
|
||||
willScroll,
|
||||
});
|
||||
|
||||
if (previousDistanceFromBottom < 50) {
|
||||
console.log('[ResizeObserver] Forcing scroll to bottom');
|
||||
if (willScroll) {
|
||||
scroller.scrollTop = scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -1169,6 +1246,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Passive scroll listener: track real pixel distance from bottom
|
||||
// This is the ground-truth for whether the user has scrolled up
|
||||
useEffect(() => {
|
||||
const scroller = scrollerElRef.current;
|
||||
if (!scroller) return;
|
||||
const onScroll = () => {
|
||||
const dist = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
realDistanceFromBottomRef.current = dist;
|
||||
const wasUp = userIsScrolledUpRef.current;
|
||||
// User is "scrolled up" if >150px from bottom
|
||||
userIsScrolledUpRef.current = dist > 150;
|
||||
if (wasUp !== userIsScrolledUpRef.current) {
|
||||
scrollLog('[SCROLL:userScrollState]', { dist, scrolledUp: userIsScrolledUpRef.current });
|
||||
}
|
||||
};
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => scroller.removeEventListener('scroll', onScroll);
|
||||
}, [scrollerElRef.current]);
|
||||
|
||||
const saveSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
||||
@@ -1666,6 +1762,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
useEffect(() => {
|
||||
if (scrollOnNextDataRef.current) {
|
||||
scrollOnNextDataRef.current = false;
|
||||
scrollLog('[SCROLL:scrollOnNextData] user sent message, forcing scroll to bottom');
|
||||
// Reset scrolled-up state since user just sent a message
|
||||
userIsScrolledUpRef.current = false;
|
||||
// followOutput already returned 'auto' but it's unreliable — force DOM scroll
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollerElRef.current;
|
||||
@@ -1701,6 +1800,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
);
|
||||
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
||||
|
||||
// Stable Virtuoso components — avoids remounting Header/Footer every render
|
||||
const virtuosoComponents = useMemo(() => ({
|
||||
Header: () => renderListHeader(),
|
||||
Footer: () => <div style={{ height: '1px' }} />,
|
||||
}), [renderListHeader]);
|
||||
|
||||
// Render individual message item for Virtuoso
|
||||
const renderMessageItem = useCallback((item, arrayIndex) => {
|
||||
// Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages)
|
||||
@@ -1854,10 +1959,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||
defaultItemHeight={60}
|
||||
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
||||
components={{
|
||||
Header: () => renderListHeader(),
|
||||
Footer: () => <div style={{ height: '1px' }} />,
|
||||
}}
|
||||
components={virtuosoComponents}
|
||||
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user