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