feat: implement room list reordering and animation hooks; add scroll to latest behavior settings
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -44,7 +45,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { settingsAtom, ScrollToLatestBehavior } from '../../state/settings';
|
||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import {
|
||||
@@ -294,6 +295,8 @@ export function RoomNavItem({
|
||||
}: RoomNavItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const navigate = useNavigate();
|
||||
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||
@@ -365,6 +368,20 @@ export function RoomNavItem({
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -380,7 +397,7 @@ export function RoomNavItem({
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavLink to={linkPath}>
|
||||
<NavLink to={linkPath} onClick={handleRoomClick}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||
{shouldShowIcon && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<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() {
|
||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||
settingsAtom,
|
||||
@@ -1011,6 +1088,12 @@ function Messages() {
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||
</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">
|
||||
<SettingTile
|
||||
title="Legacy Username Color"
|
||||
|
||||
Reference in New Issue
Block a user