feat: Implement view transitions for smoother navigation

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
};
/**

View File

@@ -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',
}),
[]
);

View 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]
);
};

View File

@@ -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>
}
>

View File

@@ -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}
</>
);

View File

@@ -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();

View File

@@ -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>

View File

@@ -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());

View File

@@ -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);

View File

@@ -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>();

View File

@@ -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());

View File

@@ -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();

View 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);
};

View File

@@ -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)',
},
});

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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',