feat: implement room list reordering and animation hooks; add scroll to latest behavior settings
This commit is contained in:
@@ -50,6 +50,7 @@ import {
|
|||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the DirectMenu component
|
* Props for the DirectMenu component
|
||||||
@@ -214,6 +215,8 @@ export function DirectList({
|
|||||||
const createDirectSelected = useDirectCreateSelected();
|
const createDirectSelected = useDirectCreateSelected();
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
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.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -275,6 +280,7 @@ export function DirectList({
|
|||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -287,7 +293,12 @@ export function DirectList({
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -44,7 +45,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
|
|||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom, ScrollToLatestBehavior } from '../../state/settings';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import {
|
import {
|
||||||
@@ -294,6 +295,8 @@ export function RoomNavItem({
|
|||||||
}: RoomNavItemProps) {
|
}: RoomNavItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
@@ -365,6 +368,20 @@ export function RoomNavItem({
|
|||||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoomClick: MouseEventHandler<HTMLAnchorElement> = (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;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -380,7 +397,7 @@ export function RoomNavItem({
|
|||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath}>
|
<NavLink to={linkPath} onClick={handleRoomClick}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||||
{shouldShowIcon && (
|
{shouldShowIcon && (
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
@@ -511,6 +512,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
@@ -525,6 +527,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atBottomRef = useRef(atBottom);
|
const atBottomRef = useRef(atBottom);
|
||||||
atBottomRef.current = 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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollToBottomRef = useRef({
|
const scrollToBottomRef = useRef({
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -635,15 +643,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
room,
|
room,
|
||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
// if user is at bottom of timeline
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
// keep paginating timeline and conditionally mark as read
|
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage);
|
||||||
// otherwise we update timeline without paginating
|
|
||||||
// so timeline can be updated with evt like: edits, reactions etc
|
if (shouldScroll) {
|
||||||
if (atBottomRef.current) {
|
if (isOwnMessage) {
|
||||||
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
|
shouldAutoScrollRef.current = true;
|
||||||
// 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.
|
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) {
|
||||||
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +659,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = true;
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
|
||||||
@@ -663,6 +672,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}));
|
}));
|
||||||
return;
|
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 }));
|
setTimeline((ct) => ({ ...ct }));
|
||||||
if (!unreadInfo) {
|
if (!unreadInfo) {
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
@@ -749,7 +772,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
const debounceSetAtBottom = useDebounce(
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
useCallback((entry: IntersectionObserverEntry) => {
|
||||||
if (!entry.isIntersecting) setAtBottom(false);
|
if (!entry.isIntersecting) {
|
||||||
|
setAtBottom(false);
|
||||||
|
shouldAutoScrollRef.current = false;
|
||||||
|
}
|
||||||
}, []),
|
}, []),
|
||||||
{ wait: 1000 }
|
{ wait: 1000 }
|
||||||
);
|
);
|
||||||
@@ -762,6 +788,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
shouldAutoScrollRef.current = true;
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
tryAutoMarkAsRead();
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
@@ -831,6 +859,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [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
|
// Scroll to bottom on initial timeline load
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
@@ -936,6 +977,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
|
shouldAutoScrollRef.current = true;
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
@@ -2036,15 +2079,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{!atBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Chip
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
variant="SurfaceVariant"
|
{latestUnreadMessage && (
|
||||||
radii="Pill"
|
<Box
|
||||||
outlined
|
style={{
|
||||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
backgroundColor: color.Surface.Container,
|
||||||
onClick={handleJumpToLatest}
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
>
|
borderRadius: config.radii.R400,
|
||||||
<Text size="L400">Jump to Latest</Text>
|
maxWidth: toRem(400),
|
||||||
</Chip>
|
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>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
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 { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
@@ -984,6 +984,83 @@ function SelectMessageSpacing() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectScrollToLatestBehavior() {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
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<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (behavior: ScrollToLatestBehavior) => {
|
||||||
|
setScrollToLatestBehavior(behavior);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLabel = behaviors.find((b) => b.value === scrollToLatestBehavior)?.label ?? 'Always';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">{currentLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{behaviors.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.value}
|
||||||
|
size="300"
|
||||||
|
variant={scrollToLatestBehavior === item.value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{item.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Messages() {
|
function Messages() {
|
||||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -1011,6 +1088,12 @@ function Messages() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Scroll to Latest on Room Reselect"
|
||||||
|
after={<SelectScrollToLatestBehavior />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Legacy Username Color"
|
title="Legacy Username Color"
|
||||||
|
|||||||
110
src/app/hooks/useRoomListReorder.ts
Normal file
110
src/app/hooks/useRoomListReorder.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -48,6 +48,7 @@ import { stopPropagation } from '../../../utils/keyboard';
|
|||||||
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../../hooks/useRoomListReorder';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
@@ -245,6 +246,8 @@ export function Direct() {
|
|||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
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.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -307,6 +312,7 @@ export function Direct() {
|
|||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -321,8 +327,9 @@ export function Direct() {
|
|||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={roomId}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -289,7 +289,15 @@ export function CodeBlock({
|
|||||||
fill="None"
|
fill="None"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
before={copied && <Icon size="50" src={Icons.Check} />}
|
before={
|
||||||
|
copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -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<HTMLSpanElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(extractTextFromChildren(children));
|
||||||
|
setCopied();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={css.InlineCodeWrapper}>
|
||||||
|
<span className={css.InlineCodeCopyButton} onClick={handleCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
||||||
|
{domToReact(children, opts)}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const getReactCustomHtmlParser = (
|
export const getReactCustomHtmlParser = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
@@ -450,11 +493,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <InlineCode opts={opts} props={props}>{children}</InlineCode>;
|
||||||
<Text as="code" size="T300" className={css.Code} {...props}>
|
|
||||||
{domToReact(children, opts)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export enum EmojiStyle {
|
|||||||
Twemoji = 'twemoji',
|
Twemoji = 'twemoji',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ScrollToLatestBehavior {
|
||||||
|
Always = 'always',
|
||||||
|
Never = 'never',
|
||||||
|
OnNewMessage = 'onNewMessage',
|
||||||
|
}
|
||||||
|
|
||||||
export type CallPanelDockPosition = 'right' | 'sidebar';
|
export type CallPanelDockPosition = 'right' | 'sidebar';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -56,6 +62,8 @@ export interface Settings {
|
|||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
|
|
||||||
autoJoinSpaceRooms: boolean;
|
autoJoinSpaceRooms: boolean;
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -97,6 +105,8 @@ const defaultSettings: Settings = {
|
|||||||
developerTools: false,
|
developerTools: false,
|
||||||
|
|
||||||
autoJoinSpaceRooms: true,
|
autoJoinSpaceRooms: true,
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior.Always,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
|
|||||||
@@ -51,15 +51,56 @@ const CodeFont = style({
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const InlineCodeWrapper = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
});
|
||||||
|
|
||||||
export const Code = style([
|
export const Code = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
BaseCode,
|
BaseCode,
|
||||||
CodeFont,
|
CodeFont,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
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({
|
export const Spoiler = recipe({
|
||||||
base: [
|
base: [
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
|
|||||||
Reference in New Issue
Block a user