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 (
-
-
-
-
-
- );
-}
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)
+
+
+