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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user