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

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