diff --git a/.changeset/improve-sliding-sync.md b/.changeset/improve-sliding-sync.md
new file mode 100644
index 0000000000..e4569ce8fb
--- /dev/null
+++ b/.changeset/improve-sliding-sync.md
@@ -0,0 +1,13 @@
+---
+default: minor
+---
+
+Improve sliding sync.
+
+- Make sliding sync easier to enable during login or loading and from settings.
+- Move the sliding sync toggle from Experimental to the bottom of General settings.
+- Start faster and restore rooms and spaces sooner.
+- Reduce unnecessary background room data.
+- Improve room details, memberships, presence, and unread indicators.
+- Make startup loading smoother for both sliding and classic sync.
+- It's fast now :3
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
index b80e408847..413aeb0831 100644
--- a/src/app/components/ClientConfigLoader.test.tsx
+++ b/src/app/components/ClientConfigLoader.test.tsx
@@ -1,99 +1,132 @@
-/**
- * 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';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { getMatrixToRoom, setMatrixToBase } from '$plugins/matrix-to';
+import { ClientConfigLoader, FALLBACK_CLIENT_CONFIG } from './ClientConfigLoader';
+
+const successfulResponse = (config: unknown) => ({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: vi.fn<() => Promise>().mockResolvedValue(config),
+});
afterEach(() => {
- setMatrixToBase(); // reset module state to 'https://matrix.to'
+ setMatrixToBase();
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({});
+describe('ClientConfigLoader', () => {
+ it('does not render config-dependent children while loading', () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => new Promise(() => undefined))
+ );
render(
-
- {(config) => {
- setMatrixToBase(config.matrixToBaseUrl);
- return {getMatrixToRoom('!room:example.com')};
- }}
+ Loading}>
+ {() => Configured app}
);
- await waitFor(() =>
- expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com')
- );
+ expect(screen.getByText('Loading')).toBeInTheDocument();
+ expect(screen.queryByText('Configured app')).not.toBeInTheDocument();
});
- it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => {
- mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
+ it('loads the config before rendering children', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(successfulResponse({ allowCustomHomeservers: true }))
+ );
render(
- {(config) => {
- setMatrixToBase(config.matrixToBaseUrl);
- return {getMatrixToRoom('!room:example.com')};
- }}
+ {(config) => {String(config.allowCustomHomeservers)}}
);
- await waitFor(() =>
- expect(screen.getByTestId('link')).toHaveTextContent(
- 'https://custom.example.org/#/!room:example.com'
- )
+ expect(await screen.findByText('true')).toBeInTheDocument();
+ });
+
+ it('reports non-success responses and retries', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ statusText: 'Service Unavailable',
+ json: vi.fn<() => Promise>(),
+ } as unknown as Response)
+ .mockResolvedValueOnce(
+ successfulResponse({ homeserverList: ['example.org'] }) as unknown as Response
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ render(
+ (
+
+ )}
+ >
+ {(config) => {config.homeserverList?.[0]}}
+
);
+
+ fireEvent.click(await screen.findByRole('button', { name: /503 Service Unavailable/ }));
+ expect(await screen.findByText('example.org')).toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
});
- it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => {
- mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
+ it('rejects non-object JSON', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(successfulResponse([])));
render(
-
- {(config) => {
- setMatrixToBase(config.matrixToBaseUrl);
- return {getMatrixToUser('@alice:example.com')};
- }}
+ {String(error)}}>
+ {() => Configured app}
);
- await waitFor(() =>
- expect(screen.getByTestId('user')).toHaveTextContent(
- 'https://custom.example.org/#/@alice:example.com'
- )
+ expect(await screen.findByText(/must contain a JSON object/)).toBeInTheDocument();
+ expect(screen.queryByText('Configured app')).not.toBeInTheDocument();
+ });
+
+ it('uses deliberate defaults only after continuing', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+
+ render(
+ (
+
+ )}
+ >
+ {(config) => {config.homeserverList?.join(',')}}
+
);
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
+ expect(screen.getByText(FALLBACK_CLIENT_CONFIG.homeserverList?.[0] ?? '')).toBeInTheDocument();
});
- it('strips a trailing slash from matrixToBaseUrl', async () => {
- mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' });
+ it('applies matrix.to configuration before rendering links', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(successfulResponse({ matrixToBaseUrl: 'https://custom.example/' }))
+ );
render(
{(config) => {
setMatrixToBase(config.matrixToBaseUrl);
- return {getMatrixToRoom('!room:example.com')};
+ return {getMatrixToRoom('!room:example.com')};
}}
);
await waitFor(() =>
- expect(screen.getByTestId('link')).toHaveTextContent(
- 'https://custom.example.org/#/!room:example.com'
- )
+ expect(screen.getByText('https://custom.example/#/!room:example.com')).toBeInTheDocument()
);
});
});
diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx
index f03b8398e0..6097be06ef 100644
--- a/src/app/components/ClientConfigLoader.tsx
+++ b/src/app/components/ClientConfigLoader.tsx
@@ -1,39 +1,110 @@
import type { ReactNode } from 'react';
-import { useCallback, useEffect, useState } from 'react';
-import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Box, Button, Dialog, Text, color, config } from 'folds';
import type { ClientConfig } from '$hooks/useClientConfig';
import { trimTrailingSlash } from '$utils/common';
+import { SplashScreen } from '$components/splash-screen';
-const getClientConfig = async (): Promise => {
+export const FALLBACK_CLIENT_CONFIG: ClientConfig = {
+ defaultHomeserver: 0,
+ homeserverList: ['matrix.org'],
+ allowCustomHomeservers: false,
+ hashRouter: { enabled: false, basename: '/' },
+};
+
+export const getClientConfig = async (): Promise => {
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
- const config = await fetch(url, { method: 'GET' });
- return config.json();
+ const response = await fetch(url, { method: 'GET' });
+ if (!response.ok) {
+ throw new Error(`Failed to load config.json (${response.status} ${response.statusText})`);
+ }
+
+ const data: unknown = await response.json();
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
+ throw new Error('config.json must contain a JSON object.');
+ }
+ return data as ClientConfig;
};
type ClientConfigLoaderProps = {
fallback?: () => ReactNode;
- error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
+ error?: (error: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (config: ClientConfig) => ReactNode;
};
+
+type ConfigLoadState =
+ | { status: 'loading' }
+ | { status: 'ready'; config: ClientConfig }
+ | { status: 'error'; error: unknown };
+
+const renderConfigError = (error: unknown, retry: () => void, ignore: () => void) => (
+
+
+
+
+
+);
+
export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
- const [state, load] = useAsyncCallback(getClientConfig);
- const [ignoreError, setIgnoreError] = useState(false);
+ const [state, setState] = useState({ status: 'loading' });
+ const [useFallback, setUseFallback] = useState(false);
+ const requestIdRef = useRef(0);
+
+ const load = useCallback(() => {
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+ setUseFallback(false);
+ setState({ status: 'loading' });
- const ignoreCallback = useCallback(() => setIgnoreError(true), []);
+ void getClientConfig().then(
+ (loadedConfig) => {
+ if (requestId === requestIdRef.current) {
+ setState({ status: 'ready', config: loadedConfig });
+ }
+ },
+ (loadError: unknown) => {
+ if (requestId === requestIdRef.current) setState({ status: 'error', error: loadError });
+ }
+ );
+ }, []);
+
+ const ignoreError = useCallback(() => setUseFallback(true), []);
useEffect(() => {
load();
+ return () => {
+ requestIdRef.current += 1;
+ };
}, [load]);
- if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
+ if (state.status === 'loading') {
return fallback?.();
}
-
- if (!ignoreError && state.status === AsyncStatus.Error) {
- return error?.(state.error, load, ignoreCallback);
+ if (state.status === 'error' && !useFallback) {
+ return (error ?? renderConfigError)(state.error, load, ignoreError);
}
- const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};
-
- return children(config);
+ const clientConfig = state.status === 'ready' ? state.config : FALLBACK_CLIENT_CONFIG;
+ return children(clientConfig);
}
diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx
index 0a92aa2a33..8dbfe1305c 100644
--- a/src/app/components/SpecVersionsLoader.tsx
+++ b/src/app/components/SpecVersionsLoader.tsx
@@ -6,18 +6,20 @@ import { specVersions } from '../cs-api';
type SpecVersionsLoaderProps = {
baseUrl: string;
+ loadVersions?: () => Promise;
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (versions: SpecVersions) => ReactNode;
};
export function SpecVersionsLoader({
baseUrl,
+ loadVersions,
fallback,
error,
children,
}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback(
- useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
+ useCallback(() => loadVersions?.() ?? specVersions(fetch, baseUrl), [baseUrl, loadVersions])
);
const [ignoreError, setIgnoreError] = useState(false);
@@ -32,7 +34,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/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
index ac43b4c563..48f11d0e8a 100644
--- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
+++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
@@ -34,7 +34,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
const [leaveState, leaveRoom] = useAsyncCallback(
useCallback(async () => {
debugLog.info('ui', 'Leave room button clicked', { roomId });
- mx.leave(roomId);
+ await mx.leave(roomId);
}, [mx, roomId])
);
diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx
index 82abef0a17..1c00610a47 100644
--- a/src/app/components/room-card/RoomCard.tsx
+++ b/src/app/components/room-card/RoomCard.tsx
@@ -39,6 +39,7 @@ import * as css from './style.css';
import type { RoomBannerContent } from '$types/matrix-sdk-events';
import { CustomStateEvent } from '$types/matrix/room';
import colorMXID from '$utils/colorMXID';
+import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics';
type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
@@ -191,7 +192,7 @@ export const RoomCard = as<'div', RoomCardProps>(
? getStateEvent(joinedRoom, CustomStateEvent.RoomBanner)
: undefined;
const bannerMXC = bannerState?.getContent()?.url;
- const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true);
+ const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication);
const roomName = joinedRoom?.name || name || fallbackName;
const roomTopic =
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
@@ -238,6 +239,7 @@ export const RoomCard = as<'div', RoomCardProps>(
src={bannerURI || avatar || undefined}
alt={`${name} cover`}
draggable="false"
+ onError={() => reportMediaLoadFailure('room_card_banner')}
/>
)}
diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts
index f4c0bafe68..92f193dbbe 100644
--- a/src/app/features/call/callRingtoneStorage.ts
+++ b/src/app/features/call/callRingtoneStorage.ts
@@ -41,36 +41,48 @@ async function putCustomCallAudio(
blob: file,
};
- await new Promise((resolve, reject) => {
- const tx = db.transaction(STORE, 'readwrite');
- tx.objectStore(STORE).put(entry);
- tx.addEventListener('complete', () => resolve());
- tx.addEventListener('error', () => reject(tx.error));
- });
+ try {
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).put(entry);
+ tx.addEventListener('complete', () => resolve());
+ tx.addEventListener('error', () => reject(tx.error));
+ });
+ } finally {
+ db.close();
+ }
return entry;
}
async function getCustomCallAudio(key: string): Promise {
const db = await openDb();
- return new Promise((resolve, reject) => {
- const tx = db.transaction(STORE, 'readonly');
- const req = tx.objectStore(STORE).get(key);
- req.addEventListener('error', () => reject(req.error));
- req.addEventListener('success', () => {
- resolve(req.result as StoredCallRingtone | undefined);
+ try {
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readonly');
+ const req = tx.objectStore(STORE).get(key);
+ req.addEventListener('error', () => reject(req.error));
+ req.addEventListener('success', () => {
+ resolve(req.result as StoredCallRingtone | undefined);
+ });
});
- });
+ } finally {
+ db.close();
+ }
}
async function clearCustomCallAudio(key: string): Promise {
const db = await openDb();
- await new Promise((resolve, reject) => {
- const tx = db.transaction(STORE, 'readwrite');
- tx.objectStore(STORE).delete(key);
- tx.addEventListener('complete', () => resolve());
- tx.addEventListener('error', () => reject(tx.error));
- });
+ try {
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).delete(key);
+ tx.addEventListener('complete', () => resolve());
+ tx.addEventListener('error', () => reject(tx.error));
+ });
+ } finally {
+ db.close();
+ }
}
export const putCustomCallRingtone = (
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
index 34fab623c9..6af3ab3764 100644
--- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
@@ -421,11 +421,10 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
{syncDiagnostics.sliding ? (
<>
- Proxy: {syncDiagnostics.sliding.proxyBaseUrl}
+ Base URL: {syncDiagnostics.sliding.baseUrl}
- Room timeline: {syncDiagnostics.sliding.timelineLimit} | page
- size: {syncDiagnostics.sliding.listPageSize}
+ Room timeline: {syncDiagnostics.sliding.timelineLimit}
>
) : (
diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx
index 6d2b764efe..ccfbb0e6a1 100644
--- a/src/app/features/common-settings/general/RoomProfile.tsx
+++ b/src/app/features/common-settings/general/RoomProfile.tsx
@@ -59,6 +59,7 @@ import { CustomStateEvent } from '$types/matrix/room';
import { SettingTile } from '$components/setting-tile';
import { stopPropagation } from '$utils/keyboard';
import FocusTrap from 'focus-trap-react';
+import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics';
type RoomProfileEditProps = {
canEditAvatar: boolean;
@@ -404,6 +405,7 @@ function RoomBannerEdit({ bannerURI, permissions }: Readonly) {
key={previewUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
alt="Banner Preview"
+ onError={() => reportMediaLoadFailure('room_banner_preview')}
/>
) : (
@@ -522,7 +524,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner);
const bannerMXC = bannerState?.getContent()?.url;
- const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true);
+ const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication);
return (
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index e8ba663823..fa7d171cb6 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -62,7 +62,7 @@ export function Room() {
});
}, [isWidgetDrawerOpen, room.roomId]);
const powerLevels = usePowerLevels(room);
- const members = useRoomMembers(mx, room.roomId);
+ const members = useRoomMembers(mx, room.roomId, screenSize === ScreenSize.Desktop && isDrawer);
const chat = useAtomValue(callChatAtom);
const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId));
const [threadBrowserOpen, setThreadBrowserOpen] = useAtom(
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d44751aae8..7ffc2f7e43 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -12,7 +12,7 @@ import {
import type { Editor } from 'slate';
import { useAtomValue, useSetAtom } from 'jotai';
import type { Room } from '$types/matrix-sdk';
-import { PushProcessor, Direction } from '$types/matrix-sdk';
+import { PushProcessor, Direction, EventType } from '$types/matrix-sdk';
import classNames from 'classnames';
import type { VListHandle } from 'virtua';
import { VList } from 'virtua';
@@ -41,6 +41,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
+import { useSlidingSyncRoomLoading } from '$hooks/useSlidingSyncActiveRoom';
import { useMentionClickHandler } from '$hooks/useMentionClickHandler';
import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl';
import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler';
@@ -282,6 +283,7 @@ export function RoomTimeline({
}: Readonly) {
const mx = useMatrixClient();
const alive = useAlive();
+ const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId);
const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive });
const { navigateRoom } = useRoomNavigate();
@@ -939,6 +941,8 @@ export function RoomTimeline({
timelineSync.backwardStatus === 'loading' && timelineSync.eventsLength > 0;
const showFrontPaginationSpinner =
timelineSync.forwardStatus === 'loading' && timelineSync.eventsLength > 0;
+ const hasPowerLevelState = !!room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
+ const hideTimelineForRoomState = roomSyncLoading && hideMemberInReadOnly && !hasPowerLevelState;
const timelineBottomFloatLift =
!atBottomState && isReady ? { bottom: `calc(${config.space.S400} + ${toRem(52)})` } : undefined;
const timelineTopFloatLift =
@@ -1049,6 +1053,15 @@ export function RoomTimeline({
return (
+ {(hideTimelineForRoomState || (roomSyncLoading && timelineSync.eventsLength === 0)) && (
+
+
+
+ )}
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && isReady && (
diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
index bce39fe571..e0b897d93f 100644
--- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
+++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { useAtomValue } from 'jotai';
import { Box, Button, Text } from 'folds';
import { CaretDown, CaretUp, menuIcon } from '$components/icons/phosphor';
import { SequenceCard } from '$components/sequence-card';
@@ -9,6 +10,9 @@ import { Direction, EventType, NotificationCountType, KnownMembership } from '$t
import { SequenceCardStyle } from '$features/settings/styles.css';
import { getUnreadInfo, isNotificationEvent } from '$utils/room';
+import { allRoomsAtom } from '$state/room-list/roomList';
+import { allInvitesAtom } from '$state/room-list/inviteList';
+import { getMediaLoadFailureCounts } from '$utils/mediaLoadDiagnostics';
type RoomRenderingDiagnostics = {
totalRooms: number;
@@ -116,6 +120,8 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => {
export function SyncDiagnostics() {
const mx = useMatrixClient();
+ const listedJoinedRoomIds = useAtomValue(allRoomsAtom);
+ const listedInviteRoomIds = useAtomValue(allInvitesAtom);
const [, setTick] = useState(0);
const [expandSliding, setExpandSliding] = useState(false);
@@ -127,6 +133,15 @@ export function SyncDiagnostics() {
const diagnostics = getClientSyncDiagnostics(mx);
const roomDiagnostics = getRoomRenderingDiagnostics(mx.getRooms());
const unreadDriftRooms = getUnreadDriftRooms(mx);
+ const listedJoinedRoomIdSet = new Set(listedJoinedRoomIds);
+ const sdkRoomsMissingFromList = mx
+ .getRooms()
+ .filter(
+ (room) =>
+ room.getMyMembership() === (KnownMembership.Join as string) &&
+ !listedJoinedRoomIdSet.has(room.roomId)
+ );
+ const mediaLoadFailures = [...getMediaLoadFailureCounts()];
return (
@@ -144,6 +159,24 @@ export function SyncDiagnostics() {
Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '}
{roomDiagnostics.inviteRooms} invites
+
+ Room-list atoms: {listedJoinedRoomIds.length} joined, {listedInviteRoomIds.length}{' '}
+ invites
+
+
+ SDK joined rooms missing from room list: {sdkRoomsMissingFromList.length}
+
+ {sdkRoomsMissingFromList.slice(0, 10).map((room) => (
+
+ {room.name || room.roomId} ({room.roomId})
+
+ ))}
+
+ Media load failures this session:{' '}
+ {mediaLoadFailures.length === 0
+ ? 'none'
+ : mediaLoadFailures.map(([kind, count]) => `${kind}: ${count}`).join(', ')}
+
Rooms missing name: {roomDiagnostics.roomsMissingName}
Rooms missing avatar: {roomDiagnostics.roomsMissingAvatar}
@@ -179,15 +212,15 @@ export function SyncDiagnostics() {
{expandSliding && (
- Sliding proxy: {diagnostics.sliding.proxyBaseUrl}
-
- Room timeline limit: {diagnostics.sliding.timelineLimit} | page size:{' '}
- {diagnostics.sliding.listPageSize}
-
+ Sliding sync base URL: {diagnostics.sliding.baseUrl}
+ Room timeline limit: {diagnostics.sliding.timelineLimit}
{diagnostics.sliding.lists.map((list) => (
List `{list.key}` coverage:{' '}
{formatListCoverage(list.knownCount, list.rangeEnd)}
+ {list.requestedRangeEnd > list.rangeEnd
+ ? ` (requested through ${list.requestedRangeEnd + 1})`
+ : ''}
))}
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 36a7c6d771..91a511dc50 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) => {
@@ -1400,76 +1401,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);
@@ -1498,7 +1429,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={
+
+ }
+ />
+
}
@@ -1667,7 +1625,7 @@ export function General({ requestBack, requestClose }: Readonly) {
-
+
diff --git a/src/app/hooks/router/useResolvedRoomId.ts b/src/app/hooks/router/useResolvedRoomId.ts
new file mode 100644
index 0000000000..43bffe3823
--- /dev/null
+++ b/src/app/hooks/router/useResolvedRoomId.ts
@@ -0,0 +1,64 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+
+type AliasResolutionState = {
+ alias: string;
+ roomId?: string;
+};
+
+export type ResolvedRoomId = {
+ roomId?: string;
+ resolving: boolean;
+};
+
+/** Resolve aliases even when the room is not present in the local sync store yet. */
+export const useResolvedRoomIdOrAlias = (roomIdOrAlias?: string): ResolvedRoomId => {
+ const mx = useMatrixClient();
+ const localRoomId =
+ roomIdOrAlias && isRoomAlias(roomIdOrAlias)
+ ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
+ : roomIdOrAlias;
+ const [aliasResolution, setAliasResolution] = useState();
+
+ useEffect(() => {
+ if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias) || localRoomId) return undefined;
+ if (aliasResolution?.alias === roomIdOrAlias) return undefined;
+
+ let cancelled = false;
+ void mx
+ .getRoomIdForAlias(roomIdOrAlias)
+ .then(({ room_id: resolvedRoomId }) => {
+ if (!cancelled) setAliasResolution({ alias: roomIdOrAlias, roomId: resolvedRoomId });
+ })
+ .catch(() => {
+ if (!cancelled) setAliasResolution({ alias: roomIdOrAlias });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [aliasResolution?.alias, localRoomId, mx, roomIdOrAlias]);
+
+ if (localRoomId) return { roomId: localRoomId, resolving: false };
+ if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias)) {
+ return { roomId: roomIdOrAlias, resolving: false };
+ }
+ if (aliasResolution?.alias === roomIdOrAlias) {
+ return { roomId: aliasResolution.roomId, resolving: false };
+ }
+ return { resolving: true };
+};
+
+export const useResolvedSelectedRoom = (): ResolvedRoomId => {
+ const { roomIdOrAlias: encodedRoomIdOrAlias } = useParams();
+ const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias);
+ return useResolvedRoomIdOrAlias(roomIdOrAlias);
+};
+
+export const useResolvedSelectedSpace = (): ResolvedRoomId => {
+ const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams();
+ const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
+ return useResolvedRoomIdOrAlias(spaceIdOrAlias);
+};
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 6850a7e774..76ca7eae6b 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -27,18 +27,6 @@ export type ClientConfig = {
webPushAppID?: string;
};
- slidingSync?: {
- enabled?: boolean;
- proxyBaseUrl?: string;
- bootstrapClassicOnColdCache?: boolean;
- listPageSize?: number;
- timelineLimit?: number;
- pollTimeoutMs?: number;
- maxRooms?: number;
- includeInviteList?: boolean;
- probeTimeoutMs?: number;
- };
-
featuredCommunities?: {
openAsDefault?: boolean;
spaces?: string[];
@@ -59,17 +47,17 @@ 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);
}
diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts
index 63cfb0d73a..516aba467b 100644
--- a/src/app/hooks/useImagePacks.ts
+++ b/src/app/hooks/useImagePacks.ts
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ImagePack, ImageUsage } from '$plugins/custom-emoji';
import {
getGlobalImagePacks,
+ getGlobalImagePackRoomIds,
getRoomImagePack,
getRoomImagePacks,
getUserImagePack,
@@ -15,6 +16,7 @@ import {
writeCachedPack,
writeCachedPacks,
} from '$plugins/custom-emoji';
+import { getSlidingSyncManager } from '$client/initMatrix';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
@@ -55,6 +57,9 @@ const imagePackListEqual = (a: ImagePack[], b: ImagePack[]): boolean => {
return a.every((pack, index) => imagePackEqual(pack, b[index]));
};
+const roomIdListEqual = (a: string[], b: string[]): boolean =>
+ a.length === b.length && a.every((roomId, index) => roomId === b[index]);
+
export const useUserImagePack = (): ImagePack | undefined => {
const mx = useMatrixClient();
// Seed from cache during initial state when live data is not available yet.
@@ -100,6 +105,24 @@ export const useGlobalImagePacks = (): ImagePack[] => {
const cachedPacks = readCachedPacks(userId, globalPacksScope());
return cachedPacks.length > 0 ? cachedPacks : livePacks;
});
+ const [packRoomIds, setPackRoomIds] = useState(() => getGlobalImagePackRoomIds(mx));
+
+ useEffect(() => {
+ const manager = getSlidingSyncManager(mx);
+ if (!manager) return undefined;
+
+ const refresh = () => {
+ setGlobalPacks((prev) => {
+ const next = getGlobalImagePacks(mx);
+ return imagePackListEqual(prev, next) ? prev : next;
+ });
+ };
+
+ manager.setImagePackSubscriptions(packRoomIds);
+ const listeners = packRoomIds.map((roomId) => manager.onRoomData(roomId, refresh));
+
+ return () => listeners.forEach((unsubscribe) => unsubscribe());
+ }, [mx, packRoomIds]);
useEffect(() => {
const userId = mx.getUserId();
@@ -114,6 +137,12 @@ export const useGlobalImagePacks = (): ImagePack[] => {
mEvent.getType() === (CustomAccountDataEvent.ImagePackRooms as string) ||
mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string)
) {
+ const manager = getSlidingSyncManager(mx);
+ const nextPackRoomIds = getGlobalImagePackRoomIds(mx);
+ manager?.setImagePackSubscriptions(nextPackRoomIds);
+ setPackRoomIds((current) =>
+ roomIdListEqual(current, nextPackRoomIds) ? current : nextPackRoomIds
+ );
setGlobalPacks((prev) => {
const next = getGlobalImagePacks(mx);
return imagePackListEqual(prev, next) ? prev : next;
diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts
index 46640040ec..31e04867f5 100644
--- a/src/app/hooks/useRoomMembers.ts
+++ b/src/app/hooks/useRoomMembers.ts
@@ -2,10 +2,15 @@ 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 undefined;
+ }
+
const room = mx.getRoom(roomId);
let loadingMembers = true;
let disposed = false;
@@ -40,7 +45,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
mx.removeListener(RoomStateEvent.Events, handleStateEvent);
};
- }, [mx, roomId]);
+ }, [enabled, mx, roomId]);
return members;
};
diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts
index 42481970f9..a46e56b45c 100644
--- a/src/app/hooks/useSlidingSyncActiveRoom.ts
+++ b/src/app/hooks/useSlidingSyncActiveRoom.ts
@@ -1,20 +1,129 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
+import { matchPath, useLocation } from 'react-router-dom';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { getSlidingSyncManager } from '$client/initMatrix';
-import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
+import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId';
+import { useSpaces } from '$state/hooks/roomList';
+import { allRoomsAtom } from '$state/room-list/roomList';
+import { ClientEvent } from '$types/matrix-sdk';
+import {
+ DIRECT_ROOM_PATH,
+ HOME_ROOM_PATH,
+ SPACE_LOBBY_PATH,
+ SPACE_PATH,
+ SPACE_ROOM_PATH,
+ SPACE_SEARCH_PATH,
+} from '$pages/paths';
+import { isRoomAlias, isRoomId } from '$utils/matrix';
-export const useSlidingSyncActiveRoom = (): void => {
+type RouteRoomRefs = {
+ roomIdOrAlias?: string;
+ spaceIdOrAlias?: string;
+};
+
+const decodeRoomRef = (value: string | undefined): string | undefined => {
+ if (!value) return undefined;
+ const decoded = decodeURIComponent(value);
+ return isRoomId(decoded) || isRoomAlias(decoded) ? decoded : undefined;
+};
+
+export const getRouteRoomRefs = (pathname: string): RouteRoomRefs => {
+ const homeRoomMatch = matchPath(HOME_ROOM_PATH, pathname);
+ const directRoomMatch = matchPath(DIRECT_ROOM_PATH, pathname);
+ const nonSpaceRoomMatch = homeRoomMatch ?? directRoomMatch;
+ if (nonSpaceRoomMatch) {
+ return { roomIdOrAlias: decodeRoomRef(nonSpaceRoomMatch.params.roomIdOrAlias) };
+ }
+
+ const spaceRoomMatch = matchPath(SPACE_ROOM_PATH, pathname);
+ if (spaceRoomMatch) {
+ return {
+ roomIdOrAlias: decodeRoomRef(spaceRoomMatch.params.roomIdOrAlias),
+ spaceIdOrAlias: decodeRoomRef(spaceRoomMatch.params.spaceIdOrAlias),
+ };
+ }
+
+ const spaceMatch =
+ matchPath(SPACE_LOBBY_PATH, pathname) ??
+ matchPath(SPACE_SEARCH_PATH, pathname) ??
+ matchPath(SPACE_PATH, pathname);
+ return { spaceIdOrAlias: decodeRoomRef(spaceMatch?.params.spaceIdOrAlias) };
+};
+
+const useAvailableSlidingSyncManager = () => {
const mx = useMatrixClient();
- const roomId = useSelectedRoom();
+ const [manager, setManager] = useState(() => getSlidingSyncManager(mx));
useEffect(() => {
- if (!roomId) return undefined;
- const manager = getSlidingSyncManager(mx);
- if (!manager) return undefined;
+ if (manager) return undefined;
+
+ const checkForManager = () => {
+ const nextManager = getSlidingSyncManager(mx);
+ if (nextManager) setManager(nextManager);
+ };
- manager.subscribeToRoom(roomId);
+ const retryId = globalThis.setTimeout(checkForManager, 0);
+ mx.on(ClientEvent.Sync, checkForManager);
return () => {
- manager.unsubscribeFromRoom(roomId);
+ globalThis.clearTimeout(retryId);
+ mx.removeListener(ClientEvent.Sync, checkForManager);
};
- }, [mx, roomId]);
+ }, [manager, mx]);
+
+ return manager;
+};
+
+export const useSlidingSyncRouteRooms = (): void => {
+ const manager = useAvailableSlidingSyncManager();
+ const { pathname } = useLocation();
+ const { roomIdOrAlias, spaceIdOrAlias } = getRouteRoomRefs(pathname);
+ const { roomId, resolving: resolvingRoom } = useResolvedRoomIdOrAlias(roomIdOrAlias);
+ const { roomId: spaceId, resolving: resolvingSpace } = useResolvedRoomIdOrAlias(spaceIdOrAlias);
+
+ useEffect(() => {
+ if (!manager || resolvingRoom || resolvingSpace) return undefined;
+
+ const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[];
+ manager.setActiveRoomSubscriptions(activeRoomIds);
+
+ return undefined;
+ }, [manager, resolvingRoom, resolvingSpace, roomId, spaceId]);
+
+ useEffect(() => {
+ if (!manager) return undefined;
+ return () => manager.setActiveRoomSubscriptions([]);
+ }, [manager]);
+};
+
+export const useSlidingSyncSpaceSubscriptions = (): void => {
+ const manager = useAvailableSlidingSyncManager();
+ const mx = useMatrixClient();
+ const spaces = useSpaces(mx, allRoomsAtom);
+
+ useEffect(() => {
+ if (!manager) return undefined;
+ manager.setSpaceSubscriptions(spaces);
+ return undefined;
+ }, [manager, spaces]);
+};
+
+export const useSlidingSyncRoomLoading = (roomId: string): boolean => {
+ const manager = useAvailableSlidingSyncManager();
+ const [loading, setLoading] = useState(() => manager?.isRoomSubscriptionLoading(roomId) ?? false);
+
+ useEffect(() => {
+ if (!manager) {
+ setLoading(false);
+ return undefined;
+ }
+
+ return manager.onRoomSubscriptionStatus(roomId, setLoading);
+ }, [manager, roomId]);
+
+ return loading;
+};
+
+export const useSlidingSyncActiveRoom = (): void => {
+ useSlidingSyncRouteRooms();
+ useSlidingSyncSpaceSubscriptions();
};
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/hooks/useSyncState.ts b/src/app/hooks/useSyncState.ts
index 61d48ed40c..b7cc987830 100644
--- a/src/app/hooks/useSyncState.ts
+++ b/src/app/hooks/useSyncState.ts
@@ -7,7 +7,13 @@ export const useSyncState = (
onChange: ClientEventHandlerMap[ClientEvent.Sync]
): void => {
useEffect(() => {
- mx?.on(ClientEvent.Sync, onChange);
+ if (!mx) return undefined;
+
+ mx.on(ClientEvent.Sync, onChange);
+
+ const currentState = mx.getSyncState();
+ if (currentState) onChange(currentState, null);
+
return () => {
mx?.removeListener(ClientEvent.Sync, onChange);
};
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 95cb850010..141f503765 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, useCallback, useMemo, useRef } from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { createStore } from 'jotai/vanilla';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
@@ -14,7 +14,6 @@ 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';
@@ -36,23 +35,22 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell
const jotaiStoreRef = useRef>();
if (!jotaiStoreRef.current) {
jotaiStoreRef.current = createStore();
+ bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults);
}
- bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults);
+ const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]);
const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled();
return (
-
-
-
-
-
- {reactQueryDevtoolsEnabled && (
-
-
-
- )}
-
-
+
+
+
+
+ {reactQueryDevtoolsEnabled && (
+
+
+
+ )}
+
);
}
@@ -65,28 +63,20 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId
);
}
-function appConfigFallback() {
- return ;
-}
-
-function appConfigError(err: unknown, retry: () => void, ignore: () => void) {
- return ;
-}
-
-function AppConfigLoaded({ clientConfig, screenSize }: BootstrappedAppShellProps) {
- setMatrixToBase(clientConfig.matrixToBaseUrl);
- return ;
-}
-
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
const portalContainer = document.getElementById('portalContainer') ?? undefined;
- const renderAppConfig = useCallback(
- (clientConfig: ClientConfig) => (
-
- ),
+ const renderConfiguredApp = useCallback(
+ (clientConfig: ClientConfig) => {
+ setMatrixToBase(clientConfig.matrixToBaseUrl);
+ return (
+
+
+
+ );
+ },
[screenSize]
);
@@ -97,9 +87,7 @@ function App() {
-
- {renderAppConfig}
-
+ {renderConfiguredApp}
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/Login.tsx b/src/app/pages/auth/login/Login.tsx
index c5b5b58422..4b399be619 100644
--- a/src/app/pages/auth/login/Login.tsx
+++ b/src/app/pages/auth/login/Login.tsx
@@ -1,5 +1,5 @@
-import { useMemo } from 'react';
-import { Box, Text, color } from 'folds';
+import { useMemo, useState } from 'react';
+import { Box, Switch, Text, Tooltip, TooltipProvider, color, config } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { SSOAction } from '$types/matrix-sdk';
import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows';
@@ -15,6 +15,8 @@ import { OrDivider } from '$pages/auth/OrDivider';
import { PasswordLoginForm } from './PasswordLoginForm';
import { TokenLogin } from './TokenLogin';
import { OidcLoginButton, OidcCallback } from './OidcLogin';
+import { sizedIcon, Info } from '$components/icons/phosphor';
+import { getPendingSlidingSyncLogin, setPendingSlidingSyncLogin } from './slidingSyncLogin';
// react-router ignores the real query string in hashRouter mode, so read callback params direct.
const getExternalSearchParams = () => {
@@ -38,6 +40,46 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
[searchParams]
);
+type SlidingSyncLoginOptionProps = {
+ value: boolean;
+ onChange: (value: boolean) => void;
+};
+
+function SlidingSyncLoginOption({ value, onChange }: SlidingSyncLoginOptionProps) {
+ return (
+
+
+
+ Use sliding sync
+
+
+
+ Sliding sync is faster and uses less bandwidth, but it can be buggier. You can
+ toggle it later in Settings at any time.
+
+
+ }
+ >
+ {(triggerRef) => (
+
+ {sizedIcon(Info, '100')}
+
+ )}
+
+
+
+
+ );
+}
+
export function Login() {
const server = useAuthServer();
const { hashRouter } = useClientConfig();
@@ -51,6 +93,12 @@ export function Login() {
const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true });
const external = getExternalSearchParams();
const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
+ const [useSlidingSync, setUseSlidingSync] = useState(getPendingSlidingSyncLogin);
+
+ const handleSlidingSyncChange = (enabled: boolean) => {
+ setUseSlidingSync(enabled);
+ setPendingSlidingSyncLogin(enabled);
+ };
if (hashRouter?.enabled && (external.loginToken || (external.code && external.state))) {
window.location.replace(
@@ -85,7 +133,7 @@ export function Login() {
const showLegacySso = !showOidc && parsedFlows.sso !== undefined;
if (showOidc && oidcCode && oidcState) {
- return ;
+ return ;
}
return (
@@ -93,14 +141,21 @@ export function Login() {
Login
+ {!showPassword && (
+
+ )}
{parsedFlows.token && loginSearchParams.loginToken && (
-
+
)}
{showPassword && (
<>
+ }
/>
{(showLegacySso || showOidc) && }
diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx
index 06ab23bad5..58de6f9d55 100644
--- a/src/app/pages/auth/login/OidcLogin.tsx
+++ b/src/app/pages/auth/login/OidcLogin.tsx
@@ -83,8 +83,9 @@ export function OidcLoginButton({
type OidcCallbackProps = {
code: string;
state: string;
+ slidingSyncOptIn: boolean;
};
-export function OidcCallback({ code, state }: OidcCallbackProps) {
+export function OidcCallback({ code, state, slidingSyncOptIn }: OidcCallbackProps) {
const commitSession = useCommitLoginSession();
const server = useAuthServer();
const navigate = useNavigate();
@@ -99,9 +100,9 @@ export function OidcCallback({ code, state }: OidcCallbackProps) {
useEffect(() => {
if (loginState.status === AsyncStatus.Success) {
- commitSession(loginState.data.session);
+ commitSession(loginState.data.session, slidingSyncOptIn);
}
- }, [loginState, commitSession]);
+ }, [loginState, slidingSyncOptIn, commitSession]);
return (
<>
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx
index f681e31f75..943daf94e1 100644
--- a/src/app/pages/auth/login/PasswordLoginForm.tsx
+++ b/src/app/pages/auth/login/PasswordLoginForm.tsx
@@ -1,4 +1,4 @@
-import type { FormEventHandler, MouseEventHandler } from 'react';
+import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react';
import { useCallback, useState } from 'react';
import type { RectCords } from 'folds';
import {
@@ -106,8 +106,15 @@ function UsernameHint({ server }: { server: string }) {
type PasswordLoginFormProps = {
defaultUsername?: string;
defaultEmail?: string;
+ slidingSyncOptIn: boolean;
+ slidingSyncOption?: ReactNode;
};
-export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) {
+export function PasswordLoginForm({
+ defaultUsername,
+ defaultEmail,
+ slidingSyncOptIn,
+ slidingSyncOption,
+}: PasswordLoginFormProps) {
const server = useAuthServer();
const clientConfig = useClientConfig();
@@ -120,7 +127,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
Parameters
>(useCallback(login, []));
- useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
+ useLoginComplete(
+ loginState.status === AsyncStatus.Success ? loginState.data : undefined,
+ slidingSyncOptIn
+ );
const handleUsernameLogin = (username: string, password: string) => {
startLogin(baseUrl, {
@@ -265,6 +275,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
+ {slidingSyncOption}
;
+
if (!space || !allRooms.includes(space.roomId)) {
return ;
}
diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts
index 4597b64baf..318a2a4e6d 100644
--- a/src/app/plugins/custom-emoji/utils.ts
+++ b/src/app/plugins/custom-emoji/utils.ts
@@ -105,6 +105,18 @@ export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] {
return packs;
}
+export function getGlobalImagePackRoomIds(mx: MatrixClient): string[] {
+ const emoteRoomsContent =
+ getAccountData(mx, CustomAccountDataEvent.ImagePackRooms)?.getContent() ||
+ getAccountData(mx, CustomAccountDataEvent.PoniesEmoteRooms)?.getContent();
+
+ if (typeof emoteRoomsContent !== 'object' || !emoteRoomsContent) return [];
+ const { rooms: roomIdToPackInfo } = emoteRoomsContent as { rooms?: unknown };
+ if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return [];
+
+ return Object.keys(roomIdToPackInfo);
+}
+
export function getUserImagePack(mx: MatrixClient): ImagePack | undefined {
const packEvent = getAccountData(mx, CustomAccountDataEvent.PoniesUserEmotes);
const userId = mx.getUserId();
diff --git a/src/app/state/room-list/inviteList.ts b/src/app/state/room-list/inviteList.ts
index efd4d6f808..5222f19ac2 100644
--- a/src/app/state/room-list/inviteList.ts
+++ b/src/app/state/room-list/inviteList.ts
@@ -4,22 +4,15 @@ import type { MatrixClient } from '$types/matrix-sdk';
import { useMemo } from 'react';
import type { RoomsAction } from './utils';
-import { useBindRoomsWithMembershipsAtom } from './utils';
+import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
import { KnownMembership } from '$types/matrix-sdk';
const baseRoomsAtom = atom([]);
export const allInvitesAtom = atom(
(get) => get(baseRoomsAtom),
- (get, set, action) => {
- if (action.type === 'INITIALIZE') {
- set(baseRoomsAtom, action.rooms);
- return;
- }
- set(baseRoomsAtom, (ids) => {
- const newIds = ids.filter((id) => id !== action.roomId);
- if (action.type === 'PUT') newIds.push(action.roomId);
- return newIds;
- });
+ (_get, set, action) => {
+ set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action));
+ return undefined;
}
);
diff --git a/src/app/state/room-list/roomList.ts b/src/app/state/room-list/roomList.ts
index 240efe828c..96efbf40bf 100644
--- a/src/app/state/room-list/roomList.ts
+++ b/src/app/state/room-list/roomList.ts
@@ -3,22 +3,15 @@ import type { MatrixClient } from '$types/matrix-sdk';
import { useMemo } from 'react';
import type { RoomsAction } from './utils';
-import { useBindRoomsWithMembershipsAtom } from './utils';
+import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
import { KnownMembership } from '$types/matrix-sdk';
const baseRoomsAtom = atom([]);
export const allRoomsAtom = atom(
(get) => get(baseRoomsAtom),
- (get, set, action) => {
- if (action.type === 'INITIALIZE') {
- set(baseRoomsAtom, action.rooms);
- return;
- }
- set(baseRoomsAtom, (ids) => {
- const newIds = ids.filter((id) => id !== action.roomId);
- if (action.type === 'PUT') newIds.push(action.roomId);
- return newIds;
- });
+ (_get, set, action) => {
+ set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action));
+ return undefined;
}
);
export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
diff --git a/src/app/state/room-list/utils.test.ts b/src/app/state/room-list/utils.test.ts
new file mode 100644
index 0000000000..a6fdb5932e
--- /dev/null
+++ b/src/app/state/room-list/utils.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { applyRoomsAction } from './utils';
+
+describe('applyRoomsAction', () => {
+ it('applies a page of room changes in one stable update', () => {
+ const current = ['!one:example.com', '!remove:example.com'];
+
+ expect(
+ applyRoomsAction(current, {
+ type: 'BATCH',
+ put: ['!one:example.com', '!two:example.com', '!three:example.com'],
+ delete: ['!remove:example.com'],
+ })
+ ).toEqual(['!one:example.com', '!two:example.com', '!three:example.com']);
+ });
+
+ it('preserves the existing array when a batch makes no changes', () => {
+ const current = ['!one:example.com'];
+ const next = applyRoomsAction(current, {
+ type: 'BATCH',
+ put: ['!one:example.com'],
+ delete: [],
+ });
+
+ expect(next).toBe(current);
+ });
+});
diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts
index b1184a2641..f0f5074279 100644
--- a/src/app/state/room-list/utils.ts
+++ b/src/app/state/room-list/utils.ts
@@ -10,10 +10,46 @@ export type RoomsAction =
rooms: string[];
}
| {
- type: 'PUT' | 'DELETE';
+ type: 'PUT';
roomId: string;
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ }
+ | {
+ type: 'BATCH';
+ put: string[];
+ delete: string[];
};
+export const applyRoomsAction = (ids: string[], action: RoomsAction): string[] => {
+ if (action.type === 'INITIALIZE') return [...new Set(action.rooms)];
+
+ if (action.type === 'PUT') {
+ if (ids.includes(action.roomId)) return ids;
+ return [...ids, action.roomId];
+ }
+
+ if (action.type === 'DELETE') {
+ if (!ids.includes(action.roomId)) return ids;
+ return ids.filter((id) => id !== action.roomId);
+ }
+
+ const deleted = new Set(action.delete);
+ const next = deleted.size === 0 ? [...ids] : ids.filter((id) => !deleted.has(id));
+ const known = new Set(next);
+ action.put.forEach((roomId) => {
+ if (known.has(roomId)) return;
+ known.add(roomId);
+ next.push(roomId);
+ });
+
+ const unchanged =
+ next.length === ids.length && next.every((roomId, index) => roomId === ids[index]);
+ return unchanged ? ids : next;
+};
+
export const useBindRoomsWithMembershipsAtom = (
mx: MatrixClient,
roomsAtom: WritableAtom,
@@ -22,6 +58,30 @@ export const useBindRoomsWithMembershipsAtom = (
const setRoomsAtom = useSetAtom(roomsAtom);
useEffect(() => {
+ let flushTimer: ReturnType | undefined;
+ const pendingRoomActions = new Map();
+
+ const flushPendingRoomActions = () => {
+ flushTimer = undefined;
+ if (pendingRoomActions.size === 0) return;
+
+ const put: string[] = [];
+ const deleted: string[] = [];
+ pendingRoomActions.forEach((action, roomId) => {
+ if (action === 'PUT') put.push(roomId);
+ else deleted.push(roomId);
+ });
+ pendingRoomActions.clear();
+ setRoomsAtom({ type: 'BATCH', put, delete: deleted });
+ };
+
+ const queueRoomAction = (action: 'PUT' | 'DELETE', roomId: string) => {
+ pendingRoomActions.set(roomId, action);
+ if (flushTimer === undefined) {
+ flushTimer = globalThis.setTimeout(flushPendingRoomActions, 0);
+ }
+ };
+
const satisfyMembership = (room: Room): boolean =>
!!memberships.find((membership) => membership === room.getMyMembership());
setRoomsAtom({
@@ -34,26 +94,28 @@ export const useBindRoomsWithMembershipsAtom = (
const handleAddRoom = (room: Room) => {
if (satisfyMembership(room)) {
- setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+ queueRoomAction('PUT', room.roomId);
}
};
const handleMembershipChange = (room: Room) => {
if (satisfyMembership(room)) {
- setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+ queueRoomAction('PUT', room.roomId);
} else {
- setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
+ queueRoomAction('DELETE', room.roomId);
}
};
const handleDeleteRoom = (roomId: string) => {
- setRoomsAtom({ type: 'DELETE', roomId });
+ queueRoomAction('DELETE', roomId);
};
mx.on(ClientEvent.Room, handleAddRoom);
mx.on(RoomEvent.MyMembership, handleMembershipChange);
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
return () => {
+ if (flushTimer !== undefined) globalThis.clearTimeout(flushTimer);
+ pendingRoomActions.clear();
mx.removeListener(ClientEvent.Room, handleAddRoom);
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts
index 24ded9f800..c3fb1cd432 100644
--- a/src/app/state/room/roomToUnread.ts
+++ b/src/app/state/room/roomToUnread.ts
@@ -9,7 +9,7 @@ import type {
ReceiptType,
} from '$types/matrix-sdk';
import { RoomEvent, SyncState, EventType, ClientEvent, KnownMembership } from '$types/matrix-sdk';
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useEffect } from 'react';
import type { RoomToUnread, UnreadInfo, Unread } from '$types/matrix/room';
import { NotificationType } from '$types/matrix/room';
import {
@@ -19,10 +19,9 @@ import {
getUnreadInfos,
isNotificationEvent,
} from '$utils/room';
-import { useStateEventCallback } from '$hooks/useStateEventCallback';
import { useSyncState } from '$hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '$hooks/useRoomsNotificationPreferences';
-import { getClientSyncDiagnostics } from '$client/initMatrix';
+import { getClientSyncDiagnostics, getSlidingSyncManager } from '$client/initMatrix';
import { mDirectAtom } from '$state/mDirectList';
import { roomToParentsAtom } from './roomToParents';
@@ -180,21 +179,45 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
const mDirects = useAtomValue(mDirectAtom);
- const spaceChildResetTimerRef = useRef(null);
+ const roomToParents = useAtomValue(roomToParentsAtom);
const shouldApplyUnreadFixup = useCallback(
() => getClientSyncDiagnostics(mx).transport === 'sliding',
[mx]
);
+ const publishUnreadAction = useCallback(
+ (action: RoomToUnreadAction) => {
+ if (getClientSyncDiagnostics(mx).transport === 'sliding') {
+ const manager = getSlidingSyncManager(mx);
+ if (manager?.isResponseProcessing()) return;
+ }
+ setUnreadAtom(action);
+ },
+ [mx, setUnreadAtom]
+ );
+
+ useEffect(() => {
+ const manager = getSlidingSyncManager(mx);
+ if (!manager) return undefined;
+ return manager.subscribeToResponseSettled(() => {
+ setUnreadAtom({
+ type: 'RESET',
+ unreadInfos: getUnreadInfos(mx, {
+ applyFixup: true,
+ mDirects,
+ }),
+ });
+ });
+ }, [mx, setUnreadAtom, mDirects]);
useEffect(() => {
- setUnreadAtom({
+ publishUnreadAction({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, {
applyFixup: shouldApplyUnreadFixup(),
mDirects,
}),
});
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects, roomToParents]);
useSyncState(
mx,
@@ -204,7 +227,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
(state === SyncState.Prepared && prevState === null) ||
(state === SyncState.Syncing && prevState !== SyncState.Syncing)
) {
- setUnreadAtom({
+ publishUnreadAction({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, {
applyFixup: shouldApplyUnreadFixup(),
@@ -213,7 +236,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
});
}
},
- [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]
+ [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]
)
);
@@ -227,7 +250,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
) => {
if (!room || room.isSpaceRoom()) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
- setUnreadAtom({
+ publishUnreadAction({
type: 'DELETE',
roomId: room.roomId,
});
@@ -241,7 +264,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
applyFixup: shouldApplyUnreadFixup(),
mDirects,
});
- setUnreadAtom({
+ publishUnreadAction({
type: 'PUT',
unreadInfo,
});
@@ -258,7 +281,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
mDirects,
});
if (unreadInfo.total > 0 || unreadInfo.highlight > 0) {
- setUnreadAtom({
+ publishUnreadAction({
type: 'PUT',
unreadInfo,
});
@@ -269,7 +292,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]);
useEffect(() => {
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
@@ -289,17 +312,17 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
mDirects,
});
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
- setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
+ publishUnreadAction({ type: 'DELETE', roomId: room.roomId });
return;
}
- setUnreadAtom({ type: 'PUT', unreadInfo });
+ publishUnreadAction({ type: 'PUT', unreadInfo });
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
return () => {
mx.removeListener(RoomEvent.Receipt, handleReceipt);
};
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]);
useEffect(() => {
const roomListeners = new Map();
@@ -317,10 +340,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
mDirects,
});
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
- setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
+ publishUnreadAction({ type: 'DELETE', roomId: room.roomId });
return;
}
- setUnreadAtom({ type: 'PUT', unreadInfo });
+ publishUnreadAction({ type: 'PUT', unreadInfo });
};
room.on(RoomEvent.UnreadNotifications, handleUnreadNotifications);
@@ -336,7 +359,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
room.removeListener(RoomEvent.UnreadNotifications, listener);
});
};
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]);
useEffect(() => {
const handleRoomAccountData = (mEvent: MatrixEvent, room: Room) => {
@@ -348,31 +371,31 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
mDirects,
});
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
- setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
+ publishUnreadAction({ type: 'DELETE', roomId: room.roomId });
return;
}
- setUnreadAtom({ type: 'PUT', unreadInfo });
+ publishUnreadAction({ type: 'PUT', unreadInfo });
};
mx.on(RoomEvent.AccountData, handleRoomAccountData);
return () => {
mx.removeListener(RoomEvent.AccountData, handleRoomAccountData);
};
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]);
useEffect(() => {
- setUnreadAtom({
+ publishUnreadAction({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, {
applyFixup: shouldApplyUnreadFixup(),
mDirects,
}),
});
- }, [mx, setUnreadAtom, roomsNotificationPreferences, shouldApplyUnreadFixup, mDirects]);
+ }, [mx, publishUnreadAction, roomsNotificationPreferences, shouldApplyUnreadFixup, mDirects]);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== (KnownMembership.Join as string)) {
- setUnreadAtom({
+ publishUnreadAction({
type: 'DELETE',
roomId: room.roomId,
});
@@ -382,7 +405,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
return () => {
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
};
- }, [mx, setUnreadAtom]);
+ }, [mx, publishUnreadAction]);
// Seed badge state immediately when a room is first registered with the client
// (e.g. after joining or receiving an invite that gets auto-accepted).
@@ -397,52 +420,12 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
mDirects,
});
if (unreadInfo.total > 0 || unreadInfo.highlight > 0) {
- setUnreadAtom({ type: 'PUT', unreadInfo });
+ publishUnreadAction({ type: 'PUT', unreadInfo });
}
};
mx.on(ClientEvent.Room, handleRoomAdded as (room: Room) => void);
return () => {
mx.removeListener(ClientEvent.Room, handleRoomAdded as (room: Room) => void);
};
- }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]);
-
- useEffect(
- () => () => {
- if (spaceChildResetTimerRef.current !== null) {
- window.clearTimeout(spaceChildResetTimerRef.current);
- spaceChildResetTimerRef.current = null;
- }
- },
- []
- );
-
- useStateEventCallback(
- mx,
- useCallback(
- (mEvent) => {
- if (mEvent.getType() === (EventType.SpaceChild as string)) {
- const roomId = mEvent.getRoomId();
- if (!roomId) return;
- const parentRoom = mx.getRoom(roomId);
- if (!parentRoom || parentRoom.getMyMembership() !== (KnownMembership.Join as string))
- return;
-
- if (spaceChildResetTimerRef.current !== null) {
- window.clearTimeout(spaceChildResetTimerRef.current);
- }
- spaceChildResetTimerRef.current = window.setTimeout(() => {
- setUnreadAtom({
- type: 'RESET',
- unreadInfos: getUnreadInfos(mx, {
- applyFixup: shouldApplyUnreadFixup(),
- mDirects,
- }),
- });
- spaceChildResetTimerRef.current = null;
- }, 150);
- }
- },
- [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]
- )
- );
+ }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]);
};
diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts
index b10b68c1e4..59b4519bfa 100644
--- a/src/app/theme/cache.ts
+++ b/src/app/theme/cache.ts
@@ -82,6 +82,30 @@ function openDb(): Promise {
});
}
+let dbPromise: Promise | undefined;
+
+function getDb(): Promise {
+ if (dbPromise) return dbPromise;
+ const pending = openDb()
+ .then((db) => {
+ const forget = () => {
+ if (dbPromise === pending) dbPromise = undefined;
+ };
+ db.addEventListener('close', forget);
+ db.addEventListener('versionchange', () => {
+ forget();
+ db.close();
+ });
+ return db;
+ })
+ .catch((error) => {
+ if (dbPromise === pending) dbPromise = undefined;
+ throw error;
+ });
+ dbPromise = pending;
+ return pending;
+}
+
export async function hashThemeCss(cssText: string): Promise {
try {
const bytes = new TextEncoder().encode(cssText);
@@ -99,7 +123,7 @@ export async function hashThemeCss(cssText: string): Promise {
}
export async function getCachedThemeEntry(url: string): Promise {
- const db = await openDb();
+ const db = await getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(url);
@@ -109,7 +133,7 @@ export async function getCachedThemeEntry(url: string): Promise {
- const db = await openDb();
+ const db = await getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(entry);
@@ -196,7 +220,7 @@ export async function revalidateCachedThemeCss(
}
export async function clearThemeCache(): Promise {
- const db = await openDb();
+ const db = await getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
@@ -206,7 +230,7 @@ export async function clearThemeCache(): Promise {
}
export async function getThemeCacheStats(): Promise {
- const db = await openDb();
+ const db = await getDb();
const entries = await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).getAll();
@@ -240,7 +264,7 @@ export async function getThemeCacheStats(): Promise {
/** Clears only refetchable network CSS. Uploaded local theme packages are preserved. */
export async function clearRemoteThemeCache(): Promise {
- const db = await openDb();
+ const db = await getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
diff --git a/src/app/utils/featureCheck.ts b/src/app/utils/featureCheck.ts
index d5564e749d..16a82829ad 100644
--- a/src/app/utils/featureCheck.ts
+++ b/src/app/utils/featureCheck.ts
@@ -11,6 +11,7 @@ export const checkIndexedDBSupport = async (): Promise => {
}
db.addEventListener('success', () => {
resolve(true);
+ db.result.close();
indexedDB.deleteDatabase(dbName);
});
db.addEventListener('error', () => {
diff --git a/src/app/utils/mediaLoadDiagnostics.ts b/src/app/utils/mediaLoadDiagnostics.ts
new file mode 100644
index 0000000000..79bf773f89
--- /dev/null
+++ b/src/app/utils/mediaLoadDiagnostics.ts
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/react';
+import { createDebugLogger } from './debugLogger';
+
+export type MediaLoadKind = 'room_banner' | 'room_card_banner' | 'room_banner_preview';
+
+const debugLog = createDebugLogger('media');
+const failureCounts = new Map();
+
+export const reportMediaLoadFailure = (kind: MediaLoadKind): void => {
+ failureCounts.set(kind, (failureCounts.get(kind) ?? 0) + 1);
+ debugLog.warn('network', 'Media failed to load', { kind });
+ Sentry.metrics.count('sable.media.load_error', 1, { attributes: { kind } });
+};
+
+export const getMediaLoadFailureCounts = (): ReadonlyMap =>
+ new Map(failureCounts);
diff --git a/src/app/utils/presence.ts b/src/app/utils/presence.ts
index cb50bab9d0..cbe835f90f 100644
--- a/src/app/utils/presence.ts
+++ b/src/app/utils/presence.ts
@@ -1,5 +1,6 @@
import type { MatrixClient } from 'matrix-js-sdk';
import { SetPresence } from 'matrix-js-sdk';
+import { getPresenceSyncManager } from '$client/initMatrix';
import { Presence } from '../hooks/useUserPresence';
const PRESENCE_TO_SET_PRESENCE: Record = {
@@ -16,8 +17,10 @@ export const setUserPresence = async (
presence: Presence,
statusMsg?: string
): Promise => {
- Promise.all([
- mx.setSyncPresence(presenceToSetPresence(presence)),
+ const syncPresence = presenceToSetPresence(presence);
+ getPresenceSyncManager(mx)?.setPresence(syncPresence);
+ await Promise.all([
+ mx.setSyncPresence(syncPresence),
mx.setPresence({
presence,
status_msg: statusMsg,
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 381739cd16..d2c615df4f 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -457,10 +457,18 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn
// If we have no read receipt, SDK counts may be unreliable. Always check timeline.
if (!readUpToId) {
const liveEvents = room.getLiveTimeline().getEvents();
-
- const hasActivity = liveEvents.some(
- (event) => event.getSender() !== userId && isNotificationEvent(event, room, userId)
- );
+ const fullyReadEventId = room
+ .getAccountData(EventType.FullyRead)
+ ?.getContent<{ event_id?: string }>()?.event_id;
+ let hasActivity = false;
+ for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+ const event = liveEvents[i];
+ if (!event || event.getId() === fullyReadEventId) break;
+ if (event.getSender() !== userId && isNotificationEvent(event, room, userId)) {
+ hasActivity = true;
+ break;
+ }
+ }
if (hasActivity) {
// If SDK already has counts, use those. Otherwise show dot badge (count=1).
diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts
index 8f9a413884..a232ea8e58 100644
--- a/src/app/utils/sort.test.ts
+++ b/src/app/utils/sort.test.ts
@@ -14,14 +14,18 @@ import {
} from './sort';
// Minimal stub that satisfies the MatrixClient shape needed by these sort functions.
-function makeClient(rooms: Record): MatrixClient {
+function makeClient(
+ rooms: Record
+): MatrixClient {
return {
getRoom: (id: string) => {
const r = rooms[id];
if (!r) return null;
- return { name: r.name, getLastActiveTimestamp: () => r.ts } as unknown as ReturnType<
- MatrixClient['getRoom']
- >;
+ return {
+ name: r.name,
+ getLastActiveTimestamp: () => r.ts,
+ getBumpStamp: () => r.bumpStamp,
+ } as unknown as ReturnType;
},
} as unknown as MatrixClient;
}
@@ -63,6 +67,15 @@ describe('factoryRoomIdByActivity', () => {
const sort = factoryRoomIdByActivity(mx);
expect(['!unknown:h', '!known:h'].toSorted(sort)).toEqual(['!known:h', '!unknown:h']);
});
+
+ it('uses sliding-sync bump stamps when no timeline event is loaded', () => {
+ const mx = makeClient({
+ '!old:h': { name: 'Old', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 1000 },
+ '!new:h': { name: 'New', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 9000 },
+ });
+ const sort = factoryRoomIdByActivity(mx);
+ expect(['!old:h', '!new:h'].toSorted(sort)).toEqual(['!new:h', '!old:h']);
+ });
});
describe('factoryRoomIdByAtoZ', () => {
diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts
index 454521df60..0de221b28b 100644
--- a/src/app/utils/sort.ts
+++ b/src/app/utils/sort.ts
@@ -2,16 +2,19 @@ import type { MatrixClient } from '$types/matrix-sdk';
export type SortFunc = (a: T, b: T) => number;
+const getRoomActivity = (mx: MatrixClient, roomId: string): number => {
+ const room = mx.getRoom(roomId);
+ if (!room) return Number.MIN_SAFE_INTEGER;
+ const timelineTimestamp = room.getLastActiveTimestamp();
+ return timelineTimestamp === Number.MIN_SAFE_INTEGER
+ ? (room.getBumpStamp() ?? Number.MIN_SAFE_INTEGER)
+ : timelineTimestamp;
+};
+
export const factoryRoomIdByActivity =
(mx: MatrixClient): SortFunc =>
(a, b) => {
- const room1 = mx.getRoom(a);
- const room2 = mx.getRoom(b);
-
- return (
- (room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) -
- (room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER)
- );
+ return getRoomActivity(mx, b) - getRoomActivity(mx, a);
};
export const factoryRoomIdByAtoZ =
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 1c2d0a4866..fc29e68260 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -4,27 +4,100 @@ import type {
MSC3575SlidingSyncRequest,
MSC3575SlidingSyncResponse,
} from '$types/matrix-sdk';
-import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk';
+import {
+ ClientEvent,
+ createClient,
+ IndexedDBStore,
+ IndexedDBCryptoStore,
+ KnownMembership,
+ SyncState,
+} from '$types/matrix-sdk';
import { clearNavToActivePathStore } from '$state/navToActivePath';
-import type { Session, Sessions, SessionStoreName } from '$state/sessions';
-import { getSessionStoreName, MATRIX_SESSIONS_KEY } from '$state/sessions';
-import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage';
+import type { Session, SessionStoreName } from '$state/sessions';
+import { getSessionStoreName } from '$state/sessions';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import * as Sentry from '@sentry/react';
import { pushSessionToSW } from '../sw-session';
import { SessionOidcTokenRefresher } from './oidcTokenRefresher';
import { cryptoCallbacks } from './secretStorageKeys';
-import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync';
-import { SlidingSyncManager } from './slidingSync';
+import type { SlidingSyncDiagnostics } from './slidingSync';
+import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync';
import { PresenceSyncManager } from './presenceSync';
+import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
const log = createLogger('initMatrix');
const debugLog = createDebugLogger('initMatrix');
const slidingSyncByClient = new WeakMap();
+const membershipActionCleanupByClient = new WeakMap void>();
const presenceSyncByClient = new WeakMap();
-const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000;
+const presenceStartCleanupByClient = new WeakMap void>();
+const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000;
+
+const isInitialSyncReady = (state: string | null): boolean =>
+ state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup;
+
+const startPresenceAfterInitialSync = (
+ mx: MatrixClient,
+ manager: PresenceSyncManager
+): (() => void) => {
+ let started = false;
+ let startTimer: ReturnType | undefined;
+
+ const start = () => {
+ if (started) return;
+ started = true;
+ presenceStartCleanupByClient.delete(mx);
+ mx.removeListener(ClientEvent.Sync, onSync);
+ manager.start();
+ };
+
+ const scheduleStart = () => {
+ if (startTimer !== undefined) return;
+ startTimer = globalThis.setTimeout(() => {
+ startTimer = undefined;
+ start();
+ }, 0);
+ };
+
+ const onSync = (state: SyncState) => {
+ if (isInitialSyncReady(state)) scheduleStart();
+ };
+
+ if (isInitialSyncReady(mx.getSyncState())) scheduleStart();
+ else mx.on(ClientEvent.Sync, onSync);
+
+ const cleanup = () => {
+ if (startTimer !== undefined) globalThis.clearTimeout(startTimer);
+ mx.removeListener(ClientEvent.Sync, onSync);
+ presenceStartCleanupByClient.delete(mx);
+ };
+ presenceStartCleanupByClient.set(mx, cleanup);
+ return cleanup;
+};
+
+type StartupPhase = 'sync_store' | 'rust_crypto' | 'client_init' | 'client_start';
+
+const measureStartupPhase = async (
+ phase: StartupPhase,
+ task: () => Promise,
+ attributes?: Record
+): Promise => {
+ const startTime = performance.now();
+ try {
+ const result = await task();
+ Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, {
+ attributes: { phase, outcome: 'success', ...attributes },
+ });
+ return result;
+ } catch (error) {
+ Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, {
+ attributes: { phase, outcome: 'error', ...attributes },
+ });
+ throw error;
+ }
+};
type FetchRoomEventResult = Awaited>;
type MatrixClientWithWritableFetchRoomEvent = MatrixClient & {
@@ -33,11 +106,11 @@ type MatrixClientWithWritableFetchRoomEvent = MatrixClient & {
const fetchRoomEventStartupCleanupByClient = new WeakMap void>();
-const slidingSyncConnIdCleanupByClient = new WeakMap void>();
+const slidingSyncRequestCleanupByClient = new WeakMap void>();
type SlidingSyncMethod = (
reqBody: MSC3575SlidingSyncRequest,
- proxyBaseUrl?: string,
+ baseUrl?: string,
abortSignal?: AbortSignal
) => Promise;
@@ -51,22 +124,25 @@ type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & {
const SLIDING_SYNC_CONN_ID = 'sable-main';
-function installSlidingSyncConnId(mx: MatrixClient): void {
- slidingSyncConnIdCleanupByClient.get(mx)?.();
+function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncManager): void {
+ slidingSyncRequestCleanupByClient.get(mx)?.();
const mxWritable = mx as MatrixClientWithWritableSlidingSync;
const original = mx.slidingSync.bind(mx) as SlidingSyncMethod;
-
- mxWritable.slidingSync = (reqBody, proxyBaseUrl, abortSignal) => {
+ mxWritable.slidingSync = (reqBody, baseUrl, abortSignal) => {
const req = reqBody as SlidingSyncRequestWithConnId;
if (req.conn_id === undefined) {
req.conn_id = SLIDING_SYNC_CONN_ID;
}
- return original(reqBody, proxyBaseUrl, abortSignal);
+
+ const roomIds = manager.getActiveRoomSubscriptionIds();
+ scopeEphemeralExtensions(req.extensions, roomIds);
+
+ return original(reqBody, baseUrl, abortSignal);
};
- slidingSyncConnIdCleanupByClient.set(mx, () => {
- slidingSyncConnIdCleanupByClient.delete(mx);
+ slidingSyncRequestCleanupByClient.set(mx, () => {
+ slidingSyncRequestCleanupByClient.delete(mx);
mxWritable.slidingSync = original;
});
}
@@ -80,11 +156,19 @@ function installStartupFetchRoomEventPatch(
const mxWritable = mx as MatrixClientWithWritableFetchRoomEvent;
const origFetchRoomEvent = mx.fetchRoomEvent.bind(mx);
+ let restored = false;
const restore = () => {
+ if (restored) return;
+ restored = true;
fetchRoomEventStartupCleanupByClient.delete(mx);
+ mx.removeListener(ClientEvent.Sync, onSync);
mxWritable.fetchRoomEvent = origFetchRoomEvent;
};
+ const onSync = (state: SyncState) => {
+ if (isInitialSyncReady(state)) restore();
+ };
+
mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => {
if (slidingSyncManager.isRoomActive(roomId)) {
return origFetchRoomEvent(roomId, eventId);
@@ -98,19 +182,9 @@ function installStartupFetchRoomEventPatch(
};
fetchRoomEventStartupCleanupByClient.set(mx, restore);
+ mx.on(ClientEvent.Sync, onSync);
}
-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);
@@ -119,14 +193,6 @@ const deleteDatabase = (name: string): Promise =>
req.addEventListener('blocked', () => resolve());
});
-const deleteSyncStoreGroup = async (syncStoreName: string): Promise => {
- await Promise.all([
- deleteDatabase(syncStoreName),
- deleteDatabase(syncStoreName.replace(/^sync/, 'crypto')),
- deleteDatabase(`${syncStoreName}::matrix-sdk-crypto`),
- ]);
-};
-
const deleteSessionStores = async (storeName: SessionStoreName): Promise => {
await Promise.all([
deleteDatabase(storeName.sync),
@@ -135,60 +201,6 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise =
]);
};
-/**
- * Reads the account stored in an IndexedDB sync store without opening a full MatrixClient.
- * Returns undefined if the database doesn't exist or has no account record.
- */
-const readStoredAccount = (dbName: string): Promise =>
- new Promise((resolve) => {
- let settled = false;
- const finish = (value: string | undefined) => {
- if (settled) return;
- settled = true;
- resolve(value);
- };
- const req = window.indexedDB.open(dbName);
- req.addEventListener('error', () => finish(undefined));
- req.addEventListener('success', () => {
- const db = req.result;
- try {
- if (!db.objectStoreNames.contains('account')) {
- db.close();
- finish(undefined);
- } else {
- const tx = db.transaction('account', 'readonly');
- const store = tx.objectStore('account');
- const getReq = store.get('account');
- getReq.addEventListener('success', () => {
- db.close();
- const record = getReq.result;
- if (!record?.account_data) {
- finish(undefined);
- } else {
- try {
- const data = JSON.parse(record.account_data);
- finish(data?.user_id ?? undefined);
- } catch {
- finish(undefined);
- }
- }
- });
- getReq.addEventListener('error', () => {
- db.close();
- finish(undefined);
- });
- }
- } catch {
- try {
- db.close();
- } catch {
- /* ignore */
- }
- finish(undefined);
- }
- });
- });
-
const isMismatch = (err: unknown): boolean => {
const msg = err instanceof Error ? err.message : String(err);
return (
@@ -199,75 +211,12 @@ const isMismatch = (err: unknown): boolean => {
);
};
-/**
- * Pre-flight check: scans every IndexedDB database and deletes any that
- * belong to a userId not present in the stored sessions list, or whose
- * sync-store data contradicts the expected session userId.
- * Call this once on startup before initClient.
- */
-export const clearMismatchedStores = async (): Promise => {
- const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []);
- const knownUserIds = new Set(sessions.map((s) => s.userId));
- const knownStoreNames = new Set(
- sessions.flatMap((s) => {
- const sn = getSessionStoreName(s);
- return [sn.sync, sn.crypto, `${sn.rustCryptoPrefix}::matrix-sdk-crypto`];
- })
- );
-
- let allDbs: IDBDatabaseInfo[] = [];
- try {
- allDbs = await window.indexedDB.databases();
- } catch {
- // databases() not supported in all browsers
- }
-
- await Promise.all(
- allDbs.map(async ({ name }) => {
- if (!name) return;
-
- const containsKnownUser = Array.from(knownUserIds).some((uid) => name.includes(uid));
- const looksLikeUserDb = name.includes('@');
- if (looksLikeUserDb && !containsKnownUser && !knownStoreNames.has(name)) {
- log.warn(`clearMismatchedStores: "${name}" has unknown user — deleting`);
- await deleteDatabase(name);
- return;
- }
-
- if (!name.startsWith('sync')) return;
-
- const storedUserId = await readStoredAccount(name);
- if (!storedUserId) return;
-
- if (!knownUserIds.has(storedUserId)) {
- log.warn(`clearMismatchedStores: "${name}" has unknown user ${storedUserId} — deleting`);
- await deleteSyncStoreGroup(name);
- return;
- }
-
- const expectedStore = `sync${storedUserId}`;
- if (name !== expectedStore && !knownStoreNames.has(name)) {
- log.warn(`clearMismatchedStores: "${name}" is misplaced for ${storedUserId} — deleting`);
- await deleteSyncStoreGroup(name);
- }
- })
- );
-
- await Promise.all(
- sessions.map(async (session) => {
- const sn = getSessionStoreName(session);
- const storedUserId = await readStoredAccount(sn.sync);
- if (storedUserId && storedUserId !== session.userId) {
- log.warn(
- `clearMismatchedStores: "${sn.sync}" has ${storedUserId} but session is ${session.userId} — deleting`
- );
- await deleteSessionStores(sn);
- }
- })
- );
+type BuiltClient = {
+ mx: MatrixClient;
+ indexedDBStore: IndexedDBStore;
};
-const buildClient = async (session: Session): Promise => {
+const buildClient = (session: Session): BuiltClient => {
const storeName = getSessionStoreName(session);
const indexedDBStore = new IndexedDBStore({
@@ -297,8 +246,46 @@ const buildClient = async (session: Session): Promise => {
: undefined,
});
- await indexedDBStore.startup();
- return mx;
+ return { mx, indexedDBStore };
+};
+
+type ClientInitializationResult =
+ | { ok: true; mx: MatrixClient }
+ | { ok: false; error: unknown; phase: 'sync_store' | 'rust_crypto' };
+
+const initializeClient = async (
+ session: Session,
+ cryptoDatabasePrefix: string
+): Promise => {
+ let builtClient: BuiltClient;
+ try {
+ builtClient = buildClient(session);
+ } catch (error) {
+ return { ok: false, error, phase: 'sync_store' };
+ }
+ const { mx, indexedDBStore } = builtClient;
+
+ void mx.getVersions().catch(() => undefined);
+
+ const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup());
+ const cryptoPromise = measureStartupPhase('rust_crypto', () =>
+ mx.initRustCrypto({ cryptoDatabasePrefix })
+ );
+ const [syncStoreResult, cryptoResult] = await Promise.allSettled([
+ syncStorePromise,
+ cryptoPromise,
+ ]);
+
+ if (syncStoreResult.status === 'rejected') {
+ mx.stopClient();
+ return { ok: false, error: syncStoreResult.reason, phase: 'sync_store' };
+ }
+ if (cryptoResult.status === 'rejected') {
+ mx.stopClient();
+ return { ok: false, error: cryptoResult.reason, phase: 'rust_crypto' };
+ }
+
+ return { ok: true, mx };
};
export const initClient = async (session: Session): Promise => {
@@ -335,51 +322,54 @@ export const initClient = async (session: Session): Promise => {
}
};
- let mx: MatrixClient;
+ const initStartTime = performance.now();
+ let initOutcome = 'success';
try {
- mx = await buildClient(session);
- } catch (err) {
- if (!isMismatch(err)) {
- debugLog.error('sync', 'Failed to build client', { error: err });
- throw err;
- }
- log.warn('initClient: mismatch on buildClient — wiping and retrying:', err);
- debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err });
- await wipeAllStores();
- mx = await buildClient(session);
- }
+ let result = await initializeClient(session, storeName.rustCryptoPrefix);
+ if (!result.ok) {
+ if (!isMismatch(result.error)) {
+ debugLog.error('sync', 'Failed to initialize client', {
+ phase: result.phase,
+ error: result.error,
+ });
+ throw result.error;
+ }
- try {
- await mx.initRustCrypto({
- cryptoDatabasePrefix: storeName.rustCryptoPrefix,
- });
- } catch (err) {
- if (!isMismatch(err)) {
- debugLog.error('sync', 'Failed to initialize crypto', { error: err });
- throw err;
+ log.warn(`initClient: mismatch during ${result.phase} — wiping and retrying:`, result.error);
+ debugLog.warn('sync', 'Client initialization mismatch - wiping stores and retrying', {
+ phase: result.phase,
+ error: result.error,
+ });
+ await wipeAllStores();
+ result = await initializeClient(session, storeName.rustCryptoPrefix);
+ if (!result.ok) {
+ debugLog.error('sync', 'Failed to initialize client after store reset', {
+ phase: result.phase,
+ error: result.error,
+ });
+ throw result.error;
+ }
}
- log.warn('initClient: mismatch on initRustCrypto — wiping and retrying:', err);
- debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', {
- error: err,
- });
- mx.stopClient();
- await wipeAllStores();
- mx = await buildClient(session);
- await mx.initRustCrypto({
- cryptoDatabasePrefix: storeName.rustCryptoPrefix,
+
+ result.mx.setMaxListeners(50);
+ return result.mx;
+ } catch (error) {
+ initOutcome = 'error';
+ throw error;
+ } finally {
+ Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - initStartTime, {
+ attributes: { phase: 'client_init', outcome: initOutcome },
});
}
-
- mx.setMaxListeners(50);
- return mx;
};
export type StartClientConfig = {
baseUrl?: string;
- slidingSync?: SlidingSyncConfig;
sessionSlidingSyncOptIn?: boolean;
pollTimeoutMs?: number;
timelineLimit?: number;
+ initialRoomIds?: Iterable;
+ onCachedRoomsLoaded?: () => void;
};
export type ClientSyncDiagnostics = {
@@ -389,13 +379,51 @@ export type ClientSyncDiagnostics = {
};
const disposeSlidingSync = (mx: MatrixClient): void => {
+ membershipActionCleanupByClient.get(mx)?.();
const manager = slidingSyncByClient.get(mx);
if (!manager) return;
manager.dispose();
slidingSyncByClient.delete(mx);
};
+type MatrixClientWithWritableMembershipActions = MatrixClient & {
+ joinRoom: MatrixClient['joinRoom'];
+ leave: MatrixClient['leave'];
+};
+
+const installMembershipActionReconciliation = (
+ mx: MatrixClient,
+ manager: SlidingSyncManager
+): void => {
+ membershipActionCleanupByClient.get(mx)?.();
+
+ const writableMx = mx as MatrixClientWithWritableMembershipActions;
+ // Keep the exact methods so cleanup restores the client without leaving bound replacements behind.
+ // oxlint-disable-next-line typescript/unbound-method
+ const originalJoinRoom = mx.joinRoom;
+ // oxlint-disable-next-line typescript/unbound-method
+ const originalLeave = mx.leave;
+
+ writableMx.joinRoom = async (...args) => {
+ const room = await originalJoinRoom.apply(mx, args);
+ manager.reconcileRoomMembership(room.roomId, KnownMembership.Join);
+ return room;
+ };
+ writableMx.leave = async (...args) => {
+ const result = await originalLeave.apply(mx, args);
+ manager.reconcileRoomMembership(args[0], KnownMembership.Leave);
+ return result;
+ };
+
+ membershipActionCleanupByClient.set(mx, () => {
+ membershipActionCleanupByClient.delete(mx);
+ writableMx.joinRoom = originalJoinRoom;
+ writableMx.leave = originalLeave;
+ });
+};
+
const disposePresenceSync = (mx: MatrixClient): void => {
+ presenceStartCleanupByClient.get(mx)?.();
const manager = presenceSyncByClient.get(mx);
if (!manager) return;
manager.dispose();
@@ -409,51 +437,60 @@ export const getPresenceSyncManager = (mx: MatrixClient): PresenceSyncManager |
presenceSyncByClient.get(mx);
export const startClient = async (mx: MatrixClient, config?: StartClientConfig): Promise => {
- debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() });
disposeSlidingSync(mx);
disposePresenceSync(mx);
- const slidingConfig = config?.slidingSync;
- const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl;
- const useSliding =
- config?.sessionSlidingSyncOptIn === true &&
- !!slidingConfig &&
- resolveSlidingEnabled(slidingConfig?.enabled);
+ const baseUrl = config?.baseUrl ?? mx.baseUrl;
+ const useSliding = config?.sessionSlidingSyncOptIn === true;
+
+ debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() });
let manager: SlidingSyncManager | undefined;
- let presenceManager: PresenceSyncManager | undefined;
if (useSliding) {
- manager = new SlidingSyncManager(mx, proxyBaseUrl, {
- ...slidingConfig,
- includeInviteList: true,
- pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS,
+ const presenceManager = new PresenceSyncManager(mx);
+ presenceSyncByClient.set(mx, presenceManager);
+ startPresenceAfterInitialSync(mx, presenceManager);
+
+ manager = new SlidingSyncManager(mx, baseUrl, {
+ pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS,
+ timelineLimit: config?.timelineLimit,
+ initialRoomIds: config?.initialRoomIds,
});
installStartupFetchRoomEventPatch(mx, manager);
- installSlidingSyncConnId(mx);
+ installSlidingSyncRequestPatch(mx, manager);
manager.attach();
+ manager.prepareSidebarCacheHydration();
+ installMembershipActionReconciliation(mx, manager);
slidingSyncByClient.set(mx, manager);
-
- presenceManager = new PresenceSyncManager(mx);
- presenceSyncByClient.set(mx, presenceManager);
}
try {
- await mx.startClient({
- lazyLoadMembers: true,
- slidingSync: manager?.slidingSync,
- threadSupport: true,
- });
- presenceManager?.start();
+ await measureStartupPhase(
+ 'client_start',
+ () =>
+ mx.startClient({
+ lazyLoadMembers: true,
+ slidingSync: manager?.slidingSync,
+ threadSupport: true,
+ }),
+ { transport: useSliding ? 'sliding' : 'classic' }
+ );
+ if (manager && (await manager.waitForSidebarCacheHydration())) {
+ config?.onCachedRoomsLoaded?.();
+ }
} catch (err) {
debugLog.error('network', 'Failed to start client with sliding sync', {
error: err instanceof Error ? err.message : String(err),
userId: mx.getUserId(),
- proxyBaseUrl: useSliding ? proxyBaseUrl : undefined,
+ baseUrl: useSliding ? baseUrl : undefined,
stack: err instanceof Error ? err.stack : undefined,
});
+ fetchRoomEventStartupCleanupByClient.get(mx)?.();
+ slidingSyncRequestCleanupByClient.get(mx)?.();
+ membershipActionCleanupByClient.get(mx)?.();
disposeSlidingSync(mx);
disposePresenceSync(mx);
throw err;
@@ -463,7 +500,8 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig):
export const stopClient = (mx: MatrixClient): void => {
log.log('stopClient', mx.getUserId());
debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() });
- slidingSyncConnIdCleanupByClient.get(mx)?.();
+ fetchRoomEventStartupCleanupByClient.get(mx)?.();
+ slidingSyncRequestCleanupByClient.get(mx)?.();
disposeSlidingSync(mx);
disposePresenceSync(mx);
mx.stopClient();
@@ -473,6 +511,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
log.log('clearCacheAndReload', mx.getUserId());
stopClient(mx);
clearNavToActivePathStore(mx.getSafeUserId());
+ SlidingSyncSidebarCache.clear(mx.getSafeUserId());
await mx.store.deleteAllData();
window.location.reload();
};
@@ -548,6 +587,7 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => {
}
if (session) {
+ SlidingSyncSidebarCache.clear(session.userId);
const storeName: SessionStoreName = getSessionStoreName(session);
await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix });
await deleteDatabase(storeName.sync);
diff --git a/src/client/presenceSync.ts b/src/client/presenceSync.ts
index 3148fd1651..556a314db2 100644
--- a/src/client/presenceSync.ts
+++ b/src/client/presenceSync.ts
@@ -1,5 +1,12 @@
import type { CryptoBackend, IDeviceLists, IToDeviceEvent, MatrixClient } from '$types/matrix-sdk';
-import { ClientEvent, Filter, Method, processToDeviceMessages, User } from '$types/matrix-sdk';
+import {
+ ClientEvent,
+ Filter,
+ Method,
+ processToDeviceMessages,
+ SetPresence,
+ User,
+} from '$types/matrix-sdk';
import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('presenceSync');
@@ -19,6 +26,12 @@ export class PresenceSyncManager {
private enabled = true;
+ private started = false;
+
+ private presence = SetPresence.Online;
+
+ private restartAfterRequest = false;
+
private activeRequest: Promise | null = null;
private abortController: AbortController | undefined;
@@ -40,23 +53,40 @@ export class PresenceSyncManager {
this.enabled = enabled;
if (!enabled) {
+ this.restartAfterRequest = false;
if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer);
this.nextPollTimer = undefined;
this.abortController?.abort();
return;
}
- if (!this.activeRequest && !this.disposed) this.poll();
+ if (this.started && !this.activeRequest && !this.disposed) this.poll();
+ }
+
+ public setPresence(presence: SetPresence = SetPresence.Online): void {
+ if (this.presence === presence) return;
+ this.presence = presence;
+
+ if (!this.started || !this.enabled || this.disposed) return;
+ if (this.activeRequest) {
+ this.restartAfterRequest = true;
+ this.abortController?.abort();
+ } else {
+ this.poll();
+ }
}
public start(): void {
- if (this.disposed || this.activeRequest || !this.enabled) return;
+ if (this.disposed || this.started) return;
+ this.started = true;
+ if (!this.enabled) return;
this.poll();
}
public dispose(): void {
this.disposed = true;
this.enabled = false;
+ this.started = false;
if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer);
this.nextPollTimer = undefined;
this.abortController?.abort();
@@ -135,7 +165,7 @@ export class PresenceSyncManager {
filter: filterId,
since: this.syncToken,
timeout: this.pollTimeoutMs,
- set_presence: 'online',
+ set_presence: this.presence,
},
undefined,
{ abortSignal: signal }
@@ -161,7 +191,10 @@ export class PresenceSyncManager {
this.activeRequest = null;
this.abortController = undefined;
- if (this.enabled && !this.disposed) {
+ if (this.restartAfterRequest && this.started && this.enabled && !this.disposed) {
+ this.restartAfterRequest = false;
+ this.poll();
+ } else if (this.started && this.enabled && !this.disposed) {
this.nextPollTimer = setTimeout(() => {
this.nextPollTimer = undefined;
this.poll();
diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts
index 4f8f5c80fe..fc7d846d2f 100644
--- a/src/client/slidingSync.test.ts
+++ b/src/client/slidingSync.test.ts
@@ -3,15 +3,17 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import type { MatrixClient } from '$types/matrix-sdk';
+import type { MatrixClient, MSC3575List } from '$types/matrix-sdk';
+import { EventType, KnownMembership, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk';
-import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync';
+import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync';
// ── vi.hoisted mocks ─────────────────────────────────────────────────────────
// Must be defined via vi.hoisted
const mocks = vi.hoisted(() => ({
+ slidingSyncConstructorArgs: undefined as unknown[] | undefined,
slidingSyncInstance: {
- on: vi.fn<() => void>(),
+ on: vi.fn<(event: unknown, handler: unknown) => void>(),
off: vi.fn<() => void>(),
removeListener: vi.fn<() => void>(),
stop: vi.fn<() => void>(),
@@ -20,10 +22,10 @@ const mocks = vi.hoisted(() => ({
addCustomSubscription: vi.fn<() => void>(),
useCustomSubscription: vi.fn<() => void>(),
registerExtension: vi.fn<() => void>(),
- getListData: vi.fn<() => null>(),
- getListParams: vi.fn<() => null>(),
+ getListData: vi.fn<(key: string) => null>(),
+ getListParams: vi.fn<(key: string) => null>(),
setList: vi.fn<() => void>(),
- setListRanges: vi.fn<() => void>(),
+ setListRanges: vi.fn<(key: string, ranges: number[][]) => void>(),
},
}));
@@ -44,7 +46,8 @@ vi.mock('@sentry/react', () => ({
// A plain function constructor is the correct pattern
vi.mock('$types/matrix-sdk', async (importOriginal) => {
const actual = await importOriginal>();
- function MockSlidingSync() {
+ function MockSlidingSync(...args: unknown[]) {
+ mocks.slidingSyncConstructorArgs = args;
return mocks.slidingSyncInstance;
}
return { ...actual, SlidingSync: MockSlidingSync };
@@ -58,6 +61,9 @@ function makeMockMx(overrides: Record = {}) {
getSafeUserId: vi.fn<() => string>().mockReturnValue('@user:example.com'),
isRoomEncrypted: vi.fn<() => boolean>().mockReturnValue(false),
getRoom: vi.fn<() => null>().mockReturnValue(null),
+ getJoinedRooms: vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({
+ joined_rooms: [],
+ }),
on: vi.fn<() => void>(),
off: vi.fn<() => void>(),
removeListener: vi.fn<() => void>(),
@@ -66,12 +72,460 @@ function makeMockMx(overrides: Record = {}) {
}
function makeManager(mx: ReturnType): SlidingSyncManager {
- const config: SlidingSyncConfig = {};
- return new SlidingSyncManager(mx, 'https://sliding.example.com', config);
+ return new SlidingSyncManager(mx, 'https://sliding.example.com');
}
beforeEach(() => {
vi.clearAllMocks();
+ localStorage.clear();
+ mocks.slidingSyncInstance.getListData.mockReset().mockReturnValue(null);
+ mocks.slidingSyncInstance.getListParams.mockReset().mockReturnValue(null);
+ mocks.slidingSyncInstance.setListRanges.mockReset();
+ mocks.slidingSyncConstructorArgs = undefined;
+});
+
+function fireLifecycle(state: SlidingSyncState, response: unknown = {}) {
+ const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find(
+ ([event]) => event === SlidingSyncEvent.Lifecycle
+ );
+ const lifecycle = lifecycleCall?.[1] as
+ | ((nextState: SlidingSyncState, nextResponse: unknown, error?: Error) => void)
+ | undefined;
+ lifecycle?.(state, response);
+}
+
+function fireRoomData(roomId: string, data: Record = {}) {
+ const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls
+ .toReversed()
+ .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as
+ | ((dataRoomId: string, data: unknown) => void)
+ | undefined;
+ roomDataHandler?.(roomId, data);
+}
+
+describe('SlidingSyncManager initial request', () => {
+ it('starts with a small room-list range and only row-level state', () => {
+ makeManager(makeMockMx());
+
+ const lists = mocks.slidingSyncConstructorArgs?.[1] as Map;
+ const joined = lists.get('joined');
+ const updates = lists.get('updates');
+ const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as {
+ timeline_limit: number;
+ };
+
+ expect(joined?.ranges).toEqual([[0, 29]]);
+ expect(joined?.timeline_limit).toBe(1);
+ expect(joined?.required_state).toHaveLength(8);
+ expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']);
+ expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']);
+ expect(updates).toMatchObject({
+ ranges: [[0, 29]],
+ timeline_limit: 1,
+ required_state: [[EventType.RoomMember, '$ME']],
+ filters: { is_invite: false },
+ });
+ expect(defaultSubscription.timeline_limit).toBe(30);
+ expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000);
+ });
+
+ it('settles response processing after post-response work can finish', async () => {
+ const manager = makeManager(makeMockMx());
+ const settled = vi.fn<() => void>();
+ manager.subscribeToResponseSettled(settled);
+ manager.attach();
+
+ fireLifecycle(SlidingSyncState.RequestFinished, {});
+ expect(manager.isResponseProcessing()).toBe(true);
+
+ fireLifecycle(SlidingSyncState.Complete, {});
+ expect(manager.isResponseProcessing()).toBe(true);
+ expect(settled).not.toHaveBeenCalled();
+
+ await Promise.resolve();
+ expect(manager.isResponseProcessing()).toBe(false);
+ expect(settled).toHaveBeenCalledOnce();
+ });
+
+ it('includes the selected room subscription before the first request', () => {
+ const manager = new SlidingSyncManager(makeMockMx(), 'https://sliding.example.com', {
+ initialRoomIds: ['!selected:example.com'],
+ });
+
+ expect(manager.isRoomActive('!selected:example.com')).toBe(true);
+ expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith(
+ '!selected:example.com',
+ 'active_room'
+ );
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith(
+ new Set(['!selected:example.com'])
+ );
+ });
+
+ it('expands by one page after the first response instead of jumping to all rooms', () => {
+ const manager = makeManager(makeMockMx());
+ mocks.slidingSyncInstance.getListData.mockImplementation((key: string) =>
+ key === 'joined' ? ({ joinedCount: 1000 } as never) : null
+ );
+ mocks.slidingSyncInstance.getListParams.mockReturnValue({
+ ranges: [[0, 29]],
+ } as never);
+ manager.attach();
+
+ const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find(
+ ([event]) => event === SlidingSyncEvent.Lifecycle
+ );
+ const lifecycle = lifecycleCall?.[1] as
+ | ((state: SlidingSyncState, response: unknown, error?: Error) => void)
+ | undefined;
+ lifecycle?.(SlidingSyncState.Complete, {});
+
+ expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 59]]);
+ });
+
+ it('does not expand lists synchronously while adding an active-room subscription', () => {
+ const manager = makeManager(makeMockMx());
+ mocks.slidingSyncInstance.getListData.mockImplementation((key: string) =>
+ key === 'joined' ? ({ joinedCount: 1000 } as never) : null
+ );
+ mocks.slidingSyncInstance.getListParams.mockReturnValue({
+ ranges: [[0, 29]],
+ } as never);
+ manager.attach();
+ manager.subscribeToRoom('!active:example.com');
+
+ expect(mocks.slidingSyncInstance.setListRanges).not.toHaveBeenCalledWith('joined', [[0, 59]]);
+
+ const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find(
+ ([event]) => event === SlidingSyncEvent.Lifecycle
+ );
+ const lifecycle = lifecycleCall?.[1] as
+ | ((state: SlidingSyncState, response: unknown, error?: Error) => void)
+ | undefined;
+ lifecycle?.(SlidingSyncState.Complete, {});
+
+ expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 59]]);
+ });
+
+ it('prioritizes active subscriptions while an expanded range is awaiting its response', () => {
+ let joinedRange: [number, number][] = [[0, 29]];
+ const manager = makeManager(makeMockMx());
+ mocks.slidingSyncInstance.getListData.mockImplementation((key: string) =>
+ key === 'joined' ? ({ joinedCount: 193 } as never) : null
+ );
+ mocks.slidingSyncInstance.getListParams.mockImplementation((key: string) =>
+ key === 'joined' ? ({ ranges: joinedRange } as never) : ({ ranges: [[0, 29]] } as never)
+ );
+ mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => {
+ if (key === 'joined') joinedRange = ranges as [number, number][];
+ });
+ manager.attach();
+
+ fireLifecycle(SlidingSyncState.Complete);
+ manager.subscribeToRoom('!active:example.com');
+
+ const diagnostics = manager.getDiagnostics();
+ expect(manager.isRoomActive('!active:example.com')).toBe(true);
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith(
+ new Set(['!active:example.com'])
+ );
+ expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith(
+ '!active:example.com',
+ 'active_room'
+ );
+ expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({
+ rangeEnd: 29,
+ });
+ });
+
+ it('keeps full lightweight event coverage after narrowing the detailed joined list', () => {
+ const listRanges = new Map([
+ ['joined', [[0, 29]]],
+ ['updates', [[0, 29]]],
+ ]);
+ const manager = makeManager(makeMockMx());
+ mocks.slidingSyncInstance.getListData.mockImplementation((key: string) =>
+ key === 'joined' || key === 'updates' ? ({ joinedCount: 45 } as never) : null
+ );
+ mocks.slidingSyncInstance.getListParams.mockImplementation(
+ (key: string) => ({ ranges: listRanges.get(key) ?? [[0, 29]] }) as never
+ );
+ mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => {
+ if (key === 'joined' || key === 'updates') {
+ listRanges.set(key, ranges as [number, number][]);
+ }
+ });
+ manager.attach();
+
+ fireLifecycle(SlidingSyncState.Complete);
+ expect(listRanges.get('joined')).toEqual([[0, 44]]);
+ expect(listRanges.get('updates')).toEqual([[0, 44]]);
+
+ fireLifecycle(SlidingSyncState.Complete);
+ fireLifecycle(SlidingSyncState.Complete);
+ expect(listRanges.get('joined')).toEqual([[0, 2]]);
+ expect(listRanges.get('updates')).toEqual([[0, 44]]);
+ });
+
+ it('keeps detailed coverage when the homeserver does not provide the updates list', () => {
+ let joinedRange: [number, number][] = [[0, 29]];
+ const manager = makeManager(makeMockMx());
+ mocks.slidingSyncInstance.getListData.mockImplementation((key: string) =>
+ key === 'joined' ? ({ joinedCount: 45 } as never) : null
+ );
+ mocks.slidingSyncInstance.getListParams.mockImplementation((key: string) =>
+ key === 'joined' ? ({ ranges: joinedRange } as never) : ({ ranges: [[0, 29]] } as never)
+ );
+ mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => {
+ if (key === 'joined') joinedRange = ranges as [number, number][];
+ });
+ manager.attach();
+
+ fireLifecycle(SlidingSyncState.Complete);
+ fireLifecycle(SlidingSyncState.Complete);
+
+ expect(joinedRange).toEqual([[0, 44]]);
+ });
+});
+
+describe('SlidingSyncManager room subscription coordination', () => {
+ it('replaces route subscriptions atomically without cycling the retained space', () => {
+ const manager = makeManager(makeMockMx());
+ const spaceId = '!space:example.com';
+ const oldRoomId = '!old:example.com';
+ const newRoomId = '!new:example.com';
+
+ manager.setActiveRoomSubscriptions([spaceId, oldRoomId]);
+ mocks.slidingSyncInstance.modifyRoomSubscriptions.mockClear();
+
+ manager.setActiveRoomSubscriptions([spaceId, newRoomId]);
+
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledOnce();
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith(
+ new Set([spaceId, newRoomId])
+ );
+ expect(manager.isRoomActive(spaceId)).toBe(true);
+ expect(manager.isRoomActive(oldRoomId)).toBe(false);
+ expect(manager.isRoomActive(newRoomId)).toBe(true);
+ });
+
+ it('reports loading until the subscribed room returns data', () => {
+ const manager = makeManager(makeMockMx());
+ const roomId = '!slow:example.com';
+ const loadingStates: boolean[] = [];
+
+ manager.onRoomSubscriptionStatus(roomId, (loading) => loadingStates.push(loading));
+ manager.subscribeToRoom(roomId);
+
+ fireRoomData(roomId);
+ expect(loadingStates).toEqual([false, true]);
+ expect(manager.isRoomSubscriptionLoading(roomId)).toBe(true);
+
+ manager.attach();
+ fireLifecycle(SlidingSyncState.Complete);
+
+ expect(loadingStates).toEqual([false, true, false]);
+ });
+
+ it('uses the active subscription while a room is also an image-pack room', () => {
+ const manager = makeManager(makeMockMx());
+ const roomId = '!pack:example.com';
+
+ manager.setImagePackSubscriptions([roomId]);
+ manager.subscribeToRoom(roomId);
+
+ expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith(
+ roomId,
+ 'active_room'
+ );
+ });
+
+ it('restores the image-pack subscription when the room is no longer active', () => {
+ const manager = makeManager(makeMockMx());
+ const roomId = '!pack:example.com';
+
+ manager.setImagePackSubscriptions([roomId]);
+ manager.subscribeToRoom(roomId);
+ manager.unsubscribeFromRoom(roomId);
+
+ expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith(
+ roomId,
+ 'image_packs'
+ );
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(
+ new Set([roomId])
+ );
+ });
+
+ it('keeps space subscriptions active for future hierarchy changes', async () => {
+ const manager = makeManager(makeMockMx());
+ const roomId = '!space:example.com';
+ (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true;
+ manager.attach();
+
+ manager.setSpaceSubscriptions([roomId]);
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(
+ new Set([roomId])
+ );
+
+ fireRoomData(roomId);
+ await Promise.resolve();
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(
+ new Set([roomId])
+ );
+ });
+
+ it('hydrates sidebar state once for rooms first seen after startup', async () => {
+ const manager = makeManager(makeMockMx());
+ const roomId = '!new:example.com';
+ (
+ manager as unknown as { initialListHydrationCompleted: boolean }
+ ).initialListHydrationCompleted = true;
+ manager.attach();
+
+ fireRoomData(roomId, { initial: true, notification_count: 0, highlight_count: 0 });
+ await Promise.resolve();
+ expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith(
+ roomId,
+ 'sidebar_room'
+ );
+
+ fireRoomData(roomId, { initial: true });
+ await Promise.resolve();
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set());
+ });
+
+ it('removes image-pack subscriptions which are no longer configured', () => {
+ const manager = makeManager(makeMockMx());
+
+ manager.setImagePackSubscriptions(['!old:example.com', '!current:example.com']);
+ manager.setImagePackSubscriptions(['!current:example.com']);
+
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(
+ new Set(['!current:example.com'])
+ );
+ });
+});
+
+describe('scopeEphemeralExtensions', () => {
+ it('limits typing and receipts to active rooms without changing other extensions', () => {
+ const extensions = {
+ typing: { enabled: true },
+ receipts: { enabled: true },
+ account_data: { enabled: true },
+ };
+
+ scopeEphemeralExtensions(extensions, ['!space:example.com', '!room:example.com']);
+
+ expect(extensions).toEqual({
+ typing: {
+ enabled: true,
+ lists: [],
+ rooms: ['!space:example.com', '!room:example.com'],
+ },
+ receipts: {
+ enabled: true,
+ lists: [],
+ rooms: ['!space:example.com', '!room:example.com'],
+ },
+ account_data: { enabled: true },
+ });
+ });
+
+ it('uses an empty room scope when no timeline is open', () => {
+ const extensions = {
+ typing: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] },
+ receipts: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] },
+ };
+
+ scopeEphemeralExtensions(extensions, []);
+
+ expect(extensions.typing).toMatchObject({ lists: [], rooms: [] });
+ expect(extensions.receipts).toMatchObject({ lists: [], rooms: [] });
+ });
+});
+
+describe('SlidingSyncManager local membership reconciliation', () => {
+ it('updates an existing invite immediately after a successful join', () => {
+ const updateMyMembership = vi.fn<() => void>();
+ const manager = makeManager(
+ makeMockMx({
+ getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({
+ updateMyMembership,
+ }),
+ })
+ );
+
+ manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join);
+
+ expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Join);
+ });
+
+ it('updates and unsubscribes a room immediately after a successful leave', () => {
+ const updateMyMembership = vi.fn<() => void>();
+ const manager = makeManager(
+ makeMockMx({
+ getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({
+ updateMyMembership,
+ }),
+ })
+ );
+ manager.subscribeToRoom('!room:example.com');
+
+ manager.reconcileRoomMembership('!room:example.com', KnownMembership.Leave);
+
+ expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Leave);
+ expect(manager.isRoomActive('!room:example.com')).toBe(false);
+ expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set());
+ });
+
+ it('waits for cache hydration and unions authoritative joined rooms before reconciling', async () => {
+ let finishHydration: ((hydrated: boolean) => void) | undefined;
+ const hydration = new Promise((resolve) => {
+ finishHydration = resolve;
+ });
+ const getJoinedRooms = vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({
+ joined_rooms: ['!authoritative:example.com'],
+ });
+ const manager = makeManager(makeMockMx({ getJoinedRooms }));
+ const internals = manager as unknown as {
+ cacheHydrationPromise: Promise;
+ reconcileSidebarCacheMembership: () => void;
+ serverMembershipRoomIds: Set;
+ sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] };
+ };
+ internals.cacheHydrationPromise = hydration;
+ internals.serverMembershipRoomIds.add('!observed:example.com');
+ const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]);
+
+ internals.reconcileSidebarCacheMembership();
+ expect(getJoinedRooms).not.toHaveBeenCalled();
+
+ finishHydration?.(true);
+ await vi.waitFor(() => expect(reconcileRooms).toHaveBeenCalledOnce());
+
+ expect(getJoinedRooms).toHaveBeenCalledOnce();
+ expect(reconcileRooms).toHaveBeenCalledWith(
+ new Set(['!observed:example.com', '!authoritative:example.com'])
+ );
+ });
+
+ it('keeps cached rooms when authoritative membership cannot be fetched', async () => {
+ const getJoinedRooms = vi
+ .fn<() => Promise<{ joined_rooms: string[] }>>()
+ .mockRejectedValue(new Error('offline'));
+ const manager = makeManager(makeMockMx({ getJoinedRooms }));
+ const internals = manager as unknown as {
+ reconcileSidebarCacheMembership: () => void;
+ sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] };
+ };
+ const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]);
+
+ internals.reconcileSidebarCacheMembership();
+ await vi.waitFor(() => expect(getJoinedRooms).toHaveBeenCalledOnce());
+
+ expect(reconcileRooms).not.toHaveBeenCalled();
+ });
});
// ── dispose() ────────────────────────────────────────────────────────────────
@@ -109,6 +563,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
const mx = makeMockMx();
const manager = makeManager(mx);
manager.attach();
+ fireLifecycle(SlidingSyncState.Complete);
manager.subscribeToRoom('!room:example.com');
fireMembershipEvent(mx, 'leave');
@@ -121,6 +576,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
const mx = makeMockMx();
const manager = makeManager(mx);
manager.attach();
+ fireLifecycle(SlidingSyncState.Complete);
manager.subscribeToRoom('!room:example.com');
fireMembershipEvent(mx, 'ban');
@@ -132,6 +588,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
const mx = makeMockMx();
const manager = makeManager(mx);
manager.attach();
+ fireLifecycle(SlidingSyncState.Complete);
manager.subscribeToRoom('!room:example.com');
fireMembershipEvent(mx, 'leave', '!room:example.com', '@other:example.com');
@@ -144,6 +601,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
const mx = makeMockMx();
const manager = makeManager(mx);
manager.attach();
+ fireLifecycle(SlidingSyncState.Complete);
manager.subscribeToRoom('!room:example.com');
fireMembershipEvent(mx, 'join');
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 862b122d9f..eba55eea16 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -1,5 +1,6 @@
import type {
MatrixClient,
+ MatrixEvent,
MSC3575List,
MSC3575RoomData,
MSC3575RoomSubscription,
@@ -15,29 +16,36 @@ import {
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
EventType,
+ EventEmitterEvents,
+ ClientEvent,
} from '$types/matrix-sdk';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { CustomStateEvent } from '$types/matrix/room';
import * as Sentry from '@sentry/react';
+import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
const log = createLogger('slidingSync');
const debugLog = createDebugLogger('slidingSync');
export const LIST_JOINED = 'joined';
export const LIST_INVITES = 'invites';
+export const LIST_UPDATES = 'updates';
export const LIST_SEARCH = 'search';
export const LIST_ROOM_SEARCH = 'room_search';
export const LIST_SPACE = 'space';
const LIST_TIMELINE_LIMIT = 1;
-const DEFAULT_LIST_PAGE_SIZE = 250;
-const DEFAULT_POLL_TIMEOUT_MS = 20000;
-const DEFAULT_MAX_ROOMS = 5000;
+const LIST_PAGE_SIZE = 30;
+const STEADY_STATE_DETAILED_ROOMS = 3;
+const DEFAULT_POLL_TIMEOUT_MS = 45000;
const LIST_SORT_ORDER = ['by_recency', 'by_name'];
-const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
-const ACTIVE_ROOM_TIMELINE_LIMIT = 50;
+const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room';
+const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room';
+const SPACE_SUBSCRIPTION_KEY = 'space';
+const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs';
+const ACTIVE_ROOM_TIMELINE_LIMIT = 30;
export type PartialSlidingSyncRequest = {
filters?: MSC3575List['filters'];
@@ -45,28 +53,22 @@ export type PartialSlidingSyncRequest = {
ranges?: [number, number][];
};
-export type SlidingSyncConfig = {
- enabled?: boolean;
- proxyBaseUrl?: string;
- bootstrapClassicOnColdCache?: boolean;
- listPageSize?: number;
+type SlidingSyncOptions = {
timelineLimit?: number;
pollTimeoutMs?: number;
- maxRooms?: number;
- includeInviteList?: boolean;
- probeTimeoutMs?: number;
+ initialRoomIds?: Iterable;
};
export type SlidingSyncListDiagnostics = {
key: string;
knownCount: number;
rangeEnd: number;
+ requestedRangeEnd: number;
};
export type SlidingSyncDiagnostics = {
- proxyBaseUrl: string;
+ baseUrl: string;
timelineLimit: number;
- listPageSize: number;
lists: SlidingSyncListDiagnostics[];
};
@@ -76,23 +78,32 @@ const clampPositive = (value: number | undefined, fallback: number): number => {
};
const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [
- [EventType.RoomJoinRules, ''],
+ // first sync limited solely to what's needed to render rooms
[EventType.RoomAvatar, ''],
[EventType.RoomTombstone, ''],
[EventType.RoomEncryption, ''],
[EventType.RoomCreate, ''],
- [EventType.RoomTopic, ''],
+ [EventType.RoomName, ''],
[EventType.RoomCanonicalAlias, ''],
+ [EventType.RoomJoinRules, ''],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
- ['m.space.child', MSC3575_WILDCARD],
- [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD],
- [CustomStateEvent.ImagePack, MSC3575_WILDCARD],
- [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD],
- [CustomStateEvent.RoomAbbreviations, ''],
+];
+
+const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [
+ [EventType.RoomCreate, ''],
+ [EventType.RoomName, ''],
+ [EventType.RoomAvatar, ''],
+ [EventType.RoomCanonicalAlias, ''],
+ [EventType.RoomJoinRules, ''],
+ [EventType.RoomEncryption, ''],
+ [EventType.RoomTombstone, ''],
[CustomStateEvent.RoomBanner, ''],
+ ['m.space.child', MSC3575_WILDCARD],
+ ['m.space.parent', MSC3575_WILDCARD],
];
const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [
+ // active room subscription includes all room data
[EventType.RoomPowerLevels, ''],
[EventType.RoomName, ''],
[EventType.RoomTopic, ''],
@@ -118,7 +129,7 @@ const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [
const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({
timeline_limit: timelineLimit,
- required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]],
+ required_state: ACTIVE_ROOM_REQUIRED_STATE,
});
const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({
@@ -126,29 +137,51 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri
required_state: ACTIVE_ROOM_REQUIRED_STATE,
});
-const buildLists = (pageSize: number, includeInviteList: boolean): Map => {
+const buildImagePackSubscription = (): MSC3575RoomSubscription => ({
+ timeline_limit: 0,
+ required_state: [
+ [CustomStateEvent.ImagePack, MSC3575_WILDCARD],
+ [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD],
+ ],
+});
+
+const buildSpaceSubscription = (): MSC3575RoomSubscription => ({
+ timeline_limit: 0,
+ required_state: SPACE_REQUIRED_STATE,
+});
+
+const buildSidebarRoomSubscription = (): MSC3575RoomSubscription => ({
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: buildListRequiredState(),
+});
+
+const buildLists = (): Map => {
const lists = new Map();
const listRequiredState = buildListRequiredState();
- const initialRange = Math.min(pageSize, 100);
-
lists.set(LIST_JOINED, {
- ranges: [[0, Math.max(0, initialRange - 1)]],
+ ranges: [[0, LIST_PAGE_SIZE - 1]],
sort: LIST_SORT_ORDER,
timeline_limit: LIST_TIMELINE_LIMIT,
required_state: listRequiredState,
filters: { is_invite: false },
});
- if (includeInviteList) {
- lists.set(LIST_INVITES, {
- ranges: [[0, Math.max(0, initialRange - 1)]],
- sort: LIST_SORT_ORDER,
- timeline_limit: LIST_TIMELINE_LIMIT,
- required_state: listRequiredState,
- filters: { is_invite: true },
- });
- }
+ lists.set(LIST_INVITES, {
+ ranges: [[0, LIST_PAGE_SIZE - 1]],
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: listRequiredState,
+ filters: { is_invite: true },
+ });
+
+ lists.set(LIST_UPDATES, {
+ ranges: [[0, LIST_PAGE_SIZE - 1]],
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: [[EventType.RoomMember, MSC3575_STATE_KEY_ME]],
+ filters: { is_invite: false },
+ });
return lists;
};
@@ -158,19 +191,64 @@ const getListEndIndex = (list: MSC3575List | null): number => {
return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1);
};
+type RoomScopedExtension = {
+ enabled?: boolean;
+ lists?: string[];
+ rooms?: string[];
+};
+
+export const scopeEphemeralExtensions = (
+ extensions: object | undefined,
+ roomIds: readonly string[]
+): void => {
+ if (!extensions) return;
+ const extensionMap = extensions as Record;
+
+ ['typing', 'receipts'].forEach((name) => {
+ const extension = extensionMap[name];
+ if (!extension || typeof extension !== 'object') return;
+
+ const scopedExtension = extension as RoomScopedExtension;
+ scopedExtension.lists = [];
+ scopedExtension.rooms = [...roomIds];
+ });
+};
+
export class SlidingSyncManager {
private disposed = false;
- private readonly maxRooms: number;
-
private readonly listKeys: string[];
private readonly activeRoomSubscriptions = new Set();
- private readonly listPageSize: number;
+ private readonly sidebarRoomSubscriptions = new Set();
+
+ private readonly spaceSubscriptions = new Set();
+
+ private deferredSpaceSubscriptions = new Set();
+
+ private readonly imagePackRoomSubscriptions = new Set();
+
+ private roomSubscriptionSyncQueued = false;
private readonly roomTimelineLimit: number;
+ private readonly sidebarCache: SlidingSyncSidebarCache;
+
+ private cacheHydrationPromise: Promise = Promise.resolve(false);
+
+ private cacheHydrationNewListener:
+ | ((eventName: string | symbol, listener: (...args: unknown[]) => void) => void)
+ | undefined;
+
+ private cacheHydrationResolve: ((hydrated: boolean) => void) | undefined;
+
+ private hydratingSidebarCache = false;
+
+ private readonly onCacheRoomData: (roomId: string, data: MSC3575RoomData) => void;
+
+ private readonly onCacheAccountData: (event: MatrixEvent) => void;
+
private readonly onLifecycle: (
state: SlidingSyncState,
resp: MSC3575SlidingSyncResponse | null,
@@ -184,12 +262,30 @@ export class SlidingSyncManager {
private listsFullyLoaded = false;
+ private initialListHydrationCompleted = false;
+
+ private sidebarCacheReconciled = false;
+
+ private sidebarCacheReconciliationQueued = false;
+
+ private readonly serverMembershipRoomIds = new Set();
+
private initialSyncCompleted = false;
private syncCount = 0;
+ private responseProcessingStartedAt: number | undefined;
+
+ private responseProcessing = false;
+
+ private readonly responseSettledListeners = new Set<() => void>();
+
private previousListCounts: Map = new Map();
+ private readonly requestedListRangeEnds = new Map();
+
+ private readonly confirmedListRangeEnds = new Map();
+
/**
* One-shot RoomData listeners keyed by roomId, used to measure the latency
* between subscribeToRoom() and the first data arriving for that room.
@@ -200,6 +296,13 @@ export class SlidingSyncManager {
(roomId: string, data: MSC3575RoomData) => void
>();
+ private readonly roomSubscriptionStatusListeners = new Map<
+ string,
+ Set<(loading: boolean) => void>
+ >();
+
+ private readonly roomDataAwaitingSyncCompletion = new Set();
+
/** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */
private attachTime: number | null = null;
@@ -208,44 +311,43 @@ export class SlidingSyncManager {
public readonly slidingSync: SlidingSync;
- public readonly probeTimeoutMs: number;
-
public constructor(
private readonly mx: MatrixClient,
- private readonly proxyBaseUrl: string,
- config: SlidingSyncConfig
+ private readonly baseUrl: string,
+ options: SlidingSyncOptions = {}
) {
- const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE);
- const pollTimeoutMs = clampPositive(config.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS);
- this.probeTimeoutMs = clampPositive(config.probeTimeoutMs, 5000);
- this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS);
- this.listPageSize = listPageSize;
- const includeInviteList = config.includeInviteList !== false;
-
- const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT);
+ const pollTimeoutMs = clampPositive(options.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS);
+
+ const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT);
this.roomTimelineLimit = roomTimelineLimit;
+ this.sidebarCache = new SlidingSyncSidebarCache(mx.getSafeUserId());
const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit);
- const lists = buildLists(listPageSize, includeInviteList);
+ const lists = buildLists();
this.listKeys = Array.from(lists.keys());
- this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs);
+ lists.forEach((list, key) => {
+ this.requestedListRangeEnds.set(key, getListEndIndex(list));
+ });
+ this.slidingSync = new SlidingSync(baseUrl, lists, defaultSubscription, mx, pollTimeoutMs);
this.slidingSync.addCustomSubscription(
- UNENCRYPTED_SUBSCRIPTION_KEY,
+ ACTIVE_ROOM_SUBSCRIPTION_KEY,
buildUnencryptedSubscription(roomTimelineLimit)
);
+ this.slidingSync.addCustomSubscription(
+ SIDEBAR_ROOM_SUBSCRIPTION_KEY,
+ buildSidebarRoomSubscription()
+ );
+ this.slidingSync.addCustomSubscription(
+ IMAGE_PACK_SUBSCRIPTION_KEY,
+ buildImagePackSubscription()
+ );
+ this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription());
this.onLifecycle = (state, resp, err) => {
- const syncStartTime = performance.now();
- this.syncCount += 1;
- Sentry.metrics.count('sable.sync.cycle', 1, {
- attributes: { transport: 'sliding', state },
- });
-
- debugLog.info('sync', `Sliding sync lifecycle: ${state} (cycle #${this.syncCount})`, {
+ debugLog.info('sync', `Sliding sync lifecycle: ${state}`, {
state,
hasError: !!err,
- syncNumber: this.syncCount,
isInitialSync: !this.initialSyncCompleted,
});
@@ -262,12 +364,38 @@ export class SlidingSyncManager {
}
if (this.disposed) {
- debugLog.warn('sync', 'Sync lifecycle called after disposal', { state });
+ debugLog.warn('sync', 'Sync lifecycle called after disposal', {
+ state,
+ });
+ return;
+ }
+
+ if (state === SlidingSyncState.RequestFinished) {
+ if (!err && resp) {
+ this.responseProcessing = true;
+ this.responseProcessingStartedAt = performance.now();
+ }
return;
}
if (err || !resp || state !== SlidingSyncState.Complete) return;
+ this.recordServerMembershipRooms(resp);
+
+ this.roomDataAwaitingSyncCompletion.forEach((roomId) =>
+ this.notifyRoomSubscriptionStatus(roomId, false)
+ );
+ this.roomDataAwaitingSyncCompletion.clear();
+
+ this.syncCount += 1;
+ Sentry.metrics.count('sable.sync.cycle', 1, {
+ attributes: { transport: 'sliding' },
+ });
+
+ this.requestedListRangeEnds.forEach((rangeEnd, key) => {
+ this.confirmedListRangeEnds.set(key, rangeEnd);
+ });
+
const changes: Record = {};
let totalRoomCount = 0;
let hasChanges = false;
@@ -277,7 +405,7 @@ export class SlidingSyncManager {
const currentCount = listData?.joinedCount ?? 0;
const previousCount = this.previousListCounts.get(key) ?? 0;
- totalRoomCount += currentCount;
+ if (key !== LIST_UPDATES) totalRoomCount += currentCount;
if (currentCount !== previousCount) {
changes[key] = {
@@ -299,7 +427,11 @@ export class SlidingSyncManager {
});
}
- const syncDuration = performance.now() - syncStartTime;
+ const syncDuration =
+ this.responseProcessingStartedAt === undefined
+ ? 0
+ : performance.now() - this.responseProcessingStartedAt;
+ this.responseProcessingStartedAt = undefined;
if (!this.initialSyncCompleted) {
this.initialSyncCompleted = true;
@@ -324,7 +456,7 @@ export class SlidingSyncManager {
this.initialSyncSpan = null;
}
- this.expandListsToKnownCount();
+ this.expandListsByPage();
Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, {
attributes: { transport: 'sliding' },
@@ -336,23 +468,59 @@ export class SlidingSyncManager {
totalRoomCount,
});
}
+
+ globalThis.queueMicrotask(() => {
+ if (this.disposed) return;
+ this.responseProcessing = false;
+ this.responseSettledListeners.forEach((listener) => listener());
+ });
};
this.onMembershipLeave = (_event, member) => {
if (member.userId !== this.mx.getUserId()) return;
if (member.membership !== KnownMembership.Leave && member.membership !== KnownMembership.Ban)
return;
- if (!this.activeRoomSubscriptions.has(member.roomId)) return;
- this.unsubscribeFromRoom(member.roomId);
+ this.sidebarCache.removeRoom(member.roomId);
+ const removedSpaceSubscription = this.spaceSubscriptions.delete(member.roomId);
+ const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(member.roomId);
+ if (this.activeRoomSubscriptions.has(member.roomId)) {
+ this.unsubscribeFromRoom(member.roomId);
+ } else if (removedSpaceSubscription || removedSidebarSubscription) {
+ this.queueRoomSubscriptionSync();
+ }
};
+
+ this.onCacheRoomData = (roomId, data) => {
+ this.sidebarCache.cacheRoom(roomId, data);
+
+ if (!this.initialListHydrationCompleted || this.hydratingSidebarCache) return;
+
+ let subscriptionsChanged = false;
+ const completedSidebarHydration = this.sidebarRoomSubscriptions.delete(roomId);
+ if (completedSidebarHydration) subscriptionsChanged = true;
+
+ if (
+ data.initial === true &&
+ !completedSidebarHydration &&
+ !this.activeRoomSubscriptions.has(roomId)
+ ) {
+ this.sidebarRoomSubscriptions.add(roomId);
+ subscriptionsChanged = true;
+ }
+
+ if (subscriptionsChanged) this.queueRoomSubscriptionSync();
+ };
+ this.onCacheAccountData = (event) => this.sidebarCache.cacheAccountData(event);
+
+ for (const roomId of options.initialRoomIds ?? []) {
+ this.subscribeToRoom(roomId);
+ }
}
public attach(): void {
debugLog.info('sync', 'Attaching sliding sync listeners', {
- proxyBaseUrl: this.proxyBaseUrl,
- listPageSize: this.listPageSize,
+ baseUrl: this.baseUrl,
roomTimelineLimit: this.roomTimelineLimit,
- maxRooms: this.maxRooms,
lists: this.listKeys,
});
@@ -360,11 +528,16 @@ export class SlidingSyncManager {
this.initialSyncSpan = Sentry.startInactiveSpan({
name: 'sync.initial',
op: 'matrix.sync',
- attributes: { 'sync.transport': 'sliding', 'sync.proxy': this.proxyBaseUrl },
+ attributes: {
+ 'sync.transport': 'sliding',
+ 'sync.base_url': this.baseUrl,
+ },
});
this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle);
+ this.slidingSync.on(SlidingSyncEvent.RoomData, this.onCacheRoomData);
this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave);
+ this.mx.on(ClientEvent.AccountData, this.onCacheAccountData);
debugLog.info('sync', 'Sliding sync listeners attached successfully');
}
@@ -378,36 +551,153 @@ export class SlidingSyncManager {
});
this.pendingRoomDataListeners.clear();
+ this.responseProcessing = false;
+ this.responseSettledListeners.clear();
+ this.roomDataAwaitingSyncCompletion.clear();
+ this.roomSubscriptionStatusListeners.forEach((listeners) =>
+ listeners.forEach((listener) => listener(false))
+ );
+ this.roomSubscriptionStatusListeners.clear();
this.disposed = true;
this.slidingSync.stop();
this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle);
+ this.slidingSync.removeListener(SlidingSyncEvent.RoomData, this.onCacheRoomData);
+ if (this.cacheHydrationNewListener) {
+ this.slidingSync.removeListener(
+ EventEmitterEvents.NewListener,
+ this.cacheHydrationNewListener as never
+ );
+ this.cacheHydrationNewListener = undefined;
+ }
+ this.cacheHydrationResolve?.(false);
+ this.cacheHydrationResolve = undefined;
this.mx.removeListener(RoomMemberEvent.Membership, this.onMembershipLeave);
+ this.mx.removeListener(ClientEvent.AccountData, this.onCacheAccountData);
+ this.sidebarCache.dispose();
debugLog.info('sync', 'Sliding sync disposed successfully', {
totalSyncCycles: this.syncCount,
});
}
+ private recordServerMembershipRooms(response: MSC3575SlidingSyncResponse): void {
+ const userId = this.mx.getSafeUserId();
+ Object.entries(response.rooms ?? {}).forEach(([roomId, roomData]) => {
+ const membershipEvent = [
+ ...(roomData.required_state ?? []),
+ ...(roomData.invite_state ?? []),
+ ].find(
+ (event) => event.type === (EventType.RoomMember as string) && event.state_key === userId
+ );
+ const content = membershipEvent?.content;
+ const membership =
+ content && typeof content === 'object'
+ ? (content as { membership?: unknown }).membership
+ : undefined;
+
+ if (membership === KnownMembership.Leave || membership === KnownMembership.Ban) {
+ this.serverMembershipRoomIds.delete(roomId);
+ } else {
+ this.serverMembershipRoomIds.add(roomId);
+ }
+ });
+ }
+
+ private reconcileSidebarCacheMembership(): void {
+ if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return;
+ this.sidebarCacheReconciliationQueued = true;
+
+ void this.cacheHydrationPromise.then(async () => {
+ if (this.disposed || this.sidebarCacheReconciled) return;
+
+ let joinedRoomIds: string[];
+ try {
+ const response = await this.mx.getJoinedRooms();
+ joinedRoomIds = response.joined_rooms;
+ } catch (error) {
+ debugLog.warn('sync', 'Skipped sidebar cache reconciliation: joined rooms unavailable', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
+
+ if (this.disposed || this.sidebarCacheReconciled) return;
+ this.sidebarCacheReconciled = true;
+
+ const validRoomIds = new Set([...this.serverMembershipRoomIds, ...joinedRoomIds]);
+
+ const removedRoomIds = this.sidebarCache.reconcileRooms(validRoomIds);
+ removedRoomIds.forEach((roomId) => {
+ this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave);
+ this.unsubscribeFromRoom(roomId);
+ });
+
+ if (removedRoomIds.length > 0) {
+ debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', {
+ removedRoomCount: removedRoomIds.length,
+ joinedRoomCount: joinedRoomIds.length,
+ observedRoomCount: this.serverMembershipRoomIds.size,
+ });
+ }
+ });
+ }
+
public getDiagnostics(): SlidingSyncDiagnostics {
return {
- proxyBaseUrl: this.proxyBaseUrl,
+ baseUrl: this.baseUrl,
timelineLimit: this.roomTimelineLimit,
- listPageSize: this.listPageSize,
lists: this.listKeys.map((key) => {
const listData = this.slidingSync.getListData(key);
const params = this.slidingSync.getListParams(key);
return {
key,
knownCount: listData?.joinedCount ?? 0,
- rangeEnd: getListEndIndex(params),
+ rangeEnd: this.confirmedListRangeEnds.get(key) ?? -1,
+ requestedRangeEnd: getListEndIndex(params),
};
}),
};
}
- private expandListsToKnownCount(): void {
- if (this.listsFullyLoaded) return;
+ public prepareSidebarCacheHydration(): void {
+ if (this.disposed || this.cacheHydrationNewListener) return;
+
+ this.cacheHydrationPromise = new Promise((resolve) => {
+ this.cacheHydrationResolve = resolve;
+ });
+
+ this.cacheHydrationNewListener = (eventName) => {
+ if (eventName !== SlidingSyncEvent.RoomData) return;
+ const listener = this.cacheHydrationNewListener;
+ if (listener) {
+ this.slidingSync.removeListener(EventEmitterEvents.NewListener, listener as never);
+ }
+ this.cacheHydrationNewListener = undefined;
+
+ globalThis.queueMicrotask(() => {
+ this.hydratingSidebarCache = true;
+ void this.sidebarCache
+ .hydrate(this.mx, this.slidingSync)
+ .then((hydrated) => this.cacheHydrationResolve?.(hydrated))
+ .catch(() => this.cacheHydrationResolve?.(false))
+ .finally(() => {
+ this.hydratingSidebarCache = false;
+ this.cacheHydrationResolve = undefined;
+ });
+ });
+ };
+
+ this.slidingSync.on(EventEmitterEvents.NewListener, this.cacheHydrationNewListener as never);
+ }
+
+ public waitForSidebarCacheHydration(): Promise {
+ return this.cacheHydrationPromise;
+ }
+
+ private expandListsByPage(): void {
+ if (!this.initialSyncCompleted) return;
+ if (this.initialListHydrationCompleted) return;
let allListsComplete = true;
let expandedAny = false;
@@ -435,9 +725,21 @@ export class SlidingSyncManager {
}
const existing = this.slidingSync.getListParams(key);
- const currentEnd = getListEndIndex(existing);
+ const requestedEnd = getListEndIndex(existing);
+ const currentEnd = this.confirmedListRangeEnds.get(key) ?? -1;
- const maxEnd = Math.min(knownCount, this.maxRooms) - 1;
+ const maxEnd = knownCount - 1;
+
+ if (requestedEnd > currentEnd) {
+ allListsComplete = false;
+ expansionDetails[key] = {
+ status: 'awaiting-response',
+ knownCount,
+ currentEnd,
+ desiredEnd: requestedEnd,
+ };
+ return;
+ }
if (currentEnd >= maxEnd) {
expansionDetails[key] = { status: 'complete', knownCount, currentEnd };
@@ -446,7 +748,7 @@ export class SlidingSyncManager {
allListsComplete = false;
- const desiredEnd = maxEnd;
+ const desiredEnd = Math.min(maxEnd, currentEnd + LIST_PAGE_SIZE);
if (desiredEnd === currentEnd) {
expansionDetails[key] = {
@@ -459,6 +761,7 @@ export class SlidingSyncManager {
}
this.slidingSync.setListRanges(key, [[0, desiredEnd]]);
+ this.requestedListRangeEnds.set(key, desiredEnd);
expandedAny = true;
expansionDetails[key] = {
@@ -469,46 +772,39 @@ export class SlidingSyncManager {
roomsToLoad: desiredEnd - currentEnd,
};
- debugLog.info('sync', `Expanding list "${key}" to full range`, {
+ debugLog.info('sync', `Expanding list "${key}" by page`, {
list: key,
knownCount,
previousEnd: currentEnd,
newEnd: desiredEnd,
roomsToLoad: desiredEnd - currentEnd,
});
-
- if (knownCount > this.maxRooms) {
- log.warn(
- `Sliding Sync list "${key}" capped at ${this.maxRooms}/${knownCount} rooms for ${this.mx.getUserId()}`
- );
- debugLog.warn('sync', `List "${key}" exceeds maxRooms limit`, {
- list: key,
- knownCount,
- maxRooms: this.maxRooms,
- cappedCount: this.maxRooms,
- });
- }
});
const expansionDuration = performance.now() - expansionStartTime;
const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding');
-
if (allListsComplete) {
- this.listsFullyLoaded = true;
- log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`);
- const totalRooms = this.listKeys.reduce(
- (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0),
- 0
- );
- const listsLoadedMs =
- this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0;
- Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, {
- attributes: { transport: 'sliding' },
- });
- Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, {
- attributes: { transport: 'sliding' },
- });
+ if (!this.listsFullyLoaded) {
+ this.listsFullyLoaded = true;
+ this.initialListHydrationCompleted = true;
+ this.reconcileSidebarCacheMembership();
+ globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0);
+ this.applySteadyStateListRanges();
+ log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`);
+ const totalRooms =
+ (this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0) +
+ (this.slidingSync.getListData(LIST_INVITES)?.joinedCount ?? 0);
+ const listsLoadedMs =
+ this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0;
+ Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, {
+ attributes: { transport: 'sliding' },
+ });
+ Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, {
+ attributes: { transport: 'sliding' },
+ });
+ }
} else if (expandedAny) {
+ this.listsFullyLoaded = false;
log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`);
}
@@ -530,11 +826,39 @@ export class SlidingSyncManager {
}
}
+ private applySteadyStateListRanges(): void {
+ const joinedList = this.slidingSync.getListParams(LIST_JOINED);
+ const currentEnd = getListEndIndex(joinedList);
+ const steadyStateEnd = Math.min(currentEnd, STEADY_STATE_DETAILED_ROOMS - 1);
+ if (steadyStateEnd < 0 || steadyStateEnd === currentEnd) return;
+
+ const joinedCount = this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0;
+ const updatesCount = this.slidingSync.getListData(LIST_UPDATES)?.joinedCount ?? 0;
+ const updatesConfirmedEnd = this.confirmedListRangeEnds.get(LIST_UPDATES) ?? -1;
+ if (updatesCount !== joinedCount || updatesConfirmedEnd < joinedCount - 1) {
+ debugLog.warn('sync', 'Kept detailed joined list fully covered: updates list unavailable', {
+ joinedCount,
+ updatesCount,
+ updatesConfirmedEnd,
+ });
+ return;
+ }
+
+ this.slidingSync.setListRanges(LIST_JOINED, [[0, steadyStateEnd]]);
+ this.requestedListRangeEnds.set(LIST_JOINED, steadyStateEnd);
+ debugLog.info('sync', 'Reduced detailed joined list to steady-state window', {
+ previousEnd: currentEnd,
+ newEnd: steadyStateEnd,
+ retainedDetailedRooms: steadyStateEnd + 1,
+ updatesCoverageEnd: updatesConfirmedEnd,
+ });
+ }
+
public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List {
let list = this.slidingSync.getListParams(listKey);
if (!list) {
list = {
- ranges: [[0, 20]],
+ ranges: [[0, LIST_PAGE_SIZE - 1]],
sort: LIST_SORT_ORDER,
timeline_limit: LIST_TIMELINE_LIMIT,
required_state: buildListRequiredState(),
@@ -580,7 +904,7 @@ export class SlidingSyncManager {
: { is_invite: false };
this.ensureListRegistered(LIST_SPACE, {
filters,
- ranges: spaceId ? [[0, Math.min(this.listPageSize - 1, 499)]] : [[0, 0]],
+ ranges: spaceId ? [[0, LIST_PAGE_SIZE - 1]] : [[0, 0]],
sort: LIST_SORT_ORDER,
});
}
@@ -589,18 +913,140 @@ export class SlidingSyncManager {
return this.activeRoomSubscriptions.has(roomId);
}
- public subscribeToRoom(roomId: string): void {
+ public isResponseProcessing(): boolean {
+ return this.responseProcessing;
+ }
+
+ public subscribeToResponseSettled(listener: () => void): () => void {
+ this.responseSettledListeners.add(listener);
+ return () => this.responseSettledListeners.delete(listener);
+ }
+
+ public reconcileRoomMembership(
+ roomId: string,
+ membership: KnownMembership.Join | KnownMembership.Leave
+ ): void {
+ this.mx.getRoom(roomId)?.updateMyMembership(membership);
+
+ if (membership === KnownMembership.Leave) {
+ this.sidebarCache.removeRoom(roomId);
+ const removedSpaceSubscription = this.spaceSubscriptions.delete(roomId);
+ const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(roomId);
+ if (this.activeRoomSubscriptions.has(roomId)) {
+ this.unsubscribeFromRoom(roomId);
+ } else if (removedSpaceSubscription || removedSidebarSubscription) {
+ this.queueRoomSubscriptionSync();
+ }
+ }
+ }
+
+ private flushDeferredSubscriptions(): void {
+ if (this.disposed || !this.listsFullyLoaded) return;
+
+ const spaces = this.deferredSpaceSubscriptions;
+ this.deferredSpaceSubscriptions = new Set();
+ this.setSpaceSubscriptions(spaces);
+ }
+
+ private queueRoomSubscriptionSync(): void {
+ if (this.disposed || this.roomSubscriptionSyncQueued) return;
+ this.roomSubscriptionSyncQueued = true;
+ globalThis.queueMicrotask(() => {
+ this.roomSubscriptionSyncQueued = false;
+ if (!this.disposed) this.syncRoomSubscriptions();
+ });
+ }
+
+ private syncRoomSubscriptions(): void {
+ const desiredSubscriptions = new Set([
+ ...this.activeRoomSubscriptions,
+ ...this.sidebarRoomSubscriptions,
+ ...this.spaceSubscriptions,
+ ...this.imagePackRoomSubscriptions,
+ ]);
+
+ desiredSubscriptions.forEach((roomId) => {
+ if (this.activeRoomSubscriptions.has(roomId)) {
+ this.slidingSync.useCustomSubscription(roomId, ACTIVE_ROOM_SUBSCRIPTION_KEY);
+ } else if (this.sidebarRoomSubscriptions.has(roomId)) {
+ this.slidingSync.useCustomSubscription(roomId, SIDEBAR_ROOM_SUBSCRIPTION_KEY);
+ } else if (this.spaceSubscriptions.has(roomId)) {
+ this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY);
+ } else {
+ this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY);
+ }
+ });
+
+ this.slidingSync.modifyRoomSubscriptions(desiredSubscriptions);
+ }
+
+ private pauseUnconfirmedListExpansion(): void {
+ this.listKeys.forEach((key) => {
+ const confirmedEnd = this.confirmedListRangeEnds.get(key) ?? -1;
+ if (confirmedEnd < 0) return;
+
+ const requestedEnd = getListEndIndex(this.slidingSync.getListParams(key));
+ if (requestedEnd <= confirmedEnd) return;
+
+ this.slidingSync.setListRanges(key, [[0, confirmedEnd]]);
+ this.requestedListRangeEnds.set(key, confirmedEnd);
+ });
+ }
+
+ public setImagePackSubscriptions(roomIds: Iterable): void {
if (this.disposed) return;
+ const next = new Set(roomIds);
+ const unchanged =
+ next.size === this.imagePackRoomSubscriptions.size &&
+ [...next].every((roomId) => this.imagePackRoomSubscriptions.has(roomId));
+ if (unchanged) return;
+
+ this.imagePackRoomSubscriptions.clear();
+ next.forEach((roomId) => this.imagePackRoomSubscriptions.add(roomId));
+ this.syncRoomSubscriptions();
+ }
+
+ /* Listen for raw sliding-sync data for one room */
+ public onRoomData(roomId: string, listener: () => void): () => void {
+ const handler = (dataRoomId: string) => {
+ // Let matrix-js-sdk finish applying the room data before reading state events
+ if (dataRoomId === roomId) globalThis.setTimeout(listener, 0);
+ };
+ this.slidingSync.on(SlidingSyncEvent.RoomData, handler);
+ return () => this.slidingSync.removeListener(SlidingSyncEvent.RoomData, handler);
+ }
+
+ public onRoomSubscriptionStatus(
+ roomId: string,
+ listener: (loading: boolean) => void
+ ): () => void {
+ const listeners = this.roomSubscriptionStatusListeners.get(roomId) ?? new Set();
+ listeners.add(listener);
+ this.roomSubscriptionStatusListeners.set(roomId, listeners);
+ listener(this.isRoomSubscriptionLoading(roomId));
+
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) this.roomSubscriptionStatusListeners.delete(roomId);
+ };
+ }
+
+ public isRoomSubscriptionLoading(roomId: string): boolean {
+ return (
+ this.pendingRoomDataListeners.has(roomId) || this.roomDataAwaitingSyncCompletion.has(roomId)
+ );
+ }
+
+ private notifyRoomSubscriptionStatus(roomId: string, loading: boolean): void {
+ this.roomSubscriptionStatusListeners.get(roomId)?.forEach((listener) => listener(loading));
+ }
+
+ private addActiveRoomSubscription(roomId: string): boolean {
+ if (this.activeRoomSubscriptions.has(roomId)) return false;
const room = this.mx.getRoom(roomId);
const isEncrypted = this.mx.isRoomEncrypted(roomId);
- if (room && !isEncrypted) {
- this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY);
- }
this.activeRoomSubscriptions.add(roomId);
- this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
- Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, {
- attributes: { transport: 'sliding' },
- });
+ this.pauseUnconfirmedListExpansion();
log.log(`Sliding Sync active room subscription added: ${roomId}`);
debugLog.info('sync', 'Room subscription requested (sliding)', {
encrypted: isEncrypted,
@@ -624,6 +1070,7 @@ export class SlidingSyncManager {
const subscribeMs = performance.now();
const onFirstRoomData = (dataRoomId: string) => {
if (dataRoomId !== roomId) return;
+ if (this.hydratingSidebarCache) return;
const latencyMs = Math.round(performance.now() - subscribeMs);
const subscribedRoom = this.mx.getRoom(roomId);
const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0;
@@ -646,27 +1093,88 @@ export class SlidingSyncManager {
});
this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData);
this.pendingRoomDataListeners.delete(roomId);
+ this.roomDataAwaitingSyncCompletion.add(roomId);
};
this.pendingRoomDataListeners.set(roomId, onFirstRoomData);
this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData);
+ this.notifyRoomSubscriptionStatus(roomId, true);
+ return true;
}
- public unsubscribeFromRoom(roomId: string): void {
- if (this.disposed) return;
+ private removeActiveRoomSubscription(roomId: string): boolean {
+ if (!this.activeRoomSubscriptions.has(roomId)) return false;
const pendingListener = this.pendingRoomDataListeners.get(roomId);
if (pendingListener) {
this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener);
this.pendingRoomDataListeners.delete(roomId);
}
+ this.roomDataAwaitingSyncCompletion.delete(roomId);
+ this.notifyRoomSubscriptionStatus(roomId, false);
this.activeRoomSubscriptions.delete(roomId);
- this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
- Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, {
- attributes: { transport: 'sliding' },
- });
log.log(`Sliding Sync active room subscription removed: ${roomId}`);
debugLog.info('sync', 'Room subscription removed (sliding)', {
remainingSubscriptions: this.activeRoomSubscriptions.size,
syncCycle: this.syncCount,
});
+ return true;
+ }
+
+ private reportActiveSubscriptionCount(): void {
+ Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, {
+ attributes: { transport: 'sliding' },
+ });
+ }
+
+ public setActiveRoomSubscriptions(roomIds: Iterable): void {
+ if (this.disposed) return;
+ const next = new Set(roomIds);
+ let changed = false;
+
+ this.activeRoomSubscriptions.forEach((roomId) => {
+ if (!next.has(roomId)) changed = this.removeActiveRoomSubscription(roomId) || changed;
+ });
+ next.forEach((roomId) => {
+ if (!this.activeRoomSubscriptions.has(roomId)) {
+ changed = this.addActiveRoomSubscription(roomId) || changed;
+ }
+ });
+
+ if (!changed) return;
+ this.syncRoomSubscriptions();
+ this.reportActiveSubscriptionCount();
+ }
+
+ public getActiveRoomSubscriptionIds(): string[] {
+ return [...this.activeRoomSubscriptions];
+ }
+
+ public subscribeToRoom(roomId: string): void {
+ if (this.disposed || !this.addActiveRoomSubscription(roomId)) return;
+ this.syncRoomSubscriptions();
+ this.reportActiveSubscriptionCount();
+ }
+
+ public unsubscribeFromRoom(roomId: string): void {
+ if (this.disposed || !this.removeActiveRoomSubscription(roomId)) return;
+ this.syncRoomSubscriptions();
+ this.reportActiveSubscriptionCount();
+ }
+
+ public setSpaceSubscriptions(roomIds: Iterable): void {
+ if (this.disposed) return;
+ const next = new Set(roomIds);
+ if (!this.listsFullyLoaded) {
+ this.deferredSpaceSubscriptions = next;
+ return;
+ }
+ const unchanged =
+ next.size === this.spaceSubscriptions.size &&
+ [...next].every((roomId) => this.spaceSubscriptions.has(roomId));
+ if (unchanged) return;
+
+ this.spaceSubscriptions.clear();
+ next.forEach((roomId) => this.spaceSubscriptions.add(roomId));
+ this.syncRoomSubscriptions();
+ this.expandListsByPage();
}
}
diff --git a/src/client/slidingSyncSidebarCache.test.ts b/src/client/slidingSyncSidebarCache.test.ts
new file mode 100644
index 0000000000..f288a87f04
--- /dev/null
+++ b/src/client/slidingSyncSidebarCache.test.ts
@@ -0,0 +1,132 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk';
+import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk';
+import { CustomAccountDataEvent } from '$types/matrix/accountData';
+import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
+
+const userId = '@user:example.com';
+
+const stateEvent = (type: string, stateKey: string, content: Record = {}) => ({
+ type,
+ state_key: stateKey,
+ content,
+ event_id: `$${type}-${stateKey}`,
+ sender: userId,
+ origin_server_ts: 1,
+});
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+describe('SlidingSyncSidebarCache', () => {
+ it('restores sidebar state without caching timelines or bulk members', async () => {
+ const cache = new SlidingSyncSidebarCache(userId);
+ cache.cacheRoom('!room:example.com', {
+ name: 'Cached Room',
+ initial: true,
+ bump_stamp: 42,
+ required_state: [
+ stateEvent(EventType.RoomName, '', { name: 'Cached Room' }),
+ stateEvent(EventType.SpaceChild, '!child:example.com', { via: ['example.com'] }),
+ stateEvent(EventType.RoomMember, userId, { membership: 'join' }),
+ stateEvent(EventType.RoomMember, '@other:example.com', { membership: 'join' }),
+ ],
+ timeline: [
+ {
+ type: EventType.RoomMessage,
+ content: { body: 'do not cache me' },
+ event_id: '$message',
+ sender: '@other:example.com',
+ origin_server_ts: 2,
+ },
+ ],
+ } as MSC3575RoomData);
+ cache.cacheAccountData(
+ new MatrixEvent({ type: EventType.Direct, content: { '@other:example.com': ['!room'] } })
+ );
+ cache.cacheAccountData(
+ new MatrixEvent({
+ type: CustomAccountDataEvent.CinnySpaces,
+ content: { categories: ['!room:example.com'] },
+ })
+ );
+ cache.dispose();
+
+ const emitPromised = vi
+ .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>()
+ .mockResolvedValue(true);
+ const storeAccountDataEvents = vi.fn<(events: MatrixEvent[]) => void>();
+ const restored = new SlidingSyncSidebarCache(userId);
+ const hydrated = await restored.hydrate(
+ { store: { storeAccountDataEvents } } as unknown as MatrixClient,
+ { emitPromised } as unknown as SlidingSync
+ );
+
+ expect(hydrated).toBe(true);
+ expect(emitPromised).toHaveBeenCalledWith(
+ SlidingSyncEvent.RoomData,
+ '!room:example.com',
+ expect.objectContaining({
+ name: 'Cached Room',
+ timeline: [],
+ initial: true,
+ bump_stamp: 42,
+ })
+ );
+ const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData;
+ expect(hydratedRoom.required_state).toHaveLength(3);
+ expect(hydratedRoom.required_state).toContainEqual(
+ expect.objectContaining({
+ type: EventType.SpaceChild,
+ state_key: '!child:example.com',
+ })
+ );
+ expect(hydratedRoom.required_state).not.toContainEqual(
+ expect.objectContaining({ state_key: '@other:example.com' })
+ );
+ expect(storeAccountDataEvents).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ event: expect.objectContaining({ type: EventType.Direct }) }),
+ expect.objectContaining({
+ event: expect.objectContaining({ type: CustomAccountDataEvent.CinnySpaces }),
+ }),
+ ])
+ );
+ });
+
+ it('merges later state into the existing cached room snapshot', async () => {
+ const cache = new SlidingSyncSidebarCache(userId);
+ cache.cacheRoom('!room:example.com', {
+ name: 'Before',
+ required_state: [stateEvent(EventType.RoomName, '', { name: 'Before' })],
+ timeline: [],
+ });
+ cache.cacheRoom('!room:example.com', {
+ name: 'After',
+ required_state: [stateEvent(EventType.RoomAvatar, '', { url: 'mxc://example/avatar' })],
+ timeline: [],
+ });
+ cache.dispose();
+
+ const emitPromised = vi
+ .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>()
+ .mockResolvedValue(true);
+ const restored = new SlidingSyncSidebarCache(userId);
+ await restored.hydrate(
+ {
+ store: { storeAccountDataEvents: vi.fn<(events: MatrixEvent[]) => void>() },
+ } as unknown as MatrixClient,
+ { emitPromised } as unknown as SlidingSync
+ );
+
+ const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData;
+ expect(hydratedRoom.name).toBe('After');
+ expect(hydratedRoom.required_state).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: EventType.RoomName }),
+ expect.objectContaining({ type: EventType.RoomAvatar }),
+ ])
+ );
+ });
+});
diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts
new file mode 100644
index 0000000000..4aad4638d4
--- /dev/null
+++ b/src/client/slidingSyncSidebarCache.ts
@@ -0,0 +1,223 @@
+import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk';
+import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk';
+import { CustomAccountDataEvent } from '$types/matrix/accountData';
+
+const CACHE_VERSION = 1;
+const CACHE_KEY_PREFIX = 'sable.slidingSyncSidebarCache.';
+const CACHE_WRITE_DELAY_MS = 500;
+const MAX_CACHED_ROOMS = 2000;
+const HYDRATION_BATCH_SIZE = 50;
+
+type StateEvent = MSC3575RoomData['required_state'][number];
+
+type SidebarCacheData = {
+ version: number;
+ rooms: Record;
+ accountData: Record>;
+};
+
+const CACHED_ACCOUNT_DATA_TYPES = new Set([
+ EventType.Direct,
+ CustomAccountDataEvent.CinnySpaces,
+]);
+
+const CACHED_STATE_TYPES = new Set([
+ EventType.RoomCreate,
+ EventType.RoomName,
+ EventType.RoomTopic,
+ EventType.RoomAvatar,
+ EventType.RoomCanonicalAlias,
+ EventType.RoomJoinRules,
+ EventType.RoomHistoryVisibility,
+ EventType.RoomGuestAccess,
+ EventType.RoomEncryption,
+ EventType.RoomTombstone,
+ EventType.RoomPowerLevels,
+ EventType.RoomPinnedEvents,
+ EventType.RoomServerAcl,
+ EventType.SpaceChild,
+ EventType.SpaceParent,
+]);
+
+const emptyCache = (): SidebarCacheData => ({
+ version: CACHE_VERSION,
+ rooms: {},
+ accountData: {},
+});
+
+const stateEventKey = (event: StateEvent): string => `${event.type}\u0000${event.state_key}`;
+
+const mergeStateEvents = (
+ previous: StateEvent[] | undefined,
+ incoming: StateEvent[] | undefined,
+ userId: string
+): StateEvent[] => {
+ const merged = new Map();
+ previous?.forEach((event) => merged.set(stateEventKey(event), event));
+ incoming?.forEach((event) => {
+ const cacheMember = event.type === 'm.room.member' && event.state_key === userId;
+ if (cacheMember || CACHED_STATE_TYPES.has(event.type)) {
+ merged.set(stateEventKey(event), event);
+ }
+ });
+ return [...merged.values()];
+};
+
+const mergeRoomData = (
+ previous: MSC3575RoomData | undefined,
+ incoming: MSC3575RoomData,
+ userId: string
+): MSC3575RoomData => ({
+ ...previous,
+ ...incoming,
+ name: incoming.name ?? previous?.name ?? '',
+ heroes: incoming.heroes ?? previous?.heroes,
+ notification_count: incoming.notification_count ?? previous?.notification_count,
+ highlight_count: incoming.highlight_count ?? previous?.highlight_count,
+ joined_count: incoming.joined_count ?? previous?.joined_count,
+ invited_count: incoming.invited_count ?? previous?.invited_count,
+ is_dm: incoming.is_dm ?? previous?.is_dm,
+ bump_stamp: incoming.bump_stamp ?? previous?.bump_stamp,
+ required_state: mergeStateEvents(previous?.required_state, incoming.required_state, userId),
+ invite_state: incoming.invite_state
+ ? mergeStateEvents(previous?.invite_state, incoming.invite_state, userId)
+ : previous?.invite_state,
+ timeline: [],
+ initial: true,
+ limited: false,
+ num_live: 0,
+ prev_batch: undefined,
+});
+
+const parseCache = (value: string | null): SidebarCacheData => {
+ if (!value) return emptyCache();
+ try {
+ const parsed = JSON.parse(value) as Partial;
+ if (parsed.version !== CACHE_VERSION || !parsed.rooms || !parsed.accountData) {
+ return emptyCache();
+ }
+ return parsed as SidebarCacheData;
+ } catch {
+ return emptyCache();
+ }
+};
+
+const hydrateRoomBatch = async (
+ slidingSync: SlidingSync,
+ rooms: [string, MSC3575RoomData][],
+ startIndex = 0
+): Promise => {
+ const batch = rooms.slice(startIndex, startIndex + HYDRATION_BATCH_SIZE);
+ await Promise.all(
+ batch.map(([roomId, roomData]) =>
+ slidingSync.emitPromised(SlidingSyncEvent.RoomData, roomId, roomData)
+ )
+ );
+ if (startIndex + HYDRATION_BATCH_SIZE < rooms.length) {
+ await hydrateRoomBatch(slidingSync, rooms, startIndex + HYDRATION_BATCH_SIZE);
+ }
+};
+
+export class SlidingSyncSidebarCache {
+ public static clear(userId: string): void {
+ try {
+ globalThis.localStorage?.removeItem(`${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`);
+ } catch {
+ // Storage can be disabled for this origin.
+ }
+ }
+
+ private readonly storageKey: string;
+
+ private data: SidebarCacheData;
+
+ private writeTimer: ReturnType | undefined;
+
+ public constructor(private readonly userId: string) {
+ this.storageKey = `${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`;
+ let stored: string | null = null;
+ try {
+ stored = globalThis.localStorage?.getItem(this.storageKey) ?? null;
+ } catch {
+ // Storage can be disabled for this origin.
+ }
+ this.data = parseCache(stored);
+ }
+
+ public cacheRoom(roomId: string, roomData: MSC3575RoomData): void {
+ this.data.rooms[roomId] = mergeRoomData(this.data.rooms[roomId], roomData, this.userId);
+ this.scheduleWrite();
+ }
+
+ public removeRoom(roomId: string): void {
+ if (!this.data.rooms[roomId]) return;
+ delete this.data.rooms[roomId];
+ this.scheduleWrite();
+ }
+
+ public reconcileRooms(validRoomIds: ReadonlySet): string[] {
+ const removedRoomIds = Object.keys(this.data.rooms).filter(
+ (roomId) => !validRoomIds.has(roomId)
+ );
+ if (removedRoomIds.length === 0) return removedRoomIds;
+
+ removedRoomIds.forEach((roomId) => delete this.data.rooms[roomId]);
+ this.scheduleWrite();
+ return removedRoomIds;
+ }
+
+ public cacheAccountData(event: MatrixEvent): void {
+ if (!CACHED_ACCOUNT_DATA_TYPES.has(event.getType())) return;
+ this.data.accountData[event.getType()] = event.getContent>();
+ this.scheduleWrite();
+ }
+
+ public async hydrate(mx: MatrixClient, slidingSync: SlidingSync): Promise {
+ const rooms = Object.entries(this.data.rooms);
+ const accountData = Object.entries(this.data.accountData);
+ if (rooms.length === 0 && accountData.length === 0) return false;
+
+ if (accountData.length > 0) {
+ mx.store.storeAccountDataEvents(
+ accountData.map(([type, content]) => new MatrixEvent({ type, content }))
+ );
+ }
+
+ await hydrateRoomBatch(slidingSync, rooms);
+ return rooms.length > 0;
+ }
+
+ public dispose(): void {
+ if (this.writeTimer !== undefined) {
+ globalThis.clearTimeout(this.writeTimer);
+ this.writeTimer = undefined;
+ this.write();
+ }
+ }
+
+ private scheduleWrite(): void {
+ if (this.writeTimer !== undefined) return;
+ this.writeTimer = globalThis.setTimeout(() => {
+ this.writeTimer = undefined;
+ this.write();
+ }, CACHE_WRITE_DELAY_MS);
+ }
+
+ private write(): void {
+ let rooms = Object.entries(this.data.rooms)
+ .toSorted(([, a], [, b]) => (b.bump_stamp ?? 0) - (a.bump_stamp ?? 0))
+ .slice(0, MAX_CACHED_ROOMS);
+
+ while (true) {
+ const nextData = { ...this.data, rooms: Object.fromEntries(rooms) };
+ try {
+ globalThis.localStorage?.setItem(this.storageKey, JSON.stringify(nextData));
+ this.data = nextData;
+ return;
+ } catch {
+ if (rooms.length <= 1) return;
+ rooms = rooms.slice(0, Math.floor(rooms.length / 2));
+ }
+ }
+ }
+}
diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts
index 1705580b01..12f41e0712 100644
--- a/src/types/matrix-sdk.ts
+++ b/src/types/matrix-sdk.ts
@@ -22,6 +22,7 @@ export * from 'matrix-js-sdk/lib/models/user';
export * from 'matrix-js-sdk/lib/models/search-result';
export * from 'matrix-js-sdk/lib/models/event-timeline';
export * from 'matrix-js-sdk/lib/models/event-timeline-set';
+export { EventEmitterEvents } from 'matrix-js-sdk/lib/models/typed-event-emitter';
export { Relations, RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
export * from 'matrix-js-sdk/lib/store/indexeddb';