diff --git a/.changeset/feat-sliding-sync-presence.md b/.changeset/feat-sliding-sync-presence.md
new file mode 100644
index 000000000..136556540
--- /dev/null
+++ b/.changeset/feat-sliding-sync-presence.md
@@ -0,0 +1,13 @@
+---
+sable: minor
+---
+
+Rewrites the sliding sync implementation to match the Element Web approach (MSC4186).
+
+- Room list sorted by notification level, recency, then name
+- `include_old_rooms` added so tombstoned rooms pass predecessor state to replacements
+- Active-room custom subscription: focused room receives `timeline_limit=50`
+- `subscribeToRoom` / `unsubscribeFromRoom` API on `SlidingSyncManager`
+- `useSlidingSyncActiveRoom` hook + `SlidingSyncActiveRoomSubscriber` component
+- Registers a custom `ExtensionPresence` so `m.presence` events from the server are processed into the SDK's `User` model — fixes components using `useUserPresence` always showing stale/default presence
+- Always reinitialises the timeline on `TimelineRefresh` events to fix a silent hang where the room timeline stops updating after a reconnect
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
index ffbb9a117..f8552e19a 100644
--- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
@@ -445,9 +445,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
Proxy: {syncDiagnostics.sliding.proxyBaseUrl}
- Timeline limit: {syncDiagnostics.sliding.timelineLimit} | page
- size: {syncDiagnostics.sliding.listPageSize} | adaptive:{' '}
- {syncDiagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}
+ Room timeline: {syncDiagnostics.sliding.timelineLimit}
+ {syncDiagnostics.sliding.adaptiveTimeline
+ ? ' (adaptive)'
+ : ''}{' '}
+ | page size: {syncDiagnostics.sliding.listPageSize}
>
) : (
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 030accab0..668b33f7b 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -858,10 +858,33 @@ export function RoomTimeline({
useLiveTimelineRefresh(
room,
useCallback(() => {
- if (liveTimelineLinked || timeline.linkedTimelines.length === 0) {
- setTimeline(getInitialTimeline(room));
- }
- }, [room, liveTimelineLinked, timeline.linkedTimelines.length])
+ // Always reinitialize on TimelineRefresh. With sliding sync, a limited
+ // response replaces the room's live EventTimeline with a brand-new object,
+ // firing TimelineRefresh. At that moment liveTimelineLinked is stale-false
+ // (the stored linkedTimelines still reference the old detached object),
+ // so the previous guard `if (liveTimelineLinked || ...)` would silently
+ // skip reinit. Back-pagination then calls paginateEventTimeline against
+ // the dead old timeline, which no-ops, and the IntersectionObserver never
+ // re-fires because intersection state didn't change — causing a permanent
+ // hang at the top of the timeline with no spinner and no history loaded.
+ // Unconditionally reinitializing is correct: TimelineRefresh signals that
+ // the SDK has replaced the timeline chain, so any stored range/indices
+ // against the old chain are invalid anyway.
+ //
+ // Also force atBottom=true and queue a scroll-to-bottom. The SDK fires
+ // TimelineRefresh before adding new events to the fresh live timeline, so
+ // getInitialTimeline captures range.end=0. Once events arrive the
+ // rangeAtEnd self-heal useEffect needs atBottom=true to run; the
+ // IntersectionObserver may have transiently fired isIntersecting=false
+ // during the render transition, leaving atBottom=false and causing the
+ // "Jump to Latest" button to stick permanently. Forcing atBottom here is
+ // correct: TimelineRefresh always reinits to the live end, so the user
+ // should be repositioned to the bottom regardless.
+ setTimeline(getInitialTimeline(room));
+ setAtBottom(true);
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = false;
+ }, [room, setAtBottom])
);
// Re-render when non-live Replace relations arrive (bundled/historical edits
@@ -882,6 +905,21 @@ export function RoomTimeline({
setTimeline(getInitialTimeline(room));
}, [eventId, room, timeline.linkedTimelines.length]);
+ // Fix stale rangeAtEnd after a sliding sync TimelineRefresh. The SDK fires
+ // TimelineRefresh before adding new events to the freshly-created live
+ // EventTimeline, so getInitialTimeline captures range.end=0. New events then
+ // arrive via useLiveEventArrive, but its atLiveEndRef guard is stale-false
+ // (hasn't re-rendered yet), bypassing the range-advance path. The next render
+ // ends up with liveTimelineLinked=true but rangeAtEnd=false, making the
+ // "Jump to Latest" button appear while the user is already at the bottom.
+ // Re-running getInitialTimeline post-render (after events were added to the
+ // live EventTimeline object) snaps range.end to the correct event count.
+ useEffect(() => {
+ if (liveTimelineLinked && !rangeAtEnd && atBottom) {
+ setTimeline(getInitialTimeline(room));
+ }
+ }, [liveTimelineLinked, rangeAtEnd, atBottom, room]);
+
// Stay at bottom when room editor resize
useResizeObserver(
useMemo(() => {
@@ -2127,6 +2165,19 @@ export function RoomTimeline({
);
} else if (timelineItems.length === 0) {
+ // When eventsLength===0 AND liveTimelineLinked the live EventTimeline was
+ // just reset by a sliding sync TimelineRefresh and new events haven't
+ // arrived yet. Attaching the IntersectionObserver anchor here would
+ // immediately fire a server-side /messages request before current events
+ // land — potentially causing a "/messages hangs → spinner stuck" scenario.
+ // Suppressing the anchor for this transient state is safe: the rangeAtEnd
+ // self-heal useEffect will call getInitialTimeline once events arrive, and
+ // at that point the correct anchor (below) will be re-observed.
+ // eventsLength>0 covers the range={K,K} case from recalibratePagination
+ // where items=0 but events exist — that needs the anchor for local range
+ // extension (no server call since start>0).
+ const placeholderBackAnchor =
+ eventsLength > 0 || !liveTimelineLinked ? observeBackAnchor : undefined;
backPaginationJSX =
messageLayout === MessageLayout.Compact ? (
<>
@@ -2142,7 +2193,7 @@ export function RoomTimeline({
-
+
>
@@ -2154,7 +2205,7 @@ export function RoomTimeline({
-
+
>
diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
index 6c5c1b270..07824c859 100644
--- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
+++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx
@@ -199,18 +199,9 @@ export function SyncDiagnostics() {
Sliding proxy: {diagnostics.sliding.proxyBaseUrl}
- Timeline limit: {diagnostics.sliding.timelineLimit} (page size:{' '}
- {diagnostics.sliding.listPageSize})
-
-
- Adaptive timeline: {diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}
-
-
- Device/network: saveData {diagnostics.sliding.device.saveData ? 'on' : 'off'} |
- effectiveType {diagnostics.sliding.device.effectiveType ?? 'unknown'} | memory{' '}
- {diagnostics.sliding.device.deviceMemoryGb ?? 'unknown'} GB | mobile{' '}
- {diagnostics.sliding.device.mobile ? 'yes' : 'no'} | missing signals{' '}
- {diagnostics.sliding.device.missingSignals}
+ Room timeline limit: {diagnostics.sliding.timelineLimit} (adaptive:{' '}
+ {diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}) | page size:{' '}
+ {diagnostics.sliding.listPageSize}
{diagnostics.sliding.lists.map((list) => (
diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts
new file mode 100644
index 000000000..f138a6148
--- /dev/null
+++ b/src/app/hooks/useSlidingSyncActiveRoom.ts
@@ -0,0 +1,27 @@
+import { useEffect } from 'react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { getSlidingSyncManager } from '$client/initMatrix';
+import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
+
+/**
+ * Subscribes the currently selected room to the sliding sync "active room"
+ * custom subscription (higher timeline limit) for the duration the room is open.
+ *
+ * Safe to call unconditionally — it is a no-op when classic sync is in use
+ * (i.e. when there is no SlidingSyncManager for the client).
+ */
+export const useSlidingSyncActiveRoom = (): void => {
+ const mx = useMatrixClient();
+ const roomId = useSelectedRoom();
+
+ useEffect(() => {
+ if (!roomId) return undefined;
+ const manager = getSlidingSyncManager(mx);
+ if (!manager) return undefined;
+
+ manager.subscribeToRoom(roomId);
+ return () => {
+ manager.unsubscribeFromRoom(roomId);
+ };
+ }, [mx, roomId]);
+};
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index f5a948013..580135e64 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -37,6 +37,7 @@ import {
resolveNotificationPreviewText,
} from '$utils/notificationStyle';
import { mobileOrTablet } from '$utils/user-agent';
+import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -369,7 +370,7 @@ function MessageNotifications() {
if (document.visibilityState !== 'visible') return;
// Page is visible — show the themed in-app notification banner.
- if (showNotifications && (isHighlightByRule || loudByRule || isDM)) {
+ if (showNotifications && (isHighlightByRule || isLoud)) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const roomAvatar = avatarMxc
@@ -569,6 +570,11 @@ function SyncNotificationSettingsWithServiceWorker() {
return null;
}
+function SlidingSyncActiveRoomSubscriber() {
+ useSlidingSyncActiveRoom();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -580,6 +586,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
{children}
>
);
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 16835d4fe..47c8ba29d 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -23,6 +23,7 @@ import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from '.
const log = createLogger('initMatrix');
const slidingSyncByClient = new WeakMap();
const FAST_SYNC_POLL_TIMEOUT_MS = 10000;
+const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000;
type SyncTransport = 'classic' | 'sliding';
type SyncTransportReason =
| 'sliding_active'
@@ -340,6 +341,9 @@ export const stopClient = (mx: MatrixClient): void => {
mx.stopClient();
};
+export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined =>
+ slidingSyncByClient.get(mx);
+
export const startClient = async (mx: MatrixClient, config?: StartClientConfig) => {
log.log('startClient', mx.getUserId());
disposeSlidingSync(mx);
@@ -419,16 +423,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig)
}
const resolvedProxyBaseUrl = proxyBaseUrl;
- const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, {
- ...(slidingConfig ?? {}),
- includeInviteList: true,
- pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? FAST_SYNC_POLL_TIMEOUT_MS,
- });
- const supported = await SlidingSyncManager.probe(
- mx,
- resolvedProxyBaseUrl,
- manager.probeTimeoutMs
- );
+ const probeTimeoutMs = (() => {
+ const v = slidingConfig?.probeTimeoutMs;
+ return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000;
+ })();
+ const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs);
log.log('startClient sliding probe result', {
userId: mx.getUserId(),
requestedEnabled: slidingRequested,
@@ -442,7 +441,15 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig)
return;
}
+ const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, {
+ ...(slidingConfig ?? {}),
+ includeInviteList: true,
+ pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS,
+ });
manager.attach();
+ // Begin background spidering so all rooms are eventually indexed.
+ // Not awaited — this runs incrementally in the background.
+ manager.startSpidering(100, 50);
slidingSyncByClient.set(mx, manager);
syncTransportByClient.set(mx, {
transport: 'sliding',
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 0c8cf98e3..0029a63c9 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -1,29 +1,60 @@
+/* eslint-disable max-classes-per-file */
import {
+ ClientEvent,
+ Extension,
+ ExtensionState,
MatrixClient,
MSC3575List,
MSC3575RoomSubscription,
+ MSC3575_WILDCARD,
SlidingSync,
SlidingSyncEvent,
SlidingSyncState,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
EventType,
+ User,
} from '$types/matrix-sdk';
-import { StateEvent } from '$types/matrix/room';
import { createLogger } from '$utils/debug';
const log = createLogger('slidingSync');
-const LIST_JOINED = 'joined';
-const LIST_INVITES = 'invites';
+export const LIST_JOINED = 'joined';
+export const LIST_INVITES = 'invites';
+export const LIST_DMS = 'dms';
+export const LIST_SEARCH = 'search';
+// Separate key for live room-name filtering; avoids conflicting with the spidering list.
+export const LIST_ROOM_SEARCH = 'room_search';
+// Dynamic list key used for space-scoped room views.
+export const LIST_SPACE = 'space';
+// One event of timeline per list room is enough to compute unread counts;
+// the full history is loaded when the user opens the room.
+const LIST_TIMELINE_LIMIT = 1;
const DEFAULT_LIST_PAGE_SIZE = 250;
-const DEFAULT_TIMELINE_LIMIT = 30;
-const TIMELINE_LIMIT_LOW = 10;
-const TIMELINE_LIMIT_MEDIUM = 15;
-const TIMELINE_LIMIT_HIGH = 30;
-const DEFAULT_POLL_TIMEOUT_MS = 10000;
+const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
+// Sort order for MSC4186 (Simplified Sliding Sync): most recently active first,
+// then alphabetical as a tiebreaker. by_notification_level is MSC3575-only and
+// not supported by Synapse's native MSC4186 implementation.
+const LIST_SORT_ORDER = ['by_recency', 'by_name'];
+
+// Subscription key for the room the user is actively viewing.
+// Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members.
+const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
+// Adaptive timeline limits for the room the user is actively viewing.
+// Lower limits reduce initial bandwidth on constrained devices/connections;
+// the user can always paginate further once the room is open.
+const ACTIVE_ROOM_TIMELINE_LIMIT_LOW = 20;
+const ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM = 35;
+const ACTIVE_ROOM_TIMELINE_LIMIT_HIGH = 50;
+
+export type PartialSlidingSyncRequest = {
+ filters?: MSC3575List['filters'];
+ sort?: string[];
+ ranges?: [number, number][];
+};
+
export type SlidingSyncConfig = {
enabled?: boolean;
proxyBaseUrl?: string;
@@ -42,20 +73,11 @@ export type SlidingSyncListDiagnostics = {
rangeEnd: number;
};
-export type SlidingSyncDeviceDiagnostics = {
- saveData: boolean;
- effectiveType: string | null;
- deviceMemoryGb: number | null;
- mobile: boolean;
- missingSignals: number;
-};
-
export type SlidingSyncDiagnostics = {
proxyBaseUrl: string;
timelineLimit: number;
- listPageSize: number;
adaptiveTimeline: boolean;
- device: SlidingSyncDeviceDiagnostics;
+ listPageSize: number;
lists: SlidingSyncListDiagnostics[];
};
@@ -64,27 +86,30 @@ const clampPositive = (value: number | undefined, fallback: number): number => {
return Math.round(value);
};
-type AdaptiveSignals = SlidingSyncDeviceDiagnostics;
+type AdaptiveSignals = {
+ saveData: boolean;
+ effectiveType: string | null;
+ deviceMemoryGb: number | null;
+ mobile: boolean;
+ missingSignals: number;
+};
const readAdaptiveSignals = (): AdaptiveSignals => {
const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined;
const connection = (navigatorLike as any)?.connection;
const effectiveType = connection?.effectiveType;
const deviceMemory = (navigatorLike as any)?.deviceMemory;
-
const uaMobile = (navigatorLike as any)?.userAgentData?.mobile;
const fallbackMobileUA = navigatorLike?.userAgent ?? '';
const mobileByUA =
typeof uaMobile === 'boolean'
? uaMobile
: /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA);
-
const saveData = connection?.saveData === true;
const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null;
const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null;
const missingSignals =
Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null);
-
return {
saveData,
effectiveType: normalizedEffectiveType,
@@ -94,89 +119,117 @@ const readAdaptiveSignals = (): AdaptiveSignals => {
};
};
-const resolveAdaptiveTimelineLimit = (
+// Resolve the timeline limit for the active-room subscription based on device/network.
+// The list subscription always uses LIST_TIMELINE_LIMIT=1 regardless of conditions.
+const resolveAdaptiveRoomTimelineLimit = (
configuredLimit: number | undefined,
- pageSize: number,
signals: AdaptiveSignals
): number => {
if (typeof configuredLimit === 'number' && configuredLimit > 0) {
- return clampPositive(configuredLimit, DEFAULT_TIMELINE_LIMIT);
+ return clampPositive(configuredLimit, ACTIVE_ROOM_TIMELINE_LIMIT_HIGH);
}
-
if (signals.saveData || signals.effectiveType === 'slow-2g' || signals.effectiveType === '2g') {
- return Math.min(pageSize, TIMELINE_LIMIT_LOW);
+ return ACTIVE_ROOM_TIMELINE_LIMIT_LOW;
}
-
if (
signals.effectiveType === '3g' ||
(signals.deviceMemoryGb !== null && signals.deviceMemoryGb <= 4)
) {
- return Math.min(pageSize, TIMELINE_LIMIT_MEDIUM);
+ return ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM;
}
-
- // Mobile PWAs/browsers often omit NetworkInformation and/or device memory APIs.
- // If any key adaptive signals are missing on mobile, pick a conservative medium limit.
if (signals.mobile && signals.missingSignals > 0) {
- return Math.min(pageSize, TIMELINE_LIMIT_MEDIUM);
+ return ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM;
}
-
- return Math.min(pageSize, TIMELINE_LIMIT_HIGH);
+ return ACTIVE_ROOM_TIMELINE_LIMIT_HIGH;
};
-const buildDefaultSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({
+// Minimal required_state for list entries; enough to render the room list sidebar,
+// compute unread state, and build the space hierarchy without fetching full room history.
+// Notes:
+// - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a
+// top-level field in every list response, so fetching them as state events is redundant.
+// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the
+// user is actively viewing a room; loading them for every list entry wastes bandwidth.
+// - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child
+// state events (one per child, keyed by child room ID) to build the space hierarchy.
+// Without these events the SDK has no parent→child mapping, so all rooms appear as
+// orphans in the Home view and spaces appear empty.
+// - im.ponies.room_emotes with wildcard is required: custom emoji/sticker packs are
+// stored as im.ponies.room_emotes state events (one per pack, keyed by pack state key).
+// getGlobalImagePacks reads these from pack rooms listed in im.ponies.emote_rooms
+// account data; imagePackRooms also reads them from parent spaces. Without these
+// events all list-entry rooms would show no emoji or sticker packs.
+// - m.room.topic is required: topics are displayed for joined child rooms in space
+// lobby (RoomItem → LocalRoomSummaryLoader → useLocalRoomSummary) and in the
+// invite list. Without this event the topic always shows as blank for non-active
+// rooms.
+// - m.room.canonical_alias is required: getCanonicalAlias() is used in several places
+// for non-active rooms — notification serverName extraction, mention autocomplete
+// alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases
+// fall back silently to room IDs.
+const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [
+ [EventType.RoomJoinRules, ''],
+ [EventType.RoomAvatar, ''],
+ [EventType.RoomTombstone, ''],
+ [EventType.RoomEncryption, ''],
+ [EventType.RoomCreate, ''],
+ [EventType.RoomTopic, ''],
+ [EventType.RoomCanonicalAlias, ''],
+ [EventType.RoomMember, MSC3575_STATE_KEY_ME],
+ ['m.space.child', MSC3575_WILDCARD],
+ ['im.ponies.room_emotes', MSC3575_WILDCARD],
+];
+
+// For an active encrypted room: fetch everything so the client can decrypt all events.
+const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({
+ timeline_limit: timelineLimit,
+ required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]],
+});
+
+// For an active unencrypted room: fetch everything, plus explicit lazy+ME members so
+// the member list and display names are always available.
+const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({
timeline_limit: timelineLimit,
required_state: [
+ [MSC3575_WILDCARD, MSC3575_WILDCARD],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
[EventType.RoomMember, MSC3575_STATE_KEY_LAZY],
- [EventType.RoomCreate, ''],
- [EventType.RoomName, ''],
- [EventType.RoomAvatar, ''],
- [EventType.RoomCanonicalAlias, ''],
- [EventType.RoomEncryption, ''],
- [EventType.RoomTombstone, ''],
- [EventType.RoomJoinRules, ''],
- [EventType.RoomHistoryVisibility, ''],
- [EventType.RoomPowerLevels, ''],
- [StateEvent.PoniesRoomEmotes, '*'],
- [StateEvent.RoomWidget, '*'],
- [StateEvent.GroupCallPrefix, '*'],
- [EventType.SpaceChild, '*'],
- [EventType.SpaceParent, '*'],
- [StateEvent.RoomCosmeticsColor, '*'],
- [StateEvent.RoomCosmeticsFont, '*'],
- [StateEvent.RoomCosmeticsPronouns, '*'],
],
});
-const buildLists = (
- pageSize: number,
- timelineLimit: number,
- includeInviteList: boolean,
- requiredState: MSC3575RoomSubscription['required_state']
-): Map => {
+const buildLists = (pageSize: number, includeInviteList: boolean): Map => {
const lists = new Map();
+ const listRequiredState = buildListRequiredState();
+
lists.set(LIST_JOINED, {
ranges: [[0, Math.max(0, pageSize - 1)]],
- timeline_limit: timelineLimit,
- required_state: requiredState,
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: listRequiredState,
slow_get_all_rooms: true,
- filters: {
- is_invite: false,
- },
+ filters: { is_invite: false },
});
if (includeInviteList) {
lists.set(LIST_INVITES, {
ranges: [[0, Math.max(0, pageSize - 1)]],
- timeline_limit: timelineLimit,
- required_state: requiredState,
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: listRequiredState,
slow_get_all_rooms: true,
- filters: {
- is_invite: true,
- },
+ filters: { is_invite: true },
});
}
+ lists.set(LIST_DMS, {
+ ranges: [[0, Math.max(0, pageSize - 1)]],
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: listRequiredState,
+ slow_get_all_rooms: true,
+ filters: { is_dm: true },
+ });
+
return lists;
};
@@ -185,6 +238,48 @@ const getListEndIndex = (list: MSC3575List | null): number => {
return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1);
};
+// MSC4186 presence extension: requests `extensions.presence` in every sliding sync
+// poll and feeds received `m.presence` events into the SDK's User objects so that
+// components using `useUserPresence` see live updates (same path as regular /sync).
+class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: object[] }> {
+ public constructor(private readonly mx: MatrixClient) {}
+
+ // eslint-disable-next-line class-methods-use-this
+ public name(): string {
+ return 'presence';
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ public when(): ExtensionState {
+ // Run after the main response body has been processed so room/member state is ready.
+ return ExtensionState.PostProcess;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ public async onRequest(): Promise<{ enabled: boolean }> {
+ return { enabled: true };
+ }
+
+ public async onResponse(data: { events?: object[] }): Promise {
+ if (!data?.events?.length) return;
+ const mapper = this.mx.getEventMapper();
+ data.events.forEach((rawEvent) => {
+ const event = mapper(rawEvent as Parameters[0]);
+ const userId = event.getSender() ?? (event.getContent().user_id as string | undefined);
+ if (!userId) return;
+ let user = this.mx.store.getUser(userId);
+ if (user) {
+ user.setPresenceEvent(event);
+ } else {
+ user = User.createUser(userId, this.mx);
+ user.setPresenceEvent(event);
+ this.mx.store.storeUser(user);
+ }
+ this.mx.emit(ClientEvent.Event, event);
+ });
+ }
+}
+
export class SlidingSyncManager {
private disposed = false;
@@ -192,13 +287,13 @@ export class SlidingSyncManager {
private readonly listKeys: string[];
- private timelineLimit: number;
+ private readonly activeRoomSubscriptions = new Set();
private readonly listPageSize: number;
- private readonly adaptiveTimeline: boolean;
+ private roomTimelineLimit: number;
- private deviceDiagnostics: SlidingSyncDeviceDiagnostics;
+ private readonly adaptiveTimeline: boolean;
private readonly configuredTimelineLimit?: number;
@@ -216,30 +311,36 @@ export class SlidingSyncManager {
config: SlidingSyncConfig
) {
const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE);
- const adaptiveTimeline = !(
- typeof config.timelineLimit === 'number' && config.timelineLimit > 0
- );
- const signals = readAdaptiveSignals();
- const timelineLimit = resolveAdaptiveTimelineLimit(config.timelineLimit, listPageSize, signals);
const pollTimeoutMs = clampPositive(config.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS);
this.probeTimeoutMs = clampPositive(config.probeTimeoutMs, 5000);
this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS);
- this.timelineLimit = timelineLimit;
this.listPageSize = listPageSize;
- this.adaptiveTimeline = adaptiveTimeline;
- this.deviceDiagnostics = signals;
- this.configuredTimelineLimit = config.timelineLimit;
const includeInviteList = config.includeInviteList !== false;
- const subscription = buildDefaultSubscription(timelineLimit);
- const lists = buildLists(
- listPageSize,
- timelineLimit,
- includeInviteList,
- subscription.required_state
+ const adaptiveTimeline = !(
+ typeof config.timelineLimit === 'number' && config.timelineLimit > 0
);
+ const signals = readAdaptiveSignals();
+ const roomTimelineLimit = resolveAdaptiveRoomTimelineLimit(config.timelineLimit, signals);
+ this.adaptiveTimeline = adaptiveTimeline;
+ this.roomTimelineLimit = roomTimelineLimit;
+ this.configuredTimelineLimit = config.timelineLimit;
+
+ const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit);
+ const lists = buildLists(listPageSize, includeInviteList);
this.listKeys = Array.from(lists.keys());
- this.slidingSync = new SlidingSync(proxyBaseUrl, lists, subscription, mx, pollTimeoutMs);
+ this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs);
+
+ // Register the presence extension so m.presence events from the server are fed
+ // into the SDK's User objects, keeping useUserPresence accurate during sliding sync.
+ this.slidingSync.registerExtension(new ExtensionPresence(mx));
+
+ // Register a custom subscription for unencrypted active rooms; encrypted rooms use
+ // the default subscription (which already has [*,*]).
+ this.slidingSync.addCustomSubscription(
+ UNENCRYPTED_SUBSCRIPTION_KEY,
+ buildUnencryptedSubscription(roomTimelineLimit)
+ );
this.onLifecycle = (state, resp, err) => {
if (this.disposed || err || !resp || state !== SlidingSyncState.Complete) return;
@@ -248,18 +349,15 @@ export class SlidingSyncManager {
this.onConnectionChange = () => {
if (this.disposed || !this.adaptiveTimeline) return;
- const currentSignals = readAdaptiveSignals();
- this.deviceDiagnostics = currentSignals;
- const nextTimelineLimit = resolveAdaptiveTimelineLimit(
+ const nextLimit = resolveAdaptiveRoomTimelineLimit(
this.configuredTimelineLimit,
- this.listPageSize,
- currentSignals
+ readAdaptiveSignals()
);
- if (nextTimelineLimit === this.timelineLimit) return;
- this.timelineLimit = nextTimelineLimit;
- this.applyTimelineLimit(nextTimelineLimit);
+ if (nextLimit === this.roomTimelineLimit) return;
+ this.roomTimelineLimit = nextLimit;
+ this.applyRoomTimelineLimit(nextLimit);
log.log(
- `Sliding Sync adaptive timeline updated to ${nextTimelineLimit} for ${this.mx.getUserId()}`
+ `Sliding Sync adaptive room timeline updated to ${nextLimit} for ${this.mx.getUserId()}`
);
};
}
@@ -270,15 +368,13 @@ export class SlidingSyncManager {
typeof navigator !== 'undefined' ? (navigator as any).connection : undefined
) as
| {
- addEventListener?: (event: string, cb: () => void) => void;
- removeEventListener?: (event: string, cb: () => void) => void;
+ addEventListener?: (e: string, cb: () => void) => void;
+ removeEventListener?: (e: string, cb: () => void) => void;
onchange?: (() => void) | null;
}
| undefined;
connection?.addEventListener?.('change', this.onConnectionChange);
- if (connection && connection.onchange === null) {
- connection.onchange = this.onConnectionChange;
- }
+ if (connection && connection.onchange === null) connection.onchange = this.onConnectionChange;
if (typeof window !== 'undefined') {
window.addEventListener('online', this.onConnectionChange);
window.addEventListener('offline', this.onConnectionChange);
@@ -293,28 +389,33 @@ export class SlidingSyncManager {
typeof navigator !== 'undefined' ? (navigator as any).connection : undefined
) as
| {
- addEventListener?: (event: string, cb: () => void) => void;
- removeEventListener?: (event: string, cb: () => void) => void;
+ addEventListener?: (e: string, cb: () => void) => void;
+ removeEventListener?: (e: string, cb: () => void) => void;
onchange?: (() => void) | null;
}
| undefined;
connection?.removeEventListener?.('change', this.onConnectionChange);
- if (connection?.onchange === this.onConnectionChange) {
- connection.onchange = null;
- }
+ if (connection?.onchange === this.onConnectionChange) connection.onchange = null;
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.onConnectionChange);
window.removeEventListener('offline', this.onConnectionChange);
}
}
+ private applyRoomTimelineLimit(timelineLimit: number): void {
+ this.slidingSync.modifyRoomSubscriptionInfo(buildEncryptedSubscription(timelineLimit));
+ this.slidingSync.addCustomSubscription(
+ UNENCRYPTED_SUBSCRIPTION_KEY,
+ buildUnencryptedSubscription(timelineLimit)
+ );
+ }
+
public getDiagnostics(): SlidingSyncDiagnostics {
return {
proxyBaseUrl: this.proxyBaseUrl,
- timelineLimit: this.timelineLimit,
- listPageSize: this.listPageSize,
+ timelineLimit: this.roomTimelineLimit,
adaptiveTimeline: this.adaptiveTimeline,
- device: this.deviceDiagnostics,
+ listPageSize: this.listPageSize,
lists: this.listKeys.map((key) => {
const listData = this.slidingSync.getListData(key);
const params = this.slidingSync.getListParams(key);
@@ -347,18 +448,193 @@ export class SlidingSyncManager {
});
}
- private applyTimelineLimit(timelineLimit: number): void {
- this.slidingSync.modifyRoomSubscriptionInfo(buildDefaultSubscription(timelineLimit));
- this.listKeys.forEach((key) => {
- const existing = this.slidingSync.getListParams(key);
- if (!existing) return;
- this.slidingSync.setList(key, {
- ...existing,
- timeline_limit: timelineLimit,
- });
+ /**
+ * Ensure a dynamic list is registered (or updated) on the sliding sync session.
+ * If the list does not yet exist it is created with sensible defaults merged with
+ * `updateArgs`. If it already exists and the merged result differs, only the ranges
+ * are updated (cheaper — avoids resending sticky params) when `updateArgs` only
+ * contains `ranges`; otherwise the full list is replaced.
+ *
+ * This mirrors Element Web's `SlidingSyncManager.ensureListRegistered`.
+ */
+ public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List {
+ let list = this.slidingSync.getListParams(listKey);
+ if (!list) {
+ list = {
+ ranges: [[0, 20]],
+ sort: LIST_SORT_ORDER,
+ timeline_limit: LIST_TIMELINE_LIMIT,
+ required_state: buildListRequiredState(),
+ ...updateArgs,
+ };
+ } else {
+ const updated = { ...list, ...updateArgs };
+ if (JSON.stringify(list) === JSON.stringify(updated)) return list;
+ list = updated;
+ }
+
+ try {
+ if (updateArgs.ranges && Object.keys(updateArgs).length === 1) {
+ this.slidingSync.setListRanges(listKey, updateArgs.ranges);
+ } else {
+ this.slidingSync.setList(listKey, list);
+ }
+ } catch {
+ // ignore — the list will be re-sent on the next sync cycle
+ }
+ return this.slidingSync.getListParams(listKey) ?? list;
+ }
+
+ /**
+ * Spider through all rooms by incrementally expanding the search list, matching
+ * Element Web's `startSpidering` behaviour. Called once after `attach()` and runs
+ * in the background; callers must not await it.
+ *
+ * The first request uses `setList` to register the list with its full config;
+ * subsequent page advances use the cheaper `setListRanges` (sticky params are
+ * not resent). A gap sleep is applied before the first request and after each
+ * subsequent one to avoid hammering the proxy at startup.
+ */
+ public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise {
+ // Delay before the first request — startSpidering is called right after attach(),
+ // so give the initial sync a moment to settle first.
+ await new Promise((res) => {
+ setTimeout(res, gapBetweenRequestsMs);
+ });
+ if (this.disposed) return;
+
+ // Use a single expanding range [[0, endIndex]] rather than a two-range sliding
+ // window. Synapse's extension handler asserts len(actual_list.ops) == 1, which
+ // fails when the response contains multiple ops (one per range). A single range
+ // always produces a single SYNC op, avoiding the assertion.
+ let endIndex = batchSize - 1;
+ let hasMore = true;
+ let firstTime = true;
+
+ const spideringRequiredState: MSC3575List['required_state'] = [
+ [EventType.RoomJoinRules, ''],
+ [EventType.RoomAvatar, ''],
+ [EventType.RoomTombstone, ''],
+ [EventType.RoomEncryption, ''],
+ [EventType.RoomCreate, ''],
+ [EventType.RoomTopic, ''],
+ [EventType.RoomCanonicalAlias, ''],
+ [EventType.RoomMember, MSC3575_STATE_KEY_ME],
+ ['m.space.child', MSC3575_WILDCARD],
+ ['im.ponies.room_emotes', MSC3575_WILDCARD],
+ ];
+
+ while (hasMore) {
+ if (this.disposed) return;
+ const ranges: [number, number][] = [[0, endIndex]];
+ try {
+ if (firstTime) {
+ // Full setList on first call to register the list with all params.
+ this.slidingSync.setList(LIST_SEARCH, {
+ ranges,
+ sort: ['by_recency'],
+ timeline_limit: 0,
+ required_state: spideringRequiredState,
+ });
+ } else {
+ // Cheaper range-only update for subsequent pages; sticky params are preserved.
+ this.slidingSync.setListRanges(LIST_SEARCH, ranges);
+ }
+ } catch {
+ // Swallow errors — the next iteration will retry with updated ranges.
+ } finally {
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((res) => {
+ setTimeout(res, gapBetweenRequestsMs);
+ });
+ }
+
+ if (this.disposed) return;
+ const listData = this.slidingSync.getListData(LIST_SEARCH);
+ hasMore = endIndex + 1 < (listData?.joinedCount ?? 0);
+ endIndex += batchSize;
+ firstTime = false;
+ }
+ log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`);
+ }
+
+ /**
+ * Enable or disable server-side room name filtering.
+ * When `query` is a non-empty string, registers (or updates) a dedicated
+ * `room_search` list that uses the MSC4186 `room_name_like` filter so the
+ * server returns only rooms whose name matches the query. When `query` is
+ * null or empty the list is reset to an unfiltered minimal range — callers
+ * should hide/ignore the list results in that case.
+ * This is a no-op after dispose().
+ */
+ public setRoomNameSearch(query: string | null): void {
+ if (this.disposed) return;
+ const trimmed = query?.trim() ?? '';
+ const filters: MSC3575List['filters'] = trimmed ? { room_name_like: trimmed } : {};
+ this.ensureListRegistered(LIST_ROOM_SEARCH, {
+ filters,
+ ranges: [[0, 19]],
+ sort: LIST_SORT_ORDER,
+ });
+ }
+
+ /**
+ * Activate or clear a space-scoped room list.
+ * When `spaceId` is provided, registers (or updates) a dedicated `space`
+ * list filtered to rooms that are children of that space, returning the
+ * first page sorted by recency. This supplements the main `joined` list
+ * rather than replacing it, so background sync of all rooms is unaffected.
+ * Pass `null` to deactivate the space list (collapses range to 0–0).
+ * This is a no-op after dispose().
+ */
+ public setSpaceScope(spaceId: string | null): void {
+ if (this.disposed) return;
+ const filters: MSC3575List['filters'] = spaceId
+ ? { is_invite: false, spaces: [spaceId] }
+ : { is_invite: false };
+ this.ensureListRegistered(LIST_SPACE, {
+ filters,
+ ranges: spaceId ? [[0, Math.min(this.listPageSize - 1, 499)]] : [[0, 0]],
+ sort: LIST_SORT_ORDER,
});
}
+ /**
+ * Subscribe to a room with the appropriate active-room subscription.
+ * Encrypted rooms use the default subscription ([*,*]); unencrypted rooms use a
+ * custom subscription that also requests lazy members.
+ * If the room is not yet known to the SDK (e.g. navigating directly to a room URL
+ * before the list has synced it), we default to the encrypted subscription — it is
+ * always safe to over-request state.
+ * Safe to call when already subscribed — the SDK deduplicates.
+ * This is a no-op after dispose().
+ */
+ public subscribeToRoom(roomId: string): void {
+ if (this.disposed) return;
+ const room = this.mx.getRoom(roomId);
+ if (room && !this.mx.isRoomEncrypted(roomId)) {
+ // Only use the unencrypted (lazy-load) subscription when we are certain
+ // the room is unencrypted. Unknown rooms fall through to the safer
+ // encrypted default.
+ this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY);
+ }
+ this.activeRoomSubscriptions.add(roomId);
+ this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
+ log.log(`Sliding Sync active room subscription added: ${roomId}`);
+ }
+
+ /**
+ * Remove the explicit room subscription for a room.
+ * Rooms that are still in a list will continue to receive background updates.
+ * This is a no-op after dispose().
+ */
+ public unsubscribeFromRoom(roomId: string): void {
+ if (this.disposed) return;
+ this.activeRoomSubscriptions.delete(roomId);
+ this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
+ log.log(`Sliding Sync active room subscription removed: ${roomId}`);
+ }
+
public static async probe(
mx: MatrixClient,
proxyBaseUrl: string,