From 35ccdc0a2299d9daa376249e56f683f2c868c171 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 23 Apr 2026 14:53:00 +1000 Subject: [PATCH] feat: implement room list reordering and animation hooks; add scroll to latest behavior settings --- src/app/components/direct-list/DirectList.tsx | 15 ++- src/app/features/room-nav/RoomNavItem.tsx | 21 +++- src/app/features/room/RoomTimeline.tsx | 102 +++++++++++++--- src/app/features/settings/general/General.tsx | 85 +++++++++++++- src/app/hooks/useRoomListReorder.ts | 110 ++++++++++++++++++ src/app/pages/client/direct/Direct.tsx | 11 +- src/app/plugins/react-custom-html-parser.tsx | 51 +++++++- src/app/state/settings.ts | 10 ++ src/app/styles/CustomHtml.css.ts | 41 +++++++ 9 files changed, 414 insertions(+), 32 deletions(-) create mode 100644 src/app/hooks/useRoomListReorder.ts 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 ( + <> + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {behaviors.map((item) => ( + handleSelect(item.value)} + > + {item.label} + + ))} + + + + } + /> + + ); +} + 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>(new Map()); + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const currentPositions = new Map(); + const elements = new Map(); + + items.forEach((itemId) => { + const element = containerRef.current!.querySelector(`[data-room-id="${itemId}"]`) as HTMLElement; + if (element) { + currentPositions.set(itemId, element.offsetTop); + elements.set(itemId, element); + } + }); + + if (positionsRef.current.size === 0) { + positionsRef.current = currentPositions; + return; + } + + const animations: Array<{ element: HTMLElement; deltaY: number }> = []; + + items.forEach((itemId) => { + const element = elements.get(itemId); + const oldPos = positionsRef.current.get(itemId); + const newPos = currentPositions.get(itemId); + + if (element && oldPos !== undefined && newPos !== undefined) { + const deltaY = oldPos - newPos; + + if (Math.abs(deltaY) > 1) { + animations.push({ element, deltaY }); + } + } + }); + + if (animations.length > 0) { + animations.forEach(({ element, deltaY }) => { + element.style.transform = `translateY(${deltaY}px)`; + element.style.transition = 'none'; + }); + + requestAnimationFrame(() => { + animations.forEach(({ element }) => { + element.style.transition = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)'; + element.style.transform = ''; + }); + }); + } + + positionsRef.current = currentPositions; + }, [items]); + + return (container: HTMLElement | null) => { + containerRef.current = container; + }; +}; diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 68db464..4240317 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -48,6 +48,7 @@ import { stopPropagation } from '../../../utils/keyboard'; import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot'; import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { useSetting } from '../../../state/hooks/settings'; +import { useRoomListReorder, useListReorderAnimation } from '../../../hooks/useRoomListReorder'; import { settingsAtom } from '../../../state/settings'; import { getRoomNotificationMode, @@ -245,6 +246,8 @@ export function Direct() { const selectedRoomId = useSelectedRoom(); const noRoomToDisplay = directs.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const reorderTrigger = useRoomListReorder(mx, directs); const sortedDirects = useMemo(() => { const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); @@ -252,7 +255,9 @@ export function Direct() { 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, @@ -307,6 +312,7 @@ export function Direct() {
} + before={ + copied ? ( + + ) : ( + + + + ) + } > {copied ? 'Copied' : 'Copy'} @@ -327,6 +335,41 @@ export function CodeBlock({ ); } +export function InlineCode({ + children, + opts, + props, +}: { + children: ChildNode[]; + opts: HTMLReactParserOptions; + props: any; +}) { + const [copied, setCopied] = useTimeoutToggle(); + + const handleCopy = (e: MouseEvent) => { + e.stopPropagation(); + copyToClipboard(extractTextFromChildren(children)); + setCopied(); + }; + + return ( + + + {copied ? ( + + ) : ( + + + + )} + + + {domToReact(children, opts)} + + + ); +} + export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, @@ -450,11 +493,7 @@ export const getReactCustomHtmlParser = ( ); } } else { - return ( - - {domToReact(children, opts)} - - ); + return {children}; } } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 80ba460..8e26011 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -15,6 +15,12 @@ export enum EmojiStyle { Twemoji = 'twemoji', } +export enum ScrollToLatestBehavior { + Always = 'always', + Never = 'never', + OnNewMessage = 'onNewMessage', +} + export type CallPanelDockPosition = 'right' | 'sidebar'; export interface Settings { @@ -56,6 +62,8 @@ export interface Settings { developerTools: boolean; autoJoinSpaceRooms: boolean; + + scrollToLatestBehavior: ScrollToLatestBehavior; } const defaultSettings: Settings = { @@ -97,6 +105,8 @@ const defaultSettings: Settings = { developerTools: false, autoJoinSpaceRooms: true, + + scrollToLatestBehavior: ScrollToLatestBehavior.Always, }; export const getSettings = () => { diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index ba7b921..2b8ae7e 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -51,15 +51,56 @@ const CodeFont = style({ fontFamily: 'monospace', }); +export const InlineCodeWrapper = style({ + position: 'relative', + display: 'inline-block', +}); + export const Code = style([ DefaultReset, BaseCode, CodeFont, { padding: `0 ${config.space.S100}`, + display: 'inline-block', }, ]); +export const InlineCodeCopyButton = style({ + position: 'absolute', + top: 0, + right: 0, + transform: 'translateY(-100%)', + opacity: 0, + transition: 'opacity 0.15s ease-in-out', + background: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderBottom: 'none', + borderTopLeftRadius: config.radii.R300, + borderTopRightRadius: config.radii.R300, + padding: `${config.space.S200} ${config.space.S300}`, + cursor: 'pointer', + fontSize: toRem(11), + color: color.SurfaceVariant.OnContainer, + fontWeight: config.fontWeight.W500, + whiteSpace: 'nowrap', + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + selectors: { + [`${InlineCodeWrapper}:hover &`]: { + opacity: 1, + }, + '&:hover': { + background: color.SurfaceVariant.ContainerHover, + }, + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + export const Spoiler = recipe({ base: [ DefaultReset,