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 ( +
+
+ {children} +
+
+ ); +} 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 ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +/** + * 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>(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 - - ); + 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) { + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 09c6579..9413f10 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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(null); const { baseUrl } = getCurrentSession() ?? {}; + // Enable view transitions for smooth navigation + useViewTransitions(); + const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => { const session = getCurrentSession(); diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 67d6021..7ac9094 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -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(null); const notificationsSelected = useInboxNotificationsSelected(); return ( - - - - Inbox - - - + - + + + + Inbox + + @@ -80,6 +81,7 @@ export function Inbox() { + diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index be7d505..707a5b5 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -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(({ requestClose, onHi }); export function HomeTab() { - const navigate = useNavigate(); + const navigate = useNavigateWithTransition(); const mx = useMatrixClient(); const screenSize = useScreenSizeContext(); const navToActivePath = useAtomValue(useNavToActivePathAtom()); diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 5d13a9a..50bc093 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -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); diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 01d6c01..682a9c6 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -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(); diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index fa022a2..3fb26ff 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -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; }; 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()); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index b4bba2a..e6add72 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -191,6 +191,7 @@ const NOTIFICATION_EVENT_TYPES = [ 'm.room.encrypted', 'm.room.member', 'm.sticker', + 'm.reaction', ]; export const isNotificationEvent = (mEvent: MatrixEvent) => { const eType = mEvent.getType(); diff --git a/src/app/utils/viewTransitions.ts b/src/app/utils/viewTransitions.ts new file mode 100644 index 0000000..7944ecf --- /dev/null +++ b/src/app/utils/viewTransitions.ts @@ -0,0 +1,80 @@ +/** + * Utility for enabling View Transitions API in the app + */ + +/** + * Check if View Transitions API is supported + */ +export const supportsViewTransitions = (): boolean => 'startViewTransition' in document; + +/** + * Execute a function with view transition if supported + * Falls back to immediate execution if not supported + */ +export const withViewTransition = async (callback: () => void | Promise): Promise => { + if (!supportsViewTransitions()) { + await callback(); + return; + } + + const doc = document as Document & { + startViewTransition: (callback: () => void | Promise) => { + finished: Promise; + updateCallbackDone: Promise; + ready: Promise; + }; + }; + + try { + const transition = doc.startViewTransition(async () => { + await callback(); + }); + + await transition.finished; + } catch (error) { + // Fallback if transition fails + await callback(); + } +}; + +/** + * Enable view transitions for router navigation + * Call this in your app initialization + */ +export const enableViewTransitionsForNavigation = (): void => { + if (!supportsViewTransitions()) { + return; + } + + // Intercept link clicks and navigation to add view transitions + const handleLinkClick = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest('a'); + + if (!target) return; + if (target.target === '_blank') return; + if (target.origin !== window.location.origin) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const url = new URL(target.href); + + // Only handle same-origin navigation + if (url.origin === window.location.origin) { + event.preventDefault(); + + withViewTransition(() => { + window.history.pushState({}, '', target.href); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + } + }; + + // Handle back/forward navigation + const handlePopState = () => { + withViewTransition(() => { + // The URL has already changed, just trigger re-render + }); + }; + + window.addEventListener('click', handleLinkClick); + window.addEventListener('popstate', handlePopState); +}; diff --git a/src/colors.css.ts b/src/colors.css.ts index 6044cd1..3685f4a 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -430,3 +430,100 @@ export const discordDarkerTheme = createTheme(color, { Overlay: 'rgba(0, 0, 0, 0.9)', }, }); + +export const twilightTheme = createTheme(color, { + Background: { + Container: '#14121F', + ContainerHover: '#1C1A2E', + ContainerActive: '#24223D', + ContainerLine: '#2D2A4C', + OnContainer: '#F0F0FF', + }, + + Surface: { + Container: '#1C1A2E', + ContainerHover: '#24223D', + ContainerActive: '#2D2A4C', + ContainerLine: '#35325B', + OnContainer: '#F0F0FF', + }, + + SurfaceVariant: { + Container: '#24223D', + ContainerHover: '#2D2A4C', + ContainerActive: '#35325B', + ContainerLine: '#3E3A6A', + OnContainer: '#F0F0FF', + }, + + Primary: { + Main: '#8B7FFF', + MainHover: '#7A6EF5', + MainActive: '#6E62E8', + MainLine: '#6256DB', + OnMain: '#FFFFFF', + Container: '#3E3A6A', + ContainerHover: '#4A4580', + ContainerActive: '#565096', + ContainerLine: '#625BAC', + OnContainer: '#E8E5FF', + }, + + Secondary: { + Main: '#FFFFFF', + MainHover: '#E5E5E5', + MainActive: '#D9D9D9', + MainLine: '#CCCCCC', + OnMain: '#1A1A1A', + Container: '#404040', + ContainerHover: '#4D4D4D', + ContainerActive: '#595959', + ContainerLine: '#666666', + OnContainer: '#F2F2F2', + }, + + Success: { + Main: '#85E0BA', + MainHover: '#70DBAF', + MainActive: '#66D9A9', + MainLine: '#5CD6A3', + OnMain: '#0F3D2A', + Container: '#175C3F', + ContainerHover: '#1A6646', + ContainerActive: '#1C704D', + ContainerLine: '#1F7A54', + OnContainer: '#CCF2E2', + }, + + Warning: { + Main: '#E3BA91', + MainHover: '#DFAF7E', + MainActive: '#DDA975', + MainLine: '#DAA36C', + OnMain: '#3F2A15', + Container: '#5E3F20', + ContainerHover: '#694624', + ContainerActive: '#734D27', + ContainerLine: '#7D542B', + OnContainer: '#F3E2D1', + }, + + Critical: { + Main: '#E69D9D', + MainHover: '#E28D8D', + MainActive: '#E08585', + MainLine: '#DE7D7D', + OnMain: '#401C1C', + Container: '#602929', + ContainerHover: '#6B2E2E', + ContainerActive: '#763333', + ContainerLine: '#803737', + OnContainer: '#F5D6D6', + }, + + Other: { + FocusRing: 'rgba(139, 127, 255, 0.5)', + Shadow: 'rgba(0, 0, 0, 1)', + Overlay: 'rgba(0, 0, 0, 0.85)', + }, +}); diff --git a/src/index.css b/src/index.css index cbc66aa..27b4598 100644 --- a/src/index.css +++ b/src/index.css @@ -48,7 +48,8 @@ .dark-theme, .butter-theme, .discord-theme, -.discord-darker-theme { +.discord-darker-theme, +.twilight-theme { --tc-link: hsl(213deg 100% 80%); --mx-uc-1: hsl(208, 100%, 75%); @@ -111,6 +112,9 @@ body.discord-theme { body.discord-darker-theme { background-color: #1E1F22; } +body.twilight-theme { + background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%); +} #root { width: 100%; height: 100%; @@ -119,6 +123,160 @@ body.discord-darker-theme { background-color: #262626; } +.twilight-theme #root { + background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%); +} + +/* Twilight theme gradient overlays */ +.twilight-theme [role="navigation"], +.twilight-theme aside { + background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%); + backdrop-filter: blur(8px); +} + +.twilight-theme main { + background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%); +} + +.twilight-theme header { + background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%); +} + +/* CSS animations for route transitions */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Apply transition to route changes - works on ALL themes */ +[data-route-transition="true"] { + animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Twilight theme enhanced transitions */ +.twilight-theme [data-route-transition="true"] { + animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Also apply to main content areas for double coverage */ +.twilight-theme [data-room-view], +.twilight-theme [data-space-view], +.twilight-theme [data-inbox-view], +.twilight-theme [data-explore-view] { + animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Smooth transitions for interactive elements */ +.twilight-theme button, +.twilight-theme [role="button"], +.twilight-theme a, +.twilight-theme input, +.twilight-theme textarea { + transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease; +} + +.twilight-theme button:hover, +.twilight-theme [role="button"]:hover { + transform: translateY(-1px); +} + +.twilight-theme button:active, +.twilight-theme [role="button"]:active { + transform: translateY(0); +} + +/* Message transitions */ +.twilight-theme [data-message-item] { + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.twilight-theme [data-message-item]:hover { + background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%); + transform: translateX(2px); +} + +/* Selected item indicator */ +.twilight-theme [data-selected="true"], +.twilight-theme [aria-selected="true"], +.twilight-theme [aria-current="true"] { + position: relative; + transition: all 0.2s ease; +} + +.twilight-theme [data-selected="true"]::before, +.twilight-theme [aria-selected="true"]::before, +.twilight-theme [aria-current="true"]::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%); + transition: width 0.2s ease; +} + +/* Scrollbar styling */ +.twilight-theme ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.twilight-theme ::-webkit-scrollbar-track { + background: rgba(20, 18, 31, 0.5); +} + +.twilight-theme ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%); + border-radius: 5px; + border: 2px solid rgba(20, 18, 31, 0.5); + transition: background 0.2s ease; +} + +.twilight-theme ::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%); +} + +/* Subtle dot pattern overlay */ +.twilight-theme::after { + content: ''; + position: fixed; + inset: 0; + background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px); + background-size: 24px 24px; + pointer-events: none; + z-index: 0; +} + +.twilight-theme #root { + position: relative; + z-index: 1; +} + +/* Focus states */ +.twilight-theme input:focus, +.twilight-theme textarea:focus, +.twilight-theme button:focus-visible { + outline: 2px solid rgba(139, 127, 255, 0.5); + outline-offset: 2px; + transition: outline 0.2s ease; +} + *, *::before, *::after { diff --git a/src/index.tsx b/src/index.tsx index 5a8e727..e2796a1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,7 @@ import './index.css'; import { trimTrailingSlash } from './app/utils/common'; import App from './app/pages/App'; import { applySafeAreaInsets, isTauri } from './app/utils/tauri'; +import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions'; // import i18n (needs to be bundled ;)) import './app/i18n'; @@ -22,6 +23,9 @@ document.body.classList.add(configClass, varsClass); // Apply safe area insets for mobile devices applySafeAreaInsets(); +// Enable View Transitions API for smooth navigation +enableViewTransitionsForNavigation(); + // Register Service Worker if ('serviceWorker' in navigator) { const swUrl = diff --git a/vite.config.js b/vite.config.js index fb49d09..83b0c94 100644 --- a/vite.config.js +++ b/vite.config.js @@ -42,6 +42,14 @@ const copyFiles = { src: 'public/locales', dest: 'public/', }, + { + src: 'public/sound', + dest: 'public/', + }, + { + src: 'public/font', + dest: 'public/', + }, ], }; @@ -97,10 +105,41 @@ function serveGifWorker() { }; } +function servePublicAssets() { + return { + name: 'vite-plugin-serve-public-assets', + configureServer(server) { + server.middlewares.use((req, res, next) => { + // Serve sound files + if (req.url?.startsWith('/sound/')) { + const fileName = req.url.replace('/sound/', ''); + const resolvedPath = path.join(path.resolve(), 'public', 'sound', fileName); + + if (fs.existsSync(resolvedPath)) { + const ext = path.extname(fileName); + const contentType = ext === '.ogg' ? 'audio/ogg' : 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache'); + + const fileStream = fs.createReadStream(resolvedPath); + fileStream.pipe(res); + } else { + res.writeHead(404); + res.end('File not found'); + } + } else { + next(); + } + }); + }, + }; +} + export default defineConfig({ appType: 'spa', publicDir: false, base: buildConfig.base, + assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'], server: { port: 38347, host: '0.0.0.0', @@ -112,6 +151,7 @@ export default defineConfig({ plugins: [ serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), serveGifWorker(), + servePublicAssets(), topLevelAwait({ // The export name of top-level await promise for each chunk module promiseExportName: '__tla',