diff --git a/src/app/components/direct-list/DirectList.tsx b/src/app/components/direct-list/DirectList.tsx
index 96c2947..c7f5e16 100644
--- a/src/app/components/direct-list/DirectList.tsx
+++ b/src/app/components/direct-list/DirectList.tsx
@@ -50,6 +50,7 @@ import {
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
+import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
/**
* Props for the DirectMenu component
@@ -214,6 +215,8 @@ export function DirectList({
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+ const reorderTrigger = useRoomListReorder(mx, directs);
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
@@ -221,7 +224,9 @@ export function DirectList({
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
- }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
+ }, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
+
+ const animationRef = useListReorderAnimation(sortedDirects);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
@@ -275,6 +280,7 @@ export function DirectList({
)}
+
= (evt) => {
+ if (selected) {
+ evt.preventDefault();
+
+ const shouldScroll =
+ scrollToLatestBehavior === ScrollToLatestBehavior.Always ||
+ (scrollToLatestBehavior === ScrollToLatestBehavior.OnNewMessage && unread !== undefined);
+
+ if (shouldScroll) {
+ navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
+ }
+ }
+ };
+
const optionsVisible = hover || !!menuAnchor;
return (
@@ -380,7 +397,7 @@ export function RoomNavItem({
{...hoverProps}
{...focusWithinProps}
>
-
+
{shouldShowIcon && (
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d85695e..4a39150 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -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(true);
+ const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
+ sender: string;
+ content: string;
+ } | null>(null);
+
const scrollRef = useRef(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
{!atBottom && (
- }
- onClick={handleJumpToLatest}
- >
- Jump to Latest
-
+
+ {latestUnreadMessage && (
+
+
+ {latestUnreadMessage.sender}: {latestUnreadMessage.content}
+
+
+ )}
+ }
+ onClick={handleJumpToLatest}
+ >
+ Jump to Latest
+
+
)}
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 809b2a5..44d6291 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -34,7 +34,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
-import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
+import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, ScrollToLatestBehavior, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -984,6 +984,83 @@ function SelectMessageSpacing() {
);
}
+function SelectScrollToLatestBehavior() {
+ const [menuCords, setMenuCords] = useState();
+ const [scrollToLatestBehavior, setScrollToLatestBehavior] = useSetting(
+ settingsAtom,
+ 'scrollToLatestBehavior'
+ );
+
+ const behaviors = [
+ { value: ScrollToLatestBehavior.Always, label: 'Always' },
+ { value: ScrollToLatestBehavior.OnNewMessage, label: 'On New Message' },
+ { value: ScrollToLatestBehavior.Never, label: 'Never' },
+ ];
+
+ const handleMenu: MouseEventHandler = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleSelect = (behavior: ScrollToLatestBehavior) => {
+ setScrollToLatestBehavior(behavior);
+ setMenuCords(undefined);
+ };
+
+ const currentLabel = behaviors.find((b) => b.value === scrollToLatestBehavior)?.label ?? 'Always';
+
+ return (
+ <>
+ }
+ onClick={handleMenu}
+ >
+ {currentLabel}
+
+ setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+ >
+ );
+}
+
function Messages() {
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom,
@@ -1011,6 +1088,12 @@ function Messages() {
} />
+
+ }
+ />
+
{
+ const [updateCounter, setUpdateCounter] = useState(0);
+
+ useEffect(() => {
+ const handleTimelineEvent = (
+ mEvent: MatrixEvent,
+ room: Room | undefined,
+ toStartOfTimeline: boolean | undefined,
+ removed: boolean,
+ data: IRoomTimelineData
+ ) => {
+ if (!room || !data.liveEvent) return;
+ if (toStartOfTimeline) return;
+
+ if (roomIds && !roomIds.includes(room.roomId)) return;
+
+ setUpdateCounter((prev) => prev + 1);
+ };
+
+ mx.on(RoomEvent.Timeline, handleTimelineEvent);
+
+ return () => {
+ mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ };
+ }, [mx, roomIds]);
+
+ return updateCounter;
+};
+
+/**
+ * Hook that implements FLIP (First, Last, Invert, Play) animation for list reordering.
+ * Tracks element positions before and after reorder, then animates the difference.
+ *
+ * @param items - Array of item IDs in their current order
+ * @returns Ref callback to attach to the list container
+ */
+export const useListReorderAnimation = (
+ items: T[]
+): React.RefCallback => {
+ const positionsRef = useRef