diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index 83c521a546..402e625f3e 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react'; -import { Box, Text } from 'folds'; +import { Box, Button, Icon, Icons, Text } from 'folds'; import { SequenceCard } from '$components/sequence-card'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getClientSyncDiagnostics } from '$client/initMatrix'; import { Direction, EventType, NotificationCountType, Room } from '$types/matrix-sdk'; import { Membership } from '$types/matrix/room'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { isNotificationEvent } from '$utils/room'; +import { getUnreadInfo, isNotificationEvent } from '$utils/room'; type RoomRenderingDiagnostics = { totalRooms: number; @@ -71,9 +71,11 @@ const getUnreadDriftRooms = (mx: ReturnType): UnreadDrif .getRooms() .filter((room) => !room.isSpaceRoom() && room.getMyMembership() === Membership.Join) .reduce((driftRooms, room) => { + const reconciledUnread = getUnreadInfo(room); const sdkTotal = room.getUnreadNotificationCount(NotificationCountType.Total); const sdkHighlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); if (sdkTotal <= 0 && sdkHighlight <= 0) return driftRooms; + if (reconciledUnread.total <= 0 && reconciledUnread.highlight <= 0) return driftRooms; const latestNotificationEvent = [...room.getLiveTimeline().getEvents()] .reverse() @@ -111,6 +113,7 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => { export function SyncDiagnostics() { const mx = useMatrixClient(); const [, setTick] = useState(0); + const [expandSliding, setExpandSliding] = useState(false); useEffect(() => { const intervalId = window.setInterval(() => setTick((v) => v + 1), 1000); @@ -149,7 +152,7 @@ export function SyncDiagnostics() { Rooms without live events: {roomDiagnostics.roomsWithoutLiveEvents} - Rooms with more history to paginate: {roomDiagnostics.roomsWithBackPagination} + Rooms with additional history available: {roomDiagnostics.roomsWithBackPagination} Unread drift rooms: {unreadDriftRooms.length} {unreadDriftRooms.slice(0, 10).map((room) => ( @@ -161,14 +164,46 @@ export function SyncDiagnostics() { ))} {diagnostics.sliding && ( - <> - Sliding proxy: {diagnostics.sliding.proxyBaseUrl} - {diagnostics.sliding.lists.map((list) => ( - - List `{list.key}` coverage: {formatListCoverage(list.knownCount, list.rangeEnd)} - - ))} - + + + Sliding Sync + + + {expandSliding && ( + + 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} + + {diagnostics.sliding.lists.map((list) => ( + + List `{list.key}` coverage:{' '} + {formatListCoverage(list.knownCount, list.rangeEnd)} + + ))} + + )} + )} diff --git a/src/app/state/room/roomToParents.ts b/src/app/state/room/roomToParents.ts index 240599886c..65b0a3a36f 100644 --- a/src/app/state/room/roomToParents.ts +++ b/src/app/state/room/roomToParents.ts @@ -7,8 +7,9 @@ import { Room, RoomEvent, RoomStateEvent, + SyncState, } from '$types/matrix-sdk'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { Membership, RoomToParents, StateEvent } from '$types/matrix/room'; import { getRoomToParents, @@ -17,6 +18,7 @@ import { isValidChild, mapParentWithChildren, } from '$utils/room'; +import { useSyncState } from '$hooks/useSyncState'; export type RoomToParentsAction = | { @@ -93,18 +95,37 @@ export const useBindRoomToParentsAtom = ( roomToParents: typeof roomToParentsAtom ) => { const setRoomToParents = useSetAtom(roomToParents); + const resetRoomToParents = useCallback( + () => setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) }), + [mx, setRoomToParents] + ); + + useSyncState( + mx, + useCallback( + (state, prevState) => { + if ( + (state === SyncState.Prepared && prevState === null) || + (state === SyncState.Syncing && prevState !== SyncState.Syncing) + ) { + resetRoomToParents(); + } + }, + [resetRoomToParents] + ) + ); useEffect(() => { - setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) }); + resetRoomToParents(); const handleAddRoom = (room: Room) => { - if (isSpace(room) && room.getMyMembership() !== Membership.Invite) { + if (isSpace(room) && room.getMyMembership() === Membership.Join) { setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) }); } }; const handleMembershipChange = (room: Room, membership: string) => { - if (isSpace(room) && room.getMyMembership() === Membership.Leave) { + if (isSpace(room) && membership !== Membership.Join) { setRoomToParents({ type: 'DELETE', roomId: room.roomId }); return; } @@ -118,6 +139,8 @@ export const useBindRoomToParentsAtom = ( const childId = mEvent.getStateKey(); const roomId = mEvent.getRoomId(); if (childId && roomId) { + const parentRoom = mx.getRoom(roomId); + if (!parentRoom || parentRoom.getMyMembership() !== Membership.Join) return; if (isValidChild(mEvent)) { setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] }); } else { @@ -141,5 +164,5 @@ export const useBindRoomToParentsAtom = ( mx.removeListener(RoomStateEvent.Events, handleStateChange); mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom); }; - }, [mx, setRoomToParents]); + }, [mx, setRoomToParents, resetRoomToParents]); }; diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index e2134db7d4..0e8df7fd0e 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -11,7 +11,7 @@ import { ReceiptType, EventType, } from '$types/matrix-sdk'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Membership, NotificationType, @@ -186,6 +186,7 @@ export const roomToUnreadAtom = atom { const setUnreadAtom = useSetAtom(unreadAtom); const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); + const spaceChildResetTimerRef = useRef(null); useEffect(() => { setUnreadAtom({ @@ -327,15 +328,36 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo }; }, [mx, setUnreadAtom]); + useEffect( + () => () => { + if (spaceChildResetTimerRef.current !== null) { + window.clearTimeout(spaceChildResetTimerRef.current); + spaceChildResetTimerRef.current = null; + } + }, + [] + ); + useStateEventCallback( mx, useCallback( (mEvent) => { if (mEvent.getType() === StateEvent.SpaceChild) { - setUnreadAtom({ - type: 'RESET', - unreadInfos: getUnreadInfos(mx), - }); + const roomId = mEvent.getRoomId(); + if (!roomId) return; + const parentRoom = mx.getRoom(roomId); + if (!parentRoom || parentRoom.getMyMembership() !== Membership.Join) return; + + if (spaceChildResetTimerRef.current !== null) { + window.clearTimeout(spaceChildResetTimerRef.current); + } + spaceChildResetTimerRef.current = window.setTimeout(() => { + setUnreadAtom({ + type: 'RESET', + unreadInfos: getUnreadInfos(mx), + }); + spaceChildResetTimerRef.current = null; + }, 150); } }, [mx, setUnreadAtom] diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 78db758f0f..be4dc570c5 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -144,7 +144,7 @@ export const mapParentWithChildren = ( export const getRoomToParents = (mx: MatrixClient): RoomToParents => { const map: RoomToParents = new Map(); mx.getRooms() - .filter((room) => isSpace(room)) + .filter((room) => isSpace(room) && room.getMyMembership() === Membership.Join) .forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room))); return map; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index b33a06d451..b02daf97dc 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -18,6 +18,9 @@ const LIST_JOINED = 'joined'; const LIST_INVITES = 'invites'; const DEFAULT_LIST_PAGE_SIZE = 50; 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 = 30000; const DEFAULT_MAX_ROOMS = 5000; @@ -38,8 +41,20 @@ 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; lists: SlidingSyncListDiagnostics[]; }; @@ -48,6 +63,65 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; +type AdaptiveSignals = SlidingSyncDeviceDiagnostics; + +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, + deviceMemoryGb: normalizedDeviceMemory, + mobile: mobileByUA, + missingSignals, + }; +}; + +const resolveAdaptiveTimelineLimit = ( + configuredLimit: number | undefined, + pageSize: number, + signals: AdaptiveSignals +): number => { + if (typeof configuredLimit === 'number' && configuredLimit > 0) { + return clampPositive(configuredLimit, DEFAULT_TIMELINE_LIMIT); + } + + if (signals.saveData || signals.effectiveType === 'slow-2g' || signals.effectiveType === '2g') { + return Math.min(pageSize, TIMELINE_LIMIT_LOW); + } + + if ( + signals.effectiveType === '3g' || + (signals.deviceMemoryGb !== null && signals.deviceMemoryGb <= 4) + ) { + return Math.min(pageSize, 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 Math.min(pageSize, TIMELINE_LIMIT_HIGH); +}; + const buildDefaultSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, required_state: [ @@ -55,19 +129,15 @@ const buildDefaultSubscription = (timelineLimit: number): MSC3575RoomSubscriptio [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], [EventType.RoomCreate, ''], [EventType.RoomName, ''], - [EventType.RoomTopic, ''], [EventType.RoomAvatar, ''], [EventType.RoomCanonicalAlias, ''], - [EventType.RoomPowerLevels, ''], [EventType.RoomEncryption, ''], [EventType.RoomTombstone, ''], - [EventType.RoomPinnedEvents, ''], [EventType.RoomJoinRules, ''], [EventType.RoomHistoryVisibility, ''], [StateEvent.PoniesRoomEmotes, '*'], [StateEvent.RoomWidget, '*'], [StateEvent.GroupCallPrefix, '*'], - [StateEvent.GroupCallMemberPrefix, '*'], [EventType.SpaceChild, '*'], [EventType.SpaceParent, '*'], ], @@ -117,6 +187,18 @@ export class SlidingSyncManager { private readonly listKeys: string[]; + private timelineLimit: number; + + private readonly listPageSize: number; + + private readonly adaptiveTimeline: boolean; + + private deviceDiagnostics: SlidingSyncDeviceDiagnostics; + + private readonly configuredTimelineLimit?: number; + + private readonly onConnectionChange: () => void; + private readonly onLifecycle: (state: SlidingSyncState, resp: unknown, err?: Error) => void; public readonly slidingSync: SlidingSync; @@ -129,10 +211,19 @@ export class SlidingSyncManager { config: SlidingSyncConfig ) { const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE); - const timelineLimit = clampPositive(config.timelineLimit, DEFAULT_TIMELINE_LIMIT); + 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); @@ -149,21 +240,76 @@ export class SlidingSyncManager { if (this.disposed || err || !resp || state !== SlidingSyncState.Complete) return; this.expandListsToKnownCount(); }; + + this.onConnectionChange = () => { + if (this.disposed || !this.adaptiveTimeline) return; + const currentSignals = readAdaptiveSignals(); + this.deviceDiagnostics = currentSignals; + const nextTimelineLimit = resolveAdaptiveTimelineLimit( + this.configuredTimelineLimit, + this.listPageSize, + currentSignals + ); + if (nextTimelineLimit === this.timelineLimit) return; + this.timelineLimit = nextTimelineLimit; + this.applyTimelineLimit(nextTimelineLimit); + log.log( + `Sliding Sync adaptive timeline updated to ${nextTimelineLimit} for ${this.mx.getUserId()}` + ); + }; } public attach(): void { this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); + const connection = ( + typeof navigator !== 'undefined' ? (navigator as any).connection : undefined + ) as + | { + addEventListener?: (event: string, cb: () => void) => void; + removeEventListener?: (event: string, cb: () => void) => void; + onchange?: (() => void) | null; + } + | undefined; + connection?.addEventListener?.('change', 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); + } } public dispose(): void { if (this.disposed) return; this.disposed = true; this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); + const connection = ( + typeof navigator !== 'undefined' ? (navigator as any).connection : undefined + ) as + | { + addEventListener?: (event: string, cb: () => void) => void; + removeEventListener?: (event: string, cb: () => void) => void; + onchange?: (() => void) | null; + } + | undefined; + connection?.removeEventListener?.('change', this.onConnectionChange); + if (connection?.onchange === this.onConnectionChange) { + connection.onchange = null; + } + if (typeof window !== 'undefined') { + window.removeEventListener('online', this.onConnectionChange); + window.removeEventListener('offline', this.onConnectionChange); + } } public getDiagnostics(): SlidingSyncDiagnostics { return { proxyBaseUrl: this.proxyBaseUrl, + timelineLimit: this.timelineLimit, + listPageSize: this.listPageSize, + adaptiveTimeline: this.adaptiveTimeline, + device: this.deviceDiagnostics, lists: this.listKeys.map((key) => { const listData = this.slidingSync.getListData(key); const params = this.slidingSync.getListParams(key); @@ -196,6 +342,18 @@ 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, + }); + }); + } + public static async probe( mx: MatrixClient, proxyBaseUrl: string,