diff --git a/apps/electron/package.json b/apps/electron/package.json index 412cb6b..aac820f 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -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", diff --git a/packages/shared/package.json b/packages/shared/package.json index ba48b79..440cdf2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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": { diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index ce21de1..e5cc181 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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: () =>
, + }), [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: () => , - }} + components={virtuosoComponents} itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)} /> )}