Fix scrolling
All checks were successful
Build and Release / build-and-release (push) Successful in 9m44s

This commit is contained in:
Bryan1029384756
2026-02-18 10:45:23 -06:00
parent ff269ee154
commit 50a0795671
3 changed files with 129 additions and 27 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/electron",
"private": true,
"version": "1.0.22",
"version": "1.0.23",
"description": "Discord Clone - Electron app",
"author": "Moyettes",
"type": "module",

View File

@@ -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": {

View File

@@ -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)}
/>
)}