diff --git a/.changeset/perf-cold-start-bundle.md b/.changeset/perf-cold-start-bundle.md new file mode 100644 index 0000000000..7b6bcaa3e4 --- /dev/null +++ b/.changeset/perf-cold-start-bundle.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Speed up cold start by keeping the PDF viewer out of the initial bundle (it now loads on demand), cutting the amount of JavaScript fetched and parsed on launch. The desktop and mobile windows also paint the app's background color immediately, so startup no longer flashes a blank black screen before the first frame. diff --git a/index.html b/index.html index 3a9c594781..a30ee45e73 100644 --- a/index.html +++ b/index.html @@ -143,6 +143,23 @@ +
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcf7e1b480..0aa7f0a788 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,7 +105,8 @@ pub fn show_or_create_main_window(app: &AppHandle) -> taur let builder = tauri::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, tauri::WebviewUrl::default()) .disable_drag_drop_handler() - .use_https_scheme(true); + .use_https_scheme(true) + .background_color(tauri::window::Color(0x1A, 0x1C, 0x28, 0xFF)); #[cfg(desktop)] let title = if app @@ -276,6 +277,25 @@ pub fn run() { show_or_create_main_window(app.handle())?; + // Failsafe: if the frontend never calls show() (hung webview), force-show + // the window after 5s so the app isn't invisible forever. + #[cfg(desktop)] + { + let handle = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(5)); + if let Some(window) = handle.get_webview_window(MAIN_WINDOW_LABEL) { + if !window.is_visible().unwrap_or(true) { + let _ = window.show(); + let _ = window.set_focus(); + log::warn!( + "Frontend show() not received within 5s — force-showing window" + ); + } + } + }); + } + #[cfg(target_os = "ios")] if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { ios::hide_form_accessory_bar(&window); diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx index b601773701..96fc93630c 100644 --- a/src/app/components/ClientConfigLoader.tsx +++ b/src/app/components/ClientConfigLoader.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, Dialog, Text, color, config } from 'folds'; import type { ClientConfig } from '$hooks/useClientConfig'; +import { takePreloadedConfig } from '$utils/preload'; import { trimTrailingSlash } from '$utils/common'; import { fetch } from '$utils/fetch'; import { SplashScreen } from '$components/splash-screen'; @@ -14,6 +15,15 @@ export const FALLBACK_CLIENT_CONFIG: ClientConfig = { }; export const getClientConfig = async (): Promise => { + const preloaded = takePreloadedConfig(); + if (preloaded !== undefined) { + const data = await preloaded.catch(() => undefined); + if (data && typeof data === 'object' && !Array.isArray(data)) { + return data as ClientConfig; + } + // Preload missed or invalid — fall through to fresh fetch. + } + const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; const response = await fetch(url, { method: 'GET' }); if (!response.ok) { diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index ddddf84c73..96f4647d88 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -269,7 +269,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } - if (mx > DIRECTION_DEADZONE || !canOpenRoom) { + if (mx > DIRECTION_DEADZONE) { if (draggingRef.current) { draggingRef.current = false; settle(0); @@ -277,6 +277,10 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra cancel(); return; } + if (!canOpenRoom && mx < -DIRECTION_DEADZONE) { + cancel(); + return; + } if (active) { if (first) { x.stop(); @@ -307,6 +311,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra { axis: 'x', filterTaps: true, + tapsThreshold: DIRECTION_DEADZONE, pointer: { capture: false }, from: () => [readX(), 0], } diff --git a/src/app/components/tauri/TauriFrontendReady.test.tsx b/src/app/components/tauri/TauriFrontendReady.test.tsx index cdce69ee73..d999a09444 100644 --- a/src/app/components/tauri/TauriFrontendReady.test.tsx +++ b/src/app/components/tauri/TauriFrontendReady.test.tsx @@ -35,12 +35,20 @@ function setDocumentReadyState(value: DocumentReadyState) { }); } +function setDocumentVisibility(value: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value, + }); +} + describe('TauriFrontendReady', () => { beforeEach(() => { vi.clearAllMocks(); mockIsTauri.mockReturnValue(true); mockGetCurrentWindow.mockReturnValue({ show: mockShow }); setDocumentReadyState('complete'); + setDocumentVisibility('visible'); }); afterEach(() => { @@ -77,9 +85,10 @@ describe('TauriFrontendReady', () => { expect(mockGetCurrentWindow).toHaveBeenCalledOnce(); }); - it('waits for the window load event before showing the desktop window', async () => { + it('falls back to the load event when the document is hidden and still loading', async () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); mockOsType.mockReturnValue('linux'); + setDocumentVisibility('hidden'); setDocumentReadyState('loading'); render(); diff --git a/src/app/components/tauri/TauriFrontendReady.tsx b/src/app/components/tauri/TauriFrontendReady.tsx index f64b166f91..adb93945f6 100644 --- a/src/app/components/tauri/TauriFrontendReady.tsx +++ b/src/app/components/tauri/TauriFrontendReady.tsx @@ -6,20 +6,23 @@ import { createLogger } from '$utils/debug'; const log = createLogger('TauriFrontendReady'); -function onPageFullyLoaded(cb: () => void): () => void { - if (document.readyState === 'complete') { - cb(); - return () => {}; +// Show the window at the first paint frame after the shell mounts, not after +// the full `window.load` event (which fires after ALL resources). This cuts the +// perceived blank-screen time substantially on desktop cold starts. +function showOnFirstPaint(cb: () => void): () => void { + if (document.visibilityState === 'hidden') { + // Headless/test: fall back to load event to avoid showing a hidden window. + if (document.readyState === 'complete') { + cb(); + return () => {}; + } + const handleLoad = () => cb(); + window.addEventListener('load', handleLoad, { once: true }); + return () => window.removeEventListener('load', handleLoad); } - const handleLoad = () => { - cb(); - }; - - window.addEventListener('load', handleLoad, { once: true }); - return () => { - window.removeEventListener('load', handleLoad); - }; + const id = requestAnimationFrame(() => cb()); + return () => cancelAnimationFrame(id); } export function TauriFrontendReady() { @@ -30,9 +33,9 @@ export function TauriFrontendReady() { if (os !== 'windows' && os !== 'linux' && os !== 'macos') return undefined; const appWindow = getCurrentWindow(); - return onPageFullyLoaded(() => { + return showOnFirstPaint(() => { appWindow.show().catch((error) => { - log.warn('Failed to show main window after frontend fully loaded:', error); + log.warn('Failed to show main window after first paint:', error); }); }); }, []); diff --git a/src/app/i18n.ts b/src/app/i18n.ts index 72a4ba3dd2..49d8202d16 100644 --- a/src/app/i18n.ts +++ b/src/app/i18n.ts @@ -4,6 +4,12 @@ import type { HttpBackendOptions } from 'i18next-http-backend'; import Backend from 'i18next-http-backend'; import { initReactI18next } from 'react-i18next'; import { trimTrailingSlash } from './utils/common'; +import { takePreloadedLocale } from './utils/preload'; + +const langFromUrl = (url: string): string | undefined => { + const match = url.match(/\/public\/locales\/([^/]+)\.json/); + return match?.[1]; +}; i18n // i18next-http-backend @@ -26,6 +32,11 @@ i18n load: 'languageOnly', backend: { loadPath: `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/locales/{{lng}}.json`, + alternateFetch: (url: string) => { + const lng = langFromUrl(url); + if (!lng) return undefined; + return takePreloadedLocale(lng) ?? undefined; + }, }, }); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4686929ca8..d1ffe04883 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,16 +11,12 @@ 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'; @@ -31,7 +28,7 @@ import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/navigate'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; -import { AuthLayout, Login, Register, ResetPassword } from './auth'; +import { SplashScreen } from '$components/splash-screen'; import { DIRECT_PATH, EXPLORE_PATH, @@ -71,8 +68,41 @@ 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, Bookmarks } from './client/inbox'; +// Lazy-loaded: auth subtree, settings, inbox/bookmarks, explore +const AuthLayout = lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout }))); +const Login = lazy(() => import('./auth').then((m) => ({ default: m.Login }))); +const Register = lazy(() => import('./auth').then((m) => ({ default: m.Register }))); +const ResetPassword = lazy(() => import('./auth').then((m) => ({ default: m.ResetPassword }))); + +const SettingsRoute = lazy(() => + import('$features/settings').then((m) => ({ default: m.SettingsRoute })) +); +const SettingsShallowRouteRenderer = lazy(() => + import('$features/settings/SettingsShallowRouteRenderer').then((m) => ({ + default: m.SettingsShallowRouteRenderer, + })) +); +const RoomSettingsRenderer = lazy(() => + import('$features/room-settings').then((m) => ({ default: m.RoomSettingsRenderer })) +); +const SpaceSettingsRenderer = lazy(() => + import('$features/space-settings').then((m) => ({ default: m.SpaceSettingsRenderer })) +); + +const Notifications = lazy(() => + import('./client/inbox').then((m) => ({ default: m.Notifications })) +); +const Inbox = lazy(() => import('./client/inbox').then((m) => ({ default: m.Inbox }))); +const Invites = lazy(() => import('./client/inbox').then((m) => ({ default: m.Invites }))); +const Bookmarks = lazy(() => import('./client/inbox').then((m) => ({ default: m.Bookmarks }))); + +const Explore = lazy(() => import('./client/explore').then((m) => ({ default: m.Explore }))); +const FeaturedRooms = lazy(() => + import('./client/explore').then((m) => ({ default: m.FeaturedRooms })) +); +const PublicRooms = lazy(() => + import('./client/explore').then((m) => ({ default: m.PublicRooms })) +); import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -148,11 +178,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} beforeCapture={(scope) => scope.setTag('section', 'auth')} > - <> - - - - + {null}}> + <> + + + + + } > @@ -215,9 +247,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */}
} nav={ - + {null}}> + + } > @@ -368,20 +408,43 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> + {null}}> + + + } + /> + {null}}> + + + } + /> } /> } /> } /> - } /> + {null}}> + + + } + /> - + {null}}> + + } > @@ -396,9 +459,30 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> - } /> + {null}}> + + + } + /> + {null}}> + + + } + /> + {null}}> + + + } + /> } /> diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index a9ee1cbc05..0a83f23402 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -344,11 +344,13 @@ const useDraggableItem = ( item: SidebarDraggable, targetRef: RefObject, onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject + dragHandleRef?: RefObject, + enabled: boolean = true ): boolean => { const [dragging, setDragging] = useState(false); useEffect(() => { + if (!enabled) return undefined; const target = targetRef.current; const dragHandle = dragHandleRef?.current ?? undefined; @@ -367,18 +369,20 @@ const useDraggableItem = ( }, }) : undefined; - }, [targetRef, dragHandleRef, item, onDragging]); + }, [targetRef, dragHandleRef, item, onDragging, enabled]); return dragging; }; const useDropTarget = ( item: SidebarDraggable, - targetRef: RefObject + targetRef: RefObject, + enabled: boolean = true ): Instruction | undefined => { const [dropState, setDropState] = useState(); useEffect(() => { + if (!enabled) return undefined; const target = targetRef.current; if (!target) return undefined; @@ -415,7 +419,7 @@ const useDropTarget = ( onDragLeave: () => setDropState(undefined), onDrop: () => setDropState(undefined), }); - }, [item, targetRef]); + }, [item, targetRef, enabled]); return dropState; }; @@ -423,11 +427,13 @@ const useDropTarget = ( function useDropTargetInstruction( item: SidebarDraggable, targetRef: RefObject, - instructionType: T + instructionType: T, + enabled: boolean = true ): T | undefined { const [dropState, setDropState] = useState(); useEffect(() => { + if (!enabled) return undefined; const target = targetRef.current; if (!target) return undefined; @@ -448,7 +454,7 @@ function useDropTargetInstruction( onDragLeave: () => setDropState(undefined), onDrop: () => setDropState(undefined), }); - }, [item, targetRef, instructionType]); + }, [item, targetRef, instructionType, enabled]); return dropState; } @@ -532,6 +538,7 @@ function SpaceTab({ disabled, onUnpin, }: Readonly) { + const isMobile = useScreenSizeContext() === ScreenSize.Mobile; const targetRef = useRef(null); const spaceDraggable: SidebarDraggable = useMemo( @@ -545,8 +552,8 @@ function SpaceTab({ [folder, space] ); - useDraggableItem(spaceDraggable, targetRef, onDragging); - const dropState = useDropTarget(spaceDraggable, targetRef); + useDraggableItem(spaceDraggable, targetRef, onDragging, undefined, !isMobile); + const dropState = useDropTarget(spaceDraggable, targetRef, !isMobile); const dropType = dropState?.type; const [menuAnchor, setMenuAnchor] = useState(); @@ -641,13 +648,24 @@ function OpenedSpaceFolder({ onFolderContextMenu, children, }: Readonly) { + const isMobile = useScreenSizeContext() === ScreenSize.Mobile; const aboveTargetRef = useRef(null); const belowTargetRef = useRef(null); const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); - const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); - const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); + const orderAbove = useDropTargetInstruction( + spaceDraggable, + aboveTargetRef, + 'reorder-above', + !isMobile + ); + const orderBelow = useDropTargetInstruction( + spaceDraggable, + belowTargetRef, + 'reorder-below', + !isMobile + ); return ( ) { const mx = useMatrixClient(); + const isMobile = useScreenSizeContext() === ScreenSize.Mobile; const handlerRef = useRef(null); const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); - useDraggableItem(spaceDraggable, handlerRef, onDragging); - const dropState = useDropTarget(spaceDraggable, handlerRef); + useDraggableItem(spaceDraggable, handlerRef, onDragging, undefined, !isMobile); + const dropState = useDropTarget(spaceDraggable, handlerRef, !isMobile); const dropType = dropState?.type; const tooltipName = folderDefaultDisplayName(mx, folder); diff --git a/src/app/utils/preload.ts b/src/app/utils/preload.ts new file mode 100644 index 0000000000..e041d6c2a8 --- /dev/null +++ b/src/app/utils/preload.ts @@ -0,0 +1,29 @@ +// Shape stashed by the inline preload script in index.html. +type SablePreload = { + config?: Promise; + locale?: { lng: string; promise: Promise }; +}; + +const win = window as Window & { __SABLE_PRELOAD?: SablePreload }; + +/** One-shot: returns the preloaded config.json fetch promise and nulls the slot. */ +export const takePreloadedConfig = (): Promise | undefined => { + const preload = win.__SABLE_PRELOAD; + if (preload?.config !== undefined) { + const promise = preload.config; + delete preload.config; + return promise; + } + return undefined; +}; + +/** One-shot: returns the preloaded locale fetch promise for the given language, nulls the slot. */ +export const takePreloadedLocale = (lng: string): Promise | undefined => { + const preload = win.__SABLE_PRELOAD; + if (preload?.locale !== undefined && preload.locale.lng === lng) { + const promise = preload.locale.promise; + delete preload.locale; + return promise; + } + return undefined; +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 2687be11c0..317b534ea8 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -29,6 +29,12 @@ import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; import { hydrateRoomMember } from './roomMemberHydration'; +import { + primeVersionsFromCache, + revalidateVersionsCache, + clearCachedVersions, + cacheVersionsFromClient, +} from './versionsCache'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); @@ -274,7 +280,10 @@ const initializeClient = async ( } const { mx, indexedDBStore } = builtClient; - void mx.getVersions().catch(() => undefined); + void primeVersionsFromCache(mx, session.baseUrl, session.userId).then((primed) => { + if (primed) void revalidateVersionsCache(mx, session.baseUrl, session.userId); + else void cacheVersionsFromClient(mx, session.baseUrl, session.userId); + }); const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup()); const cryptoPromise = measureStartupPhase('rust_crypto', () => @@ -597,6 +606,7 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { if (session) { SlidingSyncSidebarCache.clear(session.userId); + clearCachedVersions(session.baseUrl, session.userId); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); await deleteDatabase(storeName.sync); diff --git a/src/client/versionsCache.ts b/src/client/versionsCache.ts new file mode 100644 index 0000000000..0953a682ed --- /dev/null +++ b/src/client/versionsCache.ts @@ -0,0 +1,123 @@ +import type { MatrixClient } from '$types/matrix-sdk'; +import { buildFeatureSupportMap, Method, Thread } from '$types/matrix-sdk'; + +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const keyFor = (baseUrl: string, userId: string): string => + `sable.versionsCache.${baseUrl}|${userId}`; + +type CachedVersions = { + versions: unknown; + unstable_features: Record | undefined; + fetchedAt: number; +}; + +const readCache = (baseUrl: string, userId: string): CachedVersions | undefined => { + try { + const raw = localStorage.getItem(keyFor(baseUrl, userId)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as CachedVersions; + if (typeof parsed.fetchedAt !== 'number') return undefined; + if (Date.now() - parsed.fetchedAt > TTL_MS) return undefined; + return parsed; + } catch { + return undefined; + } +}; + +const writeCache = ( + baseUrl: string, + userId: string, + data: { versions: unknown; unstable_features: Record | undefined } +): void => { + try { + const entry: CachedVersions = { ...data, fetchedAt: Date.now() }; + localStorage.setItem(keyFor(baseUrl, userId), JSON.stringify(entry)); + } catch { + // localStorage full or disabled — non-fatal, skip caching. + } +}; + +/** Fetch /versions through the SDK and persist the response for the next startup. */ +export const cacheVersionsFromClient = async ( + mx: MatrixClient, + baseUrl: string, + userId: string +): Promise => { + try { + const data = await mx.getVersions(); + writeCache(baseUrl, userId, { + versions: data.versions, + unstable_features: data.unstable_features, + }); + } catch { + // startup can continue without a cache entry. + } +}; + +/** + * Seed mx.serverVersionsPromise AND mx.canSupport from a cached /versions payload. + * Seeding the promise alone leaves canSupport empty → semantic drift in + * redaction/relation checks. Both must be set before the first sync request. + */ +export const primeVersionsFromCache = async ( + mx: MatrixClient, + baseUrl: string, + userId: string +): Promise => { + const cached = readCache(baseUrl, userId); + if (!cached) return false; + const serverVersions = { + versions: cached.versions, + unstable_features: cached.unstable_features ?? {}, + }; + (mx as unknown as { serverVersionsPromise: Promise | undefined }).serverVersionsPromise = + Promise.resolve(serverVersions); + (mx as unknown as { canSupport: Map }).canSupport = + await buildFeatureSupportMap(serverVersions as Parameters[0]); + return true; +}; + +/** + * Fire-and-forget revalidation of the /versions cache. Updates both the cache + * and the in-memory promise/canSupport so the client stays current. + */ +export const revalidateVersionsCache = async ( + mx: MatrixClient, + baseUrl: string, + userId: string +): Promise => { + try { + const resp = await mx.http.authedRequest( + Method.Get, + '/_matrix/client/versions', + undefined, + undefined, + { prefix: '' } + ); + const data = resp as { versions: unknown; unstable_features?: Record }; + writeCache(baseUrl, userId, { + versions: data.versions, + unstable_features: data.unstable_features, + }); + ( + mx as unknown as { serverVersionsPromise: Promise | undefined } + ).serverVersionsPromise = Promise.resolve(data); + (mx as unknown as { canSupport: Map }).canSupport = + await buildFeatureSupportMap(data as Parameters[0]); + const { threads, list, fwdPagination } = await mx.doesServerSupportThread(); + Thread.setServerSideSupport(threads); + Thread.setServerSideListSupport(list); + Thread.setServerSideFwdPaginationSupport(fwdPagination); + } catch { + // Revalidation failed — cached values remain in effect. Non-fatal. + } +}; + +/** Clear the cached versions for a session (used on logout). */ +export const clearCachedVersions = (baseUrl: string, userId: string): void => { + try { + localStorage.removeItem(keyFor(baseUrl, userId)); + } catch { + // non-fatal + } +}; diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index 12f41e0712..561941238b 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -45,6 +45,8 @@ export * from 'matrix-js-sdk/lib/@types/read_receipts'; export * from 'matrix-js-sdk/lib/@types/membership'; export * from 'matrix-js-sdk/lib/@types/registration'; +export * from 'matrix-js-sdk/lib/feature'; + export * from 'matrix-js-sdk/lib/oidc'; // oidc-client-ts is a transitive dep (not resolvable here), so derive its type from the SDK. diff --git a/vite.config.ts b/vite.config.ts index f1b590379b..05576534e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -295,10 +295,7 @@ export default defineConfig(({ command }) => ({ plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], output: { manualChunks: (id) => { - if (id.includes('pdfjs-dist')) return 'pdf'; - if (id.includes('@sableclient/sable-call-embedded')) return 'element-call'; if (id.includes('@matrix-org') || id.includes('matrix-js-sdk')) return 'matrix'; - if (id.includes('react-prism') || id.includes('prism')) return 'prism'; return undefined; }, },