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

@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react';
import { MatrixClient, MatrixEvent, Room, RoomEvent, IRoomTimelineData } from 'matrix-js-sdk';
/**
* Hook that tracks timeline events across all rooms to trigger re-sorting of room lists.
* Returns a counter that increments whenever a message is sent or received in any room.
* This can be used as a dependency in useMemo to trigger re-sorting of room lists.
*
* @param mx - The Matrix client instance
* @param roomIds - Optional array of room IDs to track. If not provided, tracks all rooms.
* @returns A number that increments on each timeline event
*/
export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number => {
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 = <T extends string>(
items: T[]
): React.RefCallback<HTMLElement> => {
const positionsRef = useRef<Map<T, number>>(new Map());
const containerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const currentPositions = new Map<T, number>();
const elements = new Map<T, HTMLElement>();
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;
};
};