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

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