diff --git a/src/app/components/AnimatedOutlet.tsx b/src/app/components/AnimatedOutlet.tsx
new file mode 100644
index 0000000..83e56ff
--- /dev/null
+++ b/src/app/components/AnimatedOutlet.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/app/components/RouteTransition.tsx b/src/app/components/RouteTransition.tsx
new file mode 100644
index 0000000..79b0fb3
--- /dev/null
+++ b/src/app/components/RouteTransition.tsx
@@ -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>();
+
+ 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 (
+
+ );
+}
diff --git a/src/app/components/direct-list/DirectList.tsx b/src/app/components/direct-list/DirectList.tsx
new file mode 100644
index 0000000..c8073da
--- /dev/null
+++ b/src/app/components/direct-list/DirectList.tsx
@@ -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(({ 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 (
+
+ );
+});
+
+/**
+ * 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();
+
+ const handleOpenMenu: MouseEventHandler = (evt) => {
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+
+ return (
+ <>
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+ setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ setMenuAnchor(undefined)} />
+
+ }
+ />
+ >
+ );
+}
+
+/**
+ * Empty state component when there are no direct messages
+ */
+export function DirectListEmpty() {
+ const navigate = useNavigate();
+
+ return (
+
+ }
+ title={
+
+ No Direct Messages
+
+ }
+ content={
+
+ You do not have any direct messages yet.
+
+ }
+ options={
+
+ }
+ />
+
+ );
+}
+
+const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
+
+/**
+ * Props for the DirectList component
+ */
+export type DirectListProps = {
+ scrollRef: React.RefObject;
+ 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 ;
+ }
+
+ return (
+
+ {showCreateButton && (
+
+
+ navigate(getDirectCreatePath())}>
+
+
+
+
+
+
+
+ Create Chat
+
+
+
+
+
+
+
+ )}
+
+ {showCategoryHeader && (
+
+
+ Chats
+
+
+ )}
+
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const roomId = sortedDirects[vItem.index];
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ const selected = selectedRoomId === roomId;
+
+ return (
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/app/components/direct-list/index.ts b/src/app/components/direct-list/index.ts
new file mode 100644
index 0000000..e53693f
--- /dev/null
+++ b/src/app/components/direct-list/index.ts
@@ -0,0 +1,2 @@
+export { DirectList, DirectListHeader, DirectListEmpty } from './DirectList';
+export type { DirectListProps } from './DirectList';
diff --git a/src/app/features/call/CallSounds.ts b/src/app/features/call/CallSounds.ts
index e233445..954f59a 100644
--- a/src/app/features/call/CallSounds.ts
+++ b/src/app/features/call/CallSounds.ts
@@ -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.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',
};
/**
diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts
index 7d7365f..18398da 100644
--- a/src/app/hooks/useTheme.ts
+++ b/src/app/hooks/useTheme.ts
@@ -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 =>
[ButterTheme.id]: 'Butter',
[DiscordTheme.id]: 'Discord',
[DiscordDarkerTheme.id]: 'Discord Darker',
+ [TwilightTheme.id]: 'Twilight',
}),
[]
);
diff --git a/src/app/hooks/useViewTransitions.ts b/src/app/hooks/useViewTransitions.ts
new file mode 100644
index 0000000..342456e
--- /dev/null
+++ b/src/app/hooks/useViewTransitions.ts
@@ -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]
+ );
+};
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index dd68f4d..5cebde5 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -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)
{
- 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)
}
>
-
+
}
>
@@ -214,7 +215,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
-
+
}
>
@@ -240,7 +241,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
-
+
}
@@ -279,7 +280,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
-
+
}
>
@@ -304,7 +305,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
-
+
}
>
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 0f071fd..e4b54e4 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -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(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
-
- );
+ return null;
}
function MessageNotifications() {
- const audioRef = useRef(null);
const notifRef = useRef();
const unreadCacheRef = useRef