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:
27
src/app/components/AnimatedOutlet.tsx
Normal file
27
src/app/components/AnimatedOutlet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/app/components/RouteTransition.tsx
Normal file
104
src/app/components/RouteTransition.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
304
src/app/components/direct-list/DirectList.tsx
Normal file
304
src/app/components/direct-list/DirectList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/app/components/direct-list/index.ts
Normal file
2
src/app/components/direct-list/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DirectList, DirectListHeader, DirectListEmpty } from './DirectList';
|
||||
export type { DirectListProps } from './DirectList';
|
||||
@@ -1,11 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import MicMuteSound from '../../../../public/sound/Mic_Mute.ogg';
|
||||
import MicUnmuteSound from '../../../../public/sound/Mic_Unmuted.ogg';
|
||||
import SpeakersDeafenSound from '../../../../public/sound/Speakers_Deafen.ogg';
|
||||
import SpeakersUndeafenSound from '../../../../public/sound/Speakers_Undeafen.ogg';
|
||||
import CallJoinedSound from '../../../../public/sound/Call_Joined.ogg';
|
||||
import CallDepartSound from '../../../../public/sound/Call_Depart.ogg';
|
||||
import CallDialingSound from '../../../../public/sound/Call_Dialing.ogg';
|
||||
|
||||
/**
|
||||
* Sound types that can be played during a call
|
||||
@@ -21,16 +14,16 @@ export enum CallSoundType {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps sound types to their audio sources
|
||||
* Maps sound types to their audio sources (paths in public folder)
|
||||
*/
|
||||
const SOUND_SOURCES: Record<CallSoundType, string> = {
|
||||
[CallSoundType.MicMute]: MicMuteSound,
|
||||
[CallSoundType.MicUnmute]: MicUnmuteSound,
|
||||
[CallSoundType.Deafen]: SpeakersDeafenSound,
|
||||
[CallSoundType.Undeafen]: SpeakersUndeafenSound,
|
||||
[CallSoundType.CallJoined]: CallJoinedSound,
|
||||
[CallSoundType.CallDepart]: CallDepartSound,
|
||||
[CallSoundType.CallDialing]: CallDialingSound,
|
||||
[CallSoundType.MicMute]: '/sound/Mic_Mute.ogg',
|
||||
[CallSoundType.MicUnmute]: '/sound/Mic_Unmuted.ogg',
|
||||
[CallSoundType.Deafen]: '/sound/Speakers_Deafen.ogg',
|
||||
[CallSoundType.Undeafen]: '/sound/Speakers_Undeafen.ogg',
|
||||
[CallSoundType.CallJoined]: '/sound/Call_Joined.ogg',
|
||||
[CallSoundType.CallDepart]: '/sound/Call_Depart.ogg',
|
||||
[CallSoundType.CallDialing]: '/sound/Call_Dialing.ogg',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, darkTheme, discordTheme, discordDarkerTheme, silverTheme } from '../../colors.css';
|
||||
import { butterTheme, darkTheme, discordTheme, discordDarkerTheme, silverTheme, twilightTheme } from '../../colors.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
@@ -47,9 +47,14 @@ export const DiscordDarkerTheme: Theme = {
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['discord-darker-theme', discordDarkerTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const TwilightTheme: Theme = {
|
||||
id: 'twilight-theme',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['twilight-theme', twilightTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
|
||||
export const useThemes = (): Theme[] => {
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme], []);
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme], []);
|
||||
|
||||
return themes;
|
||||
};
|
||||
@@ -63,6 +68,7 @@ export const useThemeNames = (): Record<string, string> =>
|
||||
[ButterTheme.id]: 'Butter',
|
||||
[DiscordTheme.id]: 'Discord',
|
||||
[DiscordDarkerTheme.id]: 'Discord Darker',
|
||||
[TwilightTheme.id]: 'Twilight',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
85
src/app/hooks/useViewTransitions.ts
Normal file
85
src/app/hooks/useViewTransitions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom';
|
||||
import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions';
|
||||
|
||||
/**
|
||||
* Hook to enable View Transitions for React Router navigation
|
||||
* This intercepts navigation and wraps it in view transitions
|
||||
*/
|
||||
export const useViewTransitions = () => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsViewTransitions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we support view transitions
|
||||
document.documentElement.classList.add('view-transitions-enabled');
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove('view-transitions-enabled');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Route changed - view transitions will be handled by CSS
|
||||
}, [location]);
|
||||
|
||||
return {
|
||||
supportsViewTransitions: supportsViewTransitions(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom navigate hook that wraps navigation in view transitions
|
||||
*
|
||||
* **USAGE:**
|
||||
* Replace `useNavigate()` with `useNavigateWithTransition()` for smooth transitions
|
||||
*
|
||||
* ```tsx
|
||||
* // Before:
|
||||
* import { useNavigate } from 'react-router-dom';
|
||||
* const navigate = useNavigate();
|
||||
*
|
||||
* // After:
|
||||
* import { useNavigateWithTransition } from '../hooks/useViewTransitions';
|
||||
* const navigate = useNavigateWithTransition();
|
||||
*
|
||||
* // Usage remains the same:
|
||||
* navigate('/some/path');
|
||||
* navigate(-1); // Go back
|
||||
* ```
|
||||
*
|
||||
* This enables smooth animated transitions when navigating between:
|
||||
* - Different spaces
|
||||
* - Different rooms
|
||||
* - Different sections (Home, Inbox, Settings, etc.)
|
||||
*
|
||||
* Falls back to instant navigation on browsers that don't support View Transitions API.
|
||||
*/
|
||||
export const useNavigateWithTransition = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useCallback(
|
||||
(to: string | number, options?: NavigateOptions) => {
|
||||
if (!supportsViewTransitions()) {
|
||||
if (typeof to === 'number') {
|
||||
navigate(to);
|
||||
} else {
|
||||
navigate(to, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
withViewTransition(() => {
|
||||
if (typeof to === 'number') {
|
||||
navigate(to);
|
||||
} else {
|
||||
navigate(to, options);
|
||||
}
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
};
|
||||
@@ -68,6 +68,7 @@ import { Create } from './client/create';
|
||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
import { getCurrentSession } from '../state/sessions';
|
||||
import { AnimatedOutlet } from '../components/AnimatedOutlet';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
@@ -85,7 +86,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
<Route
|
||||
index
|
||||
loader={() => {
|
||||
if (getCurrentSession()) return redirect(getHomePath());
|
||||
if (getCurrentSession()) return redirect(getInboxNotificationsPath());
|
||||
const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
|
||||
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
|
||||
return redirect(getLoginPath());
|
||||
@@ -115,8 +116,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
localStorage.setItem('debug-router-logs', JSON.stringify(debugLog));
|
||||
|
||||
if (hasSession && !addingAccount) {
|
||||
console.log('[Router] Redirecting to home because session exists and not adding account');
|
||||
return redirect(getHomePath());
|
||||
console.log('[Router] Redirecting to Inbox/Notifications because session exists and not adding account');
|
||||
return redirect(getInboxNotificationsPath());
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -187,7 +188,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
<AnimatedOutlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
@@ -214,7 +215,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
<AnimatedOutlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
@@ -240,7 +241,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
<AnimatedOutlet />
|
||||
</PageRoot>
|
||||
</RouteSpaceProvider>
|
||||
}
|
||||
@@ -279,7 +280,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
<AnimatedOutlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
@@ -304,7 +305,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
<AnimatedOutlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,8 +6,6 @@ import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/r
|
||||
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
||||
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
||||
import LogoHighlightSVG from '../../../../public/res/svg/paarrot-highlight.svg';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { EmojiStyle, settingsAtom } from '../../state/settings';
|
||||
@@ -30,7 +28,7 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
||||
import { isTauri, isElectron, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||
|
||||
/**
|
||||
@@ -96,7 +94,6 @@ function FaviconUpdater() {
|
||||
}
|
||||
|
||||
function InviteNotifications() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const invites = useAtomValue(allInvitesAtom);
|
||||
const perviousInviteLen = usePreviousValue(invites.length, 0);
|
||||
const mx = useMatrixClient();
|
||||
@@ -110,7 +107,18 @@ function InviteNotifications() {
|
||||
const body = `You have ${count} new invitation request.`;
|
||||
const invitesPath = getInboxInvitesPath();
|
||||
|
||||
if (isTauri()) {
|
||||
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
|
||||
if (isElectron() && (window as any).electron?.window?.flashFrame) {
|
||||
(window as any).electron.window.flashFrame(true)
|
||||
.then((result: { success: boolean }) => {
|
||||
console.log('[InviteNotifications] flashFrame result:', result);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('[InviteNotifications] flashFrame error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (isTauri() && !isElectron()) {
|
||||
sendNotification({
|
||||
title: 'Invitation',
|
||||
body,
|
||||
@@ -131,6 +139,10 @@ function InviteNotifications() {
|
||||
window.focus();
|
||||
if (!window.closed) navigate(invitesPath);
|
||||
noti.close();
|
||||
// Stop flashing when user clicks notification
|
||||
if ((window as any).electron?.window?.flashFrame) {
|
||||
(window as any).electron.window.flashFrame(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -138,8 +150,12 @@ function InviteNotifications() {
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play();
|
||||
console.log('[InviteNotifications] playSound called');
|
||||
// Use HTML5 Audio - works reliably in Chromium/Electron (supports OGG)
|
||||
const audio = new Audio('/sound/invite.ogg');
|
||||
audio.play().catch((err) => {
|
||||
console.error('[Audio] Failed to play invite sound:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,16 +170,10 @@ function InviteNotifications() {
|
||||
}
|
||||
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<source src={InviteSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MessageNotifications() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const notifRef = useRef<Notification>();
|
||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||
const mx = useMatrixClient();
|
||||
@@ -227,7 +237,21 @@ function MessageNotifications() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isTauri()) {
|
||||
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
|
||||
console.log('[Notifications] Attempting flashFrame');
|
||||
if (isElectron() && (window as any).electron?.window?.flashFrame) {
|
||||
(window as any).electron.window.flashFrame(true)
|
||||
.then((result: { success: boolean }) => {
|
||||
console.log('[Notifications] flashFrame result:', result);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('[Notifications] flashFrame error:', err);
|
||||
});
|
||||
} else {
|
||||
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
||||
}
|
||||
|
||||
if (isTauri() && !isElectron()) {
|
||||
const roomPath = isDm
|
||||
? getDirectRoomPath(roomId, eventId)
|
||||
: getHomeRoomPath(roomId, eventId);
|
||||
@@ -257,6 +281,10 @@ function MessageNotifications() {
|
||||
if (!window.closed) navigateToRoom();
|
||||
noti.close();
|
||||
notifRef.current = undefined;
|
||||
// Stop flashing when user clicks notification
|
||||
if ((window as any).electron?.window?.flashFrame) {
|
||||
(window as any).electron.window.flashFrame(false);
|
||||
}
|
||||
};
|
||||
|
||||
notifRef.current?.close();
|
||||
@@ -267,8 +295,13 @@ function MessageNotifications() {
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play();
|
||||
console.log('[MessageNotifications] playSound called');
|
||||
// Use HTML5 Audio - works reliably in Chromium/Electron (supports OGG)
|
||||
// Main process audio is unreliable on Windows (can't play OGG natively)
|
||||
const audio = new Audio('/sound/notification.ogg');
|
||||
audio.play().catch((err) => {
|
||||
console.error('[Audio] Failed to play notification sound:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -306,11 +339,24 @@ function MessageNotifications() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showNotifications && (isTauri() || notificationPermission('granted'))) {
|
||||
if (showNotifications && ((isTauri() && !isElectron()) || notificationPermission('granted'))) {
|
||||
const avatarMxc =
|
||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||
const content = mEvent.getContent();
|
||||
const messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||
|
||||
let messageBody: string | undefined;
|
||||
if (mEvent.getType() === 'm.reaction') {
|
||||
// For reactions, show "reacted with {emoji}"
|
||||
const reactionKey = content['m.relates_to']?.key;
|
||||
if (reactionKey) {
|
||||
messageBody = `reacted with ${reactionKey}`;
|
||||
} else {
|
||||
messageBody = 'reacted to a message';
|
||||
}
|
||||
} else {
|
||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||
}
|
||||
|
||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
||||
notify({
|
||||
roomName: room.name ?? 'Unknown',
|
||||
@@ -344,12 +390,7 @@ function MessageNotifications() {
|
||||
useAuthentication,
|
||||
]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<source src={NotificationSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,6 +414,35 @@ function PaarrotAPIInitializer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops taskbar icon flashing when window gains focus
|
||||
*/
|
||||
function TaskbarFlashStopper() {
|
||||
useEffect(() => {
|
||||
// Log what's available on mount
|
||||
console.log('[TaskbarFlashStopper] window.electron:', (window as any).electron);
|
||||
console.log('[TaskbarFlashStopper] Available APIs:', {
|
||||
hasWindow: !!(window as any).electron?.window,
|
||||
hasFlashFrame: !!(window as any).electron?.window?.flashFrame,
|
||||
hasAudio: !!(window as any).electron?.audio,
|
||||
hasPlaySound: !!(window as any).electron?.audio?.playNotificationSound,
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
if ((window as any).electron?.window?.flashFrame) {
|
||||
(window as any).electron.window.flashFrame(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -386,6 +456,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<PaarrotAPIInitializer />
|
||||
<TaskbarFlashStopper />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../ut
|
||||
import { TitleBar } from '../../components/title-bar';
|
||||
import { AccountSwitcher } from '../../components/account-switcher';
|
||||
import { getLoginPath, withSearchParam } from '../pathUtils';
|
||||
import { useViewTransitions } from '../../hooks/useViewTransitions';
|
||||
|
||||
function ClientRootLoading() {
|
||||
return (
|
||||
@@ -159,6 +160,9 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
const [syncError, setSyncError] = useState<Error | null>(null);
|
||||
const { baseUrl } = getCurrentSession() ?? {};
|
||||
|
||||
// Enable view transitions for smooth navigation
|
||||
useViewTransitions();
|
||||
|
||||
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
||||
useCallback(() => {
|
||||
const session = getCurrentSession();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
|
||||
import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavLink } from '../../../components/nav';
|
||||
import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
|
||||
import {
|
||||
useInboxInvitesSelected,
|
||||
@@ -11,6 +11,7 @@ import { UnreadBadge } from '../../../components/unread-badge';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
|
||||
import { DirectList, DirectListHeader } from '../../../components/direct-list';
|
||||
|
||||
function InvitesNavItem() {
|
||||
const invitesSelected = useInboxInvitesSelected();
|
||||
@@ -45,23 +46,23 @@ function InvitesNavItem() {
|
||||
|
||||
export function Inbox() {
|
||||
useNavToActivePathMapper('inbox');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const notificationsSelected = useInboxNotificationsSelected();
|
||||
|
||||
return (
|
||||
<PageNav>
|
||||
<PageNavHeader>
|
||||
<Box grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
Inbox
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<DirectListHeader title="Inbox" />
|
||||
</PageNavHeader>
|
||||
|
||||
<PageNavContent>
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<Text size="O400" style={{ padding: '0 var(--fo-space-200)' }}>
|
||||
Inbox
|
||||
</Text>
|
||||
</NavCategoryHeader>
|
||||
<NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
|
||||
<NavLink to={getInboxNotificationsPath()}>
|
||||
<NavItemContent>
|
||||
@@ -80,6 +81,7 @@ export function Inbox() {
|
||||
</NavItem>
|
||||
<InvitesNavItem />
|
||||
</NavCategory>
|
||||
<DirectList scrollRef={scrollRef} showCreateButton showCategoryHeader />
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
|
||||
import { Box, Icon, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -84,7 +84,7 @@ const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose, onHi
|
||||
});
|
||||
|
||||
export function HomeTab() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithTransition();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
|
||||
|
||||
export function InboxTab() {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithTransition();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
const inboxSelected = useInboxSelected();
|
||||
const allInvites = useAtomValue(allInvitesAtom);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import { Box, config, Icon, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
|
||||
import { SidebarItem, SidebarAvatar } from '../../../components/sidebar';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
@@ -22,7 +22,7 @@ export function SettingsTab() {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const userId = mx.getUserId();
|
||||
const profile = useUserProfile(userId ?? '');
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithTransition();
|
||||
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
@@ -916,7 +916,7 @@ type SpaceTabsProps = {
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithTransition();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
@@ -1189,7 +1189,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
* Separate component for hidden spaces that can be rendered after other sidebar items
|
||||
*/
|
||||
export function HiddenSpacesTabs() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithTransition();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
|
||||
@@ -191,6 +191,7 @@ const NOTIFICATION_EVENT_TYPES = [
|
||||
'm.room.encrypted',
|
||||
'm.room.member',
|
||||
'm.sticker',
|
||||
'm.reaction',
|
||||
];
|
||||
export const isNotificationEvent = (mEvent: MatrixEvent) => {
|
||||
const eType = mEvent.getType();
|
||||
|
||||
80
src/app/utils/viewTransitions.ts
Normal file
80
src/app/utils/viewTransitions.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Utility for enabling View Transitions API in the app
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if View Transitions API is supported
|
||||
*/
|
||||
export const supportsViewTransitions = (): boolean => 'startViewTransition' in document;
|
||||
|
||||
/**
|
||||
* Execute a function with view transition if supported
|
||||
* Falls back to immediate execution if not supported
|
||||
*/
|
||||
export const withViewTransition = async (callback: () => void | Promise<void>): Promise<void> => {
|
||||
if (!supportsViewTransitions()) {
|
||||
await callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = document as Document & {
|
||||
startViewTransition: (callback: () => void | Promise<void>) => {
|
||||
finished: Promise<void>;
|
||||
updateCallbackDone: Promise<void>;
|
||||
ready: Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const transition = doc.startViewTransition(async () => {
|
||||
await callback();
|
||||
});
|
||||
|
||||
await transition.finished;
|
||||
} catch (error) {
|
||||
// Fallback if transition fails
|
||||
await callback();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable view transitions for router navigation
|
||||
* Call this in your app initialization
|
||||
*/
|
||||
export const enableViewTransitionsForNavigation = (): void => {
|
||||
if (!supportsViewTransitions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept link clicks and navigation to add view transitions
|
||||
const handleLinkClick = (event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement).closest('a');
|
||||
|
||||
if (!target) return;
|
||||
if (target.target === '_blank') return;
|
||||
if (target.origin !== window.location.origin) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const url = new URL(target.href);
|
||||
|
||||
// Only handle same-origin navigation
|
||||
if (url.origin === window.location.origin) {
|
||||
event.preventDefault();
|
||||
|
||||
withViewTransition(() => {
|
||||
window.history.pushState({}, '', target.href);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back/forward navigation
|
||||
const handlePopState = () => {
|
||||
withViewTransition(() => {
|
||||
// The URL has already changed, just trigger re-render
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleLinkClick);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
};
|
||||
Reference in New Issue
Block a user