feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
@@ -514,6 +514,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const space = useSpaceOptionally();
|
||||
const markAsRead = useMarkAsRead(mx);
|
||||
const location = useLocation();
|
||||
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||
|
||||
@@ -530,16 +531,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const atBottomRef = useRef(atBottom);
|
||||
atBottomRef.current = atBottom;
|
||||
|
||||
const shouldAutoScrollRef = useRef<boolean>(true);
|
||||
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
|
||||
sender: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||
const timelineContentHeightRef = useRef(0);
|
||||
const scrollToBottomRef = useRef({
|
||||
count: 0,
|
||||
smooth: true,
|
||||
force: false,
|
||||
});
|
||||
|
||||
const [focusItem, setFocusItem] = useState<
|
||||
@@ -639,6 +642,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
}, [alive, room])
|
||||
);
|
||||
|
||||
@@ -647,52 +651,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
useCallback(
|
||||
(mEvt: MatrixEvent) => {
|
||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage);
|
||||
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
||||
|
||||
if (shouldScroll) {
|
||||
if (isOwnMessage) {
|
||||
shouldAutoScrollRef.current = true;
|
||||
}
|
||||
|
||||
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) {
|
||||
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
||||
}
|
||||
|
||||
if (!document.hasFocus() && !unreadInfo) {
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
// Always update timeline with new message
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
range: {
|
||||
start: ct.range.start + 1,
|
||||
end: ct.range.end + 1,
|
||||
},
|
||||
}));
|
||||
|
||||
// Handle own messages and focus
|
||||
if (isOwnMessage) {
|
||||
setLatestUnreadMessage(null);
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
range: {
|
||||
start: ct.range.start + 1,
|
||||
end: ct.range.end + 1,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
if (document.hasFocus() && (!unreadInfo)) {
|
||||
const roomId = mEvt.getRoomId();
|
||||
if (roomId) {
|
||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||
}
|
||||
}
|
||||
} else if (!document.hasFocus() && !unreadInfo) {
|
||||
// Show unread notification for other users' messages when unfocused
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
|
||||
if (!isOwnMessage) {
|
||||
shouldAutoScrollRef.current = false;
|
||||
const senderName = getMemberDisplayName(room, mEvt.getSender() ?? '')
|
||||
?? getMxIdLocalPart(mEvt.getSender() ?? '')
|
||||
?? 'Unknown';
|
||||
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||
scrollToBottomRef.current.force = shouldStickToBottom;
|
||||
|
||||
// Show unread message preview for incoming messages when not at bottom
|
||||
if (!isOwnMessage && !atBottomRef.current) {
|
||||
const senderName =
|
||||
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
|
||||
getMxIdLocalPart(mEvt.getSender() ?? '') ??
|
||||
'Unknown';
|
||||
const content = mEvt.getContent();
|
||||
const messageText = content.body ?? content.msgtype ?? 'New message';
|
||||
setLatestUnreadMessage({
|
||||
sender: senderName,
|
||||
content: messageText.length > 100 ? messageText.substring(0, 100) + '...' : messageText,
|
||||
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
|
||||
});
|
||||
}
|
||||
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
if (!unreadInfo) {
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
},
|
||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
||||
)
|
||||
@@ -753,13 +754,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (!editorBaseEntry || !scrollElement) return;
|
||||
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottom(scrollElement);
|
||||
autoScrollToBottom(scrollElement, false, true);
|
||||
}
|
||||
};
|
||||
}, [getScrollElement, roomInputRef]),
|
||||
useCallback(() => roomInputRef.current, [roomInputRef])
|
||||
);
|
||||
|
||||
useResizeObserver(
|
||||
useCallback((entries) => {
|
||||
const timelineContent = timelineContentRef.current;
|
||||
const scrollElement = getScrollElement();
|
||||
if (!timelineContent || !scrollElement) return;
|
||||
|
||||
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
||||
if (!timelineEntry) return;
|
||||
|
||||
const nextHeight = timelineEntry.contentRect.height;
|
||||
const previousHeight = timelineContentHeightRef.current;
|
||||
timelineContentHeightRef.current = nextHeight;
|
||||
|
||||
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoScrollToBottom(scrollElement, false, true);
|
||||
}, [getScrollElement]),
|
||||
useCallback(() => timelineContentRef.current, [])
|
||||
);
|
||||
|
||||
const tryAutoMarkAsRead = useCallback(() => {
|
||||
const readUptoEventId = readUptoEventIdRef.current;
|
||||
if (!readUptoEventId) {
|
||||
@@ -777,7 +800,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
useCallback((entry: IntersectionObserverEntry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
setAtBottom(false);
|
||||
shouldAutoScrollRef.current = false;
|
||||
}
|
||||
}, []),
|
||||
{ wait: 1000 }
|
||||
@@ -791,7 +813,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||
setAtBottom(true);
|
||||
shouldAutoScrollRef.current = true;
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
@@ -864,22 +885,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
|
||||
// Scroll to latest when clicking on already-selected room
|
||||
useEffect(() => {
|
||||
const scrollToLatest = (location.state as any)?.scrollToLatest;
|
||||
if (scrollToLatest) {
|
||||
shouldAutoScrollRef.current = true;
|
||||
setLatestUnreadMessage(null);
|
||||
setUnreadInfo(undefined);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
}
|
||||
}, [(location.state as any)?.scrollToLatest, room]);
|
||||
}, [scrollToLatest, room]);
|
||||
|
||||
// Scroll to bottom on initial timeline load
|
||||
useLayoutEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
scrollToBottom(scrollEl);
|
||||
autoScrollToBottom(scrollEl, false, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -926,8 +946,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
useLayoutEffect(() => {
|
||||
if (scrollToBottomCount > 0) {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl)
|
||||
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
||||
if (scrollEl) {
|
||||
autoScrollToBottom(
|
||||
scrollEl,
|
||||
scrollToBottomRef.current.smooth,
|
||||
scrollToBottomRef.current.force
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [scrollToBottomCount]);
|
||||
|
||||
@@ -980,11 +1005,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (eventId) {
|
||||
navigateRoom(room.roomId, undefined, { replace: true });
|
||||
}
|
||||
shouldAutoScrollRef.current = true;
|
||||
setLatestUnreadMessage(null);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
};
|
||||
|
||||
const handleJumpToUnread = () => {
|
||||
@@ -1995,6 +2020,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
)}
|
||||
<Scroll ref={scrollRef} visibility="Hover">
|
||||
<Box
|
||||
ref={timelineContentRef}
|
||||
direction="Column"
|
||||
justifyContent="End"
|
||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
||||
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
} from '../../utils/matrix';
|
||||
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { scrollToBottom } from '../../utils/dom';
|
||||
import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
|
||||
|
||||
type ThreadViewProps = {
|
||||
room: Room;
|
||||
@@ -406,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
// Scroll to bottom on mount and when new events arrive.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) scrollToBottom(el);
|
||||
if (el) autoScrollToBottom(el);
|
||||
}, [events.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
const el = scrollRef.current?.querySelector(
|
||||
`[data-message-id="${targetId}"]`
|
||||
) as HTMLElement | null;
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
|
||||
}, []);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user