From 7b2e847d9c30b30430215f5ea0e20ace5fa877be Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 19:24:43 -0500 Subject: [PATCH 01/28] only load room members when needed and timeline transition when no data yet --- src/app/features/room/Room.tsx | 7 +-- src/app/features/room/RoomTimeline.tsx | 59 +++++++++++++++++++++----- src/app/hooks/useRoomMembers.ts | 13 +++++- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index da71e3f5b..b92ce855f 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -45,6 +45,8 @@ export function Room() { const [isWidgetDrawerOpen] = useSetting(settingsAtom, 'isWidgetDrawer'); const [hideReads] = useSetting(settingsAtom, 'hideReads'); const screenSize = useScreenSizeContext(); + const callView = room.isCallRoom(); + const showMembersDrawer = !callView && screenSize === ScreenSize.Desktop && isDrawer; // Log drawer state changes useEffect(() => { @@ -61,7 +63,7 @@ export function Room() { }); }, [isWidgetDrawerOpen, room.roomId]); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); + const members = useRoomMembers(mx, room.roomId, showMembersDrawer); const chat = useAtomValue(callChatAtom); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( @@ -100,7 +102,6 @@ export function Room() { ) ); - const callView = room.isCallRoom(); const abbreviations = useMergedAbbreviations(room); // Log call view state @@ -137,7 +138,7 @@ export function Room() { )} - {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( + {showMembersDrawer && ( <> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..6fe987cdc 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -333,6 +333,21 @@ export function RoomTimeline({ // It is cancelled on unmount by the dedicated effect below. }, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]); + useLayoutEffect(() => { + if (eventId || isReady) return; + if (!timelineSync.liveTimelineLinked) return; + if (timelineSync.eventsLength > 0) return; + if (timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading') return; + setIsReady(true); + }, [ + eventId, + isReady, + timelineSync.liveTimelineLinked, + timelineSync.eventsLength, + timelineSync.canPaginateBack, + timelineSync.backwardStatus, + ]); + // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). useEffect( @@ -693,9 +708,8 @@ export function RoomTimeline({ [setAtBottom] ); - const showLoadingPlaceholders = - timelineSync.eventsLength === 0 && - (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading'); + const showLoadingPlaceholders = !isReady && timelineSync.eventsLength === 0; + const showPositioningPlaceholders = !isReady && !showLoadingPlaceholders; let backPaginationJSX: ReactNode | undefined; if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') { @@ -761,11 +775,7 @@ export function RoomTimeline({ } } - const vListItemCount = - timelineSync.eventsLength === 0 && - (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading') - ? 3 - : timelineSync.eventsLength; + const vListItemCount = timelineSync.eventsLength; const vListIndices = useMemo(() => { // Keep the cache-busting timeline identity explicit for exhaustive-deps. void timelineSync.timeline; @@ -788,6 +798,12 @@ export function RoomTimeline({ processedEventsRef.current = processedEvents; + const vListData = useMemo>(() => { + if (showLoadingPlaceholders) return [undefined, undefined, undefined]; + if (isReady && processedEvents.length === 0) return [undefined]; + return processedEvents; + }, [isReady, processedEvents, showLoadingPlaceholders]); + // Recovery: if the 80 ms initial-scroll timer fired while processedEvents was // empty (timeline was mid-reset), scroll to bottom and reveal the timeline once // events repopulate. Fires on every processedEvents.length change but is @@ -898,9 +914,9 @@ export function RoomTimeline({ opacity: isReady || showLoadingPlaceholders ? 1 : 0, }} > - + ref={vListRef} - data={processedEvents} + data={vListData} shift={shift} className={css.messageList} style={{ @@ -1007,6 +1023,29 @@ export function RoomTimeline({ + {showPositioningPlaceholders && ( +
+ {[0, 1, 2].map((index) => ( + + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} + + ))} +
+ )} + {frontPaginationJSX} {!atBottomState && isReady && ( diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 46640040e..d71bf4d63 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -2,10 +2,19 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { +export const useRoomMembers = ( + mx: MatrixClient, + roomId: string, + enabled = true +): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return () => {}; + } + const room = mx.getRoom(roomId); let loadingMembers = true; let disposed = false; @@ -40,7 +49,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomStateEvent.Events, handleStateEvent); }; - }, [mx, roomId]); + }, [mx, roomId, enabled]); return members; }; From e42f6a3d8b0525819d7e2f5ddeb9d9ad0f192a7e Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 19:31:01 -0500 Subject: [PATCH 02/28] fix double sw registration --- src/index.tsx | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 1721755d5..61566cff2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,20 +58,26 @@ if ('serviceWorker' in navigator) { swRegisterOptions.type = 'module'; } - navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => { - registration.addEventListener('updatefound', () => { - const installingWorker = registration.installing; - if (installingWorker) { - installingWorker.addEventListener('statechange', () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - showUpdateAvailablePrompt(registration); + const serviceWorkerRegistration = navigator.serviceWorker.register(swUrl, swRegisterOptions); + + serviceWorkerRegistration + .then((registration) => { + registration.addEventListener('updatefound', () => { + const installingWorker = registration.installing; + if (installingWorker) { + installingWorker.addEventListener('statechange', () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + showUpdateAvailablePrompt(registration); + } } - } - }); - } + }); + } + }); + }) + .catch((err) => { + log.warn('SW registration failed:', err); }); - }); const sendSessionToSW = () => { // Use the active session from the new multi-session store, fall back to legacy @@ -82,12 +88,9 @@ if ('serviceWorker' in navigator) { pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; - navigator.serviceWorker - .register(swUrl) - .then(sendSessionToSW) - .catch((err) => { - log.warn('SW registration failed:', err); - }); + serviceWorkerRegistration.then(sendSessionToSW).catch((err) => { + log.warn('SW session sync registration failed:', err); + }); navigator.serviceWorker.ready.then(sendSessionToSW).catch((err) => { log.warn('SW ready failed:', err); }); From 24cffc84d8b9f7f41afab49623fb3d17f74a3bb2 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 19:34:17 -0500 Subject: [PATCH 03/28] prevent accidental audio embed when video and image fail on a preview and formatting --- .../components/url-preview/UrlPreviewCard.tsx | 23 +++++++++++++++++-- src/app/hooks/useRoomMembers.ts | 6 +---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 34383df76..685eed323 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -85,6 +85,25 @@ function isLikelyPlayableOgVideo(prev: IPreviewUrlResponse): boolean { return false; } +function isLikelyPlayableOgAudio(prev: IPreviewUrlResponse): boolean { + const raw = prev['og:audio']; + if (typeof raw !== 'string') return false; + const url = raw.trim(); + if (!url) return false; + const mime = + typeof prev['og:audio:type'] === 'string' ? prev['og:audio:type'].toLowerCase().trim() : ''; + if (mime.startsWith('audio/')) return true; + if (/^mxc:\/\//i.test(url)) { + return ( + mime.startsWith('audio/') || /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|webm)(\?|$)/i.test(url) + ); + } + if (/^https?:\/\//i.test(url)) { + return /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?|$)/i.test(url); + } + return false; +} + export const UrlPreviewCard = as< 'div', { @@ -320,10 +339,10 @@ export const UrlPreviewCard = as< /> )} - {!showOgVideo && !prev['og:image'] && prev['og:audio'] && ( + {!showOgVideo && !prev['og:image'] && isLikelyPlayableOgAudio(prev) && ( } diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index d71bf4d63..d36633aee 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -2,11 +2,7 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = ( - mx: MatrixClient, - roomId: string, - enabled = true -): RoomMember[] => { +export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { From 8dadeb3c8951fa4f06db738c84e8e5e67f4806b2 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 20:02:14 -0500 Subject: [PATCH 04/28] lazy load various modals & dm list --- src/app/features/room/RoomTimeline.tsx | 9 +- .../hooks/timeline/useProcessedTimeline.ts | 8 +- src/app/hooks/useGroupDMMembers.ts | 132 ++++++++---------- src/app/pages/Router.tsx | 88 +++++++++--- .../pages/client/sidebar/DirectDMsList.tsx | 2 +- 5 files changed, 140 insertions(+), 99 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6fe987cdc..d69963f92 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -251,6 +251,7 @@ export function RoomTimeline({ } const processedEventsRef = useRef([]); + const processedIndexByRawIndexRef = useRef>(new Map()); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); const scrollToBottom = useCallback(() => { @@ -291,10 +292,7 @@ export function RoomTimeline({ forwardStatusRef.current = timelineSync.forwardStatus; const getRawIndexToProcessedIndex = useCallback((rawIndex: number): number | undefined => { - const events = processedEventsRef.current; - const match = events.find((e) => e.itemIndex === rawIndex); - if (!match) return undefined; - return events.indexOf(match); + return processedIndexByRawIndexRef.current.get(rawIndex); }, []); useLayoutEffect(() => { @@ -797,6 +795,9 @@ export function RoomTimeline({ }); processedEventsRef.current = processedEvents; + processedIndexByRawIndexRef.current = new Map( + processedEvents.map((event, index) => [event.itemIndex, index]) + ); const vListData = useMemo>(() => { if (showLoadingPlaceholders) return [undefined, undefined, undefined]; diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..f0b9e8677 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -45,9 +45,11 @@ export function getProcessedRowIndexForRawTimelineIndex( startRawIndex: number ): { rowIndex: number; focusRawIndex: number } | undefined { if (startRawIndex < 0) return undefined; - for (let i = startRawIndex; i >= 0; i -= 1) { - const rowIndex = processedEvents.findIndex((e) => e.itemIndex === i); - if (rowIndex >= 0) return { rowIndex, focusRawIndex: i }; + for (let rowIndex = processedEvents.length - 1; rowIndex >= 0; rowIndex -= 1) { + const event = processedEvents[rowIndex]; + if (event && event.itemIndex <= startRawIndex) { + return { rowIndex, focusRawIndex: event.itemIndex }; + } } return undefined; } diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index f4369fe70..0e1787cf2 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -27,94 +27,80 @@ const isBridgeBot = (userId: string): boolean => { export const useGroupDMMembers = ( mx: MatrixClient, room: Room, - maxMembers = 3 + maxMembers = 3, + enabled = true ): GroupMemberInfo[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return () => {}; + } + + let disposed = false; + + const collectMembers = () => { + const currentUserId = mx.getUserId(); + const allMembers = room.getMembers(); + + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + const recentSenderOrder = new Map(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const sender = events[i]?.getSender(); + if ( + sender && + sender !== currentUserId && + !isBridgeBot(sender) && + !recentSenderOrder.has(sender) + ) { + recentSenderOrder.set(sender, recentSenderOrder.size); + } + } + + return allMembers + .filter( + (m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId) + ) + .toSorted((a, b) => { + const aIndex = recentSenderOrder.get(a.userId); + const bIndex = recentSenderOrder.get(b.userId); + + if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex; + if (aIndex !== undefined) return -1; + if (bIndex !== undefined) return 1; + return 0; + }) + .slice(0, maxMembers) + .map((member) => ({ + userId: member.userId, + displayName: member.name || member.userId, + avatarUrl: member.getMxcAvatarUrl() ?? undefined, + })); + }; + const fetchMembers = async () => { try { - const currentUserId = mx.getUserId(); + setMembers(collectMembers()); - // Load members from server if needed (handles lazy-loading) + // Load members from server if needed (handles lazy-loading), then refresh + // with fuller local room-state data without blocking the first paint. await room.loadMembersIfNeeded(); - // Now get all members - const allMembers = room.getMembers(); - - const allUserIds = allMembers - .filter( - (m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId) - ) - .map((m) => m.userId); - - // Get last message senders from timeline for sorting - const timeline = room.getLiveTimeline(); - const events = timeline.getEvents(); - - // Extract senders in reverse chronological order (most recent first) - const recentSenders: string[] = []; - for (let i = events.length - 1; i >= 0; i -= 1) { - const evt = events[i]; - if (!evt) continue; - const sender = evt.getSender(); - if ( - sender && - sender !== currentUserId && - !isBridgeBot(sender) && - !recentSenders.includes(sender) - ) { - recentSenders.push(sender); - } - } - - // Sort allUserIds by who appears first in recentSenders - const sortedUserIds = allUserIds.toSorted((a, b) => { - const aIndex = recentSenders.indexOf(a); - const bIndex = recentSenders.indexOf(b); - - // If both are in recent senders, sort by recency - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - // If only a is in recent senders, it comes first - if (aIndex !== -1) return -1; - // If only b is in recent senders, it comes first - if (bIndex !== -1) return 1; - // Neither in recent senders, maintain original order - return 0; - }); - - // Slice to max members - const limitedUserIds = sortedUserIds.slice(0, maxMembers); - - // Fetch profiles for each user - const memberPromises = limitedUserIds.map(async (userId) => { - try { - const profile = await mx.getProfileInfo(userId); - return { - userId, - displayName: profile.displayname || userId, - avatarUrl: profile.avatar_url, - }; - } catch { - // If profile fetch fails, return basic info - return { - userId, - displayName: userId, - avatarUrl: undefined, - }; - } - }); - - const fetchedMembers = await Promise.all(memberPromises); - setMembers(fetchedMembers); + if (!disposed) setMembers(collectMembers()); } catch { // If fetching fails, set empty array - setMembers([]); + if (!disposed) setMembers([]); } }; fetchMembers(); - }, [mx, room, maxMembers]); + return () => { + disposed = true; + }; + }, [mx, room, maxMembers, enabled]); return members; }; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..1a5a54b4c 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Outlet, Route, @@ -10,25 +11,16 @@ import * as Sentry from '@sentry/react'; import type { ClientConfig } from '$hooks/useClientConfig'; import { ErrorPage } from '$components/DefaultErrorPage'; -import { SettingsRoute } from '$features/settings'; -import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; import { AutoRestoreBackupOnVerification } from '$components/BackupRestore'; -import { RoomSettingsRenderer } from '$features/room-settings'; -import { SpaceSettingsRenderer } from '$features/space-settings'; -import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer'; -import { CreateRoomModalRenderer } from '$features/create-room'; -import { CreateSpaceModalRenderer } from '$features/create-space'; -import { BugReportModalRenderer } from '$features/bug-report'; import type { Sessions } from '$state/sessions'; import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; -import { SearchModalRenderer } from '$features/search'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; @@ -82,6 +74,43 @@ import { Create } from './client/create'; import { ToRoomEvent } from './client/ToRoomEvent'; import { CallStatusRenderer } from './CallStatusRenderer'; +const SearchModalRenderer = lazy(async () => { + const mod = await import('$features/search/Search'); + return { default: mod.SearchModalRenderer }; +}); +const UserRoomProfileRenderer = lazy(async () => { + const mod = await import('$components/UserRoomProfileRenderer'); + return { default: mod.UserRoomProfileRenderer }; +}); +const CreateRoomModalRenderer = lazy(async () => { + const mod = await import('$features/create-room/CreateRoomModal'); + return { default: mod.CreateRoomModalRenderer }; +}); +const CreateSpaceModalRenderer = lazy(async () => { + const mod = await import('$features/create-space/CreateSpaceModal'); + return { default: mod.CreateSpaceModalRenderer }; +}); +const BugReportModalRenderer = lazy(async () => { + const mod = await import('$features/bug-report/BugReportModal'); + return { default: mod.BugReportModalRenderer }; +}); +const SettingsShallowRouteRenderer = lazy(async () => { + const mod = await import('$features/settings/SettingsShallowRouteRenderer'); + return { default: mod.SettingsShallowRouteRenderer }; +}); +const RoomSettingsRenderer = lazy(async () => { + const mod = await import('$features/room-settings/RoomSettingsRenderer'); + return { default: mod.RoomSettingsRenderer }; +}); +const SpaceSettingsRenderer = lazy(async () => { + const mod = await import('$features/space-settings/SpaceSettingsRenderer'); + return { default: mod.SpaceSettingsRenderer }; +}); +const SettingsRoute = lazy(async () => { + const mod = await import('$features/settings/SettingsRoute'); + return { default: mod.SettingsRoute }; +}); + /** * Returns true if there is at least one stored session. * Reads localStorage directly — safe to call outside React (in route loaders). @@ -190,14 +219,30 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */}
} /> } /> - } /> + + + + } + /> Date: Sat, 9 May 2026 20:22:48 -0500 Subject: [PATCH 05/28] more lazy loading --- .../UserRoomProfileRenderer.test.tsx | 57 +++++++ .../components/UserRoomProfileRenderer.tsx | 15 +- .../features/bug-report/BugReportModal.tsx | 2 +- .../bug-report/BugReportModalRenderer.tsx | 19 +++ src/app/features/bug-report/index.ts | 2 +- .../features/create-room/CreateRoomModal.tsx | 11 +- .../create-space/CreateSpaceModal.tsx | 11 +- .../room-settings/RoomSettingsRenderer.tsx | 11 +- .../features/search/SearchModalRenderer.tsx | 44 ++++++ src/app/features/search/index.ts | 3 +- .../settings/SettingsShallowRouteRenderer.tsx | 11 +- .../space-settings/SpaceSettingsRenderer.tsx | 11 +- src/app/pages/Router.tsx | 142 +++++++++++++----- 13 files changed, 282 insertions(+), 57 deletions(-) create mode 100644 src/app/components/UserRoomProfileRenderer.test.tsx create mode 100644 src/app/features/bug-report/BugReportModalRenderer.tsx create mode 100644 src/app/features/search/SearchModalRenderer.tsx diff --git a/src/app/components/UserRoomProfileRenderer.test.tsx b/src/app/components/UserRoomProfileRenderer.test.tsx new file mode 100644 index 000000000..6f2f3a1cb --- /dev/null +++ b/src/app/components/UserRoomProfileRenderer.test.tsx @@ -0,0 +1,57 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { UserRoomProfileRenderer } from './UserRoomProfileRenderer'; + +const mocks = vi.hoisted(() => ({ + state: { + roomId: '!room:example.org', + userId: '@alice:example.org', + cords: new DOMRect(0, 0, 1, 1), + }, + room: { + roomId: '!room:example.org', + }, +})); + +vi.mock('folds', () => ({ + Menu: forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ), + PopOut: vi.fn<({ content }: { content: ReactNode }) => ReactNode>(({ content }) => ( +
{content}
+ )), + toRem: vi.fn<(value: number) => string>((value) => `${value / 16}rem`), +})); + +vi.mock('$state/hooks/userRoomProfile', () => ({ + useCloseUserRoomProfile: () => vi.fn<() => void>(), + useUserRoomProfileState: () => mocks.state, +})); + +vi.mock('$hooks/useGetRoom', () => ({ + useAllJoinedRoomsSet: () => new Set([mocks.room.roomId]), + useGetRoom: () => (roomId: string) => (roomId === mocks.room.roomId ? mocks.room : undefined), +})); + +vi.mock('$hooks/useSpace', () => ({ + SpaceProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('$hooks/useRoom', () => ({ + RoomProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('./user-profile', () => ({ + UserRoomProfile: () => , +})); + +describe('UserRoomProfileRenderer', () => { + it('does not throw while lazy profile content is loading inside the focus trap', () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx index 866bb6b0f..4e07e1270 100644 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense, useRef } from 'react'; import { Menu, PopOut, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useCloseUserRoomProfile, useUserRoomProfileState } from '$state/hooks/userRoomProfile'; @@ -6,9 +7,14 @@ import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import { stopPropagation } from '$utils/keyboard'; import { SpaceProvider } from '$hooks/useSpace'; import { RoomProvider } from '$hooks/useRoom'; -import { UserRoomProfile } from './user-profile'; + +const UserRoomProfile = lazy(async () => { + const mod = await import('./user-profile'); + return { default: mod.UserRoomProfile }; +}); function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { + const menuRef = useRef(null); const { roomId, spaceId, userId, cords, position, initialProfile } = state; const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); @@ -28,15 +34,18 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) menuRef.current ?? document.body, onDeactivate: close, clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > - + - + + + diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index ee7d263bd..7185055a9 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -85,7 +85,7 @@ export function buildGitHubUrl( return `https://github.com/${GITHUB_REPO}/issues/new?${new URLSearchParams(params)}`; } -function BugReportModal() { +export function BugReportModal() { const close = useCloseBugReportModal(); const sentryEnabled = Sentry.isInitialized(); const [type, setType] = useState('bug'); diff --git a/src/app/features/bug-report/BugReportModalRenderer.tsx b/src/app/features/bug-report/BugReportModalRenderer.tsx new file mode 100644 index 000000000..194e44ba7 --- /dev/null +++ b/src/app/features/bug-report/BugReportModalRenderer.tsx @@ -0,0 +1,19 @@ +import { lazy, Suspense } from 'react'; +import { useBugReportModalOpen } from '$state/hooks/bugReportModal'; + +const BugReportModal = lazy(async () => { + const mod = await import('./BugReportModal'); + return { default: mod.BugReportModal }; +}); + +export function BugReportModalRenderer() { + const open = useBugReportModalOpen(); + + if (!open) return null; + + return ( + + + + ); +} diff --git a/src/app/features/bug-report/index.ts b/src/app/features/bug-report/index.ts index 6ed1c3753..2fb17d031 100644 --- a/src/app/features/bug-report/index.ts +++ b/src/app/features/bug-report/index.ts @@ -1 +1 @@ -export { BugReportModalRenderer } from './BugReportModal'; +export { BugReportModalRenderer } from './BugReportModalRenderer'; diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index ba3f20a01..398394b5b 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Box, config, @@ -19,7 +20,11 @@ import { useCloseCreateRoomModal, useCreateRoomModalState } from '$state/hooks/c import type { CreateRoomModalState } from '$state/createRoomModal'; import { stopPropagation } from '$utils/keyboard'; import { CreateRoomType } from '$components/create-room/types'; -import { CreateRoomForm } from './CreateRoom'; + +const CreateRoomForm = lazy(async () => { + const mod = await import('./CreateRoom'); + return { default: mod.CreateRoomForm }; +}); type CreateRoomModalProps = { state: CreateRoomModalState; @@ -73,7 +78,9 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { direction="Column" gap="500" > - + + + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index 44d8acbf2..082d1c696 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Box, config, @@ -18,7 +19,11 @@ import { SpaceProvider } from '$hooks/useSpace'; import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal'; import type { CreateSpaceModalState } from '$state/createSpaceModal'; import { stopPropagation } from '$utils/keyboard'; -import { CreateSpaceForm } from './CreateSpace'; + +const CreateSpaceForm = lazy(async () => { + const mod = await import('./CreateSpace'); + return { default: mod.CreateSpaceForm }; +}); type CreateSpaceModalProps = { state: CreateSpaceModalState; @@ -71,7 +76,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { direction="Column" gap="500" > - + + + diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx index 7487255ec..0aa5d8a0e 100644 --- a/src/app/features/room-settings/RoomSettingsRenderer.tsx +++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { Modal500 } from '$components/Modal500'; import { useCloseRoomSettings, useRoomSettingsState } from '$state/hooks/roomSettings'; import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import type { RoomSettingsState } from '$state/roomSettings'; import { RoomProvider } from '$hooks/useRoom'; import { SpaceProvider } from '$hooks/useSpace'; -import { RoomSettings } from './RoomSettings'; + +const RoomSettings = lazy(async () => { + const mod = await import('./RoomSettings'); + return { default: mod.RoomSettings }; +}); type RenderSettingsProps = { state: RoomSettingsState; @@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) { - + + + diff --git a/src/app/features/search/SearchModalRenderer.tsx b/src/app/features/search/SearchModalRenderer.tsx new file mode 100644 index 000000000..e68a3bac4 --- /dev/null +++ b/src/app/features/search/SearchModalRenderer.tsx @@ -0,0 +1,44 @@ +import { lazy, Suspense, useCallback } from 'react'; +import { isKeyHotkey } from 'is-hotkey'; +import { useAtom } from 'jotai'; +import { useKeyDown } from '$hooks/useKeyDown'; +import { searchModalAtom } from '$state/searchModal'; + +const Search = lazy(async () => { + const mod = await import('./Search'); + return { default: mod.Search }; +}); + +export function SearchModalRenderer() { + const [opened, setOpen] = useAtom(searchModalAtom); + + useKeyDown( + window, + useCallback( + (event) => { + if (isKeyHotkey('mod+k', event) || isKeyHotkey('mod+f', event)) { + event.preventDefault(); + if (opened) { + setOpen(false); + return; + } + + const portalContainer = document.getElementById('portalContainer'); + if (portalContainer && portalContainer.children.length > 0) { + return; + } + setOpen(true); + } + }, + [opened, setOpen] + ) + ); + + if (!opened) return null; + + return ( + + setOpen(false)} /> + + ); +} diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts index addd53308..319f77d62 100644 --- a/src/app/features/search/index.ts +++ b/src/app/features/search/index.ts @@ -1 +1,2 @@ -export * from './Search'; +export { Search } from './Search'; +export * from './SearchModalRenderer'; diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx index 2fd7053ce..4d7165db0 100644 --- a/src/app/features/settings/SettingsShallowRouteRenderer.tsx +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useScreenSizeContext } from '$hooks/useScreenSize'; import { Modal500 } from '$components/Modal500'; import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet'; import { SETTINGS_PATH } from '$pages/paths'; import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; -import { SettingsRoute } from './SettingsRoute'; + +const SettingsRoute = lazy(async () => { + const mod = await import('./SettingsRoute'); + return { default: mod.SettingsRoute }; +}); export function SettingsShallowRouteRenderer() { const navigate = useNavigate(); @@ -24,7 +29,9 @@ export function SettingsShallowRouteRenderer() { return ( - + + + ); } diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx index 7f5bd1e5f..d6de384e6 100644 --- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx +++ b/src/app/features/space-settings/SpaceSettingsRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { Modal500 } from '$components/Modal500'; import { useCloseSpaceSettings, useSpaceSettingsState } from '$state/hooks/spaceSettings'; import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import type { SpaceSettingsState } from '$state/spaceSettings'; import { RoomProvider } from '$hooks/useRoom'; import { SpaceProvider } from '$hooks/useSpace'; -import { SpaceSettings } from './SpaceSettings'; + +const SpaceSettings = lazy(async () => { + const mod = await import('./SpaceSettings'); + return { default: mod.SpaceSettings }; +}); type RenderSettingsProps = { state: SpaceSettingsState; @@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) { - + + + diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 1a5a54b4c..1e9ff19ef 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -12,7 +12,6 @@ import * as Sentry from '@sentry/react'; import type { ClientConfig } from '$hooks/useClientConfig'; import { ErrorPage } from '$components/DefaultErrorPage'; import { Room } from '$features/room'; -import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; @@ -23,6 +22,14 @@ import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; +import { SearchModalRenderer } from '$features/search/SearchModalRenderer'; +import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer'; +import { CreateRoomModalRenderer } from '$features/create-room/CreateRoomModal'; +import { CreateSpaceModalRenderer } from '$features/create-space/CreateSpaceModal'; +import { BugReportModalRenderer } from '$features/bug-report/BugReportModalRenderer'; +import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; +import { RoomSettingsRenderer } from '$features/room-settings/RoomSettingsRenderer'; +import { SpaceSettingsRenderer } from '$features/space-settings/SpaceSettingsRenderer'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; import { DIRECT_PATH, @@ -60,8 +67,6 @@ import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNon import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; -import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -70,45 +75,47 @@ import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { HomeCreateRoom } from './client/home/CreateRoom'; -import { Create } from './client/create'; -import { ToRoomEvent } from './client/ToRoomEvent'; import { CallStatusRenderer } from './CallStatusRenderer'; -const SearchModalRenderer = lazy(async () => { - const mod = await import('$features/search/Search'); - return { default: mod.SearchModalRenderer }; +const SettingsRoute = lazy(async () => { + const mod = await import('$features/settings/SettingsRoute'); + return { default: mod.SettingsRoute }; }); -const UserRoomProfileRenderer = lazy(async () => { - const mod = await import('$components/UserRoomProfileRenderer'); - return { default: mod.UserRoomProfileRenderer }; +const Lobby = lazy(async () => { + const mod = await import('$features/lobby/Lobby'); + return { default: mod.Lobby }; }); -const CreateRoomModalRenderer = lazy(async () => { - const mod = await import('$features/create-room/CreateRoomModal'); - return { default: mod.CreateRoomModalRenderer }; +const Explore = lazy(async () => { + const mod = await import('./client/explore/Explore'); + return { default: mod.Explore }; }); -const CreateSpaceModalRenderer = lazy(async () => { - const mod = await import('$features/create-space/CreateSpaceModal'); - return { default: mod.CreateSpaceModalRenderer }; +const FeaturedRooms = lazy(async () => { + const mod = await import('./client/explore/Featured'); + return { default: mod.FeaturedRooms }; }); -const BugReportModalRenderer = lazy(async () => { - const mod = await import('$features/bug-report/BugReportModal'); - return { default: mod.BugReportModalRenderer }; +const PublicRooms = lazy(async () => { + const mod = await import('./client/explore/Server'); + return { default: mod.PublicRooms }; }); -const SettingsShallowRouteRenderer = lazy(async () => { - const mod = await import('$features/settings/SettingsShallowRouteRenderer'); - return { default: mod.SettingsShallowRouteRenderer }; +const Inbox = lazy(async () => { + const mod = await import('./client/inbox/Inbox'); + return { default: mod.Inbox }; }); -const RoomSettingsRenderer = lazy(async () => { - const mod = await import('$features/room-settings/RoomSettingsRenderer'); - return { default: mod.RoomSettingsRenderer }; +const Notifications = lazy(async () => { + const mod = await import('./client/inbox/Notifications'); + return { default: mod.Notifications }; }); -const SpaceSettingsRenderer = lazy(async () => { - const mod = await import('$features/space-settings/SpaceSettingsRenderer'); - return { default: mod.SpaceSettingsRenderer }; +const Invites = lazy(async () => { + const mod = await import('./client/inbox/Invites'); + return { default: mod.Invites }; }); -const SettingsRoute = lazy(async () => { - const mod = await import('$features/settings/SettingsRoute'); - return { default: mod.SettingsRoute }; +const Create = lazy(async () => { + const mod = await import('./client/create/Create'); + return { default: mod.Create }; +}); +const ToRoomEvent = lazy(async () => { + const mod = await import('./client/ToRoomEvent'); + return { default: mod.ToRoomEvent }; }); /** @@ -354,7 +361,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> + + + + } + /> } /> - + + + } > @@ -386,10 +402,31 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> + + + + } + /> + + + + } + /> - } /> + + + + } + /> - + + + } > @@ -419,10 +458,31 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> + + + + } + /> + + + + } + /> - } /> + + + + } + /> Page not found

} />
From ee11d9294e0b0263c99b98a983d0d53b584c1009 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 20:38:01 -0500 Subject: [PATCH 06/28] spaces lazy loading --- src/app/hooks/useSpaceHierarchy.ts | 25 +++++++++++++++----- src/app/pages/client/space/Space.tsx | 22 ++++++++++++++--- src/app/pages/client/space/SpaceProvider.tsx | 5 +--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 6c4ebcec7..b4a2ae128 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -44,6 +44,10 @@ const hierarchyItemByOrder: SortFunc = (a, b) => const childEventTs: SortFunc = (a, b) => byTsOldToNew(a.getTs(), b.getTs()); const childEventByOrder: SortFunc = (a, b) => byOrderKey(a.getContent().order, b.getContent().order); +const hierarchyItemOrderThenTs: SortFunc = (a, b) => + hierarchyItemByOrder(a, b) || hierarchyItemTs(a, b); +const childEventOrderThenTs: SortFunc = (a, b) => + childEventByOrder(a, b) || childEventTs(a, b); const getHierarchySpaces = ( rootSpaceId: string, @@ -87,8 +91,7 @@ const getHierarchySpaces = ( // cache which we maintain as we load summary in UI. return getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId); }) - .toSorted(childEventTs) - .toSorted(childEventByOrder); + .toSorted(childEventOrderThenTs); childEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); @@ -155,7 +158,7 @@ const getSpaceHierarchy = ( return { space: spaceItem, - rooms: childItems.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder), + rooms: childItems.toSorted(hierarchyItemOrderThenTs), }; }); @@ -214,6 +217,7 @@ const getSpaceJoinedHierarchy = ( excludeRoom, new Set() ); + const containsRoomCache = new Map(); /** * Recursively checks if the given space or any of its descendants contain non-space rooms. @@ -223,16 +227,22 @@ const getSpaceJoinedHierarchy = ( * @returns True if the space or any descendant contains non-space rooms. */ const getContainsRoom = (spaceId: string, visited: Set = new Set()) => { + const cached = containsRoomCache.get(spaceId); + if (cached !== undefined) return cached; + // Prevent infinite recursion if (visited.has(spaceId)) return false; visited.add(spaceId); const space = getRoom(spaceId); - if (!space) return false; + if (!space) { + containsRoomCache.set(spaceId, false); + return false; + } const childEvents = getStateEvents(space, EventType.SpaceChild); - return childEvents.some((childEvent): boolean => { + const contains = childEvents.some((childEvent): boolean => { if (!isValidChild(childEvent)) return false; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return false; @@ -242,6 +252,9 @@ const getSpaceJoinedHierarchy = ( if (!room.isSpaceRoom()) return true; return getContainsRoom(childId, visited); }); + visited.delete(spaceId); + containsRoomCache.set(spaceId, contains); + return contains; }; const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { @@ -298,7 +311,7 @@ export const useSpaceJoinedHierarchy = ( items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId)); return items; } - return items.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder); + return items.toSorted(hierarchyItemOrderThenTs); }, [mx, sortByActivity] ); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index c4994d829..3914073e3 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -385,7 +385,7 @@ export function Space() { const roomToParents = useAtomValue(roomToParentsAtom); const roomToChildren = useAtomValue(roomToChildrenAtom); const allRooms = useAtomValue(allRoomsAtom); - const [spaceRooms] = useAtom(spaceRoomsAtom); + const spaceRooms = useAtomValue(spaceRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); @@ -409,11 +409,16 @@ export function Space() { const closedCategoriesCache = useRef(new Map()); const ancestorsCollapsedCache = useRef(new Map()); + const containsShowRoomCache = useRef(new Map()); useEffect(() => { closedCategoriesCache.current.clear(); ancestorsCollapsedCache.current.clear(); }, [closedCategories, roomToParents, getRoom]); + useEffect(() => { + containsShowRoomCache.current.clear(); + }, [roomToUnread, selectedRoomId, roomToChildren]); + /** * Recursively checks if a given parentId (or all its ancestors) is in a closed category. * @@ -480,20 +485,31 @@ export function Space() { */ const getContainsShowRoom = useCallback( (roomId: string, visited: Set = new Set()): boolean => { + const cached = containsShowRoomCache.current.get(roomId); + if (cached !== undefined) return cached; + if (roomToUnread.has(roomId) || roomId === selectedRoomId) { + containsShowRoomCache.current.set(roomId, true); return true; } // Prevent infinite recursion - if (visited.has(roomId)) return false; + if (visited.has(roomId)) { + containsShowRoomCache.current.set(roomId, false); + return false; + } visited.add(roomId); const childIds = roomToChildren.get(roomId); if (!childIds || childIds.size === 0) { + containsShowRoomCache.current.set(roomId, false); return false; } - return Array.from(childIds).some((id) => getContainsShowRoom(id, visited)); + const contains = Array.from(childIds).some((id) => getContainsShowRoom(id, visited)); + visited.delete(roomId); + containsShowRoomCache.current.set(roomId, contains); + return contains; }, [roomToUnread, selectedRoomId, roomToChildren] ); diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx index 09c9e468d..667ef03e8 100644 --- a/src/app/pages/client/space/SpaceProvider.tsx +++ b/src/app/pages/client/space/SpaceProvider.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useSpaces } from '$state/hooks/roomList'; -import { allRoomsAtom } from '$state/room-list/roomList'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { SpaceProvider } from '$hooks/useSpace'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; @@ -13,7 +11,6 @@ type RouteSpaceProviderProps = { }; export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { const mx = useMatrixClient(); - const joinedSpaces = useSpaces(mx, allRoomsAtom); const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams(); const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); @@ -22,7 +19,7 @@ export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { const selectedSpaceId = useSelectedSpace(); const space = mx.getRoom(selectedSpaceId); - if (!space || !joinedSpaces.includes(space.roomId)) { + if (!space?.isSpaceRoom()) { return ; } From 2da69fd51a6a73fa4754b1849e238fdf4673b68b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 20:59:05 -0500 Subject: [PATCH 07/28] swipeable overlay lazy loading --- src/app/components/SwipeableChatWrapper.tsx | 106 +++++------------- .../components/SwipeableChatWrapperActive.tsx | 93 +++++++++++++++ .../components/SwipeableMessageWrapper.tsx | 85 +++----------- .../SwipeableMessageWrapperActive.tsx | 73 ++++++++++++ .../components/SwipeableOverlayWrapper.tsx | 100 ++++------------- .../SwipeableOverlayWrapperActive.tsx | 87 ++++++++++++++ 6 files changed, 312 insertions(+), 232 deletions(-) create mode 100644 src/app/components/SwipeableChatWrapperActive.tsx create mode 100644 src/app/components/SwipeableMessageWrapperActive.tsx create mode 100644 src/app/components/SwipeableOverlayWrapperActive.tsx diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index d4a547298..4035d70b0 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -1,10 +1,13 @@ -import type { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; -import { settingsAtom, RightSwipeAction } from '$state/settings'; +import { settingsAtom } from '$state/settings'; import { mobileOrTablet } from '$utils/user-agent'; +const SwipeableChatWrapperActive = lazy(async () => { + const mod = await import('./SwipeableChatWrapperActive'); + return { default: mod.SwipeableChatWrapperActive }; +}); + interface SwipeableChatWrapperProps { children: ReactNode; onOpenSidebar?: () => void; @@ -19,76 +22,10 @@ export function SwipeableChatWrapper({ onReply, }: SwipeableChatWrapperProps) { const settings = useAtomValue(settingsAtom); - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); - - const bind = useDrag( - ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => { - if (e && 'target' in e && e.target instanceof HTMLElement) { - if (e.target.closest('[data-gestures="ignore"]')) { - return; - } - } - - if (!settings.mobileGestures || !mobileOrTablet()) return; - - let val = mx; - - const canSwipeRight = !!onOpenSidebar; - const canSwipeLeft = - settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; - - if (!canSwipeRight && val > 0) val = 0; - if (!canSwipeLeft && val < 0) val = 0; - if (active) { - x.set(val); - } else { - const swipeThreshold = 120; - const velocityThreshold = 0.5; - - if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { - onOpenSidebar?.(); - } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { - if (settings.rightSwipeAction === RightSwipeAction.Members) { - onOpenMembers?.(); - } else { - onReply?.(); - } - } - x.set(0); - } - }, - { - axis: 'x', - bounds: { left: -200, right: 200 }, - rubberband: true, - filterTaps: true, - } - ); - - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( -
- {children} -
- ); - } - - return ( + const plainWrapper = (
- + ); + + if (!settings.mobileGestures || !mobileOrTablet()) { + return plainWrapper; + } + + return ( + + {children} - -
+ + ); } diff --git a/src/app/components/SwipeableChatWrapperActive.tsx b/src/app/components/SwipeableChatWrapperActive.tsx new file mode 100644 index 000000000..dd7f9839b --- /dev/null +++ b/src/app/components/SwipeableChatWrapperActive.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; +import { RightSwipeAction, type Settings } from '$state/settings'; + +interface SwipeableChatWrapperActiveProps { + children: ReactNode; + settings: Settings; + onOpenSidebar?: () => void; + onOpenMembers?: () => void; + onReply?: () => void; +} + +export function SwipeableChatWrapperActive({ + children, + settings, + onOpenSidebar, + onOpenMembers, + onReply, +}: SwipeableChatWrapperActiveProps) { + const x = useMotionValue(0); + const springX = useSpring(x, { stiffness: 400, damping: 40 }); + + const bind = useDrag( + ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => { + if (e && 'target' in e && e.target instanceof HTMLElement) { + if (e.target.closest('[data-gestures="ignore"]')) { + return; + } + } + + let val = mx; + + const canSwipeRight = !!onOpenSidebar; + const canSwipeLeft = + settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; + + if (!canSwipeRight && val > 0) val = 0; + if (!canSwipeLeft && val < 0) val = 0; + + if (active) { + x.set(val); + } else { + const swipeThreshold = 120; + const velocityThreshold = 0.5; + + if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { + onOpenSidebar?.(); + } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { + if (settings.rightSwipeAction === RightSwipeAction.Members) { + onOpenMembers?.(); + } else { + onReply?.(); + } + } + x.set(0); + } + }, + { + axis: 'x', + bounds: { left: -200, right: 200 }, + rubberband: true, + filterTaps: true, + } + ); + + return ( +
+ + {children} + +
+ ); +} diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 58fc9293e..0a7c75da4 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -1,70 +1,12 @@ -import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; -import type { ReactNode } from 'react'; -import { useMemo, useState } from 'react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; -import { config, Icon, Icons } from 'folds'; import { mobileOrTablet } from '$utils/user-agent'; import { RightSwipeAction, settingsAtom } from '$state/settings'; -function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onReply: () => void }) { - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 300, damping: 35 }); - const [isReady, setIsReady] = useState(false); - const iconOpacity = useTransform(x, [0, -8], [0, 1]); - - const bind = useDrag( - ({ active, movement: [mx] }) => { - if (active) { - const val = mx < 0 ? mx : 0; - x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); - } else { - if (mx < -50) onReply(); - x.set(0); - setIsReady(false); - } - }, - { - axis: 'x', - bounds: { right: 0 }, - rubberband: true, - filterTaps: true, - eventOptions: { passive: true }, - } - ); - - return ( -
-
- - - -
- {children} -
- ); -} +const SwipeableMessageWrapperActive = lazy(async () => { + const mod = await import('./SwipeableMessageWrapperActive'); + return { default: mod.SwipeableMessageWrapperActive }; +}); export function SwipeableMessageWrapper({ children, @@ -75,17 +17,18 @@ export function SwipeableMessageWrapper({ }) { const settings = useAtomValue(settingsAtom); - const isSwipeToReplyEnabled = useMemo( - () => - settings.mobileGestures && - mobileOrTablet() && - settings.rightSwipeAction !== RightSwipeAction.Members, - [settings.mobileGestures, settings.rightSwipeAction] - ); + const isSwipeToReplyEnabled = + settings.mobileGestures && + mobileOrTablet() && + settings.rightSwipeAction !== RightSwipeAction.Members; if (!isSwipeToReplyEnabled) { return children; } - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/components/SwipeableMessageWrapperActive.tsx b/src/app/components/SwipeableMessageWrapperActive.tsx new file mode 100644 index 000000000..1f79458fd --- /dev/null +++ b/src/app/components/SwipeableMessageWrapperActive.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; +import { config, Icon, Icons } from 'folds'; + +export function SwipeableMessageWrapperActive({ + children, + onReply, +}: { + children: ReactNode; + onReply: () => void; +}) { + const x = useMotionValue(0); + const springX = useSpring(x, { stiffness: 300, damping: 35 }); + const [isReady, setIsReady] = useState(false); + const iconOpacity = useTransform(x, [0, -8], [0, 1]); + + const bind = useDrag( + ({ active, movement: [mx] }) => { + if (active) { + const val = mx < 0 ? mx : 0; + x.set(Math.max(-80, val)); + if (mx < -50 !== isReady) setIsReady(mx < -50); + } else { + if (mx < -50) onReply(); + x.set(0); + setIsReady(false); + } + }, + { + axis: 'x', + bounds: { right: 0 }, + rubberband: true, + filterTaps: true, + eventOptions: { passive: true }, + } + ); + + const iconColor = useMemo( + () => (isReady ? 'var(--sable-surface-on-container)' : 'var(--sable-surface-container)'), + [isReady] + ); + + return ( +
+
+ + + +
+ {children} +
+ ); +} diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index a77b802f5..daeadd5cf 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -1,10 +1,13 @@ -import type { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; import { settingsAtom } from '$state/settings'; import { mobileOrTablet } from '$utils/user-agent'; +const SwipeableOverlayWrapperActive = lazy(async () => { + const mod = await import('./SwipeableOverlayWrapperActive'); + return { default: mod.SwipeableOverlayWrapperActive }; +}); + interface SwipeableOverlayWrapperProps { children: ReactNode; onClose: () => void; @@ -17,75 +20,10 @@ export function SwipeableOverlayWrapper({ direction, }: SwipeableOverlayWrapperProps) { const settings = useAtomValue(settingsAtom); - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); - - const bind = useDrag( - ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => { - if (e && 'target' in e && e.target instanceof HTMLElement) { - if (e.target.closest('[data-gestures="ignore"]')) { - return; - } - } - - if (!settings.mobileGestures || !mobileOrTablet()) return; - - event.stopPropagation(); - - let val = mx; - - if (direction === 'left' && val > 0) val = 0; - if (direction === 'right' && val < 0) val = 0; - - if (active) { - x.set(val); - } else { - const swipeThreshold = 100; - const velocityThreshold = 0.5; - - const swipedLeft = - direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0)); - const swipedRight = - direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0)); - - if (swipedLeft || swipedRight) { - onClose(); - } - - x.set(0); - } - }, - { - axis: 'x', - bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 }, - rubberband: true, - filterTaps: true, - pointer: { capture: true }, - } - ); - - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( -
- {children} -
- ); - } - return ( + const plainWrapper = (
- - {children} - + {children}
); + + if (!settings.mobileGestures || !mobileOrTablet()) { + return plainWrapper; + } + + return ( + + + {children} + + + ); } diff --git a/src/app/components/SwipeableOverlayWrapperActive.tsx b/src/app/components/SwipeableOverlayWrapperActive.tsx new file mode 100644 index 000000000..5c117358b --- /dev/null +++ b/src/app/components/SwipeableOverlayWrapperActive.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; + +interface SwipeableOverlayWrapperActiveProps { + children: ReactNode; + onClose: () => void; + direction: 'left' | 'right'; +} + +export function SwipeableOverlayWrapperActive({ + children, + onClose, + direction, +}: SwipeableOverlayWrapperActiveProps) { + const x = useMotionValue(0); + const springX = useSpring(x, { stiffness: 400, damping: 40 }); + + const bind = useDrag( + ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => { + if (e && 'target' in e && e.target instanceof HTMLElement) { + if (e.target.closest('[data-gestures="ignore"]')) { + return; + } + } + + event.stopPropagation(); + + let val = mx; + + if (direction === 'left' && val > 0) val = 0; + if (direction === 'right' && val < 0) val = 0; + + if (active) { + x.set(val); + } else { + const swipeThreshold = 100; + const velocityThreshold = 0.5; + + const swipedLeft = + direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0)); + const swipedRight = + direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0)); + + if (swipedLeft || swipedRight) { + onClose(); + } + + x.set(0); + } + }, + { + axis: 'x', + bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 }, + rubberband: true, + filterTaps: true, + pointer: { capture: true }, + } + ); + + return ( +
+ + {children} + +
+ ); +} From e195385da88e58f73c096c4e256699c206fb918d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 21:23:48 -0500 Subject: [PATCH 08/28] minimize sentry load (maybe) when disabled i dont think this actually improves performance though :/ --- src/instrument-runtime.ts | 295 ++++++++++++++++++++++++++++++++++++++ src/instrument.ts | 292 +------------------------------------ 2 files changed, 299 insertions(+), 288 deletions(-) create mode 100644 src/instrument-runtime.ts diff --git a/src/instrument-runtime.ts b/src/instrument-runtime.ts new file mode 100644 index 000000000..4dc0e5f58 --- /dev/null +++ b/src/instrument-runtime.ts @@ -0,0 +1,295 @@ +/** + * Sentry instrumentation - MUST be imported first in the application lifecycle + * + * Configure via environment variables: + * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry) + * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE) + * - VITE_APP_VERSION: Release version for tracking + */ +/* oxlint-disable no-console */ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, +} from 'react-router-dom'; +import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers'; + +const dsn = import.meta.env.VITE_SENTRY_DSN; +const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; +const release = import.meta.env.VITE_APP_VERSION; + +// Per-session error event counter for rate limiting +let sessionErrorCount = 0; +const SESSION_ERROR_LIMIT = 50; + +// Default off: Sentry only runs when the user has opted in via the banner or Settings. +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + dsn, + environment, + release, + + // Do not send PII (IP addresses, user identifiers) to protect privacy + sendDefaultPii: false, + + integrations: [ + // React Router v6 browser tracing integration + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + // Session replay with privacy settings (only if user opted in) + ...(replayEnabled + ? [ + Sentry.replayIntegration({ + maskAllText: true, // Mask all text for privacy + blockAllMedia: true, // Block images/video/audio for privacy + maskAllInputs: true, // Mask form inputs + }), + ] + : []), + // Capture console.error/warn as structured logs in the Sentry Logs product + Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), + // Browser profiling — captures JS call stacks during Sentry transactions + Sentry.browserProfilingIntegration(), + ], + + // Performance Monitoring - Tracing + // 100% in development and preview, lower in production for cost control + tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) + profileSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Control which URLs get distributed tracing headers + tracePropagationTargets: [ + 'localhost', + /^https:\/\/[^/]*\.sable\.chat/, + // Add your Matrix homeserver domains here if needed + ], + + // Session Replay sampling + // Record 100% in development and preview for testing, 10% in production + // Always record 100% of sessions with errors + replaysSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + replaysOnErrorSampleRate: 1.0, + + // Enable structured logging to Sentry + enableLogs: true, + + // Scrub sensitive data from structured logs before sending to Sentry + beforeSendLog(log) { + // Drop debug-level logs in production to reduce noise and quota usage + if (log.level === 'debug' && environment === 'production') return null; + // Redact Matrix IDs and tokens from the log message string + if (typeof log.message === 'string') { + log.message = scrubMatrixIds(log.message); + } + // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId) + // These are flattened from the structured data object and sent as searchable attributes. + if (log.attributes && typeof log.attributes === 'object') { + log.attributes = scrubDataObject(log.attributes) as typeof log.attributes; + } + return log; + }, + + // Rate limiting: cap error events per page-load session to avoid quota exhaustion. + // Separate counters for errors and transactions so perf traces do not drain the error budget. + beforeSendTransaction(event) { + // Scrub Matrix identifiers from the transaction name (the matched route or page URL). + // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls + // back to the raw URL when matching fails, so we scrub defensively here. + if (event.transaction) { + event.transaction = scrubMatrixUrl(event.transaction); + } + + // Scrub Matrix identifiers from HTTP span descriptions and data URLs. + // We scrub ALL string values in span.data rather than a single known key because + // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions: + // http.url (OTel semconv < 1.23, Sentry classic) + // url.full (OTel semconv ≥ 1.23) + // http.target, server.address, url, etc. + // For each string value: apply URL scrubbing when the value starts with "http", + // then apply ID scrubbing to catch any remaining bare Matrix IDs. + if (event.spans) { + event.spans = event.spans.map((span) => { + const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description; + const spanData = span.data as Record | undefined; + const newData = spanData + ? Object.fromEntries( + Object.entries(spanData).map(([k, v]) => [ + k, + typeof v === 'string' + ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v) + : v, + ]) + ) + : undefined; + + const descChanged = newDesc !== span.description; + const dataChanged = + newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData); + + if (!descChanged && !dataChanged) return span; + return { + ...span, + ...(descChanged ? { description: newDesc } : {}), + ...(dataChanged ? { data: newData as typeof span.data } : {}), + }; + }); + } + return event; + }, + + // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry + beforeBreadcrumb(breadcrumb) { + // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs) + const bData = breadcrumb.data as Record | undefined; + const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined; + const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined; + const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl; + + // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that + // may contain room IDs or user IDs as path segments in the app's client-side routes) + const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined; + const rawTo = typeof bData?.to === 'string' ? bData.to : undefined; + const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined; + const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined; + const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom; + const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo; + + // Scrub Matrix IDs from all remaining string values in the breadcrumb data object. + // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise + // bypass the URL-specific scrubbers above. + const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined; + + // Scrub message text — token values and Matrix entity IDs + const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message; + const messageChanged = message !== breadcrumb.message; + + if (!messageChanged && !scrubbedData) return breadcrumb; + return { + ...breadcrumb, + ...(messageChanged ? { message } : {}), + ...(scrubbedData + ? { + data: { + ...scrubbedData, + ...(urlChanged ? { url: scrubbedUrl } : {}), + ...(fromChanged ? { from: scrubbedFrom } : {}), + ...(toChanged ? { to: scrubbedTo } : {}), + }, + } + : {}), + }; + }, + + beforeSend(event, hint) { + sessionErrorCount += 1; + if (sessionErrorCount > SESSION_ERROR_LIMIT) { + return null; // Drop event — session limit reached + } + + // Improve grouping for Matrix API errors. + // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to + // split errors into meaningful issue groups rather than merging them all by stack trace. + const originalException = hint?.originalException; + if ( + originalException !== null && + typeof originalException === 'object' && + 'errcode' in originalException && + typeof (originalException as Record).errcode === 'string' + ) { + const errcode = (originalException as Record).errcode as string; + // Preserve default grouping AND split by errcode + event.fingerprint = ['{{ default }}', errcode]; + } + + // Scrub sensitive data from error messages and exception values using shared helpers + if (event.message) { + event.message = scrubMatrixIds(event.message); + } + + // Scrub sensitive data from exception values + if (event.exception?.values) { + event.exception.values.forEach((exception) => { + if (exception.value) { + exception.value = scrubMatrixUrl(scrubMatrixIds(exception.value)); + } + }); + } + + // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts, + // which can carry structured data fields like roomId, targetEventId, etc.) + if (event.contexts) { + event.contexts = scrubDataObject(event.contexts) as typeof event.contexts; + } + + // Scrub request data + if (event.request?.url) { + event.request.url = scrubMatrixUrl( + event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + ); + } + + // Scrub the transaction name on error events (set when the error occurred during a + // page-load or navigation transaction — raw URL leaks here when route matching fails) + if (event.transaction) { + event.transaction = scrubMatrixUrl(event.transaction); + } + + if (event.request?.headers) { + const headers = event.request.headers as Record; + if (headers.Authorization) { + headers.Authorization = '[REDACTED]'; + } + } + + return event; + }, + }); + + // Expose Sentry globally for debugging and console testing + // Set app-wide attributes on the global scope so they appear on all events and logs + Sentry.getGlobalScope().setAttributes({ + 'app.name': 'sable', + 'app.version': release ?? 'unknown', + }); + + // Tag all events with the PR number when running in a PR preview deployment + const prNumber = import.meta.env.VITE_SENTRY_PR; + if (prNumber) { + Sentry.getGlobalScope().setTag('pr', prNumber); + } + + // @ts-expect-error - Adding to window for debugging + window.Sentry = Sentry; + + console.info( + `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` + ); + console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); + console.info(`[Sentry] Release: ${release || 'not set'}`); +} else if (!sentryEnabled) { + console.info('[Sentry] Disabled by user preference'); +} else { + console.info('[Sentry] Disabled - no DSN provided'); +} + +// Export Sentry for use in other parts of the application +export { Sentry }; diff --git a/src/instrument.ts b/src/instrument.ts index 4dc0e5f58..118e7b244 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -1,295 +1,11 @@ -/** - * Sentry instrumentation - MUST be imported first in the application lifecycle - * - * Configure via environment variables: - * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry) - * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE) - * - VITE_APP_VERSION: Release version for tracking - */ /* oxlint-disable no-console */ -import * as Sentry from '@sentry/react'; -import React from 'react'; -import { - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, -} from 'react-router-dom'; -import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers'; - const dsn = import.meta.env.VITE_SENTRY_DSN; -const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; -const release = import.meta.env.VITE_APP_VERSION; - -// Per-session error event counter for rate limiting -let sessionErrorCount = 0; -const SESSION_ERROR_LIMIT = 50; - -// Default off: Sentry only runs when the user has opted in via the banner or Settings. -const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; -const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; +const sentryEnabled = localStorage.getItem("sable_sentry_enabled") === "true"; -// Only initialize if DSN is provided and user hasn't opted out if (dsn && sentryEnabled) { - Sentry.init({ - dsn, - environment, - release, - - // Do not send PII (IP addresses, user identifiers) to protect privacy - sendDefaultPii: false, - - integrations: [ - // React Router v6 browser tracing integration - Sentry.reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - // Session replay with privacy settings (only if user opted in) - ...(replayEnabled - ? [ - Sentry.replayIntegration({ - maskAllText: true, // Mask all text for privacy - blockAllMedia: true, // Block images/video/audio for privacy - maskAllInputs: true, // Mask form inputs - }), - ] - : []), - // Capture console.error/warn as structured logs in the Sentry Logs product - Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), - // Browser profiling — captures JS call stacks during Sentry transactions - Sentry.browserProfilingIntegration(), - ], - - // Performance Monitoring - Tracing - // 100% in development and preview, lower in production for cost control - tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, - - // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) - profileSessionSampleRate: - environment === 'development' || environment === 'preview' ? 1.0 : 0.1, - - // Control which URLs get distributed tracing headers - tracePropagationTargets: [ - 'localhost', - /^https:\/\/[^/]*\.sable\.chat/, - // Add your Matrix homeserver domains here if needed - ], - - // Session Replay sampling - // Record 100% in development and preview for testing, 10% in production - // Always record 100% of sessions with errors - replaysSessionSampleRate: - environment === 'development' || environment === 'preview' ? 1.0 : 0.1, - replaysOnErrorSampleRate: 1.0, - - // Enable structured logging to Sentry - enableLogs: true, - - // Scrub sensitive data from structured logs before sending to Sentry - beforeSendLog(log) { - // Drop debug-level logs in production to reduce noise and quota usage - if (log.level === 'debug' && environment === 'production') return null; - // Redact Matrix IDs and tokens from the log message string - if (typeof log.message === 'string') { - log.message = scrubMatrixIds(log.message); - } - // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId) - // These are flattened from the structured data object and sent as searchable attributes. - if (log.attributes && typeof log.attributes === 'object') { - log.attributes = scrubDataObject(log.attributes) as typeof log.attributes; - } - return log; - }, - - // Rate limiting: cap error events per page-load session to avoid quota exhaustion. - // Separate counters for errors and transactions so perf traces do not drain the error budget. - beforeSendTransaction(event) { - // Scrub Matrix identifiers from the transaction name (the matched route or page URL). - // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls - // back to the raw URL when matching fails, so we scrub defensively here. - if (event.transaction) { - event.transaction = scrubMatrixUrl(event.transaction); - } - - // Scrub Matrix identifiers from HTTP span descriptions and data URLs. - // We scrub ALL string values in span.data rather than a single known key because - // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions: - // http.url (OTel semconv < 1.23, Sentry classic) - // url.full (OTel semconv ≥ 1.23) - // http.target, server.address, url, etc. - // For each string value: apply URL scrubbing when the value starts with "http", - // then apply ID scrubbing to catch any remaining bare Matrix IDs. - if (event.spans) { - event.spans = event.spans.map((span) => { - const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description; - const spanData = span.data as Record | undefined; - const newData = spanData - ? Object.fromEntries( - Object.entries(spanData).map(([k, v]) => [ - k, - typeof v === 'string' - ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v) - : v, - ]) - ) - : undefined; - - const descChanged = newDesc !== span.description; - const dataChanged = - newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData); - - if (!descChanged && !dataChanged) return span; - return { - ...span, - ...(descChanged ? { description: newDesc } : {}), - ...(dataChanged ? { data: newData as typeof span.data } : {}), - }; - }); - } - return event; - }, - - // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry - beforeBreadcrumb(breadcrumb) { - // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs) - const bData = breadcrumb.data as Record | undefined; - const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined; - const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined; - const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl; - - // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that - // may contain room IDs or user IDs as path segments in the app's client-side routes) - const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined; - const rawTo = typeof bData?.to === 'string' ? bData.to : undefined; - const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined; - const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined; - const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom; - const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo; - - // Scrub Matrix IDs from all remaining string values in the breadcrumb data object. - // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise - // bypass the URL-specific scrubbers above. - const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined; - - // Scrub message text — token values and Matrix entity IDs - const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message; - const messageChanged = message !== breadcrumb.message; - - if (!messageChanged && !scrubbedData) return breadcrumb; - return { - ...breadcrumb, - ...(messageChanged ? { message } : {}), - ...(scrubbedData - ? { - data: { - ...scrubbedData, - ...(urlChanged ? { url: scrubbedUrl } : {}), - ...(fromChanged ? { from: scrubbedFrom } : {}), - ...(toChanged ? { to: scrubbedTo } : {}), - }, - } - : {}), - }; - }, - - beforeSend(event, hint) { - sessionErrorCount += 1; - if (sessionErrorCount > SESSION_ERROR_LIMIT) { - return null; // Drop event — session limit reached - } - - // Improve grouping for Matrix API errors. - // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to - // split errors into meaningful issue groups rather than merging them all by stack trace. - const originalException = hint?.originalException; - if ( - originalException !== null && - typeof originalException === 'object' && - 'errcode' in originalException && - typeof (originalException as Record).errcode === 'string' - ) { - const errcode = (originalException as Record).errcode as string; - // Preserve default grouping AND split by errcode - event.fingerprint = ['{{ default }}', errcode]; - } - - // Scrub sensitive data from error messages and exception values using shared helpers - if (event.message) { - event.message = scrubMatrixIds(event.message); - } - - // Scrub sensitive data from exception values - if (event.exception?.values) { - event.exception.values.forEach((exception) => { - if (exception.value) { - exception.value = scrubMatrixUrl(scrubMatrixIds(exception.value)); - } - }); - } - - // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts, - // which can carry structured data fields like roomId, targetEventId, etc.) - if (event.contexts) { - event.contexts = scrubDataObject(event.contexts) as typeof event.contexts; - } - - // Scrub request data - if (event.request?.url) { - event.request.url = scrubMatrixUrl( - event.request.url.replace( - /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, - '$1$2[REDACTED]' - ) - ); - } - - // Scrub the transaction name on error events (set when the error occurred during a - // page-load or navigation transaction — raw URL leaks here when route matching fails) - if (event.transaction) { - event.transaction = scrubMatrixUrl(event.transaction); - } - - if (event.request?.headers) { - const headers = event.request.headers as Record; - if (headers.Authorization) { - headers.Authorization = '[REDACTED]'; - } - } - - return event; - }, - }); - - // Expose Sentry globally for debugging and console testing - // Set app-wide attributes on the global scope so they appear on all events and logs - Sentry.getGlobalScope().setAttributes({ - 'app.name': 'sable', - 'app.version': release ?? 'unknown', - }); - - // Tag all events with the PR number when running in a PR preview deployment - const prNumber = import.meta.env.VITE_SENTRY_PR; - if (prNumber) { - Sentry.getGlobalScope().setTag('pr', prNumber); - } - - // @ts-expect-error - Adding to window for debugging - window.Sentry = Sentry; - - console.info( - `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` - ); - console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); - console.info(`[Sentry] Release: ${release || 'not set'}`); + void import("./instrument-runtime"); } else if (!sentryEnabled) { - console.info('[Sentry] Disabled by user preference'); + console.info("[Sentry] Disabled by user preference"); } else { - console.info('[Sentry] Disabled - no DSN provided'); + console.info("[Sentry] Disabled - no DSN provided"); } - -// Export Sentry for use in other parts of the application -export { Sentry }; From edfe23d36f2e8459e71584b607af72bf09664f48 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 21:45:53 -0500 Subject: [PATCH 09/28] cache space things --- src/app/hooks/router/useSelectedSpace.test.ts | 21 ++++++++ src/app/hooks/router/useSelectedSpace.ts | 24 ++++++++- src/app/hooks/useSpaceHierarchy.ts | 50 ++++++++++++++++--- 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 src/app/hooks/router/useSelectedSpace.test.ts diff --git a/src/app/hooks/router/useSelectedSpace.test.ts b/src/app/hooks/router/useSelectedSpace.test.ts new file mode 100644 index 000000000..877506a8a --- /dev/null +++ b/src/app/hooks/router/useSelectedSpace.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { decodeSpaceIdOrAlias } from './useSelectedSpace'; + +describe('decodeSpaceIdOrAlias', () => { + it('returns undefined for empty input', () => { + expect(decodeSpaceIdOrAlias(undefined)).toBeUndefined(); + }); + + it('decodes encoded values', () => { + expect(decodeSpaceIdOrAlias('%21space%3Aexample.org')).toBe('!space:example.org'); + }); + + it('returns stable cached value for repeated input', () => { + const encoded = '%23space%3Aexample.org'; + const first = decodeSpaceIdOrAlias(encoded); + const second = decodeSpaceIdOrAlias(encoded); + + expect(first).toBe('#space:example.org'); + expect(second).toBe(first); + }); +}); diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts index 33bbe4308..f593cf02e 100644 --- a/src/app/hooks/router/useSelectedSpace.ts +++ b/src/app/hooks/router/useSelectedSpace.ts @@ -3,11 +3,33 @@ import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix'; import { getSpaceLobbyPath, getSpaceSearchPath } from '$pages/pathUtils'; import { useMatrixClient } from '$hooks/useMatrixClient'; +const DECODED_SPACE_PARAM_CACHE_MAX = 128; +const decodedSpaceParamCache = new Map(); + +export const decodeSpaceIdOrAlias = (encoded?: string): string | undefined => { + if (!encoded) return undefined; + + const cached = decodedSpaceParamCache.get(encoded); + if (cached !== undefined) return cached; + + const decoded = decodeURIComponent(encoded); + decodedSpaceParamCache.set(encoded, decoded); + + if (decodedSpaceParamCache.size > DECODED_SPACE_PARAM_CACHE_MAX) { + const firstKey = decodedSpaceParamCache.keys().next().value; + if (firstKey !== undefined) { + decodedSpaceParamCache.delete(firstKey); + } + } + + return decoded; +}; + export const useSelectedSpace = (): string | undefined => { const mx = useMatrixClient(); const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams(); - const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + const spaceIdOrAlias = decodeSpaceIdOrAlias(encodedSpaceIdOrAlias); const spaceId = spaceIdOrAlias && isRoomAlias(spaceIdOrAlias) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index b4a2ae128..d527fcdb7 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -11,6 +11,7 @@ import { getAllParents, getStateEvents, isValidChild } from '$utils/room'; import { isRoomId } from '$utils/matrix'; import type { SortFunc } from '$utils/sort'; import { byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort'; +import { createLogger, isDebug } from '$utils/debug'; import { useMatrixClient } from './useMatrixClient'; import { makeLobbyCategoryId } from '../state/closedLobbyCategories'; import { useStateEventCallback } from './useStateEventCallback'; @@ -48,6 +49,21 @@ const hierarchyItemOrderThenTs: SortFunc = (a, b) => hierarchyItemByOrder(a, b) || hierarchyItemTs(a, b); const childEventOrderThenTs: SortFunc = (a, b) => childEventByOrder(a, b) || childEventTs(a, b); +const spaceHierarchyProfiler = createLogger('space-hierarchy-profiler'); + +const profileHierarchyBuild = (label: string, build: () => T): T => { + if (!isDebug()) return build(); + + const start = performance.now(); + const result = build(); + const elapsed = performance.now() - start; + + if (elapsed >= 8) { + spaceHierarchyProfiler.log(`${label} took ${elapsed.toFixed(2)}ms`); + } + + return result; +}; const getHierarchySpaces = ( rootSpaceId: string, @@ -176,12 +192,20 @@ export const useSpaceHierarchy = ( const roomToParents = useAtomValue(roomToParentsAtom); const [hierarchyAtom] = useState(() => - atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)) + atom( + profileHierarchyBuild('space-hierarchy:init', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) + ) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)); + setHierarchy( + profileHierarchyBuild('space-hierarchy:effect', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) + ); }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory, excludeRoom]); useStateEventCallback( @@ -194,7 +218,9 @@ export const useSpaceHierarchy = ( if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { setHierarchy( - getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + profileHierarchyBuild('space-hierarchy:event', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) ); } }, @@ -317,12 +343,20 @@ export const useSpaceJoinedHierarchy = ( ); const [hierarchyAtom] = useState(() => - atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)) + atom( + profileHierarchyBuild('space-joined-hierarchy:init', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)); + setHierarchy( + profileHierarchyBuild('space-joined-hierarchy:effect', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ); }, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]); useStateEventCallback( @@ -334,7 +368,11 @@ export const useSpaceJoinedHierarchy = ( if (!eventRoomId) return; if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { - setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)); + setHierarchy( + profileHierarchyBuild('space-joined-hierarchy:event', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ); } }, [spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems] From 51d3b2fc7d07ca94e9a62101dba3cc3bdfac1981 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 21:48:42 -0500 Subject: [PATCH 10/28] defer mono font loading --- .../code-highlight/CodeHighlightRenderer.tsx | 5 +++++ src/app/utils/loadSpaceMono.ts | 14 ++++++++++++++ src/index.tsx | 4 ---- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/loadSpaceMono.ts diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx index 45145fbb4..c1aefb3c1 100644 --- a/src/app/components/code-highlight/CodeHighlightRenderer.tsx +++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium'; +import { loadSpaceMono } from '$utils/loadSpaceMono'; import * as css from './CodeHighlightRenderer.css'; type CodeHighlightRendererProps = { @@ -44,6 +45,10 @@ export function CodeHighlightRenderer({ result: createPlainResult(code, language), })); + useEffect(() => { + void loadSpaceMono(); + }, []); + useEffect(() => { let cancelled = false; diff --git a/src/app/utils/loadSpaceMono.ts b/src/app/utils/loadSpaceMono.ts new file mode 100644 index 000000000..e8627299d --- /dev/null +++ b/src/app/utils/loadSpaceMono.ts @@ -0,0 +1,14 @@ +let loadPromise: Promise | null = null; + +export const loadSpaceMono = (): Promise => { + if (!loadPromise) { + loadPromise = Promise.all([ + import('@fontsource/space-mono/400.css'), + import('@fontsource/space-mono/700.css'), + import('@fontsource/space-mono/400-italic.css'), + import('@fontsource/space-mono/700-italic.css'), + ]).then(() => undefined); + } + + return loadPromise; +}; diff --git a/src/index.tsx b/src/index.tsx index 61566cff2..dca1eab28 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,10 +3,6 @@ import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource-variable/nunito'; import '@fontsource-variable/nunito/wght-italic.css'; -import '@fontsource/space-mono/400.css'; -import '@fontsource/space-mono/700.css'; -import '@fontsource/space-mono/400-italic.css'; -import '@fontsource/space-mono/700-italic.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; import { trimTrailingSlash } from './app/utils/common'; From 8ec447f9a6e78152e4e0700f7b4739681e053657 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 22:35:45 -0500 Subject: [PATCH 11/28] memoize room listings --- src/app/hooks/useSpaceHierarchy.ts | 6 ++-- src/app/state/hooks/roomList.ts | 10 +++--- src/app/utils/room.parents.test.ts | 52 ++++++++++++++++++++++++++++++ src/app/utils/room.ts | 50 ++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 src/app/utils/room.parents.test.ts diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index d527fcdb7..d48206f75 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -7,7 +7,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import type { MSpaceChildContent } from '$types/matrix/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; -import { getAllParents, getStateEvents, isValidChild } from '$utils/room'; +import { getStateEvents, hasRecursiveParent, isValidChild } from '$utils/room'; import { isRoomId } from '$utils/matrix'; import type { SortFunc } from '$utils/sort'; import { byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort'; @@ -216,7 +216,7 @@ export const useSpaceHierarchy = ( const eventRoomId = mEvent.getRoomId(); if (!eventRoomId) return; - if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { + if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) { setHierarchy( profileHierarchyBuild('space-hierarchy:event', () => getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) @@ -367,7 +367,7 @@ export const useSpaceJoinedHierarchy = ( const eventRoomId = mEvent.getRoomId(); if (!eventRoomId) return; - if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { + if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) { setHierarchy( profileHierarchyBuild('space-joined-hierarchy:event', () => getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts index f1bc6dc07..3c1793c97 100644 --- a/src/app/state/hooks/roomList.ts +++ b/src/app/state/hooks/roomList.ts @@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import type { MatrixClient } from '$types/matrix-sdk'; import { useCallback, useMemo } from 'react'; -import { getAllParents, isRoom, isSpace, isUnsupportedRoom } from '$utils/room'; +import { hasRecursiveParent, isRoom, isSpace, isUnsupportedRoom } from '$utils/room'; import type { RoomToParents } from '$types/matrix/room'; import { compareRoomsEqual } from '$state/room-list/utils'; @@ -31,7 +31,7 @@ export const useRecursiveChildScopeFactory = ( (parentId: string) => (roomId) => isRoom(mx.getRoom(roomId)) && roomToParents.has(roomId) && - getAllParents(roomToParents, roomId).has(parentId), + hasRecursiveParent(roomToParents, roomId, parentId), [mx, roomToParents] ); @@ -53,7 +53,7 @@ export const useRecursiveChildSpaceScopeFactory = ( (parentId: string) => (roomId) => isSpace(mx.getRoom(roomId)) && roomToParents.has(roomId) && - getAllParents(roomToParents, roomId).has(parentId), + hasRecursiveParent(roomToParents, roomId, parentId), [mx, roomToParents] ); @@ -80,7 +80,7 @@ export const useRecursiveChildRoomScopeFactory = ( isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId) && roomToParents.has(roomId) && - getAllParents(roomToParents, roomId).has(parentId), + hasRecursiveParent(roomToParents, roomId, parentId), [mx, mDirects, roomToParents] ); @@ -107,7 +107,7 @@ export const useRecursiveChildDirectScopeFactory = ( isRoom(mx.getRoom(roomId)) && mDirects.has(roomId) && roomToParents.has(roomId) && - getAllParents(roomToParents, roomId).has(parentId), + hasRecursiveParent(roomToParents, roomId, parentId), [mx, mDirects, roomToParents] ); diff --git a/src/app/utils/room.parents.test.ts b/src/app/utils/room.parents.test.ts new file mode 100644 index 000000000..57ea75100 --- /dev/null +++ b/src/app/utils/room.parents.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { RoomToParents } from '$types/matrix/room'; +import { getAllParents, hasRecursiveParent } from './room'; + +describe('hasRecursiveParent', () => { + it('resolves recursive ancestry', () => { + const roomToParents: RoomToParents = new Map([ + ['!room:example.org', new Set(['!space-a:example.org'])], + ['!space-a:example.org', new Set(['!space-root:example.org'])], + ]); + + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe( + true + ); + expect( + hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org') + ).toBe(true); + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!unknown:example.org')).toBe( + false + ); + }); + + it('handles cyclic parent graphs safely', () => { + const roomToParents: RoomToParents = new Map([ + ['!room:example.org', new Set(['!space-a:example.org'])], + ['!space-a:example.org', new Set(['!space-b:example.org'])], + ['!space-b:example.org', new Set(['!space-a:example.org'])], + ]); + + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe( + true + ); + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-b:example.org')).toBe( + true + ); + }); + + it('matches getAllParents semantics', () => { + const roomToParents: RoomToParents = new Map([ + ['!room:example.org', new Set(['!space-a:example.org', '!space-b:example.org'])], + ['!space-a:example.org', new Set(['!space-root:example.org'])], + ]); + + const allParents = getAllParents(roomToParents, '!room:example.org'); + Array.from(allParents).forEach((parentId) => { + expect(hasRecursiveParent(roomToParents, '!room:example.org', parentId)).toBe(true); + }); + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org')).toBe( + false + ); + }); +}); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e15630c79..2d5b12227 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -139,6 +139,56 @@ export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set return allParents; }; +const roomParentsClosureCache = new WeakMap>>(); + +const getOrCreateParentsClosure = (roomToParents: RoomToParents): Map> => { + const cached = roomParentsClosureCache.get(roomToParents); + if (cached) return cached; + + const nextCache = new Map>(); + roomParentsClosureCache.set(roomToParents, nextCache); + return nextCache; +}; + +const getAllParentsMemoized = (roomToParents: RoomToParents, roomId: string): Set => { + const closureCache = getOrCreateParentsClosure(roomToParents); + const cached = closureCache.get(roomId); + if (cached) return cached; + + const visited = new Set(); + const allParents = new Set(); + + const visit = (rId: string) => { + if (visited.has(rId)) return; + visited.add(rId); + + const parents = roomToParents.get(rId); + if (!parents) return; + + parents.forEach((parentId) => { + allParents.add(parentId); + + const parentCached = closureCache.get(parentId); + if (parentCached) { + parentCached.forEach((ancestorId) => allParents.add(ancestorId)); + return; + } + + visit(parentId); + }); + }; + + visit(roomId); + closureCache.set(roomId, allParents); + return allParents; +}; + +export const hasRecursiveParent = ( + roomToParents: RoomToParents, + roomId: string, + parentId: string +): boolean => getAllParentsMemoized(roomToParents, roomId).has(parentId); + export const getSpaceChildren = (room: Room) => getStateEvents(room, EventType.SpaceChild).reduce((filtered, mEvent) => { const stateKey = mEvent.getStateKey(); From 33e52989fdad91314293b200bf20afd51bce5b35 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 23:00:02 -0500 Subject: [PATCH 12/28] cache search room parents --- src/app/features/search/Search.test.ts | 27 ++++++++++++++++++++++++++ src/app/features/search/Search.tsx | 17 +++++++++------- src/app/features/search/searchUtils.ts | 22 +++++++++++++++++++++ src/app/hooks/useSpaceHierarchy.ts | 2 +- src/app/utils/room.parents.test.ts | 12 ++++++------ src/instrument.ts | 8 ++++---- 6 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 src/app/features/search/Search.test.ts create mode 100644 src/app/features/search/searchUtils.ts diff --git a/src/app/features/search/Search.test.ts b/src/app/features/search/Search.test.ts new file mode 100644 index 000000000..4e6284839 --- /dev/null +++ b/src/app/features/search/Search.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { RoomToParents } from '$types/matrix/room'; +import { sortRoomsBySelectedSpace } from './searchUtils'; + +describe('sortRoomsBySelectedSpace', () => { + it('returns original list when no space is selected', () => { + const roomToParents: RoomToParents = new Map(); + const items = ['!a:example.org', '!b:example.org', '!c:example.org']; + + expect(sortRoomsBySelectedSpace(items, undefined, roomToParents)).toEqual(items); + }); + + it('prioritizes rooms in selected space', () => { + const roomToParents: RoomToParents = new Map([ + ['!room-a:example.org', new Set(['!space-1:example.org'])], + ['!room-b:example.org', new Set(['!space-2:example.org'])], + ['!room-c:example.org', new Set(['!space-1:example.org'])], + ]); + const items = ['!room-b:example.org', '!room-a:example.org', '!room-c:example.org']; + + expect(sortRoomsBySelectedSpace(items, '!space-1:example.org', roomToParents)).toEqual([ + '!room-a:example.org', + '!room-c:example.org', + '!room-b:example.org', + ]); + }); +}); diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 3ca25fbb6..eae55c6af 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -50,6 +50,7 @@ import { KeySymbol } from '$utils/key-symbol'; import { isMacOS } from '$utils/user-agent'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { sortRoomsBySelectedSpace } from './searchUtils'; enum SearchRoomType { Rooms = '#', @@ -166,14 +167,16 @@ export function Search({ requestClose }: SearchProps) { const roomsToRender = useMemo(() => { const items = result ? result.items : topActiveRooms; - if (!selectedSpaceId) return items; + return sortRoomsBySelectedSpace(items, selectedSpaceId, roomToParents); + }, [result, topActiveRooms, selectedSpaceId, roomToParents]); - return [...items].toSorted((a, b) => { - const aInSpace = getAllParents(roomToParents, a)?.has(selectedSpaceId) ? 1 : 0; - const bInSpace = getAllParents(roomToParents, b)?.has(selectedSpaceId) ? 1 : 0; - return bInSpace - aInSpace; + const roomParentsCache = useMemo(() => { + const cache = new Map>(); + roomsToRender.forEach((roomId) => { + cache.set(roomId, getAllParents(roomToParents, roomId)); }); - }, [result, topActiveRooms, selectedSpaceId, roomToParents]); + return cache; + }, [roomsToRender, roomToParents]); const listFocus = useListFocusIndex(roomsToRender.length, 0); @@ -315,7 +318,7 @@ export function Search({ requestClose }: SearchProps) { const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); const dmUserServer = dmUserId && getMxIdServer(dmUserId); - const allParents = getAllParents(roomToParents, roomId); + const allParents = roomParentsCache.get(roomId); const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o)); const perfectOrphanParent = diff --git a/src/app/features/search/searchUtils.ts b/src/app/features/search/searchUtils.ts new file mode 100644 index 000000000..5e79e1fa4 --- /dev/null +++ b/src/app/features/search/searchUtils.ts @@ -0,0 +1,22 @@ +import type { RoomToParents } from '$types/matrix/room'; +import { hasRecursiveParent } from '$utils/room'; + +export const sortRoomsBySelectedSpace = ( + items: string[], + selectedSpaceId: string | undefined, + roomToParents: RoomToParents +): string[] => { + if (!selectedSpaceId) return items; + + const inSelectedSpaceCache = new Map(); + const getInSelectedSpaceScore = (roomId: string): number => { + const cached = inSelectedSpaceCache.get(roomId); + if (cached !== undefined) return cached; + + const score = hasRecursiveParent(roomToParents, roomId, selectedSpaceId) ? 1 : 0; + inSelectedSpaceCache.set(roomId, score); + return score; + }; + + return [...items].toSorted((a, b) => getInSelectedSpaceScore(b) - getInSelectedSpaceScore(a)); +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index d48206f75..639849271 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -51,7 +51,7 @@ const childEventOrderThenTs: SortFunc = (a, b) => childEventByOrder(a, b) || childEventTs(a, b); const spaceHierarchyProfiler = createLogger('space-hierarchy-profiler'); -const profileHierarchyBuild = (label: string, build: () => T): T => { +const profileHierarchyBuild = (label: string, build: () => T): T => { if (!isDebug()) return build(); const start = performance.now(); diff --git a/src/app/utils/room.parents.test.ts b/src/app/utils/room.parents.test.ts index 57ea75100..c6c9cb95d 100644 --- a/src/app/utils/room.parents.test.ts +++ b/src/app/utils/room.parents.test.ts @@ -12,9 +12,9 @@ describe('hasRecursiveParent', () => { expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe( true ); - expect( - hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org') - ).toBe(true); + expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org')).toBe( + true + ); expect(hasRecursiveParent(roomToParents, '!room:example.org', '!unknown:example.org')).toBe( false ); @@ -45,8 +45,8 @@ describe('hasRecursiveParent', () => { Array.from(allParents).forEach((parentId) => { expect(hasRecursiveParent(roomToParents, '!room:example.org', parentId)).toBe(true); }); - expect(hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org')).toBe( - false - ); + expect( + hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org') + ).toBe(false); }); }); diff --git a/src/instrument.ts b/src/instrument.ts index 118e7b244..320fd75ba 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -1,11 +1,11 @@ /* oxlint-disable no-console */ const dsn = import.meta.env.VITE_SENTRY_DSN; -const sentryEnabled = localStorage.getItem("sable_sentry_enabled") === "true"; +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; if (dsn && sentryEnabled) { - void import("./instrument-runtime"); + void import('./instrument-runtime'); } else if (!sentryEnabled) { - console.info("[Sentry] Disabled by user preference"); + console.info('[Sentry] Disabled by user preference'); } else { - console.info("[Sentry] Disabled - no DSN provided"); + console.info('[Sentry] Disabled - no DSN provided'); } From 060d38d929467e4b7a99a323f0767963d3b748db Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 23:03:01 -0500 Subject: [PATCH 13/28] combine cache with other places --- .../room-settings/abbreviations/RoomAbbreviations.tsx | 4 ++-- src/app/hooks/useRoomAbbreviations.ts | 7 ++----- src/app/pages/client/space/RoomProvider.tsx | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index 913e6dbaf..9bf3195ad 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -29,7 +29,7 @@ import { useForceUpdate } from '$hooks/useForceUpdate'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import type { MatrixError } from '$types/matrix-sdk'; import type { AbbreviationEntry, RoomAbbreviationsContent } from '$utils/abbreviations'; -import { getAllParents, getStateEvent } from '$utils/room'; +import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { CustomStateEvent } from '$types/matrix/room'; @@ -61,7 +61,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) (event) => { if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return; const eventRoomId = event.getRoomId(); - if (eventRoomId && getAllParents(roomToParents, room.roomId).has(eventRoomId)) { + if (eventRoomId && hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) { forceAncestorUpdate(); } }, diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts index b6bd30ba1..65e79dae5 100644 --- a/src/app/hooks/useRoomAbbreviations.ts +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -4,7 +4,7 @@ import type { Room } from '$types/matrix-sdk'; import type { RoomAbbreviationsContent } from '$utils/abbreviations'; import { buildAbbreviationsMap } from '$utils/abbreviations'; -import { getAllParents, getStateEvent } from '$utils/room'; +import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { useMatrixClient } from './useMatrixClient'; import { useStateEvent } from './useStateEvent'; @@ -44,10 +44,7 @@ export const useMergedAbbreviations = (room: Room): Map => { if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return; const eventRoomId = event.getRoomId(); if (!eventRoomId) return; - if ( - eventRoomId === room.roomId || - getAllParents(roomToParents, room.roomId).has(eventRoomId) - ) { + if (eventRoomId === room.roomId || hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) { forceUpdate(); } }, diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 8f672bd1f..bc9e897ed 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -6,7 +6,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSpace } from '$hooks/useSpace'; -import { getAllParents, getSpaceChildren } from '$utils/room'; +import { getSpaceChildren, hasRecursiveParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { allRoomsAtom } from '$state/room-list/roomList'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; @@ -49,7 +49,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { ); } - if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { + if (!hasRecursiveParent(roomToParents, room.roomId, space.roomId)) { if (getSpaceChildren(space).includes(room.roomId)) { // fill missing roomToParent mapping setRoomToParents({ From 3a0df9aac0ab00acbd6966f6cc14c39893dc629a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 23:10:02 -0500 Subject: [PATCH 14/28] random router memo and fallbacks doubt these ones make much difference if any --- src/app/pages/App.tsx | 8 ++++++-- src/app/pages/Router.tsx | 36 +++++++++++++++++++----------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index e4bf0d773..29ca548d3 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useRef } from 'react'; +import { lazy, Suspense, useMemo, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -39,12 +39,16 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell } bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); + const router = useMemo( + () => createRouter(clientConfig, screenSize), + [clientConfig, screenSize] + ); return ( - + {reactQueryDevtoolsEnabled && ( diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 1e9ff19ef..9d715c48f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -76,6 +76,7 @@ import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { CallStatusRenderer } from './CallStatusRenderer'; +import { ConfigConfigLoading } from './ConfigConfig'; const SettingsRoute = lazy(async () => { const mod = await import('$features/settings/SettingsRoute'); @@ -117,6 +118,7 @@ const ToRoomEvent = lazy(async () => { const mod = await import('./client/ToRoomEvent'); return { default: mod.ToRoomEvent }; }); +const routeFallback = ; /** * Returns true if there is at least one stored session. @@ -361,14 +363,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - - - - } - /> + + + + } + /> } /> - + @@ -405,7 +407,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -413,7 +415,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -422,7 +424,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -430,7 +432,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -441,7 +443,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - + @@ -461,7 +463,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -469,7 +471,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } @@ -478,7 +480,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } From 855079220ce3cb458092652bbd78378d1a563c8a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 23:36:34 -0500 Subject: [PATCH 15/28] add prefetching on hover to some tabs --- src/app/hooks/useRoomAbbreviations.ts | 5 +- src/app/pages/App.tsx | 5 +- src/app/pages/Router.tsx | 16 ++--- src/app/pages/client/SidebarNav.tsx | 7 +- src/app/pages/client/sidebar/CreateTab.tsx | 6 ++ src/app/pages/client/sidebar/ExploreTab.tsx | 13 +++- src/app/pages/client/sidebar/InboxTab.tsx | 13 +++- src/app/pages/client/sidebar/SpaceTabs.tsx | 6 ++ src/app/pages/routePrefetch.test.ts | 48 +++++++++++++ src/app/pages/routePrefetch.ts | 76 +++++++++++++++++++++ 10 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 src/app/pages/routePrefetch.test.ts create mode 100644 src/app/pages/routePrefetch.ts diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts index 65e79dae5..0e0029a1d 100644 --- a/src/app/hooks/useRoomAbbreviations.ts +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -44,7 +44,10 @@ export const useMergedAbbreviations = (room: Room): Map => { if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return; const eventRoomId = event.getRoomId(); if (!eventRoomId) return; - if (eventRoomId === room.roomId || hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) { + if ( + eventRoomId === room.roomId || + hasRecursiveParent(roomToParents, room.roomId, eventRoomId) + ) { forceUpdate(); } }, diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 29ca548d3..cbaf3523d 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -39,10 +39,7 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell } bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); - const router = useMemo( - () => createRouter(clientConfig, screenSize), - [clientConfig, screenSize] - ); + const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]); return ( diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 9d715c48f..ef5629a63 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -363,14 +363,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - - - - } - /> + + + + } + /> } /> (null); @@ -28,6 +29,10 @@ export function SidebarNav() { const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts'); + useEffect(() => { + scheduleInitialRoutePrefetch(); + }, []); + const handleContextMenu: MouseEventHandler = (evt) => { const target = evt.target as HTMLElement; if (target.closest('button, a, [role="button"]')) return; diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx index 850db9456..cd8e6339d 100644 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ b/src/app/pages/client/sidebar/CreateTab.tsx @@ -17,6 +17,7 @@ import { } from '$pages/pathUtils'; import { useCreateSelected } from '$hooks/router/useCreateSelected'; import { JoinAddressPrompt } from '$components/join-address-prompt'; +import { prefetchCreateRoute } from '../../routePrefetch'; export function CreateTab() { const createSelected = useCreateSelected(); @@ -24,6 +25,9 @@ export function CreateTab() { const navigate = useNavigate(); const [menuCords, setMenuCords] = useState(); const [joinAddress, setJoinAddress] = useState(false); + const handlePrefetch = () => { + void prefetchCreateRoute(); + }; const handleMenu: MouseEventHandler = (evt) => { setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); @@ -108,6 +112,8 @@ export function CreateTab() { ref={triggerRef} outlined onClick={handleMenu} + onMouseEnter={handlePrefetch} + onFocus={handlePrefetch} > diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx index e255aeac9..3092b5a8a 100644 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ b/src/app/pages/client/sidebar/ExploreTab.tsx @@ -14,6 +14,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { prefetchExploreRoute } from '../../routePrefetch'; export function ExploreTab() { const mx = useMatrixClient(); @@ -23,6 +24,9 @@ export function ExploreTab() { const navToActivePath = useAtomValue(useNavToActivePathAtom()); const exploreSelected = useExploreSelected(); + const handlePrefetch = () => { + void prefetchExploreRoute(); + }; const handleExploreClick = () => { if (screenSize === ScreenSize.Mobile) { @@ -53,7 +57,14 @@ export function ExploreTab() { {(triggerRef) => ( - + )} diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index c632335b0..208283a45 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -17,6 +17,7 @@ import { import { useInboxSelected } from '$hooks/router/useInbox'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; +import { prefetchInboxRoute } from '../../routePrefetch'; export function InboxTab() { const screenSize = useScreenSizeContext(); @@ -25,6 +26,9 @@ export function InboxTab() { const inboxSelected = useInboxSelected(); const allInvites = useAtomValue(allInvitesAtom); const inviteCount = allInvites.length; + const handlePrefetch = () => { + void prefetchInboxRoute(); + }; const handleInboxClick = () => { if (screenSize === ScreenSize.Mobile) { @@ -45,7 +49,14 @@ export function InboxTab() { {(triggerRef) => ( - + )} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 4ffd0b160..7cf53217b 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -82,6 +82,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import { prefetchSpaceLobbyRoute } from '../../routePrefetch'; type SpaceMenuProps = { room: Room; @@ -419,6 +420,9 @@ function SpaceTab({ return cords; }); }; + const handlePrefetch = () => { + void prefetchSpaceLobbyRoute(); + }; return ( @@ -440,6 +444,8 @@ function SpaceTab({ ref={triggerRef} size={folder ? '300' : '400'} onClick={onClick} + onMouseEnter={handlePrefetch} + onFocus={handlePrefetch} onContextMenu={handleContextMenu} > { + it('deduplicates concurrent prefetches by key', async () => { + __resetRoutePrefetchForTests(); + const importer = vi.fn<() => Promise<{ ok: boolean }>>(async () => ({ ok: true })); + + await Promise.all([ + prefetchRouteChunks('shared-key', [importer]), + prefetchRouteChunks('shared-key', [importer]), + prefetchRouteChunks('shared-key', [importer]), + ]); + + expect(importer).toHaveBeenCalledTimes(1); + }); + + it('schedules only one initial idle prefetch run', () => { + __resetRoutePrefetchForTests(); + const runPrefetch = vi.fn<() => void>(); + const requestIdleCallback = + vi.fn< + (cb: (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void) => number + >(); + const setTimeout = vi.fn<(cb: () => void, delay: number) => number>(); + const win = { requestIdleCallback, setTimeout } as unknown as Window & + typeof globalThis & { + requestIdleCallback: (cb: () => void, options: { timeout: number }) => number; + }; + + scheduleInitialRoutePrefetch(runPrefetch, win); + scheduleInitialRoutePrefetch(runPrefetch, win); + + expect(requestIdleCallback).toHaveBeenCalledTimes(1); + expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 1200 }); + expect(setTimeout).not.toHaveBeenCalled(); + + const firstCall = requestIdleCallback.mock.calls[0]; + if (!firstCall) throw new Error('Expected requestIdleCallback to have been called'); + const callback = firstCall[0] as () => void; + callback(); + expect(runPrefetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/pages/routePrefetch.ts b/src/app/pages/routePrefetch.ts new file mode 100644 index 000000000..c1af887cb --- /dev/null +++ b/src/app/pages/routePrefetch.ts @@ -0,0 +1,76 @@ +type Importer = () => Promise; + +const prefetchCache = new Map>(); + +export const prefetchRouteChunks = (key: string, importers: Importer[]): Promise => { + const cached = prefetchCache.get(key); + if (cached) return cached; + + const task = Promise.all(importers.map((importer) => importer())) + .then(() => undefined) + .catch(() => undefined); + prefetchCache.set(key, task); + return task; +}; + +export const prefetchExploreRoute = (): Promise => + prefetchRouteChunks('explore', [ + () => import('./client/explore/Explore'), + () => import('./client/explore/Featured'), + () => import('./client/explore/Server'), + ]); + +export const prefetchInboxRoute = (): Promise => + prefetchRouteChunks('inbox', [ + () => import('./client/inbox/Inbox'), + () => import('./client/inbox/Notifications'), + () => import('./client/inbox/Invites'), + ]); + +export const prefetchSettingsRoute = (): Promise => + prefetchRouteChunks('settings', [() => import('$features/settings/SettingsRoute')]); + +export const prefetchCreateRoute = (): Promise => + prefetchRouteChunks('create', [() => import('./client/create/Create')]); + +export const prefetchSpaceLobbyRoute = (): Promise => + prefetchRouteChunks('space-lobby', [() => import('$features/lobby/Lobby')]); + +type IdleDeadline = { didTimeout: boolean; timeRemaining: () => number }; +type IdleRequestCallback = (deadline: IdleDeadline) => void; +type IdleRequestWindow = Window & + typeof globalThis & { + requestIdleCallback?: (callback: IdleRequestCallback, options?: { timeout: number }) => number; + cancelIdleCallback?: (id: number) => void; + }; + +let initialPrefetchScheduled = false; + +export const runInitialRoutePrefetch = (): void => { + void prefetchExploreRoute(); + void prefetchInboxRoute(); + void prefetchSpaceLobbyRoute(); + void prefetchSettingsRoute(); + void prefetchCreateRoute(); +}; + +export const scheduleInitialRoutePrefetch = ( + runPrefetch: () => void = runInitialRoutePrefetch, + winOverride?: IdleRequestWindow +): void => { + if (initialPrefetchScheduled) return; + initialPrefetchScheduled = true; + + const win = winOverride ?? (window as IdleRequestWindow); + if (typeof win.requestIdleCallback === 'function') { + win.requestIdleCallback(() => runPrefetch(), { timeout: 1200 }); + return; + } + + win.setTimeout(runPrefetch, 0); +}; + +export const __resetRoutePrefetchForTests = (): void => { + prefetchCache.clear(); + initialPrefetchScheduled = false; +}; From 27efca1411450b1296245f4cb6ecd85ae2f6d76e Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 23:56:09 -0500 Subject: [PATCH 16/28] more prefetching --- src/app/features/lobby/HierarchyItemMenu.tsx | 26 ++++++++++++++++++- src/app/features/lobby/LobbyHeader.tsx | 11 ++++++++ src/app/features/room-nav/RoomNavItem.tsx | 11 ++++++++ src/app/features/room-nav/RoomNavUser.tsx | 11 +++++++- src/app/features/room/MembersDrawer.tsx | 7 +++++ src/app/features/room/RoomViewHeader.tsx | 11 ++++++++ .../features/search/SearchModalRenderer.tsx | 2 ++ src/app/pages/client/sidebar/SearchTab.tsx | 18 +++++++++++-- src/app/pages/client/sidebar/SpaceTabs.tsx | 7 ++++- src/app/pages/client/space/Space.tsx | 17 +++++++++++- src/app/pages/routePrefetch.ts | 15 +++++++++++ src/app/state/hooks/roomSettings.ts | 2 ++ src/app/state/hooks/spaceSettings.ts | 2 ++ src/app/state/hooks/userRoomProfile.ts | 2 ++ 14 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 165343c82..20d17a0ad 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -37,6 +37,7 @@ import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { useNavigate } from 'react-router-dom'; import { getSpaceLobbyPath } from '$pages/pathUtils'; import { EventType } from '$types/matrix-sdk'; +import { prefetchRoomSettingsModal, prefetchSpaceSettingsModal } from '$pages/routePrefetch'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -195,9 +196,23 @@ function SettingsMenuItem({ } requestClose(); }; + const handleSettingsPrefetch = () => { + if ('space' in item) { + void prefetchSpaceSettingsModal(); + return; + } + void prefetchRoomSettingsModal(); + }; return ( - + Settings @@ -237,6 +252,13 @@ export function HierarchyItemMenu({ const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + if ('space' in item) { + void prefetchSpaceSettingsModal(); + return; + } + void prefetchRoomSettingsModal(); + }; const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []); const navigate = useNavigate(); @@ -249,6 +271,8 @@ export function HierarchyItemMenu({ ( openSpaceSettings(space.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( @@ -93,6 +97,8 @@ const LobbyMenu = forwardRef( } radii="300" @@ -157,6 +163,9 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( @@ -243,6 +252,8 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..102ab94d6 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -61,6 +61,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { prefetchRoomSettingsModal } from '$pages/routePrefetch'; // Call Hooks & Plugins import { useCallMembers, useCallSession } from '$hooks/useCall'; @@ -135,6 +136,9 @@ const RoomNavItemMenu = forwardRef( openRoomSettings(room.roomId, space?.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; return ( @@ -209,6 +213,8 @@ const RoomNavItemMenu = forwardRef( } radii="300" @@ -320,6 +326,9 @@ export function RoomNavItem({ const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; const handleNavItemClick: MouseEventHandler = (evt) => { if (room.isCallRoom()) { @@ -547,6 +556,8 @@ export function RoomNavItem({ > = (evt) => { openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); }; + const handlePrefetch = () => { + void prefetchUserProfileModal(); + }; const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name; return ( - + diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index f751bcf31..813e79990 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -55,6 +55,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '$hooks/useMembe import { useRoomCreators } from '$hooks/useRoomCreators'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { formatCompactNumber } from '$utils/formatCompactNumber'; +import { prefetchUserProfileModal } from '$pages/routePrefetch'; import * as css from './MembersDrawer.css'; type MemberDrawerHeaderProps = { @@ -116,6 +117,10 @@ function MemberItem({ pressed, typing, }: MemberItemProps) { + const handlePrefetch = () => { + void prefetchUserProfileModal(); + }; + const nicknames = useAtomValue(nicknamesAtom); const name = getMemberDisplayName(room, member.userId, nicknames) ?? @@ -139,6 +144,8 @@ function MemberItem({ variant="Background" radii="400" onClick={onClick} + onMouseEnter={handlePrefetch} + onFocus={handlePrefetch} before={
(({ room, requestClose openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; return ( @@ -274,6 +278,8 @@ const RoomMenu = forwardRef(({ room, requestClose } radii="300" @@ -563,6 +569,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleRoomSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; const handleOpenPinMenu: MouseEventHandler = (evt) => { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); @@ -898,6 +907,8 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { diff --git a/src/app/features/search/SearchModalRenderer.tsx b/src/app/features/search/SearchModalRenderer.tsx index e68a3bac4..6687fe7b0 100644 --- a/src/app/features/search/SearchModalRenderer.tsx +++ b/src/app/features/search/SearchModalRenderer.tsx @@ -3,6 +3,7 @@ import { isKeyHotkey } from 'is-hotkey'; import { useAtom } from 'jotai'; import { useKeyDown } from '$hooks/useKeyDown'; import { searchModalAtom } from '$state/searchModal'; +import { prefetchSearchModal } from '$pages/routePrefetch'; const Search = lazy(async () => { const mod = await import('./Search'); @@ -18,6 +19,7 @@ export function SearchModalRenderer() { (event) => { if (isKeyHotkey('mod+k', event) || isKeyHotkey('mod+f', event)) { event.preventDefault(); + void prefetchSearchModal(); if (opened) { setOpen(false); return; diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx index c3ef38c59..05fb8bfd7 100644 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -2,17 +2,31 @@ import { Icon, Icons } from 'folds'; import { useAtom } from 'jotai'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; import { searchModalAtom } from '$state/searchModal'; +import { prefetchSearchModal } from '$pages/routePrefetch'; export function SearchTab() { const [opened, setOpen] = useAtom(searchModalAtom); - const open = () => setOpen(true); + const open = () => { + void prefetchSearchModal(); + setOpen(true); + }; + const handlePrefetch = () => { + void prefetchSearchModal(); + }; return ( {(triggerRef) => ( - + )} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 7cf53217b..423ada3f5 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -82,7 +82,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; -import { prefetchSpaceLobbyRoute } from '../../routePrefetch'; +import { prefetchSpaceLobbyRoute, prefetchSpaceSettingsModal } from '../../routePrefetch'; type SpaceMenuProps = { room: Room; @@ -135,6 +135,9 @@ const SpaceMenu = forwardRef( openSpaceSettings(room.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( @@ -200,6 +203,8 @@ const SpaceMenu = forwardRef( } radii="300" diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 3914073e3..2062cad6c 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -80,6 +80,7 @@ import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper'; import { useCallEmbed } from '$hooks/useCallEmbed'; import { createDebugLogger } from '$utils/debugLogger'; +import { prefetchSpaceSettingsModal } from '$pages/routePrefetch'; const debugLog = createDebugLogger('Space'); @@ -130,6 +131,9 @@ const SpaceMenu = forwardRef(({ room, requestClo openSpaceSettings(room.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; const handleOpenTimeline = () => { debugLog.info('ui', 'Space timeline opened', { roomId: room.roomId }); @@ -189,6 +193,8 @@ const SpaceMenu = forwardRef(({ room, requestClo } radii="300" @@ -260,6 +266,9 @@ function SpaceHeader() { return cords; }); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( <> @@ -272,7 +281,13 @@ function SpaceHeader() { {joinRules?.join_rule !== JoinRule.Public && } - + diff --git a/src/app/pages/routePrefetch.ts b/src/app/pages/routePrefetch.ts index c1af887cb..d01cca395 100644 --- a/src/app/pages/routePrefetch.ts +++ b/src/app/pages/routePrefetch.ts @@ -36,6 +36,20 @@ export const prefetchCreateRoute = (): Promise => export const prefetchSpaceLobbyRoute = (): Promise => prefetchRouteChunks('space-lobby', [() => import('$features/lobby/Lobby')]); +export const prefetchSearchModal = (): Promise => + prefetchRouteChunks('search-modal', [() => import('$features/search/Search')]); + +export const prefetchUserProfileModal = (): Promise => + prefetchRouteChunks('user-profile-modal', [() => import('$components/user-profile')]); + +export const prefetchRoomSettingsModal = (): Promise => + prefetchRouteChunks('room-settings-modal', [() => import('$features/room-settings/RoomSettings')]); + +export const prefetchSpaceSettingsModal = (): Promise => + prefetchRouteChunks('space-settings-modal', [ + () => import('$features/space-settings/SpaceSettings'), + ]); + type IdleDeadline = { didTimeout: boolean; timeRemaining: () => number }; type IdleRequestCallback = (deadline: IdleDeadline) => void; type IdleRequestWindow = Window & @@ -52,6 +66,7 @@ export const runInitialRoutePrefetch = (): void => { void prefetchSpaceLobbyRoute(); void prefetchSettingsRoute(); void prefetchCreateRoute(); + void prefetchSearchModal(); }; export const scheduleInitialRoutePrefetch = ( diff --git a/src/app/state/hooks/roomSettings.ts b/src/app/state/hooks/roomSettings.ts index 82204e9d7..b65dc3d61 100644 --- a/src/app/state/hooks/roomSettings.ts +++ b/src/app/state/hooks/roomSettings.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import type { RoomSettingsPage, RoomSettingsState } from '$state/roomSettings'; import { roomSettingsAtom } from '$state/roomSettings'; +import { prefetchRoomSettingsModal } from '$pages/routePrefetch'; export const useRoomSettingsState = (): RoomSettingsState | undefined => { const data = useAtomValue(roomSettingsAtom); @@ -26,6 +27,7 @@ export const useOpenRoomSettings = (): OpenCallback => { const open: OpenCallback = useCallback( (roomId, spaceId, page) => { + void prefetchRoomSettingsModal(); setSettings({ roomId, spaceId, page }); }, [setSettings] diff --git a/src/app/state/hooks/spaceSettings.ts b/src/app/state/hooks/spaceSettings.ts index aa153bdd1..92ac38775 100644 --- a/src/app/state/hooks/spaceSettings.ts +++ b/src/app/state/hooks/spaceSettings.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import type { SpaceSettingsPage, SpaceSettingsState } from '$state/spaceSettings'; import { spaceSettingsAtom } from '$state/spaceSettings'; +import { prefetchSpaceSettingsModal } from '$pages/routePrefetch'; export const useSpaceSettingsState = (): SpaceSettingsState | undefined => { const data = useAtomValue(spaceSettingsAtom); @@ -26,6 +27,7 @@ export const useOpenSpaceSettings = (): OpenCallback => { const open: OpenCallback = useCallback( (roomId, spaceId, page) => { + void prefetchSpaceSettingsModal(); setSettings({ roomId, spaceId, page }); }, [setSettings] diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts index 092823877..8e08f6e1c 100644 --- a/src/app/state/hooks/userRoomProfile.ts +++ b/src/app/state/hooks/userRoomProfile.ts @@ -4,6 +4,7 @@ import type { Position, RectCords } from 'folds'; import type { UserProfile } from '$hooks/useUserProfile'; import type { UserRoomProfileState } from '$state/userRoomProfile'; import { userRoomProfileAtom } from '$state/userRoomProfile'; +import { prefetchUserProfileModal } from '$pages/routePrefetch'; export const useUserRoomProfileState = (): UserRoomProfileState | undefined => { const data = useAtomValue(userRoomProfileAtom); @@ -35,6 +36,7 @@ export const useOpenUserRoomProfile = (): OpenCallback => { const open: OpenCallback = useCallback( (roomId, spaceId, userId, cords, position, initialProfile) => { + void prefetchUserProfileModal(); setUserRoomProfile({ roomId, spaceId, From 60183a9bd35260745263b3213e3d171cdd19eba8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 10 May 2026 00:10:18 -0500 Subject: [PATCH 17/28] more lazy loads --- src/app/components/DefaultErrorPage.tsx | 5 +++-- src/app/pages/Router.tsx | 14 ++++++++++++-- src/app/pages/client/home/CreateRoom.tsx | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index 62042cef1..045b1ea50 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -1,7 +1,6 @@ import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds'; import * as Sentry from '@sentry/react'; import { SplashScreen } from '$components/splash-screen'; -import { buildGitHubUrl } from '$features/bug-report/BugReportModal'; type ErrorPageProps = { error: Error; @@ -25,7 +24,9 @@ ${error.message} ${stacktrace} \`\`\``; - return buildGitHubUrl('bug', `Error: ${error.message}`, { context: automatedBugReport }); + const title = encodeURIComponent(`Error: ${error.message}`); + const body = encodeURIComponent(automatedBugReport); + return `https://github.com/SableClient/Sable/issues/new?template=bug_report.yml&title=${title}&description=${body}`; } // This component is used as the fallback for the ErrorBoundary in App.tsx, which means it will be rendered whenever an uncaught error is thrown in any of the child components and not handled locally. diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index ef5629a63..82bff3949 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -74,7 +74,6 @@ import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; -import { HomeCreateRoom } from './client/home/CreateRoom'; import { CallStatusRenderer } from './CallStatusRenderer'; import { ConfigConfigLoading } from './ConfigConfig'; @@ -118,6 +117,10 @@ const ToRoomEvent = lazy(async () => { const mod = await import('./client/ToRoomEvent'); return { default: mod.ToRoomEvent }; }); +const HomeCreateRoom = lazy(async () => { + const mod = await import('./client/home/CreateRoom'); + return { default: mod.HomeCreateRoom }; +}); const routeFallback = ; /** @@ -294,7 +297,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } > {mobile ? null : } />} - } /> + + + + } + /> join

} /> } /> Date: Sun, 10 May 2026 00:27:04 -0500 Subject: [PATCH 18/28] defer some essential features? --- src/app/pages/client/ClientNonUIFeatures.tsx | 685 ++---------------- .../client/DeferredNotificationFeatures.tsx | 430 +++++++++++ .../scheduleDeferredFeatureMount.test.ts | 62 ++ .../client/scheduleDeferredFeatureMount.ts | 28 + src/app/pages/routePrefetch.ts | 4 +- 5 files changed, 567 insertions(+), 642 deletions(-) create mode 100644 src/app/pages/client/DeferredNotificationFeatures.tsx create mode 100644 src/app/pages/client/scheduleDeferredFeatureMount.test.ts create mode 100644 src/app/pages/client/scheduleDeferredFeatureMount.ts diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b089cf8f8..82a090ae6 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,104 +1,49 @@ import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; +import { lazy, Suspense, type ReactNode, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { RoomEventHandlerMap } from '$types/matrix-sdk'; -import { - MatrixEvent, - MatrixEventEvent, - PushProcessor, - RoomEvent, - SetPresence, - SyncState, - EventType, -} from '$types/matrix-sdk'; -import parse from 'html-react-parser'; -import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; -import { sanitizeCustomHtml } from '$utils/sanitize'; +import { SetPresence } from '$types/matrix-sdk'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import LogoSVG from '$public/res/svg/cinny-logo.svg'; import LogoUnreadSVG from '$public/res/svg/cinny-unread.svg'; import LogoHighlightSVG from '$public/res/svg/cinny-highlight.svg'; -import NotificationSound from '$public/sound/notification.ogg'; -import InviteSound from '$public/sound/invite.ogg'; -import { notificationPermission, setFavicon } from '$utils/dom'; +import { setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; -import { allInvitesAtom } from '$state/room-list/inviteList'; -import { usePreviousValue } from '$hooks/usePreviousValue'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { - getMemberDisplayName, - getNotificationType, - getStateEvent, - isDMRoom, - isNotificationEvent, -} from '$utils/room'; -import { NotificationType } from '$types/matrix/room'; -import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; -import { useInboxNotificationsSelected } from '$hooks/router/useInbox'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; -import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; -import { - buildRoomMessageNotification, - resolveNotificationPreviewText, -} from '$utils/notificationStyle'; -import { mobileOrTablet } from '$utils/user-agent'; -import { createDebugLogger } from '$utils/debugLogger'; +import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { NotificationBanner } from '$components/notification-banner'; -import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; -import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; -import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { getInboxInvitesPath } from '../pathUtils'; -import { BackgroundNotifications } from './BackgroundNotifications'; - -const pushRelayLog = createDebugLogger('push-relay'); - -function clearMediaSessionQuickly(): void { - if (!('mediaSession' in navigator)) return; - // iOS registers the lock screen media player as a side-effect of - // HTMLAudioElement.play(). We delay slightly so iOS has finished updating - // the media session before we clear it — clearing too early is a no-op. - // We only clear if no real in-app media (video/audio in a room) has since - // registered meaningful metadata; if it has, leave it alone. - setTimeout(() => { - if (navigator.mediaSession.metadata !== null) return; - navigator.mediaSession.playbackState = 'none'; - }, 500); -} +import { scheduleDeferredFeatureMount } from './scheduleDeferredFeatureMount'; + +const DeferredNotificationFeatures = lazy(async () => { + const mod = await import('./DeferredNotificationFeatures'); + return { default: mod.DeferredNotificationFeatures }; +}); function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); - if (twitterEmoji) { document.documentElement.style.setProperty('--font-emoji', 'Twemoji'); } else { document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED'); } - return null; } function PageZoomFeature() { const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); - if (pageZoom === 100) { document.documentElement.style.removeProperty('font-size'); } else { document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`); } - return null; } @@ -118,490 +63,38 @@ function FaviconUpdater() { total += unread.total; highlightTotal += unread.highlight; } - if (unread.total > 0) { - notification = true; - } - if (unread.highlight > 0) { - highlight = true; - } + if (unread.total > 0) notification = true; + if (unread.highlight > 0) highlight = true; }); - if (highlight) { - setFavicon(LogoHighlightSVG); - } else if (!faviconForMentionsOnly && notification) { - setFavicon(LogoUnreadSVG); - } else { - setFavicon(LogoSVG); - } + if (highlight) setFavicon(LogoHighlightSVG); + else if (!faviconForMentionsOnly && notification) setFavicon(LogoUnreadSVG); + else setFavicon(LogoSVG); + try { - // Only badge with highlight (mention) counts — total unread is too noisy - // for an OS-level app badge. - if (highlightTotal > 0) { - navigator.setAppBadge(highlightTotal); - } else { - navigator.clearAppBadge(); - } + if (highlightTotal > 0) navigator.setAppBadge(highlightTotal); + else navigator.clearAppBadge(); + if (usePushNotifications && registration) { if (total === 0) { - // All rooms read — clear every notification. registration.getNotifications().then((notifs) => notifs.forEach((n) => n.close())); } else { - // Dismiss notifications for individual rooms that are now fully read. registration.getNotifications().then((notifs) => { notifs.forEach((n) => { const notifRoomId = n.data?.room_id; if (!notifRoomId) return; const roomUnread = roomToUnread.get(notifRoomId); - if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) { - n.close(); - } + if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) n.close(); }); }); } } - } catch { - // Likely Firefox/Gecko-based and doesn't support badging API - } + } catch {} }, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]); return null; } -function InviteNotifications() { - const audioRef = useRef(null); - const invites = useAtomValue(allInvitesAtom); - const perviousInviteLen = usePreviousValue(invites.length, 0); - const mx = useMatrixClient(); - - const navigate = useNavigate(); - const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - - const notify = useCallback( - (count: number) => { - const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, - body: `You have ${count} new invitation request.`, - silent: true, - }); - - noti.addEventListener('click', () => { - if (!window.closed) navigate(getInboxInvitesPath()); - noti.close(); - }); - }, - [navigate] - ); - - const playSound = useCallback(() => { - const audioElement = audioRef.current; - audioElement?.play(); - clearMediaSessionQuickly(); - }, []); - - useEffect(() => { - if (invites.length <= perviousInviteLen || mx.getSyncState() !== SyncState.Syncing) return; - - // SW push (via Sygnal) handles invite notifications when the app is backgrounded. - if (document.visibilityState !== 'visible' && usePushNotifications) return; - - // OS notification for invites — desktop only. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { - try { - notify(invites.length - perviousInviteLen); - } catch { - // window.Notification may be unavailable in sandboxed environments. - } - } - // Audio API requires a visible document; skip when hidden. - if (document.visibilityState === 'visible' && notificationSound) { - playSound(); - } - }, [ - mx, - invites, - perviousInviteLen, - showSystemNotifications, - usePushNotifications, - notificationSound, - notify, - playSound, - ]); - - return ( - // oxlint-disable-next-line jsx-a11y/media-has-caption - - ); -} - -function MessageNotifications() { - const audioRef = useRef(null); - const notifiedEventsRef = useRef(new Set()); - // Record mount time so we can distinguish live events from historical backfill - // on sliding sync proxies that don't set num_live (which causes liveEvent=false - // for all events, including actually-new messages). - const clientStartTimeRef = useRef(Date.now()); - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const appBaseUrl = useSettingsLinkBaseUrl(); - const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); - const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); - const [showEncryptedMessageContent] = useSetting( - settingsAtom, - 'showMessageContentInEncryptedNotifications' - ); - const nicknames = useAtomValue(nicknamesAtom); - const nicknamesRef = useRef(nicknames); - nicknamesRef.current = nicknames; - const mDirects = useAtomValue(mDirectAtom); - const mDirectsRef = useRef(mDirects); - mDirectsRef.current = mDirects; - - const setPending = useSetAtom(pendingNotificationAtom); - const setInAppBanner = useSetAtom(inAppBannerAtom); - const selectedRoomId = useSelectedRoom(); - const notificationSelected = useInboxNotificationsSelected(); - - const playSound = useCallback(() => { - const audioElement = audioRef.current; - audioElement?.play(); - clearMediaSessionQuickly(); - }, []); - - useEffect(() => { - const pushProcessor = new PushProcessor(mx); - // Track encrypted events that should skip focus check when decrypted (because we - // already checked focus when the encrypted event arrived, and want to use that - // original state rather than re-checking after decryption completes). - const skipFocusCheckEvents = new Set(); - // Tracks when each event first arrived so we can measure notification delivery latency - const notifyTimerMap = new Map(); - - const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( - mEvent, - room, - _toStartOfTimeline, - _removed, - data - ) => { - if (mx.getSyncState() !== SyncState.Syncing) return; - - const eventId = mEvent.getId(); - // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it) - if (eventId && !notifyTimerMap.has(eventId)) { - notifyTimerMap.set(eventId, performance.now()); - } - const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); - if (!shouldSkipFocusCheck) { - if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) - return; - } - - // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live, - // which causes every event to arrive with fromCache=true and therefore - // liveEvent=false — silently blocking all notifications. Fall back to an - // age check: treat the event as potentially live only when it was sent - // within 60 s of this component mounting (tight enough to avoid phantom - // notifications for pre-existing unread messages, generous enough for - // messages that arrived during a brief offline window). - // Additionally, skip the event if the user already has a read receipt - // covering it (message was read on another device before this session). - const isHistoricalEvent = - !data.liveEvent && - (mEvent.getTs() < clientStartTimeRef.current - 60 * 1000 || - (!!room && room.hasUserReadEvent(mx.getSafeUserId(), mEvent.getId()!))); - - // For encrypted events that haven't been decrypted yet, wait for decryption - // before processing the notification. The SDK's Timeline re-emission after - // decryption comes with data.liveEvent=false which would wrongly block it. - if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { - if (eventId) { - // Mark this event to skip focus check when decrypted, so we use the focus - // state from when the encrypted event originally arrived, not when it decrypts. - skipFocusCheckEvents.add(eventId); - } - - const handleDecrypted = () => { - // After decryption, run the notification logic with the decrypted event - handleTimelineEvent(mEvent, room, undefined, true, data); - // Clean up the skip-focus marker - if (eventId) { - skipFocusCheckEvents.delete(eventId); - } - }; - mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); - return; - } - - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { - return; - } - - const notificationType = getNotificationType(mx, room.roomId); - if (notificationType === NotificationType.Mute) { - return; - } - - const sender = mEvent.getSender(); - if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; - - // Deduplicate: don't show a second banner if this event fires twice - // (e.g., decrypted events re-emitted by the SDK). - if (notifiedEventsRef.current.has(eventId)) return; - - // Check if this is a DM using multiple signals for robustness - const isDM = isDMRoom(room, mDirectsRef.current); - - // Measure total notification delivery latency (includes decryption wait for E2EE events) - const arrivalMs = notifyTimerMap.get(eventId); - if (arrivalMs !== undefined) { - Sentry.metrics.distribution( - 'sable.notification.delivery_ms', - performance.now() - arrivalMs, - { - attributes: { - encrypted: String(mEvent.isEncrypted()), - dm: String(isDM), - }, - } - ); - notifyTimerMap.delete(eventId); - } - const pushActions = pushProcessor.actionsForEvent(mEvent); - - // For DMs with "All Messages" or "Default" notification settings: - // Always notify even if push rules fail to match due to sliding sync limitations. - // For "Mention & Keywords": respect the push rule (only notify if it matches). - const shouldForceDMNotification = - isDM && notificationType !== NotificationType.MentionsAndKeywords; - const shouldNotify = pushActions?.notify || shouldForceDMNotification; - - // If we shouldn't notify based on rules/settings, skip everything - if (!shouldNotify) return; - - const loudByRule = Boolean(pushActions.tweaks?.sound); - const isHighlightByRule = Boolean(pushActions.tweaks?.highlight); - - // With sliding sync we only load m.room.member/$ME in required_state, so - // PushProcessor cannot evaluate the room_member_count == 2 condition on - // .m.rule.room_one_to_one. That rule therefore fails to match, and DM - // messages fall through to .m.rule.message which carries no sound tweak — - // leaving loudByRule=false. Treat known DMs as inherently loud so that - // the OS notification and badge are consistent with the DM context. - const isLoud = loudByRule || isDM; - - // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events). - notifiedEventsRef.current.add(eventId); - if (notifiedEventsRef.current.size > 200) { - const first = notifiedEventsRef.current.values().next().value; - if (first) notifiedEventsRef.current.delete(first); - } - - // On desktop: fire an OS notification whenever system notifications are - // enabled and permission is granted — regardless of whether the window is - // focused. When the window is also visible the in-app banner fires too, - // mirroring the behaviour of apps like Discord. - // The whole block is wrapped in try/catch: window.Notification() can throw - // in sandboxed environments, browsers with DnD active, or Electron — and - // an uncaught exception here would abort the handler before setInAppBanner - // is reached, causing in-app notifications to silently vanish too. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { - try { - const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption); - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', - roomAvatar: avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined, - username: - getMemberDisplayName(room, sender, nicknamesRef.current) ?? - getMxIdLocalPart(sender) ?? - sender, - previewText: resolveNotificationPreviewText({ - content: mEvent.getContent(), - eventType: mEvent.getType(), - isEncryptedRoom, - showMessageContent, - showEncryptedMessageContent, - }), - silent: !notificationSound || !isLoud, - eventId, - }); - const noti = new window.Notification(osPayload.title, osPayload.options); - const { roomId } = room; - noti.addEventListener('click', () => { - window.focus(); - setPending({ - roomId, - eventId, - targetSessionId: mx.getUserId() ?? undefined, - }); - noti.close(); - }); - } catch { - // window.Notification unavailable or blocked (sandboxed context, DnD, etc.) - } - } - - // Everything below requires the page to be visible (in-app UI + audio). - if (document.visibilityState !== 'visible') return; - - // Page is visible — show the themed in-app notification banner. - // For non-DM rooms, only show banner for highlighted messages (mentions/keywords). - // For DMs, show banner for all messages. - if (showNotifications && (isHighlightByRule || isDM)) { - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - const roomAvatar = avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined; - const resolvedSenderName = - getMemberDisplayName(room, sender, nicknamesRef.current) ?? - getMxIdLocalPart(sender) ?? - sender; - const content = mEvent.getContent(); - // Events reaching here are already decrypted (m.room.encrypted is skipped - // above). Pass isEncryptedRoom:false so the preview always shows the actual - // message body when showMessageContent is enabled. - const previewText = resolveNotificationPreviewText({ - content: mEvent.getContent(), - eventType: mEvent.getType(), - isEncryptedRoom: false, - showMessageContent, - showEncryptedMessageContent, - }); - - // Build a rich ReactNode body using the same HTML parser as the room - // timeline — mxc images, mention pills, linkify, spoilers, code blocks. - let bodyNode: ReactNode; - if ( - showMessageContent && - content.format === 'org.matrix.custom.html' && - content.formatted_body - ) { - const htmlParserOpts = getReactCustomHtmlParser(mx, room.roomId, { - settingsLinkBaseUrl: appBaseUrl, - linkifyOpts: LINKIFY_OPTS, - useAuthentication, - nicknames: nicknamesRef.current, - }); - bodyNode = parse(sanitizeCustomHtml(content.formatted_body), htmlParserOpts) as ReactNode; - } - - const payload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', - roomAvatar, - username: resolvedSenderName, - previewText, - silent: !notificationSound || !isLoud, - eventId, - }); - const { roomId } = room; - const capturedEventId = eventId; - const capturedUserId = mx.getUserId() ?? undefined; - const canonicalAlias = room.getCanonicalAlias(); - const serverName = canonicalAlias?.split(':')[1] ?? room.roomId.split(':')[1] ?? undefined; - setInAppBanner({ - id: eventId, - title: payload.title, - roomName: room.name ?? undefined, - serverName, - senderName: resolvedSenderName, - body: previewText, - bodyNode, - icon: roomAvatar, - onClick: () => { - window.focus(); - setPending({ - roomId, - eventId: capturedEventId, - targetSessionId: capturedUserId, - }); - }, - }); - } - - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { - playSound(); - } - }; - mx.on(RoomEvent.Timeline, handleTimelineEvent); - return () => { - mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); - }; - }, [ - mx, - notificationSound, - notificationSelected, - showNotifications, - showSystemNotifications, - showMessageContent, - showEncryptedMessageContent, - usePushNotifications, - playSound, - setInAppBanner, - setPending, - selectedRoomId, - appBaseUrl, - useAuthentication, - ]); - - return ( - // oxlint-disable-next-line jsx-a11y/media-has-caption - - ); -} - -function PrivacyBlurFeature() { - const [blurMedia] = useSetting(settingsAtom, 'privacyBlur'); - const [blurAvatars] = useSetting(settingsAtom, 'privacyBlurAvatars'); - const [blurEmotes] = useSetting(settingsAtom, 'privacyBlurEmotes'); - - useEffect(() => { - document.body.classList.toggle('sable-blur-media', blurMedia); - document.body.classList.toggle('sable-blur-avatars', blurAvatars); - document.body.classList.toggle('sable-blur-emotes', blurEmotes); - }, [blurMedia, blurAvatars, blurEmotes]); - - return null; -} - -// Periodically emits memory-health gauges so Sentry dashboards can surface -// unbounded growth (e.g. blob cache never evicted, stale inflight requests). -function HealthMonitor() { - useEffect(() => { - const id = window.setInterval(() => { - const { cacheSize, inflightCount } = getBlobCacheStats(); - Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); - if (inflightCount > 0) { - Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); - if (inflightCount >= 10) { - Sentry.addBreadcrumb({ - category: 'media', - message: `High inflight request count: ${inflightCount}`, - level: 'warning', - data: { inflight_count: inflightCount }, - }); - } - } - }, 60_000); - return () => window.clearInterval(id); - }, []); - return null; -} - type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -613,25 +106,20 @@ export function HandleNotificationClick() { useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; - const handleMessage = (ev: MessageEvent) => { const { data } = ev; if (!data || data.type !== 'notificationClick') return; - const { userId, roomId, eventId, isInvite } = data as { userId?: string; roomId?: string; eventId?: string; isInvite?: boolean; }; - if (userId) setActiveSessionId(userId); - if (isInvite) { navigate(getInboxInvitesPath()); return; } - if (!roomId) return; setPending({ roomId, eventId, targetSessionId: userId }); }; @@ -653,15 +141,12 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; - const postVisibility = () => { const visible = document.visibilityState === 'visible'; const msg = { type: 'setAppVisible', visible }; navigator.serviceWorker.controller?.postMessage(msg); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; - - // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); return () => document.removeEventListener('visibilitychange', postVisibility); @@ -669,20 +154,14 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return; - // notificationSoundEnabled is intentionally excluded: push notification sound - // is governed by the push rule's tweakSound alone (OS/Sygnal handles it). - // The in-app sound setting only controls the in-page
)} - {backPagination} {dividers} {renderedEvent} diff --git a/src/app/features/room/TimelinePaginationStatus.test.tsx b/src/app/features/room/TimelinePaginationStatus.test.tsx new file mode 100644 index 000000000..79991b701 --- /dev/null +++ b/src/app/features/room/TimelinePaginationStatus.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TimelinePaginationStatusRow } from './TimelinePaginationStatus'; + +describe('TimelinePaginationStatusRow', () => { + it('does not render when hidden', () => { + const { container } = render( +