diff --git a/config.json b/config.json index 77be1ad3ca..0002880c9b 100644 --- a/config.json +++ b/config.json @@ -16,10 +16,6 @@ "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"], - "slidingSync": { - "enabled": true - }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/index.html b/index.html index 5cca4f0388..4e73dec7cd 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,24 @@ + + diff --git a/src/app/components/ClientConfigLoader.test.tsx b/src/app/components/ClientConfigLoader.test.tsx deleted file mode 100644 index b80e408847..0000000000 --- a/src/app/components/ClientConfigLoader.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Integration tests: exercises the full config-load → setMatrixToBase → URL - * generation pipeline that App.tsx runs on startup. - * - * The pattern under test mirrors App.tsx: - * - * {(config) => { setMatrixToBase(config.matrixToBaseUrl); ... }} - * - * - * We mock fetch so we don't need a real config.json or a live matrix.to instance. - */ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import { setMatrixToBase, getMatrixToRoom, getMatrixToUser } from '$plugins/matrix-to'; -import { ClientConfigLoader } from './ClientConfigLoader'; - -afterEach(() => { - setMatrixToBase(); // reset module state to 'https://matrix.to' - vi.unstubAllGlobals(); -}); - -const mockFetch = (config: object) => - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(config) })); - -describe('ClientConfigLoader + matrix-to wiring', () => { - it('generates a standard matrix.to URL when no custom base is configured', async () => { - mockFetch({}); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com') - ); - }); - - it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) - ); - }); - - it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToUser('@alice:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('user')).toHaveTextContent( - 'https://custom.example.org/#/@alice:example.com' - ) - ); - }); - - it('strips a trailing slash from matrixToBaseUrl', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) - ); - }); -}); diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx deleted file mode 100644 index f03b8398e0..0000000000 --- a/src/app/components/ClientConfigLoader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import type { ClientConfig } from '$hooks/useClientConfig'; -import { trimTrailingSlash } from '$utils/common'; - -const getClientConfig = async (): Promise => { - const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; - const config = await fetch(url, { method: 'GET' }); - return config.json(); -}; - -type ClientConfigLoaderProps = { - fallback?: () => ReactNode; - error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; - children: (config: ClientConfig) => ReactNode; -}; -export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) { - const [state, load] = useAsyncCallback(getClientConfig); - const [ignoreError, setIgnoreError] = useState(false); - - const ignoreCallback = useCallback(() => setIgnoreError(true), []); - - useEffect(() => { - load(); - }, [load]); - - if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { - return fallback?.(); - } - - if (!ignoreError && state.status === AsyncStatus.Error) { - return error?.(state.error, load, ignoreCallback); - } - - const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {}; - - return children(config); -} diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index 0a92aa2a33..fe01d4b0b7 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -32,7 +32,7 @@ export function SpecVersionsLoader({ } if (!ignoreError && state.status === AsyncStatus.Error) { - return error?.(state.error, load, ignoreCallback); + if (error) return error(state.error, load, ignoreCallback); } return children( diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 085355f40f..7cd673bc4f 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -7,7 +7,6 @@ import { useSetting } from '$state/hooks/settings'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { SettingTile } from '$components/setting-tile'; import { SequenceCard } from '$components/sequence-card'; -import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; @@ -60,7 +59,6 @@ export function Experimental({ requestBack, requestClose }: Readonly
- diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index a3fef67ac7..dc6d1f4a65 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -49,8 +49,6 @@ import { useMessageSpacingItems } from '$hooks/useMessageSpacing'; import { useDateFormatItems } from '$hooks/useDateFormat'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { sessionsAtom, activeSessionIdAtom } from '$state/sessions'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { resolveSlidingEnabled } from '$client/initMatrix'; import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; @@ -1380,14 +1378,17 @@ function Embeds() { ); } -export function Sync() { - const clientConfig = useClientConfig(); +type GeneralProps = { + requestBack?: () => void; + requestClose: () => void; +}; + +function Sync() { const sessions = useAtomValue(sessionsAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; - const serverSlidingEnabled = resolveSlidingEnabled(clientConfig.slidingSync?.enabled); const useSlidingSync = activeSession?.slidingSyncOptIn === true; const handleSetSlidingSync = (value: boolean) => { @@ -1402,76 +1403,6 @@ export function Sync() { window.location.reload(); }; - return ( - - - Sync - - - - Enable Sliding Sync for this current login/session. Requires server support and - admin configuration.{' '} - - More info/Documentation - - .{' '} - - Known issues (Sable GitHub) - - . - - ) : ( - <> - Unavailable: the server has disabled Sliding Sync in its config.{' '} - - More info - - . - - ) - } - after={ - - } - /> - - - ); -} - -type GeneralProps = { - requestBack?: () => void; - requestClose: () => void; -}; - -function SettingsSyncSection() { const [syncEnabled, setSyncEnabled] = useSetting(settingsAtom, 'settingsSyncEnabled'); const lastSynced = useAtomValue(settingsSyncLastSyncedAtom); const syncStatus = useAtomValue(settingsSyncStatusAtom); @@ -1500,7 +1431,34 @@ function SettingsSyncSection() { return ( - Settings Sync & Backup + Data Syncing + + + Enable Sliding Sync for this current login/session. Requires server support.{' '} + + Known issues (Sable GitHub) + + . + + } + after={ + + } + /> + } @@ -1669,7 +1627,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6850a7e774..fa4de2efa1 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -28,7 +28,6 @@ export type ClientConfig = { }; slidingSync?: { - enabled?: boolean; proxyBaseUrl?: string; bootstrapClassicOnColdCache?: boolean; listPageSize?: number; @@ -59,20 +58,28 @@ export type ClientConfig = { settingsDefaults?: Partial; }; -const ClientConfigContext = createContext(null); +const EMPTY_CONFIG: ClientConfig = {}; + +const ClientConfigContext = createContext(EMPTY_CONFIG); export const ClientConfigProvider = ClientConfigContext.Provider; export function useClientConfig(): ClientConfig { - const config = useContext(ClientConfigContext); - if (!config) throw new Error('Client config are not provided!'); - return config; + return useContext(ClientConfigContext); } -export function useOptionalClientConfig(): ClientConfig | null { +export function useOptionalClientConfig(): ClientConfig { return useContext(ClientConfigContext); } +const ClientConfigLoadedContext = createContext(false); + +export const ClientConfigLoadedProvider = ClientConfigLoadedContext.Provider; + +export function useClientConfigLoaded(): boolean { + return useContext(ClientConfigLoadedContext); +} + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/hooks/useSpecVersions.ts b/src/app/hooks/useSpecVersions.ts index c971b7e49f..28c932a6f5 100644 --- a/src/app/hooks/useSpecVersions.ts +++ b/src/app/hooks/useSpecVersions.ts @@ -1,12 +1,12 @@ import { createContext, useContext } from 'react'; import type { SpecVersions } from '../cs-api'; -const SpecVersionsContext = createContext(null); +const EMPTY_VERSIONS: SpecVersions = { versions: [] }; + +const SpecVersionsContext = createContext(EMPTY_VERSIONS); export const SpecVersionsProvider = SpecVersionsContext.Provider; export function useSpecVersions(): SpecVersions { - const versions = useContext(SpecVersionsContext); - if (!versions) throw new Error('Server versions are not provided!'); - return versions; + return useContext(SpecVersionsContext); } diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 95cb850010..e9275fd8b7 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useRef } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -6,19 +6,21 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; -import { ClientConfigLoader } from '$components/ClientConfigLoader'; import type { ClientConfig } from '$hooks/useClientConfig'; -import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { ClientConfigLoadedProvider, ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; -import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; import { bootstrapSettingsStore } from '$state/settings'; +import { trimTrailingSlash } from '$utils/common'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('App'); const queryClient = new QueryClient(); const ReactQueryDevtools = lazy(async () => { @@ -41,18 +43,16 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); return ( - - - - - - {reactQueryDevtoolsEnabled && ( - - - - )} - - + + + + + {reactQueryDevtoolsEnabled && ( + + + + )} + ); } @@ -65,17 +65,36 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId ); } -function appConfigFallback() { - return ; -} +function useClientConfigLoader(): { config: ClientConfig; loaded: boolean } { + const [config, setConfig] = useState({}); + const [loaded, setLoaded] = useState(false); -function appConfigError(err: unknown, retry: () => void, ignore: () => void) { - return ; -} + useEffect(() => { + let cancelled = false; -function AppConfigLoaded({ clientConfig, screenSize }: BootstrappedAppShellProps) { - setMatrixToBase(clientConfig.matrixToBaseUrl); - return ; + const loadConfig = async () => { + try { + const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; + const res = await fetch(url, { method: 'GET' }); + const data = (await res.json()) as ClientConfig; + if (cancelled) return; + setConfig(data); + setMatrixToBase(data.matrixToBaseUrl); + setLoaded(true); + } catch (err) { + log.error('Failed to load config.json, continuing with empty config:', err); + if (cancelled) return; + setLoaded(true); + } + }; + + void loadConfig(); + return () => { + cancelled = true; + }; + }, []); + + return { config, loaded }; } function App() { @@ -83,12 +102,7 @@ function App() { useCompositionEndTracking(); const portalContainer = document.getElementById('portalContainer') ?? undefined; - const renderAppConfig = useCallback( - (clientConfig: ClientConfig) => ( - - ), - [screenSize] - ); + const { config: clientConfig, loaded: configLoaded } = useClientConfigLoader(); return ( @@ -97,9 +111,11 @@ function App() { - - {renderAppConfig} - + + + + + diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ConfigConfig.tsx deleted file mode 100644 index 2c2df830cd..0000000000 --- a/src/app/pages/ConfigConfig.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds'; -import { SplashScreen } from '$components/splash-screen'; - -export function ConfigConfigLoading() { - return ( - - - - Petting cats - - - ); -} - -type ConfigConfigErrorProps = { - error: unknown; - retry: () => void; - ignore: () => void; -}; -export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) { - return ( - - - - - - Failed to load client configuration file. - {typeof error === 'object' && - error && - 'message' in error && - typeof error.message === 'string' && ( - - {error.message} - - )} - - - - - - - - ); -} diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index f681e31f75..5c18a386d2 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -13,6 +13,7 @@ import { OverlayCenter, PopOut, Spinner, + Switch, Text, config, } from 'folds'; @@ -120,7 +121,12 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Parameters >(useCallback(login, [])); - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + const [useSlidingSync, setUseSlidingSync] = useState(false); + + useLoginComplete( + loginState.status === AsyncStatus.Success ? loginState.data : undefined, + useSlidingSync + ); const handleUsernameLogin = (username: string, password: string) => { startLogin(baseUrl, { @@ -265,6 +271,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog + + + + Use sliding sync (faster, but buggier) + + + - @@ -51,8 +45,20 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: [children] ); + const renderFallback = useCallback( + () => {children}, + [children] + ); + + const renderError = useCallback( + (_err: unknown, retry: () => void) => ( + + ), + [baseUrl] + ); + return ( - + {renderChildren} ); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f7f9edd9e6..d052f23d98 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -97,17 +97,6 @@ function installStartupFetchRoomEventPatch( fetchRoomEventStartupCleanupByClient.set(mx, restore); } -export const resolveSlidingEnabled = (enabled: SlidingSyncConfig['enabled']): boolean => { - if (enabled === undefined) return false; - if (typeof enabled === 'boolean') return enabled; - const normalized = String(enabled).trim().toLowerCase(); - if (normalized === 'false' || normalized === '0' || normalized === 'off' || normalized === 'no') - return false; - if (normalized === 'true' || normalized === '1' || normalized === 'on' || normalized === 'yes') - return true; - return false; -}; - const deleteDatabase = (name: string): Promise => new Promise((resolve) => { const req = window.indexedDB.deleteDatabase(name); @@ -405,10 +394,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): const slidingConfig = config?.slidingSync; const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl; - const useSliding = - config?.sessionSlidingSyncOptIn === true && - !!slidingConfig && - resolveSlidingEnabled(slidingConfig?.enabled); + const useSliding = config?.sessionSlidingSyncOptIn === true && !!slidingConfig; const presenceManager = new PresenceSyncManager(mx); presenceSyncByClient.set(mx, presenceManager);