feat: implement room list reordering and animation hooks; add scroll to latest behavior settings

This commit is contained in:
2026-04-23 14:53:00 +10:00
parent 643608f25d
commit 35ccdc0a22
9 changed files with 414 additions and 32 deletions

View File

@@ -47,6 +47,7 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@@ -511,6 +512,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx);
const location = useLocation();
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -525,6 +527,12 @@ 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 scrollToBottomRef = useRef({
count: 0,
@@ -635,15 +643,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room,
useCallback(
(mEvt: MatrixEvent) => {
// if user is at bottom of timeline
// keep paginating timeline and conditionally mark as read
// otherwise we update timeline without paginating
// so timeline can be updated with evt like: edits, reactions etc
if (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
// Check if the document is in focus (user is actively viewing the app),
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage);
if (shouldScroll) {
if (isOwnMessage) {
shouldAutoScrollRef.current = true;
}
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) {
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
}
@@ -651,6 +659,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setUnreadInfo(getRoomUnreadInfo(room));
}
setLatestUnreadMessage(null);
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
@@ -663,6 +672,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}));
return;
}
if (!isOwnMessage) {
shouldAutoScrollRef.current = false;
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,
});
}
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
@@ -749,7 +772,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) setAtBottom(false);
if (!entry.isIntersecting) {
setAtBottom(false);
shouldAutoScrollRef.current = false;
}
}, []),
{ wait: 1000 }
);
@@ -762,6 +788,8 @@ 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();
}
@@ -831,6 +859,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [eventId, loadEventTimeline]);
// 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;
}
}, [(location.state as any)?.scrollToLatest, room]);
// Scroll to bottom on initial timeline load
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
@@ -936,6 +977,8 @@ 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;
@@ -2036,15 +2079,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Scroll>
{!atBottom && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
<Box direction="Column" gap="200" alignItems="Center">
{latestUnreadMessage && (
<Box
style={{
backgroundColor: color.Surface.Container,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R400,
maxWidth: toRem(400),
border: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="T300" priority="300" style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
</Text>
</Box>
)}
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
</TimelineFloat>
)}
</Box>