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);
|
||||
};
|
||||
@@ -430,3 +430,100 @@ export const discordDarkerTheme = createTheme(color, {
|
||||
Overlay: 'rgba(0, 0, 0, 0.9)',
|
||||
},
|
||||
});
|
||||
|
||||
export const twilightTheme = createTheme(color, {
|
||||
Background: {
|
||||
Container: '#14121F',
|
||||
ContainerHover: '#1C1A2E',
|
||||
ContainerActive: '#24223D',
|
||||
ContainerLine: '#2D2A4C',
|
||||
OnContainer: '#F0F0FF',
|
||||
},
|
||||
|
||||
Surface: {
|
||||
Container: '#1C1A2E',
|
||||
ContainerHover: '#24223D',
|
||||
ContainerActive: '#2D2A4C',
|
||||
ContainerLine: '#35325B',
|
||||
OnContainer: '#F0F0FF',
|
||||
},
|
||||
|
||||
SurfaceVariant: {
|
||||
Container: '#24223D',
|
||||
ContainerHover: '#2D2A4C',
|
||||
ContainerActive: '#35325B',
|
||||
ContainerLine: '#3E3A6A',
|
||||
OnContainer: '#F0F0FF',
|
||||
},
|
||||
|
||||
Primary: {
|
||||
Main: '#8B7FFF',
|
||||
MainHover: '#7A6EF5',
|
||||
MainActive: '#6E62E8',
|
||||
MainLine: '#6256DB',
|
||||
OnMain: '#FFFFFF',
|
||||
Container: '#3E3A6A',
|
||||
ContainerHover: '#4A4580',
|
||||
ContainerActive: '#565096',
|
||||
ContainerLine: '#625BAC',
|
||||
OnContainer: '#E8E5FF',
|
||||
},
|
||||
|
||||
Secondary: {
|
||||
Main: '#FFFFFF',
|
||||
MainHover: '#E5E5E5',
|
||||
MainActive: '#D9D9D9',
|
||||
MainLine: '#CCCCCC',
|
||||
OnMain: '#1A1A1A',
|
||||
Container: '#404040',
|
||||
ContainerHover: '#4D4D4D',
|
||||
ContainerActive: '#595959',
|
||||
ContainerLine: '#666666',
|
||||
OnContainer: '#F2F2F2',
|
||||
},
|
||||
|
||||
Success: {
|
||||
Main: '#85E0BA',
|
||||
MainHover: '#70DBAF',
|
||||
MainActive: '#66D9A9',
|
||||
MainLine: '#5CD6A3',
|
||||
OnMain: '#0F3D2A',
|
||||
Container: '#175C3F',
|
||||
ContainerHover: '#1A6646',
|
||||
ContainerActive: '#1C704D',
|
||||
ContainerLine: '#1F7A54',
|
||||
OnContainer: '#CCF2E2',
|
||||
},
|
||||
|
||||
Warning: {
|
||||
Main: '#E3BA91',
|
||||
MainHover: '#DFAF7E',
|
||||
MainActive: '#DDA975',
|
||||
MainLine: '#DAA36C',
|
||||
OnMain: '#3F2A15',
|
||||
Container: '#5E3F20',
|
||||
ContainerHover: '#694624',
|
||||
ContainerActive: '#734D27',
|
||||
ContainerLine: '#7D542B',
|
||||
OnContainer: '#F3E2D1',
|
||||
},
|
||||
|
||||
Critical: {
|
||||
Main: '#E69D9D',
|
||||
MainHover: '#E28D8D',
|
||||
MainActive: '#E08585',
|
||||
MainLine: '#DE7D7D',
|
||||
OnMain: '#401C1C',
|
||||
Container: '#602929',
|
||||
ContainerHover: '#6B2E2E',
|
||||
ContainerActive: '#763333',
|
||||
ContainerLine: '#803737',
|
||||
OnContainer: '#F5D6D6',
|
||||
},
|
||||
|
||||
Other: {
|
||||
FocusRing: 'rgba(139, 127, 255, 0.5)',
|
||||
Shadow: 'rgba(0, 0, 0, 1)',
|
||||
Overlay: 'rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
});
|
||||
|
||||
160
src/index.css
160
src/index.css
@@ -48,7 +48,8 @@
|
||||
.dark-theme,
|
||||
.butter-theme,
|
||||
.discord-theme,
|
||||
.discord-darker-theme {
|
||||
.discord-darker-theme,
|
||||
.twilight-theme {
|
||||
--tc-link: hsl(213deg 100% 80%);
|
||||
|
||||
--mx-uc-1: hsl(208, 100%, 75%);
|
||||
@@ -111,6 +112,9 @@ body.discord-theme {
|
||||
body.discord-darker-theme {
|
||||
background-color: #1E1F22;
|
||||
}
|
||||
body.twilight-theme {
|
||||
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
|
||||
}
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -119,6 +123,160 @@ body.discord-darker-theme {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.twilight-theme #root {
|
||||
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||
}
|
||||
|
||||
/* Twilight theme gradient overlays */
|
||||
.twilight-theme [role="navigation"],
|
||||
.twilight-theme aside {
|
||||
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.twilight-theme main {
|
||||
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||
}
|
||||
|
||||
.twilight-theme header {
|
||||
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* CSS animations for route transitions */
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply transition to route changes - works on ALL themes */
|
||||
[data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Twilight theme enhanced transitions */
|
||||
.twilight-theme [data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Also apply to main content areas for double coverage */
|
||||
.twilight-theme [data-room-view],
|
||||
.twilight-theme [data-space-view],
|
||||
.twilight-theme [data-inbox-view],
|
||||
.twilight-theme [data-explore-view] {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Smooth transitions for interactive elements */
|
||||
.twilight-theme button,
|
||||
.twilight-theme [role="button"],
|
||||
.twilight-theme a,
|
||||
.twilight-theme input,
|
||||
.twilight-theme textarea {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.twilight-theme button:hover,
|
||||
.twilight-theme [role="button"]:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.twilight-theme button:active,
|
||||
.twilight-theme [role="button"]:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Message transitions */
|
||||
.twilight-theme [data-message-item] {
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme [data-message-item]:hover {
|
||||
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Selected item indicator */
|
||||
.twilight-theme [data-selected="true"],
|
||||
.twilight-theme [aria-selected="true"],
|
||||
.twilight-theme [aria-current="true"] {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme [data-selected="true"]::before,
|
||||
.twilight-theme [aria-selected="true"]::before,
|
||||
.twilight-theme [aria-current="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.twilight-theme ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-track {
|
||||
background: rgba(20, 18, 31, 0.5);
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgba(20, 18, 31, 0.5);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
|
||||
}
|
||||
|
||||
/* Subtle dot pattern overlay */
|
||||
.twilight-theme::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.twilight-theme #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.twilight-theme input:focus,
|
||||
.twilight-theme textarea:focus,
|
||||
.twilight-theme button:focus-visible {
|
||||
outline: 2px solid rgba(139, 127, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
transition: outline 0.2s ease;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
||||
@@ -13,6 +13,7 @@ import './index.css';
|
||||
import { trimTrailingSlash } from './app/utils/common';
|
||||
import App from './app/pages/App';
|
||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||
|
||||
// import i18n (needs to be bundled ;))
|
||||
import './app/i18n';
|
||||
@@ -22,6 +23,9 @@ document.body.classList.add(configClass, varsClass);
|
||||
// Apply safe area insets for mobile devices
|
||||
applySafeAreaInsets();
|
||||
|
||||
// Enable View Transitions API for smooth navigation
|
||||
enableViewTransitionsForNavigation();
|
||||
|
||||
// Register Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
|
||||
@@ -42,6 +42,14 @@ const copyFiles = {
|
||||
src: 'public/locales',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'public/sound',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'public/font',
|
||||
dest: 'public/',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -97,10 +105,41 @@ function serveGifWorker() {
|
||||
};
|
||||
}
|
||||
|
||||
function servePublicAssets() {
|
||||
return {
|
||||
name: 'vite-plugin-serve-public-assets',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// Serve sound files
|
||||
if (req.url?.startsWith('/sound/')) {
|
||||
const fileName = req.url.replace('/sound/', '');
|
||||
const resolvedPath = path.join(path.resolve(), 'public', 'sound', fileName);
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
const ext = path.extname(fileName);
|
||||
const contentType = ext === '.ogg' ? 'audio/ogg' : 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
|
||||
const fileStream = fs.createReadStream(resolvedPath);
|
||||
fileStream.pipe(res);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
appType: 'spa',
|
||||
publicDir: false,
|
||||
base: buildConfig.base,
|
||||
assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'],
|
||||
server: {
|
||||
port: 38347,
|
||||
host: '0.0.0.0',
|
||||
@@ -112,6 +151,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
||||
serveGifWorker(),
|
||||
servePublicAssets(),
|
||||
topLevelAwait({
|
||||
// The export name of top-level await promise for each chunk module
|
||||
promiseExportName: '__tla',
|
||||
|
||||
Reference in New Issue
Block a user