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/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.22",
|
"version": "1.0.23",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.22",
|
"version": "1.0.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import { useVoice } from '../contexts/VoiceContext';
|
|||||||
import { useSearch } from '../contexts/SearchContext';
|
import { useSearch } from '../contexts/SearchContext';
|
||||||
import { generateUniqueMessage } from '../utils/floodMessages';
|
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||||
|
|
||||||
|
const SCROLL_DEBUG = true;
|
||||||
|
const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); };
|
||||||
|
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
const attachmentCache = new Map();
|
const attachmentCache = new Map();
|
||||||
|
|
||||||
@@ -562,6 +565,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const prevFirstMsgIdRef = useRef(null);
|
const prevFirstMsgIdRef = useRef(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
const loadMoreSettlingRef = useRef(false);
|
||||||
|
const loadMoreSettlingTimerRef = useRef(null);
|
||||||
|
const realDistanceFromBottomRef = useRef(0);
|
||||||
|
const userIsScrolledUpRef = useRef(false);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
@@ -581,6 +588,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
loadMoreRef.current = loadMore;
|
loadMoreRef.current = loadMore;
|
||||||
if (status !== 'LoadingMore') {
|
if (status !== 'LoadingMore') {
|
||||||
isLoadingMoreRef.current = false;
|
isLoadingMoreRef.current = false;
|
||||||
|
if (loadMoreSettlingRef.current) {
|
||||||
|
if (loadMoreSettlingTimerRef.current) clearTimeout(loadMoreSettlingTimerRef.current);
|
||||||
|
loadMoreSettlingTimerRef.current = setTimeout(() => {
|
||||||
|
loadMoreSettlingRef.current = false;
|
||||||
|
loadMoreSettlingTimerRef.current = null;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [status, loadMore]);
|
}, [status, loadMore]);
|
||||||
|
|
||||||
@@ -652,6 +666,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
prevMessageCountRef.current = newCount;
|
prevMessageCountRef.current = newCount;
|
||||||
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
||||||
|
|
||||||
|
scrollLog('[SCROLL:decrypt] Phase 1 — setDecryptedMessages from cache', { count: newMessages.length });
|
||||||
setDecryptedMessages(newMessages);
|
setDecryptedMessages(newMessages);
|
||||||
|
|
||||||
// Phase 2: Batch-decrypt only uncached messages in background
|
// 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
|
// Still re-render from cache in case optimistic matches were added
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
decryptionDoneRef.current = true;
|
decryptionDoneRef.current = true;
|
||||||
|
scrollLog('[SCROLL:decrypt] Phase 2 — all cached, setDecryptedMessages');
|
||||||
setDecryptedMessages(buildFromCache());
|
setDecryptedMessages(buildFromCache());
|
||||||
|
|
||||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||||
initialScrollScheduledRef.current = true;
|
initialScrollScheduledRef.current = true;
|
||||||
const loadId = channelLoadIdRef.current;
|
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(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
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
|
// Phase 3: Re-render with newly decrypted content
|
||||||
decryptionDoneRef.current = true;
|
decryptionDoneRef.current = true;
|
||||||
|
scrollLog('[SCROLL:decrypt] Phase 3 — decrypted, setDecryptedMessages', { count: needsDecryption.length });
|
||||||
setDecryptedMessages(buildFromCache());
|
setDecryptedMessages(buildFromCache());
|
||||||
|
|
||||||
// After decryption, items may be taller — re-scroll to bottom.
|
// 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) {
|
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||||
initialScrollScheduledRef.current = true;
|
initialScrollScheduledRef.current = true;
|
||||||
const loadId = channelLoadIdRef.current;
|
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(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||||
}));
|
}));
|
||||||
@@ -863,6 +882,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setEphemeralMessages([]);
|
setEphemeralMessages([]);
|
||||||
floodAbortRef.current = true;
|
floodAbortRef.current = true;
|
||||||
isLoadingMoreRef.current = false;
|
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);
|
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||||
prevMessageCountRef.current = 0;
|
prevMessageCountRef.current = 0;
|
||||||
prevFirstMsgIdRef.current = null;
|
prevFirstMsgIdRef.current = null;
|
||||||
@@ -879,6 +905,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
||||||
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
||||||
if (idx !== -1 && virtuosoRef.current) {
|
if (idx !== -1 && virtuosoRef.current) {
|
||||||
|
scrollLog('[SCROLL:jumpToMessage]', { jumpToMessageId, idx });
|
||||||
isInitialLoadRef.current = false;
|
isInitialLoadRef.current = false;
|
||||||
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -993,14 +1020,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setUnreadDividerTimestamp(readState.lastReadTimestamp);
|
setUnreadDividerTimestamp(readState.lastReadTimestamp);
|
||||||
}, [readState, channelId]);
|
}, [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
|
// Mark channel as read when scrolled to bottom
|
||||||
const markChannelAsRead = useCallback(() => {
|
const markChannelAsRead = useCallback(() => {
|
||||||
if (!currentUserId || !channelId || !decryptedMessages.length) return;
|
const msgs = decryptedMessagesRef.current;
|
||||||
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
|
if (!currentUserId || !channelId || !msgs.length) return;
|
||||||
|
const lastMsg = msgs[msgs.length - 1];
|
||||||
if (!lastMsg?.created_at) return;
|
if (!lastMsg?.created_at) return;
|
||||||
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
|
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
|
||||||
setUnreadDividerTimestamp(null);
|
setUnreadDividerTimestamp(null);
|
||||||
}, [currentUserId, channelId, decryptedMessages, markReadMutation]);
|
}, [currentUserId, channelId, markReadMutation]);
|
||||||
|
|
||||||
const markChannelAsReadRef = useRef(markChannelAsRead);
|
const markChannelAsReadRef = useRef(markChannelAsRead);
|
||||||
markChannelAsReadRef.current = markChannelAsRead;
|
markChannelAsReadRef.current = markChannelAsRead;
|
||||||
@@ -1016,6 +1048,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
||||||
];
|
];
|
||||||
const scrollToBottom = useCallback((force = false) => {
|
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) {
|
if (isInitialLoadRef.current) {
|
||||||
const el = scrollerElRef.current;
|
const el = scrollerElRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
@@ -1023,6 +1059,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
if (force) {
|
if (force) {
|
||||||
// Direct DOM scroll is more reliable than scrollToIndex for user-sent messages
|
// 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 snap = () => {
|
||||||
const el = scrollerElRef.current;
|
const el = scrollerElRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
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)
|
// Escalating retries for late-sizing content (images, embeds)
|
||||||
setTimeout(snap, 50);
|
setTimeout(snap, 50);
|
||||||
setTimeout(snap, 150);
|
setTimeout(snap, 150);
|
||||||
} else if (virtuosoRef.current) {
|
} else if (virtuosoRef.current && !userIsScrolledUpRef.current) {
|
||||||
virtuosoRef.current.scrollToIndex({
|
virtuosoRef.current.scrollToIndex({
|
||||||
index: 'LAST',
|
index: 'LAST',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
@@ -1045,6 +1083,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
if (isLoadingMoreRef.current) return;
|
if (isLoadingMoreRef.current) return;
|
||||||
if (statusRef.current === 'CanLoadMore') {
|
if (statusRef.current === 'CanLoadMore') {
|
||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
|
loadMoreSettlingRef.current = true;
|
||||||
|
if (loadMoreSettlingTimerRef.current) {
|
||||||
|
clearTimeout(loadMoreSettlingTimerRef.current);
|
||||||
|
loadMoreSettlingTimerRef.current = null;
|
||||||
|
}
|
||||||
loadMoreRef.current(50);
|
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
|
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||||
const followOutput = useCallback((isAtBottom) => {
|
const followOutput = useCallback((isAtBottom) => {
|
||||||
console.log('[Virtuoso] followOutput:', { isAtBottom, jumpTo: jumpToMessageIdRef.current, userSent: userSentMessageRef.current });
|
const metrics = {
|
||||||
if (jumpToMessageIdRef.current) return false;
|
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 user sent a message, ALWAYS scroll to bottom aggressively
|
||||||
if (userSentMessageRef.current) {
|
if (userSentMessageRef.current) {
|
||||||
userSentMessageRef.current = false;
|
userSentMessageRef.current = false;
|
||||||
|
scrollLog('[SCROLL:followOutput] USER SENT MSG → auto', metrics);
|
||||||
return 'auto';
|
return 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
|
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
|
||||||
if (isInitialLoadRef.current) {
|
if (isInitialLoadRef.current) {
|
||||||
|
scrollLog('[SCROLL:followOutput] BLOCKED by initialLoad', metrics);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use 'smooth' again to see if 'auto' was causing the jump
|
// During load-more settling, don't auto-scroll (prevents snap-to-bottom when header changes)
|
||||||
return isAtBottom ? 'smooth' : false;
|
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
|
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
||||||
const handleAtBottomStateChange = useCallback((atBottom) => {
|
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;
|
isAtBottomRef.current = atBottom;
|
||||||
if (atBottom) {
|
if (atBottom) {
|
||||||
markChannelAsRead();
|
markChannelAsRead();
|
||||||
@@ -1116,9 +1190,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||||
|
|
||||||
// If we were at bottom (approx), force stay at bottom
|
// If we were at bottom (approx) AND user hasn't scrolled up, force stay at bottom
|
||||||
if (previousDistanceFromBottom < 50) {
|
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
|
||||||
console.log('[LayoutEffect] Sync scroll adjustment', { heightDiff, previousDistanceFromBottom });
|
scrollLog('[SCROLL:layoutEffect]', { heightDiff, previousDistanceFromBottom, userScrolledUp: userIsScrolledUpRef.current, willScroll });
|
||||||
|
if (willScroll) {
|
||||||
scroller.scrollTop = scrollHeight;
|
scroller.scrollTop = scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1153,14 +1228,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||||
|
|
||||||
console.log('[ResizeObserver] Input resize:', {
|
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
|
||||||
|
scrollLog('[SCROLL:resizeObserver]', {
|
||||||
newHeight,
|
newHeight,
|
||||||
heightDiff,
|
heightDiff,
|
||||||
previousDistanceFromBottom
|
previousDistanceFromBottom,
|
||||||
|
userScrolledUp: userIsScrolledUpRef.current,
|
||||||
|
willScroll,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previousDistanceFromBottom < 50) {
|
if (willScroll) {
|
||||||
console.log('[ResizeObserver] Forcing scroll to bottom');
|
|
||||||
scroller.scrollTop = scrollHeight;
|
scroller.scrollTop = scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1169,6 +1246,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
return () => observer.disconnect();
|
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 saveSelection = () => {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
||||||
@@ -1666,6 +1762,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollOnNextDataRef.current) {
|
if (scrollOnNextDataRef.current) {
|
||||||
scrollOnNextDataRef.current = false;
|
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
|
// followOutput already returned 'auto' but it's unreliable — force DOM scroll
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = scrollerElRef.current;
|
const el = scrollerElRef.current;
|
||||||
@@ -1701,6 +1800,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
);
|
);
|
||||||
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
}, [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
|
// Render individual message item for Virtuoso
|
||||||
const renderMessageItem = useCallback((item, arrayIndex) => {
|
const renderMessageItem = useCallback((item, arrayIndex) => {
|
||||||
// Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages)
|
// 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 }}
|
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||||
defaultItemHeight={60}
|
defaultItemHeight={60}
|
||||||
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
||||||
components={{
|
components={virtuosoComponents}
|
||||||
Header: () => renderListHeader(),
|
|
||||||
Footer: () => <div style={{ height: '1px' }} />,
|
|
||||||
}}
|
|
||||||
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user