feat: Implement view transitions for smoother navigation

- Added `useViewTransitions` hook to enable view transitions in the app.
- Created `useNavigateWithTransition` for navigation with transitions.
- Integrated view transitions in `ClientRoot`, `HomeTab`, `InboxTab`, and `SettingsTab`.
- Introduced `AnimatedOutlet` and `RouteTransition` components for route-based animations.
- Enhanced CSS for transitions and added a new `twilightTheme`.
- Updated Vite configuration to serve sound and font assets.
- Added support for reaction notifications in the room utility.
- Created `DirectList` and related components for direct messaging functionality.
This commit is contained in:
2026-03-25 06:14:11 +11:00
parent a8eb404ff3
commit 9a00375568
21 changed files with 1049 additions and 70 deletions

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css } from '@vanilla-extract/css';
const transitionContainer = css({
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
});
const transitionContent = css({
width: '100%',
height: '100%',
});
const animateIn = css({
animation: 'routeFadeSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'@keyframes': {
routeFadeSlideIn: {
from: {
opacity: 0,
transform: 'translateX(12px)',
},
to: {
opacity: 1,
transform: 'translateX(0)',
},
},
},
});
const animateOut = css({
animation: 'routeFadeSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
'@keyframes': {
routeFadeSlideOut: {
from: {
opacity: 1,
transform: 'translateX(0)',
},
to: {
opacity: 0,
transform: 'translateX(-12px)',
},
},
},
});
interface RouteTransitionProps {
children: React.ReactNode;
}
/**
* Wraps content in a transition animation that triggers on route change
*/
export function RouteTransition({ children }: RouteTransitionProps) {
const location = useLocation();
const [displayLocation, setDisplayLocation] = useState(location);
const [transitionStage, setTransitionStage] = useState<'entering' | 'exiting' | 'idle'>('idle');
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (location.pathname !== displayLocation.pathname) {
// Start exit animation
setTransitionStage('exiting');
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Wait for exit animation, then update location and enter
timeoutRef.current = setTimeout(() => {
setDisplayLocation(location);
setTransitionStage('entering');
// Reset to idle after enter animation
timeoutRef.current = setTimeout(() => {
setTransitionStage('idle');
}, 250);
}, 200);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [location, displayLocation]);
const className = transitionStage === 'entering'
? `${transitionContent} ${animateIn}`
: transitionStage === 'exiting'
? `${transitionContent} ${animateOut}`
: transitionContent;
return (
<div className={transitionContainer}>
<div className={className} key={displayLocation.pathname}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import React, { MouseEventHandler, forwardRef, useMemo, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../features/room-nav';
import { makeNavCategoryId } from '../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../state/hooks/unread';
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
/**
* Props for the DirectMenu component
*/
type DirectMenuProps = {
requestClose: () => void;
};
/**
* Menu component for Direct Messages category actions
*/
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => {
if (!unread) return;
markRoomsAsRead(orphanRooms, hideActivity);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
/**
* Props for the DirectListHeader component
*/
type DirectListHeaderProps = {
title?: string;
};
/**
* Header component for the Direct Messages list with menu
*/
export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeaderProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
{title}
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
/**
* Empty state component when there are no direct messages
*/
export function DirectListEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
No Direct Messages
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any direct messages yet.
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
Direct Message
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
/**
* Props for the DirectList component
*/
export type DirectListProps = {
scrollRef: React.RefObject<HTMLDivElement>;
showCreateButton?: boolean;
showCategoryHeader?: boolean;
};
/**
* Reusable component that renders the virtualized list of direct message rooms.
* Can be embedded in any PageNavContent context.
*/
export function DirectList({
scrollRef,
showCreateButton = true,
showCategoryHeader = true,
}: DirectListProps) {
const mx = useMatrixClient();
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const noRoomToDisplay = directs.length === 0;
if (noRoomToDisplay) {
return <DirectListEmpty />;
}
return (
<Box direction="Column" gap="300">
{showCreateButton && (
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Chat
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
)}
<NavCategory>
{showCategoryHeader && (
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
)}
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
);
}

View File

@@ -0,0 +1,2 @@
export { DirectList, DirectListHeader, DirectListEmpty } from './DirectList';
export type { DirectListProps } from './DirectList';