From 7b2e847d9c30b30430215f5ea0e20ace5fa877be Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 19:24:43 -0500
Subject: [PATCH 01/28] only load room members when needed and timeline
transition when no data yet
---
src/app/features/room/Room.tsx | 7 +--
src/app/features/room/RoomTimeline.tsx | 59 +++++++++++++++++++++-----
src/app/hooks/useRoomMembers.ts | 13 +++++-
3 files changed, 64 insertions(+), 15 deletions(-)
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index da71e3f5b..b92ce855f 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -45,6 +45,8 @@ export function Room() {
const [isWidgetDrawerOpen] = useSetting(settingsAtom, 'isWidgetDrawer');
const [hideReads] = useSetting(settingsAtom, 'hideReads');
const screenSize = useScreenSizeContext();
+ const callView = room.isCallRoom();
+ const showMembersDrawer = !callView && screenSize === ScreenSize.Desktop && isDrawer;
// Log drawer state changes
useEffect(() => {
@@ -61,7 +63,7 @@ export function Room() {
});
}, [isWidgetDrawerOpen, room.roomId]);
const powerLevels = usePowerLevels(room);
- const members = useRoomMembers(mx, room.roomId);
+ const members = useRoomMembers(mx, room.roomId, showMembersDrawer);
const chat = useAtomValue(callChatAtom);
const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId));
const [threadBrowserOpen, setThreadBrowserOpen] = useAtom(
@@ -100,7 +102,6 @@ export function Room() {
)
);
- const callView = room.isCallRoom();
const abbreviations = useMergedAbbreviations(room);
// Log call view state
@@ -137,7 +138,7 @@ export function Room() {
>
)}
- {!callView && screenSize === ScreenSize.Desktop && isDrawer && (
+ {showMembersDrawer && (
<>
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d63faa989..6fe987cdc 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -333,6 +333,21 @@ export function RoomTimeline({
// It is cancelled on unmount by the dedicated effect below.
}, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]);
+ useLayoutEffect(() => {
+ if (eventId || isReady) return;
+ if (!timelineSync.liveTimelineLinked) return;
+ if (timelineSync.eventsLength > 0) return;
+ if (timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading') return;
+ setIsReady(true);
+ }, [
+ eventId,
+ isReady,
+ timelineSync.liveTimelineLinked,
+ timelineSync.eventsLength,
+ timelineSync.canPaginateBack,
+ timelineSync.backwardStatus,
+ ]);
+
// Cancel the initial-scroll timer on unmount (the useLayoutEffect above
// intentionally does not cancel it when deps change).
useEffect(
@@ -693,9 +708,8 @@ export function RoomTimeline({
[setAtBottom]
);
- const showLoadingPlaceholders =
- timelineSync.eventsLength === 0 &&
- (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading');
+ const showLoadingPlaceholders = !isReady && timelineSync.eventsLength === 0;
+ const showPositioningPlaceholders = !isReady && !showLoadingPlaceholders;
let backPaginationJSX: ReactNode | undefined;
if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') {
@@ -761,11 +775,7 @@ export function RoomTimeline({
}
}
- const vListItemCount =
- timelineSync.eventsLength === 0 &&
- (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading')
- ? 3
- : timelineSync.eventsLength;
+ const vListItemCount = timelineSync.eventsLength;
const vListIndices = useMemo(() => {
// Keep the cache-busting timeline identity explicit for exhaustive-deps.
void timelineSync.timeline;
@@ -788,6 +798,12 @@ export function RoomTimeline({
processedEventsRef.current = processedEvents;
+ const vListData = useMemo>(() => {
+ if (showLoadingPlaceholders) return [undefined, undefined, undefined];
+ if (isReady && processedEvents.length === 0) return [undefined];
+ return processedEvents;
+ }, [isReady, processedEvents, showLoadingPlaceholders]);
+
// Recovery: if the 80 ms initial-scroll timer fired while processedEvents was
// empty (timeline was mid-reset), scroll to bottom and reveal the timeline once
// events repopulate. Fires on every processedEvents.length change but is
@@ -898,9 +914,9 @@ export function RoomTimeline({
opacity: isReady || showLoadingPlaceholders ? 1 : 0,
}}
>
-
+
ref={vListRef}
- data={processedEvents}
+ data={vListData}
shift={shift}
className={css.messageList}
style={{
@@ -1007,6 +1023,29 @@ export function RoomTimeline({
+ {showPositioningPlaceholders && (
+
+ {[0, 1, 2].map((index) => (
+
+ {messageLayout === MessageLayout.Compact ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
{frontPaginationJSX}
{!atBottomState && isReady && (
diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts
index 46640040e..d71bf4d63 100644
--- a/src/app/hooks/useRoomMembers.ts
+++ b/src/app/hooks/useRoomMembers.ts
@@ -2,10 +2,19 @@ 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 () => {};
+ }
+
const room = mx.getRoom(roomId);
let loadingMembers = true;
let disposed = false;
@@ -40,7 +49,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
mx.removeListener(RoomStateEvent.Events, handleStateEvent);
};
- }, [mx, roomId]);
+ }, [mx, roomId, enabled]);
return members;
};
From e42f6a3d8b0525819d7e2f5ddeb9d9ad0f192a7e Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 19:31:01 -0500
Subject: [PATCH 02/28] fix double sw registration
---
src/index.tsx | 39 +++++++++++++++++++++------------------
1 file changed, 21 insertions(+), 18 deletions(-)
diff --git a/src/index.tsx b/src/index.tsx
index 1721755d5..61566cff2 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -58,20 +58,26 @@ if ('serviceWorker' in navigator) {
swRegisterOptions.type = 'module';
}
- navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => {
- registration.addEventListener('updatefound', () => {
- const installingWorker = registration.installing;
- if (installingWorker) {
- installingWorker.addEventListener('statechange', () => {
- if (installingWorker.state === 'installed') {
- if (navigator.serviceWorker.controller) {
- showUpdateAvailablePrompt(registration);
+ const serviceWorkerRegistration = navigator.serviceWorker.register(swUrl, swRegisterOptions);
+
+ serviceWorkerRegistration
+ .then((registration) => {
+ registration.addEventListener('updatefound', () => {
+ const installingWorker = registration.installing;
+ if (installingWorker) {
+ installingWorker.addEventListener('statechange', () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ showUpdateAvailablePrompt(registration);
+ }
}
- }
- });
- }
+ });
+ }
+ });
+ })
+ .catch((err) => {
+ log.warn('SW registration failed:', err);
});
- });
const sendSessionToSW = () => {
// Use the active session from the new multi-session store, fall back to legacy
@@ -82,12 +88,9 @@ if ('serviceWorker' in navigator) {
pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId);
};
- navigator.serviceWorker
- .register(swUrl)
- .then(sendSessionToSW)
- .catch((err) => {
- log.warn('SW registration failed:', err);
- });
+ serviceWorkerRegistration.then(sendSessionToSW).catch((err) => {
+ log.warn('SW session sync registration failed:', err);
+ });
navigator.serviceWorker.ready.then(sendSessionToSW).catch((err) => {
log.warn('SW ready failed:', err);
});
From 24cffc84d8b9f7f41afab49623fb3d17f74a3bb2 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 19:34:17 -0500
Subject: [PATCH 03/28] prevent accidental audio embed when video and image
fail on a preview and formatting
---
.../components/url-preview/UrlPreviewCard.tsx | 23 +++++++++++++++++--
src/app/hooks/useRoomMembers.ts | 6 +----
2 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx
index 34383df76..685eed323 100644
--- a/src/app/components/url-preview/UrlPreviewCard.tsx
+++ b/src/app/components/url-preview/UrlPreviewCard.tsx
@@ -85,6 +85,25 @@ function isLikelyPlayableOgVideo(prev: IPreviewUrlResponse): boolean {
return false;
}
+function isLikelyPlayableOgAudio(prev: IPreviewUrlResponse): boolean {
+ const raw = prev['og:audio'];
+ if (typeof raw !== 'string') return false;
+ const url = raw.trim();
+ if (!url) return false;
+ const mime =
+ typeof prev['og:audio:type'] === 'string' ? prev['og:audio:type'].toLowerCase().trim() : '';
+ if (mime.startsWith('audio/')) return true;
+ if (/^mxc:\/\//i.test(url)) {
+ return (
+ mime.startsWith('audio/') || /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|webm)(\?|$)/i.test(url)
+ );
+ }
+ if (/^https?:\/\//i.test(url)) {
+ return /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?|$)/i.test(url);
+ }
+ return false;
+}
+
export const UrlPreviewCard = as<
'div',
{
@@ -320,10 +339,10 @@ export const UrlPreviewCard = as<
/>
)}
- {!showOgVideo && !prev['og:image'] && prev['og:audio'] && (
+ {!showOgVideo && !prev['og:image'] && isLikelyPlayableOgAudio(prev) && (
}
diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts
index d71bf4d63..d36633aee 100644
--- a/src/app/hooks/useRoomMembers.ts
+++ b/src/app/hooks/useRoomMembers.ts
@@ -2,11 +2,7 @@ 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,
- enabled = true
-): RoomMember[] => {
+export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => {
const [members, setMembers] = useState([]);
useEffect(() => {
From 8dadeb3c8951fa4f06db738c84e8e5e67f4806b2 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 20:02:14 -0500
Subject: [PATCH 04/28] lazy load various modals & dm list
---
src/app/features/room/RoomTimeline.tsx | 9 +-
.../hooks/timeline/useProcessedTimeline.ts | 8 +-
src/app/hooks/useGroupDMMembers.ts | 132 ++++++++----------
src/app/pages/Router.tsx | 88 +++++++++---
.../pages/client/sidebar/DirectDMsList.tsx | 2 +-
5 files changed, 140 insertions(+), 99 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 6fe987cdc..d69963f92 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -251,6 +251,7 @@ export function RoomTimeline({
}
const processedEventsRef = useRef([]);
+ const processedIndexByRawIndexRef = useRef
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
index 44d8acbf2..082d1c696 100644
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -1,3 +1,4 @@
+import { lazy, Suspense } from 'react';
import {
Box,
config,
@@ -18,7 +19,11 @@ import { SpaceProvider } from '$hooks/useSpace';
import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal';
import type { CreateSpaceModalState } from '$state/createSpaceModal';
import { stopPropagation } from '$utils/keyboard';
-import { CreateSpaceForm } from './CreateSpace';
+
+const CreateSpaceForm = lazy(async () => {
+ const mod = await import('./CreateSpace');
+ return { default: mod.CreateSpaceForm };
+});
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -71,7 +76,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
direction="Column"
gap="500"
>
-
+
+
+
diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx
index 7487255ec..0aa5d8a0e 100644
--- a/src/app/features/room-settings/RoomSettingsRenderer.tsx
+++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx
@@ -1,10 +1,15 @@
+import { lazy, Suspense } from 'react';
import { Modal500 } from '$components/Modal500';
import { useCloseRoomSettings, useRoomSettingsState } from '$state/hooks/roomSettings';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import type { RoomSettingsState } from '$state/roomSettings';
import { RoomProvider } from '$hooks/useRoom';
import { SpaceProvider } from '$hooks/useSpace';
-import { RoomSettings } from './RoomSettings';
+
+const RoomSettings = lazy(async () => {
+ const mod = await import('./RoomSettings');
+ return { default: mod.RoomSettings };
+});
type RenderSettingsProps = {
state: RoomSettingsState;
@@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) {
-
+
+
+
diff --git a/src/app/features/search/SearchModalRenderer.tsx b/src/app/features/search/SearchModalRenderer.tsx
new file mode 100644
index 000000000..e68a3bac4
--- /dev/null
+++ b/src/app/features/search/SearchModalRenderer.tsx
@@ -0,0 +1,44 @@
+import { lazy, Suspense, useCallback } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import { useAtom } from 'jotai';
+import { useKeyDown } from '$hooks/useKeyDown';
+import { searchModalAtom } from '$state/searchModal';
+
+const Search = lazy(async () => {
+ const mod = await import('./Search');
+ return { default: mod.Search };
+});
+
+export function SearchModalRenderer() {
+ const [opened, setOpen] = useAtom(searchModalAtom);
+
+ useKeyDown(
+ window,
+ useCallback(
+ (event) => {
+ if (isKeyHotkey('mod+k', event) || isKeyHotkey('mod+f', event)) {
+ event.preventDefault();
+ if (opened) {
+ setOpen(false);
+ return;
+ }
+
+ const portalContainer = document.getElementById('portalContainer');
+ if (portalContainer && portalContainer.children.length > 0) {
+ return;
+ }
+ setOpen(true);
+ }
+ },
+ [opened, setOpen]
+ )
+ );
+
+ if (!opened) return null;
+
+ return (
+
+ setOpen(false)} />
+
+ );
+}
diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts
index addd53308..319f77d62 100644
--- a/src/app/features/search/index.ts
+++ b/src/app/features/search/index.ts
@@ -1 +1,2 @@
-export * from './Search';
+export { Search } from './Search';
+export * from './SearchModalRenderer';
diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx
index 2fd7053ce..4d7165db0 100644
--- a/src/app/features/settings/SettingsShallowRouteRenderer.tsx
+++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx
@@ -1,10 +1,15 @@
+import { lazy, Suspense } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import { useScreenSizeContext } from '$hooks/useScreenSize';
import { Modal500 } from '$components/Modal500';
import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet';
import { SETTINGS_PATH } from '$pages/paths';
import { getSettingsCloseTarget, type SettingsRouteState } from './navigation';
-import { SettingsRoute } from './SettingsRoute';
+
+const SettingsRoute = lazy(async () => {
+ const mod = await import('./SettingsRoute');
+ return { default: mod.SettingsRoute };
+});
export function SettingsShallowRouteRenderer() {
const navigate = useNavigate();
@@ -24,7 +29,9 @@ export function SettingsShallowRouteRenderer() {
return (
-
+
+
+
);
}
diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx
index 7f5bd1e5f..d6de384e6 100644
--- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx
+++ b/src/app/features/space-settings/SpaceSettingsRenderer.tsx
@@ -1,10 +1,15 @@
+import { lazy, Suspense } from 'react';
import { Modal500 } from '$components/Modal500';
import { useCloseSpaceSettings, useSpaceSettingsState } from '$state/hooks/spaceSettings';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import type { SpaceSettingsState } from '$state/spaceSettings';
import { RoomProvider } from '$hooks/useRoom';
import { SpaceProvider } from '$hooks/useSpace';
-import { SpaceSettings } from './SpaceSettings';
+
+const SpaceSettings = lazy(async () => {
+ const mod = await import('./SpaceSettings');
+ return { default: mod.SpaceSettings };
+});
type RenderSettingsProps = {
state: SpaceSettingsState;
@@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) {
-
+
+
+
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 1a5a54b4c..1e9ff19ef 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -12,7 +12,6 @@ import * as Sentry from '@sentry/react';
import type { ClientConfig } from '$hooks/useClientConfig';
import { ErrorPage } from '$components/DefaultErrorPage';
import { Room } from '$features/room';
-import { Lobby } from '$features/lobby';
import { PageRoot } from '$components/page';
import { ScreenSize } from '$hooks/useScreenSize';
import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification';
@@ -23,6 +22,14 @@ import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage';
import { NotificationJumper } from '$hooks/useNotificationJumper';
import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts';
import { CallEmbedProvider } from '$components/CallEmbedProvider';
+import { SearchModalRenderer } from '$features/search/SearchModalRenderer';
+import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer';
+import { CreateRoomModalRenderer } from '$features/create-room/CreateRoomModal';
+import { CreateSpaceModalRenderer } from '$features/create-space/CreateSpaceModal';
+import { BugReportModalRenderer } from '$features/bug-report/BugReportModalRenderer';
+import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer';
+import { RoomSettingsRenderer } from '$features/room-settings/RoomSettingsRenderer';
+import { SpaceSettingsRenderer } from '$features/space-settings/SpaceSettingsRenderer';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import {
DIRECT_PATH,
@@ -60,8 +67,6 @@ import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNon
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
-import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
-import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -70,45 +75,47 @@ import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { HomeCreateRoom } from './client/home/CreateRoom';
-import { Create } from './client/create';
-import { ToRoomEvent } from './client/ToRoomEvent';
import { CallStatusRenderer } from './CallStatusRenderer';
-const SearchModalRenderer = lazy(async () => {
- const mod = await import('$features/search/Search');
- return { default: mod.SearchModalRenderer };
+const SettingsRoute = lazy(async () => {
+ const mod = await import('$features/settings/SettingsRoute');
+ return { default: mod.SettingsRoute };
});
-const UserRoomProfileRenderer = lazy(async () => {
- const mod = await import('$components/UserRoomProfileRenderer');
- return { default: mod.UserRoomProfileRenderer };
+const Lobby = lazy(async () => {
+ const mod = await import('$features/lobby/Lobby');
+ return { default: mod.Lobby };
});
-const CreateRoomModalRenderer = lazy(async () => {
- const mod = await import('$features/create-room/CreateRoomModal');
- return { default: mod.CreateRoomModalRenderer };
+const Explore = lazy(async () => {
+ const mod = await import('./client/explore/Explore');
+ return { default: mod.Explore };
});
-const CreateSpaceModalRenderer = lazy(async () => {
- const mod = await import('$features/create-space/CreateSpaceModal');
- return { default: mod.CreateSpaceModalRenderer };
+const FeaturedRooms = lazy(async () => {
+ const mod = await import('./client/explore/Featured');
+ return { default: mod.FeaturedRooms };
});
-const BugReportModalRenderer = lazy(async () => {
- const mod = await import('$features/bug-report/BugReportModal');
- return { default: mod.BugReportModalRenderer };
+const PublicRooms = lazy(async () => {
+ const mod = await import('./client/explore/Server');
+ return { default: mod.PublicRooms };
});
-const SettingsShallowRouteRenderer = lazy(async () => {
- const mod = await import('$features/settings/SettingsShallowRouteRenderer');
- return { default: mod.SettingsShallowRouteRenderer };
+const Inbox = lazy(async () => {
+ const mod = await import('./client/inbox/Inbox');
+ return { default: mod.Inbox };
});
-const RoomSettingsRenderer = lazy(async () => {
- const mod = await import('$features/room-settings/RoomSettingsRenderer');
- return { default: mod.RoomSettingsRenderer };
+const Notifications = lazy(async () => {
+ const mod = await import('./client/inbox/Notifications');
+ return { default: mod.Notifications };
});
-const SpaceSettingsRenderer = lazy(async () => {
- const mod = await import('$features/space-settings/SpaceSettingsRenderer');
- return { default: mod.SpaceSettingsRenderer };
+const Invites = lazy(async () => {
+ const mod = await import('./client/inbox/Invites');
+ return { default: mod.Invites };
});
-const SettingsRoute = lazy(async () => {
- const mod = await import('$features/settings/SettingsRoute');
- return { default: mod.SettingsRoute };
+const Create = lazy(async () => {
+ const mod = await import('./client/create/Create');
+ return { default: mod.Create };
+});
+const ToRoomEvent = lazy(async () => {
+ const mod = await import('./client/ToRoomEvent');
+ return { default: mod.ToRoomEvent };
});
/**
@@ -354,7 +361,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={}
/>
)}
- } />
+
+
+
+ }
+ />
} />
-
+
+
+
}
>
@@ -386,10 +402,31 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={}
/>
)}
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
- } />
+
+
+
+ }
+ />
-
+
+
+
}
>
@@ -419,10 +458,31 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={}
/>
)}
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
- } />
+
+
+
+ }
+ />
Page not found
} />
From ee11d9294e0b0263c99b98a983d0d53b584c1009 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 20:38:01 -0500
Subject: [PATCH 06/28] spaces lazy loading
---
src/app/hooks/useSpaceHierarchy.ts | 25 +++++++++++++++-----
src/app/pages/client/space/Space.tsx | 22 ++++++++++++++---
src/app/pages/client/space/SpaceProvider.tsx | 5 +---
3 files changed, 39 insertions(+), 13 deletions(-)
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
index 6c4ebcec7..b4a2ae128 100644
--- a/src/app/hooks/useSpaceHierarchy.ts
+++ b/src/app/hooks/useSpaceHierarchy.ts
@@ -44,6 +44,10 @@ const hierarchyItemByOrder: SortFunc = (a, b) =>
const childEventTs: SortFunc = (a, b) => byTsOldToNew(a.getTs(), b.getTs());
const childEventByOrder: SortFunc = (a, b) =>
byOrderKey(a.getContent().order, b.getContent().order);
+const hierarchyItemOrderThenTs: SortFunc = (a, b) =>
+ hierarchyItemByOrder(a, b) || hierarchyItemTs(a, b);
+const childEventOrderThenTs: SortFunc = (a, b) =>
+ childEventByOrder(a, b) || childEventTs(a, b);
const getHierarchySpaces = (
rootSpaceId: string,
@@ -87,8 +91,7 @@ const getHierarchySpaces = (
// cache which we maintain as we load summary in UI.
return getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId);
})
- .toSorted(childEventTs)
- .toSorted(childEventByOrder);
+ .toSorted(childEventOrderThenTs);
childEvents.forEach((childEvent) => {
const childId = childEvent.getStateKey();
@@ -155,7 +158,7 @@ const getSpaceHierarchy = (
return {
space: spaceItem,
- rooms: childItems.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder),
+ rooms: childItems.toSorted(hierarchyItemOrderThenTs),
};
});
@@ -214,6 +217,7 @@ const getSpaceJoinedHierarchy = (
excludeRoom,
new Set()
);
+ const containsRoomCache = new Map();
/**
* Recursively checks if the given space or any of its descendants contain non-space rooms.
@@ -223,16 +227,22 @@ const getSpaceJoinedHierarchy = (
* @returns True if the space or any descendant contains non-space rooms.
*/
const getContainsRoom = (spaceId: string, visited: Set = new Set()) => {
+ const cached = containsRoomCache.get(spaceId);
+ if (cached !== undefined) return cached;
+
// Prevent infinite recursion
if (visited.has(spaceId)) return false;
visited.add(spaceId);
const space = getRoom(spaceId);
- if (!space) return false;
+ if (!space) {
+ containsRoomCache.set(spaceId, false);
+ return false;
+ }
const childEvents = getStateEvents(space, EventType.SpaceChild);
- return childEvents.some((childEvent): boolean => {
+ const contains = childEvents.some((childEvent): boolean => {
if (!isValidChild(childEvent)) return false;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return false;
@@ -242,6 +252,9 @@ const getSpaceJoinedHierarchy = (
if (!room.isSpaceRoom()) return true;
return getContainsRoom(childId, visited);
});
+ visited.delete(spaceId);
+ containsRoomCache.set(spaceId, contains);
+ return contains;
};
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
@@ -298,7 +311,7 @@ export const useSpaceJoinedHierarchy = (
items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId));
return items;
}
- return items.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder);
+ return items.toSorted(hierarchyItemOrderThenTs);
},
[mx, sortByActivity]
);
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index c4994d829..3914073e3 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -385,7 +385,7 @@ export function Space() {
const roomToParents = useAtomValue(roomToParentsAtom);
const roomToChildren = useAtomValue(roomToChildrenAtom);
const allRooms = useAtomValue(allRoomsAtom);
- const [spaceRooms] = useAtom(spaceRoomsAtom);
+ const spaceRooms = useAtomValue(spaceRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const notificationPreferences = useRoomsNotificationPreferencesContext();
@@ -409,11 +409,16 @@ export function Space() {
const closedCategoriesCache = useRef(new Map());
const ancestorsCollapsedCache = useRef(new Map());
+ const containsShowRoomCache = useRef(new Map());
useEffect(() => {
closedCategoriesCache.current.clear();
ancestorsCollapsedCache.current.clear();
}, [closedCategories, roomToParents, getRoom]);
+ useEffect(() => {
+ containsShowRoomCache.current.clear();
+ }, [roomToUnread, selectedRoomId, roomToChildren]);
+
/**
* Recursively checks if a given parentId (or all its ancestors) is in a closed category.
*
@@ -480,20 +485,31 @@ export function Space() {
*/
const getContainsShowRoom = useCallback(
(roomId: string, visited: Set = new Set()): boolean => {
+ const cached = containsShowRoomCache.current.get(roomId);
+ if (cached !== undefined) return cached;
+
if (roomToUnread.has(roomId) || roomId === selectedRoomId) {
+ containsShowRoomCache.current.set(roomId, true);
return true;
}
// Prevent infinite recursion
- if (visited.has(roomId)) return false;
+ if (visited.has(roomId)) {
+ containsShowRoomCache.current.set(roomId, false);
+ return false;
+ }
visited.add(roomId);
const childIds = roomToChildren.get(roomId);
if (!childIds || childIds.size === 0) {
+ containsShowRoomCache.current.set(roomId, false);
return false;
}
- return Array.from(childIds).some((id) => getContainsShowRoom(id, visited));
+ const contains = Array.from(childIds).some((id) => getContainsShowRoom(id, visited));
+ visited.delete(roomId);
+ containsShowRoomCache.current.set(roomId, contains);
+ return contains;
},
[roomToUnread, selectedRoomId, roomToChildren]
);
diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx
index 09c9e468d..667ef03e8 100644
--- a/src/app/pages/client/space/SpaceProvider.tsx
+++ b/src/app/pages/client/space/SpaceProvider.tsx
@@ -1,8 +1,6 @@
import type { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useMatrixClient } from '$hooks/useMatrixClient';
-import { useSpaces } from '$state/hooks/roomList';
-import { allRoomsAtom } from '$state/room-list/roomList';
import { useSelectedSpace } from '$hooks/router/useSelectedSpace';
import { SpaceProvider } from '$hooks/useSpace';
import { JoinBeforeNavigate } from '$features/join-before-navigate';
@@ -13,7 +11,6 @@ type RouteSpaceProviderProps = {
};
export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
const mx = useMatrixClient();
- const joinedSpaces = useSpaces(mx, allRoomsAtom);
const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams();
const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
@@ -22,7 +19,7 @@ export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
const selectedSpaceId = useSelectedSpace();
const space = mx.getRoom(selectedSpaceId);
- if (!space || !joinedSpaces.includes(space.roomId)) {
+ if (!space?.isSpaceRoom()) {
return ;
}
From 2da69fd51a6a73fa4754b1849e238fdf4673b68b Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 20:59:05 -0500
Subject: [PATCH 07/28] swipeable overlay lazy loading
---
src/app/components/SwipeableChatWrapper.tsx | 106 +++++-------------
.../components/SwipeableChatWrapperActive.tsx | 93 +++++++++++++++
.../components/SwipeableMessageWrapper.tsx | 85 +++-----------
.../SwipeableMessageWrapperActive.tsx | 73 ++++++++++++
.../components/SwipeableOverlayWrapper.tsx | 100 ++++-------------
.../SwipeableOverlayWrapperActive.tsx | 87 ++++++++++++++
6 files changed, 312 insertions(+), 232 deletions(-)
create mode 100644 src/app/components/SwipeableChatWrapperActive.tsx
create mode 100644 src/app/components/SwipeableMessageWrapperActive.tsx
create mode 100644 src/app/components/SwipeableOverlayWrapperActive.tsx
diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx
index d4a547298..4035d70b0 100644
--- a/src/app/components/SwipeableChatWrapper.tsx
+++ b/src/app/components/SwipeableChatWrapper.tsx
@@ -1,10 +1,13 @@
-import type { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
-import { settingsAtom, RightSwipeAction } from '$state/settings';
+import { settingsAtom } from '$state/settings';
import { mobileOrTablet } from '$utils/user-agent';
+const SwipeableChatWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableChatWrapperActive');
+ return { default: mod.SwipeableChatWrapperActive };
+});
+
interface SwipeableChatWrapperProps {
children: ReactNode;
onOpenSidebar?: () => void;
@@ -19,76 +22,10 @@ export function SwipeableChatWrapper({
onReply,
}: SwipeableChatWrapperProps) {
const settings = useAtomValue(settingsAtom);
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 400, damping: 40 });
-
- const bind = useDrag(
- ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => {
- if (e && 'target' in e && e.target instanceof HTMLElement) {
- if (e.target.closest('[data-gestures="ignore"]')) {
- return;
- }
- }
-
- if (!settings.mobileGestures || !mobileOrTablet()) return;
-
- let val = mx;
-
- const canSwipeRight = !!onOpenSidebar;
- const canSwipeLeft =
- settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply;
-
- if (!canSwipeRight && val > 0) val = 0;
- if (!canSwipeLeft && val < 0) val = 0;
- if (active) {
- x.set(val);
- } else {
- const swipeThreshold = 120;
- const velocityThreshold = 0.5;
-
- if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) {
- onOpenSidebar?.();
- } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
- if (settings.rightSwipeAction === RightSwipeAction.Members) {
- onOpenMembers?.();
- } else {
- onReply?.();
- }
- }
- x.set(0);
- }
- },
- {
- axis: 'x',
- bounds: { left: -200, right: 200 },
- rubberband: true,
- filterTaps: true,
- }
- );
-
- if (!settings.mobileGestures || !mobileOrTablet()) {
- return (
-
- {children}
-
- );
- }
-
- return (
+ const plainWrapper = (
-
+ );
+
+ if (!settings.mobileGestures || !mobileOrTablet()) {
+ return plainWrapper;
+ }
+
+ return (
+
+
{children}
-
-
+
+
);
}
diff --git a/src/app/components/SwipeableChatWrapperActive.tsx b/src/app/components/SwipeableChatWrapperActive.tsx
new file mode 100644
index 000000000..dd7f9839b
--- /dev/null
+++ b/src/app/components/SwipeableChatWrapperActive.tsx
@@ -0,0 +1,93 @@
+import type { ReactNode } from 'react';
+import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+import { RightSwipeAction, type Settings } from '$state/settings';
+
+interface SwipeableChatWrapperActiveProps {
+ children: ReactNode;
+ settings: Settings;
+ onOpenSidebar?: () => void;
+ onOpenMembers?: () => void;
+ onReply?: () => void;
+}
+
+export function SwipeableChatWrapperActive({
+ children,
+ settings,
+ onOpenSidebar,
+ onOpenMembers,
+ onReply,
+}: SwipeableChatWrapperActiveProps) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 400, damping: 40 });
+
+ const bind = useDrag(
+ ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => {
+ if (e && 'target' in e && e.target instanceof HTMLElement) {
+ if (e.target.closest('[data-gestures="ignore"]')) {
+ return;
+ }
+ }
+
+ let val = mx;
+
+ const canSwipeRight = !!onOpenSidebar;
+ const canSwipeLeft =
+ settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply;
+
+ if (!canSwipeRight && val > 0) val = 0;
+ if (!canSwipeLeft && val < 0) val = 0;
+
+ if (active) {
+ x.set(val);
+ } else {
+ const swipeThreshold = 120;
+ const velocityThreshold = 0.5;
+
+ if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) {
+ onOpenSidebar?.();
+ } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
+ if (settings.rightSwipeAction === RightSwipeAction.Members) {
+ onOpenMembers?.();
+ } else {
+ onReply?.();
+ }
+ }
+ x.set(0);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: { left: -200, right: 200 },
+ rubberband: true,
+ filterTaps: true,
+ }
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx
index 58fc9293e..0a7c75da4 100644
--- a/src/app/components/SwipeableMessageWrapper.tsx
+++ b/src/app/components/SwipeableMessageWrapper.tsx
@@ -1,70 +1,12 @@
-import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
-import type { ReactNode } from 'react';
-import { useMemo, useState } from 'react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
-import { config, Icon, Icons } from 'folds';
import { mobileOrTablet } from '$utils/user-agent';
import { RightSwipeAction, settingsAtom } from '$state/settings';
-function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onReply: () => void }) {
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 300, damping: 35 });
- const [isReady, setIsReady] = useState(false);
- const iconOpacity = useTransform(x, [0, -8], [0, 1]);
-
- const bind = useDrag(
- ({ active, movement: [mx] }) => {
- if (active) {
- const val = mx < 0 ? mx : 0;
- x.set(Math.max(-80, val));
- if (mx < -50 !== isReady) setIsReady(mx < -50);
- } else {
- if (mx < -50) onReply();
- x.set(0);
- setIsReady(false);
- }
- },
- {
- axis: 'x',
- bounds: { right: 0 },
- rubberband: true,
- filterTaps: true,
- eventOptions: { passive: true },
- }
- );
-
- return (
-
- );
-}
+const SwipeableMessageWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableMessageWrapperActive');
+ return { default: mod.SwipeableMessageWrapperActive };
+});
export function SwipeableMessageWrapper({
children,
@@ -75,17 +17,18 @@ export function SwipeableMessageWrapper({
}) {
const settings = useAtomValue(settingsAtom);
- const isSwipeToReplyEnabled = useMemo(
- () =>
- settings.mobileGestures &&
- mobileOrTablet() &&
- settings.rightSwipeAction !== RightSwipeAction.Members,
- [settings.mobileGestures, settings.rightSwipeAction]
- );
+ const isSwipeToReplyEnabled =
+ settings.mobileGestures &&
+ mobileOrTablet() &&
+ settings.rightSwipeAction !== RightSwipeAction.Members;
if (!isSwipeToReplyEnabled) {
return children;
}
- return {children};
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/app/components/SwipeableMessageWrapperActive.tsx b/src/app/components/SwipeableMessageWrapperActive.tsx
new file mode 100644
index 000000000..1f79458fd
--- /dev/null
+++ b/src/app/components/SwipeableMessageWrapperActive.tsx
@@ -0,0 +1,73 @@
+import type { ReactNode } from 'react';
+import { useMemo, useState } from 'react';
+import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+import { config, Icon, Icons } from 'folds';
+
+export function SwipeableMessageWrapperActive({
+ children,
+ onReply,
+}: {
+ children: ReactNode;
+ onReply: () => void;
+}) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 300, damping: 35 });
+ const [isReady, setIsReady] = useState(false);
+ const iconOpacity = useTransform(x, [0, -8], [0, 1]);
+
+ const bind = useDrag(
+ ({ active, movement: [mx] }) => {
+ if (active) {
+ const val = mx < 0 ? mx : 0;
+ x.set(Math.max(-80, val));
+ if (mx < -50 !== isReady) setIsReady(mx < -50);
+ } else {
+ if (mx < -50) onReply();
+ x.set(0);
+ setIsReady(false);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: { right: 0 },
+ rubberband: true,
+ filterTaps: true,
+ eventOptions: { passive: true },
+ }
+ );
+
+ const iconColor = useMemo(
+ () => (isReady ? 'var(--sable-surface-on-container)' : 'var(--sable-surface-container)'),
+ [isReady]
+ );
+
+ return (
+
+ );
+}
diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx
index a77b802f5..daeadd5cf 100644
--- a/src/app/components/SwipeableOverlayWrapper.tsx
+++ b/src/app/components/SwipeableOverlayWrapper.tsx
@@ -1,10 +1,13 @@
-import type { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '$state/settings';
import { mobileOrTablet } from '$utils/user-agent';
+const SwipeableOverlayWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableOverlayWrapperActive');
+ return { default: mod.SwipeableOverlayWrapperActive };
+});
+
interface SwipeableOverlayWrapperProps {
children: ReactNode;
onClose: () => void;
@@ -17,75 +20,10 @@ export function SwipeableOverlayWrapper({
direction,
}: SwipeableOverlayWrapperProps) {
const settings = useAtomValue(settingsAtom);
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 400, damping: 40 });
-
- const bind = useDrag(
- ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => {
- if (e && 'target' in e && e.target instanceof HTMLElement) {
- if (e.target.closest('[data-gestures="ignore"]')) {
- return;
- }
- }
-
- if (!settings.mobileGestures || !mobileOrTablet()) return;
-
- event.stopPropagation();
-
- let val = mx;
-
- if (direction === 'left' && val > 0) val = 0;
- if (direction === 'right' && val < 0) val = 0;
-
- if (active) {
- x.set(val);
- } else {
- const swipeThreshold = 100;
- const velocityThreshold = 0.5;
-
- const swipedLeft =
- direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
- const swipedRight =
- direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));
-
- if (swipedLeft || swipedRight) {
- onClose();
- }
-
- x.set(0);
- }
- },
- {
- axis: 'x',
- bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
- rubberband: true,
- filterTaps: true,
- pointer: { capture: true },
- }
- );
-
- if (!settings.mobileGestures || !mobileOrTablet()) {
- return (
-
- {children}
-
- );
- }
- return (
+ const plainWrapper = (
-
- {children}
-
+ {children}
);
+
+ if (!settings.mobileGestures || !mobileOrTablet()) {
+ return plainWrapper;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/src/app/components/SwipeableOverlayWrapperActive.tsx b/src/app/components/SwipeableOverlayWrapperActive.tsx
new file mode 100644
index 000000000..5c117358b
--- /dev/null
+++ b/src/app/components/SwipeableOverlayWrapperActive.tsx
@@ -0,0 +1,87 @@
+import type { ReactNode } from 'react';
+import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+
+interface SwipeableOverlayWrapperActiveProps {
+ children: ReactNode;
+ onClose: () => void;
+ direction: 'left' | 'right';
+}
+
+export function SwipeableOverlayWrapperActive({
+ children,
+ onClose,
+ direction,
+}: SwipeableOverlayWrapperActiveProps) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 400, damping: 40 });
+
+ const bind = useDrag(
+ ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => {
+ if (e && 'target' in e && e.target instanceof HTMLElement) {
+ if (e.target.closest('[data-gestures="ignore"]')) {
+ return;
+ }
+ }
+
+ event.stopPropagation();
+
+ let val = mx;
+
+ if (direction === 'left' && val > 0) val = 0;
+ if (direction === 'right' && val < 0) val = 0;
+
+ if (active) {
+ x.set(val);
+ } else {
+ const swipeThreshold = 100;
+ const velocityThreshold = 0.5;
+
+ const swipedLeft =
+ direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
+ const swipedRight =
+ direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));
+
+ if (swipedLeft || swipedRight) {
+ onClose();
+ }
+
+ x.set(0);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
+ rubberband: true,
+ filterTaps: true,
+ pointer: { capture: true },
+ }
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
From e195385da88e58f73c096c4e256699c206fb918d Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 21:23:48 -0500
Subject: [PATCH 08/28] minimize sentry load (maybe) when disabled
i dont think this actually improves performance though :/
---
src/instrument-runtime.ts | 295 ++++++++++++++++++++++++++++++++++++++
src/instrument.ts | 292 +------------------------------------
2 files changed, 299 insertions(+), 288 deletions(-)
create mode 100644 src/instrument-runtime.ts
diff --git a/src/instrument-runtime.ts b/src/instrument-runtime.ts
new file mode 100644
index 000000000..4dc0e5f58
--- /dev/null
+++ b/src/instrument-runtime.ts
@@ -0,0 +1,295 @@
+/**
+ * Sentry instrumentation - MUST be imported first in the application lifecycle
+ *
+ * Configure via environment variables:
+ * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry)
+ * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE)
+ * - VITE_APP_VERSION: Release version for tracking
+ */
+/* oxlint-disable no-console */
+import * as Sentry from '@sentry/react';
+import React from 'react';
+import {
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+} from 'react-router-dom';
+import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers';
+
+const dsn = import.meta.env.VITE_SENTRY_DSN;
+const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE;
+const release = import.meta.env.VITE_APP_VERSION;
+
+// Per-session error event counter for rate limiting
+let sessionErrorCount = 0;
+const SESSION_ERROR_LIMIT = 50;
+
+// Default off: Sentry only runs when the user has opted in via the banner or Settings.
+const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true';
+const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true';
+
+// Only initialize if DSN is provided and user hasn't opted out
+if (dsn && sentryEnabled) {
+ Sentry.init({
+ dsn,
+ environment,
+ release,
+
+ // Do not send PII (IP addresses, user identifiers) to protect privacy
+ sendDefaultPii: false,
+
+ integrations: [
+ // React Router v6 browser tracing integration
+ Sentry.reactRouterV6BrowserTracingIntegration({
+ useEffect: React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ }),
+ // Session replay with privacy settings (only if user opted in)
+ ...(replayEnabled
+ ? [
+ Sentry.replayIntegration({
+ maskAllText: true, // Mask all text for privacy
+ blockAllMedia: true, // Block images/video/audio for privacy
+ maskAllInputs: true, // Mask form inputs
+ }),
+ ]
+ : []),
+ // Capture console.error/warn as structured logs in the Sentry Logs product
+ Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }),
+ // Browser profiling — captures JS call stacks during Sentry transactions
+ Sentry.browserProfilingIntegration(),
+ ],
+
+ // Performance Monitoring - Tracing
+ // 100% in development and preview, lower in production for cost control
+ tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
+
+ // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header)
+ profileSessionSampleRate:
+ environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
+
+ // Control which URLs get distributed tracing headers
+ tracePropagationTargets: [
+ 'localhost',
+ /^https:\/\/[^/]*\.sable\.chat/,
+ // Add your Matrix homeserver domains here if needed
+ ],
+
+ // Session Replay sampling
+ // Record 100% in development and preview for testing, 10% in production
+ // Always record 100% of sessions with errors
+ replaysSessionSampleRate:
+ environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
+ replaysOnErrorSampleRate: 1.0,
+
+ // Enable structured logging to Sentry
+ enableLogs: true,
+
+ // Scrub sensitive data from structured logs before sending to Sentry
+ beforeSendLog(log) {
+ // Drop debug-level logs in production to reduce noise and quota usage
+ if (log.level === 'debug' && environment === 'production') return null;
+ // Redact Matrix IDs and tokens from the log message string
+ if (typeof log.message === 'string') {
+ log.message = scrubMatrixIds(log.message);
+ }
+ // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId)
+ // These are flattened from the structured data object and sent as searchable attributes.
+ if (log.attributes && typeof log.attributes === 'object') {
+ log.attributes = scrubDataObject(log.attributes) as typeof log.attributes;
+ }
+ return log;
+ },
+
+ // Rate limiting: cap error events per page-load session to avoid quota exhaustion.
+ // Separate counters for errors and transactions so perf traces do not drain the error budget.
+ beforeSendTransaction(event) {
+ // Scrub Matrix identifiers from the transaction name (the matched route or page URL).
+ // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls
+ // back to the raw URL when matching fails, so we scrub defensively here.
+ if (event.transaction) {
+ event.transaction = scrubMatrixUrl(event.transaction);
+ }
+
+ // Scrub Matrix identifiers from HTTP span descriptions and data URLs.
+ // We scrub ALL string values in span.data rather than a single known key because
+ // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions:
+ // http.url (OTel semconv < 1.23, Sentry classic)
+ // url.full (OTel semconv ≥ 1.23)
+ // http.target, server.address, url, etc.
+ // For each string value: apply URL scrubbing when the value starts with "http",
+ // then apply ID scrubbing to catch any remaining bare Matrix IDs.
+ if (event.spans) {
+ event.spans = event.spans.map((span) => {
+ const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description;
+ const spanData = span.data as Record | undefined;
+ const newData = spanData
+ ? Object.fromEntries(
+ Object.entries(spanData).map(([k, v]) => [
+ k,
+ typeof v === 'string'
+ ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v)
+ : v,
+ ])
+ )
+ : undefined;
+
+ const descChanged = newDesc !== span.description;
+ const dataChanged =
+ newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData);
+
+ if (!descChanged && !dataChanged) return span;
+ return {
+ ...span,
+ ...(descChanged ? { description: newDesc } : {}),
+ ...(dataChanged ? { data: newData as typeof span.data } : {}),
+ };
+ });
+ }
+ return event;
+ },
+
+ // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry
+ beforeBreadcrumb(breadcrumb) {
+ // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs)
+ const bData = breadcrumb.data as Record | undefined;
+ const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined;
+ const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined;
+ const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl;
+
+ // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that
+ // may contain room IDs or user IDs as path segments in the app's client-side routes)
+ const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined;
+ const rawTo = typeof bData?.to === 'string' ? bData.to : undefined;
+ const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined;
+ const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined;
+ const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom;
+ const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo;
+
+ // Scrub Matrix IDs from all remaining string values in the breadcrumb data object.
+ // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise
+ // bypass the URL-specific scrubbers above.
+ const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined;
+
+ // Scrub message text — token values and Matrix entity IDs
+ const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message;
+ const messageChanged = message !== breadcrumb.message;
+
+ if (!messageChanged && !scrubbedData) return breadcrumb;
+ return {
+ ...breadcrumb,
+ ...(messageChanged ? { message } : {}),
+ ...(scrubbedData
+ ? {
+ data: {
+ ...scrubbedData,
+ ...(urlChanged ? { url: scrubbedUrl } : {}),
+ ...(fromChanged ? { from: scrubbedFrom } : {}),
+ ...(toChanged ? { to: scrubbedTo } : {}),
+ },
+ }
+ : {}),
+ };
+ },
+
+ beforeSend(event, hint) {
+ sessionErrorCount += 1;
+ if (sessionErrorCount > SESSION_ERROR_LIMIT) {
+ return null; // Drop event — session limit reached
+ }
+
+ // Improve grouping for Matrix API errors.
+ // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to
+ // split errors into meaningful issue groups rather than merging them all by stack trace.
+ const originalException = hint?.originalException;
+ if (
+ originalException !== null &&
+ typeof originalException === 'object' &&
+ 'errcode' in originalException &&
+ typeof (originalException as Record).errcode === 'string'
+ ) {
+ const errcode = (originalException as Record).errcode as string;
+ // Preserve default grouping AND split by errcode
+ event.fingerprint = ['{{ default }}', errcode];
+ }
+
+ // Scrub sensitive data from error messages and exception values using shared helpers
+ if (event.message) {
+ event.message = scrubMatrixIds(event.message);
+ }
+
+ // Scrub sensitive data from exception values
+ if (event.exception?.values) {
+ event.exception.values.forEach((exception) => {
+ if (exception.value) {
+ exception.value = scrubMatrixUrl(scrubMatrixIds(exception.value));
+ }
+ });
+ }
+
+ // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts,
+ // which can carry structured data fields like roomId, targetEventId, etc.)
+ if (event.contexts) {
+ event.contexts = scrubDataObject(event.contexts) as typeof event.contexts;
+ }
+
+ // Scrub request data
+ if (event.request?.url) {
+ event.request.url = scrubMatrixUrl(
+ event.request.url.replace(
+ /(access_token|password|token)([=:]\s*)([^\s&]+)/gi,
+ '$1$2[REDACTED]'
+ )
+ );
+ }
+
+ // Scrub the transaction name on error events (set when the error occurred during a
+ // page-load or navigation transaction — raw URL leaks here when route matching fails)
+ if (event.transaction) {
+ event.transaction = scrubMatrixUrl(event.transaction);
+ }
+
+ if (event.request?.headers) {
+ const headers = event.request.headers as Record;
+ if (headers.Authorization) {
+ headers.Authorization = '[REDACTED]';
+ }
+ }
+
+ return event;
+ },
+ });
+
+ // Expose Sentry globally for debugging and console testing
+ // Set app-wide attributes on the global scope so they appear on all events and logs
+ Sentry.getGlobalScope().setAttributes({
+ 'app.name': 'sable',
+ 'app.version': release ?? 'unknown',
+ });
+
+ // Tag all events with the PR number when running in a PR preview deployment
+ const prNumber = import.meta.env.VITE_SENTRY_PR;
+ if (prNumber) {
+ Sentry.getGlobalScope().setTag('pr', prNumber);
+ }
+
+ // @ts-expect-error - Adding to window for debugging
+ window.Sentry = Sentry;
+
+ console.info(
+ `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}`
+ );
+ console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`);
+ console.info(`[Sentry] Release: ${release || 'not set'}`);
+} else if (!sentryEnabled) {
+ console.info('[Sentry] Disabled by user preference');
+} else {
+ console.info('[Sentry] Disabled - no DSN provided');
+}
+
+// Export Sentry for use in other parts of the application
+export { Sentry };
diff --git a/src/instrument.ts b/src/instrument.ts
index 4dc0e5f58..118e7b244 100644
--- a/src/instrument.ts
+++ b/src/instrument.ts
@@ -1,295 +1,11 @@
-/**
- * Sentry instrumentation - MUST be imported first in the application lifecycle
- *
- * Configure via environment variables:
- * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry)
- * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE)
- * - VITE_APP_VERSION: Release version for tracking
- */
/* oxlint-disable no-console */
-import * as Sentry from '@sentry/react';
-import React from 'react';
-import {
- useLocation,
- useNavigationType,
- createRoutesFromChildren,
- matchRoutes,
-} from 'react-router-dom';
-import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers';
-
const dsn = import.meta.env.VITE_SENTRY_DSN;
-const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE;
-const release = import.meta.env.VITE_APP_VERSION;
-
-// Per-session error event counter for rate limiting
-let sessionErrorCount = 0;
-const SESSION_ERROR_LIMIT = 50;
-
-// Default off: Sentry only runs when the user has opted in via the banner or Settings.
-const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true';
-const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true';
+const sentryEnabled = localStorage.getItem("sable_sentry_enabled") === "true";
-// Only initialize if DSN is provided and user hasn't opted out
if (dsn && sentryEnabled) {
- Sentry.init({
- dsn,
- environment,
- release,
-
- // Do not send PII (IP addresses, user identifiers) to protect privacy
- sendDefaultPii: false,
-
- integrations: [
- // React Router v6 browser tracing integration
- Sentry.reactRouterV6BrowserTracingIntegration({
- useEffect: React.useEffect,
- useLocation,
- useNavigationType,
- createRoutesFromChildren,
- matchRoutes,
- }),
- // Session replay with privacy settings (only if user opted in)
- ...(replayEnabled
- ? [
- Sentry.replayIntegration({
- maskAllText: true, // Mask all text for privacy
- blockAllMedia: true, // Block images/video/audio for privacy
- maskAllInputs: true, // Mask form inputs
- }),
- ]
- : []),
- // Capture console.error/warn as structured logs in the Sentry Logs product
- Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }),
- // Browser profiling — captures JS call stacks during Sentry transactions
- Sentry.browserProfilingIntegration(),
- ],
-
- // Performance Monitoring - Tracing
- // 100% in development and preview, lower in production for cost control
- tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
-
- // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header)
- profileSessionSampleRate:
- environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
-
- // Control which URLs get distributed tracing headers
- tracePropagationTargets: [
- 'localhost',
- /^https:\/\/[^/]*\.sable\.chat/,
- // Add your Matrix homeserver domains here if needed
- ],
-
- // Session Replay sampling
- // Record 100% in development and preview for testing, 10% in production
- // Always record 100% of sessions with errors
- replaysSessionSampleRate:
- environment === 'development' || environment === 'preview' ? 1.0 : 0.1,
- replaysOnErrorSampleRate: 1.0,
-
- // Enable structured logging to Sentry
- enableLogs: true,
-
- // Scrub sensitive data from structured logs before sending to Sentry
- beforeSendLog(log) {
- // Drop debug-level logs in production to reduce noise and quota usage
- if (log.level === 'debug' && environment === 'production') return null;
- // Redact Matrix IDs and tokens from the log message string
- if (typeof log.message === 'string') {
- log.message = scrubMatrixIds(log.message);
- }
- // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId)
- // These are flattened from the structured data object and sent as searchable attributes.
- if (log.attributes && typeof log.attributes === 'object') {
- log.attributes = scrubDataObject(log.attributes) as typeof log.attributes;
- }
- return log;
- },
-
- // Rate limiting: cap error events per page-load session to avoid quota exhaustion.
- // Separate counters for errors and transactions so perf traces do not drain the error budget.
- beforeSendTransaction(event) {
- // Scrub Matrix identifiers from the transaction name (the matched route or page URL).
- // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls
- // back to the raw URL when matching fails, so we scrub defensively here.
- if (event.transaction) {
- event.transaction = scrubMatrixUrl(event.transaction);
- }
-
- // Scrub Matrix identifiers from HTTP span descriptions and data URLs.
- // We scrub ALL string values in span.data rather than a single known key because
- // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions:
- // http.url (OTel semconv < 1.23, Sentry classic)
- // url.full (OTel semconv ≥ 1.23)
- // http.target, server.address, url, etc.
- // For each string value: apply URL scrubbing when the value starts with "http",
- // then apply ID scrubbing to catch any remaining bare Matrix IDs.
- if (event.spans) {
- event.spans = event.spans.map((span) => {
- const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description;
- const spanData = span.data as Record | undefined;
- const newData = spanData
- ? Object.fromEntries(
- Object.entries(spanData).map(([k, v]) => [
- k,
- typeof v === 'string'
- ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v)
- : v,
- ])
- )
- : undefined;
-
- const descChanged = newDesc !== span.description;
- const dataChanged =
- newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData);
-
- if (!descChanged && !dataChanged) return span;
- return {
- ...span,
- ...(descChanged ? { description: newDesc } : {}),
- ...(dataChanged ? { data: newData as typeof span.data } : {}),
- };
- });
- }
- return event;
- },
-
- // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry
- beforeBreadcrumb(breadcrumb) {
- // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs)
- const bData = breadcrumb.data as Record | undefined;
- const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined;
- const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined;
- const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl;
-
- // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that
- // may contain room IDs or user IDs as path segments in the app's client-side routes)
- const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined;
- const rawTo = typeof bData?.to === 'string' ? bData.to : undefined;
- const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined;
- const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined;
- const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom;
- const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo;
-
- // Scrub Matrix IDs from all remaining string values in the breadcrumb data object.
- // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise
- // bypass the URL-specific scrubbers above.
- const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined;
-
- // Scrub message text — token values and Matrix entity IDs
- const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message;
- const messageChanged = message !== breadcrumb.message;
-
- if (!messageChanged && !scrubbedData) return breadcrumb;
- return {
- ...breadcrumb,
- ...(messageChanged ? { message } : {}),
- ...(scrubbedData
- ? {
- data: {
- ...scrubbedData,
- ...(urlChanged ? { url: scrubbedUrl } : {}),
- ...(fromChanged ? { from: scrubbedFrom } : {}),
- ...(toChanged ? { to: scrubbedTo } : {}),
- },
- }
- : {}),
- };
- },
-
- beforeSend(event, hint) {
- sessionErrorCount += 1;
- if (sessionErrorCount > SESSION_ERROR_LIMIT) {
- return null; // Drop event — session limit reached
- }
-
- // Improve grouping for Matrix API errors.
- // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to
- // split errors into meaningful issue groups rather than merging them all by stack trace.
- const originalException = hint?.originalException;
- if (
- originalException !== null &&
- typeof originalException === 'object' &&
- 'errcode' in originalException &&
- typeof (originalException as Record).errcode === 'string'
- ) {
- const errcode = (originalException as Record).errcode as string;
- // Preserve default grouping AND split by errcode
- event.fingerprint = ['{{ default }}', errcode];
- }
-
- // Scrub sensitive data from error messages and exception values using shared helpers
- if (event.message) {
- event.message = scrubMatrixIds(event.message);
- }
-
- // Scrub sensitive data from exception values
- if (event.exception?.values) {
- event.exception.values.forEach((exception) => {
- if (exception.value) {
- exception.value = scrubMatrixUrl(scrubMatrixIds(exception.value));
- }
- });
- }
-
- // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts,
- // which can carry structured data fields like roomId, targetEventId, etc.)
- if (event.contexts) {
- event.contexts = scrubDataObject(event.contexts) as typeof event.contexts;
- }
-
- // Scrub request data
- if (event.request?.url) {
- event.request.url = scrubMatrixUrl(
- event.request.url.replace(
- /(access_token|password|token)([=:]\s*)([^\s&]+)/gi,
- '$1$2[REDACTED]'
- )
- );
- }
-
- // Scrub the transaction name on error events (set when the error occurred during a
- // page-load or navigation transaction — raw URL leaks here when route matching fails)
- if (event.transaction) {
- event.transaction = scrubMatrixUrl(event.transaction);
- }
-
- if (event.request?.headers) {
- const headers = event.request.headers as Record;
- if (headers.Authorization) {
- headers.Authorization = '[REDACTED]';
- }
- }
-
- return event;
- },
- });
-
- // Expose Sentry globally for debugging and console testing
- // Set app-wide attributes on the global scope so they appear on all events and logs
- Sentry.getGlobalScope().setAttributes({
- 'app.name': 'sable',
- 'app.version': release ?? 'unknown',
- });
-
- // Tag all events with the PR number when running in a PR preview deployment
- const prNumber = import.meta.env.VITE_SENTRY_PR;
- if (prNumber) {
- Sentry.getGlobalScope().setTag('pr', prNumber);
- }
-
- // @ts-expect-error - Adding to window for debugging
- window.Sentry = Sentry;
-
- console.info(
- `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}`
- );
- console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`);
- console.info(`[Sentry] Release: ${release || 'not set'}`);
+ void import("./instrument-runtime");
} else if (!sentryEnabled) {
- console.info('[Sentry] Disabled by user preference');
+ console.info("[Sentry] Disabled by user preference");
} else {
- console.info('[Sentry] Disabled - no DSN provided');
+ console.info("[Sentry] Disabled - no DSN provided");
}
-
-// Export Sentry for use in other parts of the application
-export { Sentry };
From edfe23d36f2e8459e71584b607af72bf09664f48 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 21:45:53 -0500
Subject: [PATCH 09/28] cache space things
---
src/app/hooks/router/useSelectedSpace.test.ts | 21 ++++++++
src/app/hooks/router/useSelectedSpace.ts | 24 ++++++++-
src/app/hooks/useSpaceHierarchy.ts | 50 ++++++++++++++++---
3 files changed, 88 insertions(+), 7 deletions(-)
create mode 100644 src/app/hooks/router/useSelectedSpace.test.ts
diff --git a/src/app/hooks/router/useSelectedSpace.test.ts b/src/app/hooks/router/useSelectedSpace.test.ts
new file mode 100644
index 000000000..877506a8a
--- /dev/null
+++ b/src/app/hooks/router/useSelectedSpace.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { decodeSpaceIdOrAlias } from './useSelectedSpace';
+
+describe('decodeSpaceIdOrAlias', () => {
+ it('returns undefined for empty input', () => {
+ expect(decodeSpaceIdOrAlias(undefined)).toBeUndefined();
+ });
+
+ it('decodes encoded values', () => {
+ expect(decodeSpaceIdOrAlias('%21space%3Aexample.org')).toBe('!space:example.org');
+ });
+
+ it('returns stable cached value for repeated input', () => {
+ const encoded = '%23space%3Aexample.org';
+ const first = decodeSpaceIdOrAlias(encoded);
+ const second = decodeSpaceIdOrAlias(encoded);
+
+ expect(first).toBe('#space:example.org');
+ expect(second).toBe(first);
+ });
+});
diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts
index 33bbe4308..f593cf02e 100644
--- a/src/app/hooks/router/useSelectedSpace.ts
+++ b/src/app/hooks/router/useSelectedSpace.ts
@@ -3,11 +3,33 @@ import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix';
import { getSpaceLobbyPath, getSpaceSearchPath } from '$pages/pathUtils';
import { useMatrixClient } from '$hooks/useMatrixClient';
+const DECODED_SPACE_PARAM_CACHE_MAX = 128;
+const decodedSpaceParamCache = new Map();
+
+export const decodeSpaceIdOrAlias = (encoded?: string): string | undefined => {
+ if (!encoded) return undefined;
+
+ const cached = decodedSpaceParamCache.get(encoded);
+ if (cached !== undefined) return cached;
+
+ const decoded = decodeURIComponent(encoded);
+ decodedSpaceParamCache.set(encoded, decoded);
+
+ if (decodedSpaceParamCache.size > DECODED_SPACE_PARAM_CACHE_MAX) {
+ const firstKey = decodedSpaceParamCache.keys().next().value;
+ if (firstKey !== undefined) {
+ decodedSpaceParamCache.delete(firstKey);
+ }
+ }
+
+ return decoded;
+};
+
export const useSelectedSpace = (): string | undefined => {
const mx = useMatrixClient();
const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams();
- const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
+ const spaceIdOrAlias = decodeSpaceIdOrAlias(encodedSpaceIdOrAlias);
const spaceId =
spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
index b4a2ae128..d527fcdb7 100644
--- a/src/app/hooks/useSpaceHierarchy.ts
+++ b/src/app/hooks/useSpaceHierarchy.ts
@@ -11,6 +11,7 @@ import { getAllParents, getStateEvents, isValidChild } from '$utils/room';
import { isRoomId } from '$utils/matrix';
import type { SortFunc } from '$utils/sort';
import { byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort';
+import { createLogger, isDebug } from '$utils/debug';
import { useMatrixClient } from './useMatrixClient';
import { makeLobbyCategoryId } from '../state/closedLobbyCategories';
import { useStateEventCallback } from './useStateEventCallback';
@@ -48,6 +49,21 @@ const hierarchyItemOrderThenTs: SortFunc = (a, b) =>
hierarchyItemByOrder(a, b) || hierarchyItemTs(a, b);
const childEventOrderThenTs: SortFunc = (a, b) =>
childEventByOrder(a, b) || childEventTs(a, b);
+const spaceHierarchyProfiler = createLogger('space-hierarchy-profiler');
+
+const profileHierarchyBuild = (label: string, build: () => T): T => {
+ if (!isDebug()) return build();
+
+ const start = performance.now();
+ const result = build();
+ const elapsed = performance.now() - start;
+
+ if (elapsed >= 8) {
+ spaceHierarchyProfiler.log(`${label} took ${elapsed.toFixed(2)}ms`);
+ }
+
+ return result;
+};
const getHierarchySpaces = (
rootSpaceId: string,
@@ -176,12 +192,20 @@ export const useSpaceHierarchy = (
const roomToParents = useAtomValue(roomToParentsAtom);
const [hierarchyAtom] = useState(() =>
- atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory))
+ atom(
+ profileHierarchyBuild('space-hierarchy:init', () =>
+ getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)
+ )
+ )
);
const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
useEffect(() => {
- setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory));
+ setHierarchy(
+ profileHierarchyBuild('space-hierarchy:effect', () =>
+ getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)
+ )
+ );
}, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory, excludeRoom]);
useStateEventCallback(
@@ -194,7 +218,9 @@ export const useSpaceHierarchy = (
if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
setHierarchy(
- getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)
+ profileHierarchyBuild('space-hierarchy:event', () =>
+ getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)
+ )
);
}
},
@@ -317,12 +343,20 @@ export const useSpaceJoinedHierarchy = (
);
const [hierarchyAtom] = useState(() =>
- atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems))
+ atom(
+ profileHierarchyBuild('space-joined-hierarchy:init', () =>
+ getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)
+ )
+ )
);
const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
useEffect(() => {
- setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+ setHierarchy(
+ profileHierarchyBuild('space-joined-hierarchy:effect', () =>
+ getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)
+ )
+ );
}, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]);
useStateEventCallback(
@@ -334,7 +368,11 @@ export const useSpaceJoinedHierarchy = (
if (!eventRoomId) return;
if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
- setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+ setHierarchy(
+ profileHierarchyBuild('space-joined-hierarchy:event', () =>
+ getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)
+ )
+ );
}
},
[spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems]
From 51d3b2fc7d07ca94e9a62101dba3cc3bdfac1981 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 21:48:42 -0500
Subject: [PATCH 10/28] defer mono font loading
---
.../code-highlight/CodeHighlightRenderer.tsx | 5 +++++
src/app/utils/loadSpaceMono.ts | 14 ++++++++++++++
src/index.tsx | 4 ----
3 files changed, 19 insertions(+), 4 deletions(-)
create mode 100644 src/app/utils/loadSpaceMono.ts
diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx
index 45145fbb4..c1aefb3c1 100644
--- a/src/app/components/code-highlight/CodeHighlightRenderer.tsx
+++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium';
+import { loadSpaceMono } from '$utils/loadSpaceMono';
import * as css from './CodeHighlightRenderer.css';
type CodeHighlightRendererProps = {
@@ -44,6 +45,10 @@ export function CodeHighlightRenderer({
result: createPlainResult(code, language),
}));
+ useEffect(() => {
+ void loadSpaceMono();
+ }, []);
+
useEffect(() => {
let cancelled = false;
diff --git a/src/app/utils/loadSpaceMono.ts b/src/app/utils/loadSpaceMono.ts
new file mode 100644
index 000000000..e8627299d
--- /dev/null
+++ b/src/app/utils/loadSpaceMono.ts
@@ -0,0 +1,14 @@
+let loadPromise: Promise | null = null;
+
+export const loadSpaceMono = (): Promise => {
+ if (!loadPromise) {
+ loadPromise = Promise.all([
+ import('@fontsource/space-mono/400.css'),
+ import('@fontsource/space-mono/700.css'),
+ import('@fontsource/space-mono/400-italic.css'),
+ import('@fontsource/space-mono/700-italic.css'),
+ ]).then(() => undefined);
+ }
+
+ return loadPromise;
+};
diff --git a/src/index.tsx b/src/index.tsx
index 61566cff2..dca1eab28 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,10 +3,6 @@ import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
import '@fontsource-variable/nunito';
import '@fontsource-variable/nunito/wght-italic.css';
-import '@fontsource/space-mono/400.css';
-import '@fontsource/space-mono/700.css';
-import '@fontsource/space-mono/400-italic.css';
-import '@fontsource/space-mono/700-italic.css';
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
import { trimTrailingSlash } from './app/utils/common';
From 8ec447f9a6e78152e4e0700f7b4739681e053657 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 22:35:45 -0500
Subject: [PATCH 11/28] memoize room listings
---
src/app/hooks/useSpaceHierarchy.ts | 6 ++--
src/app/state/hooks/roomList.ts | 10 +++---
src/app/utils/room.parents.test.ts | 52 ++++++++++++++++++++++++++++++
src/app/utils/room.ts | 50 ++++++++++++++++++++++++++++
4 files changed, 110 insertions(+), 8 deletions(-)
create mode 100644 src/app/utils/room.parents.test.ts
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
index d527fcdb7..d48206f75 100644
--- a/src/app/hooks/useSpaceHierarchy.ts
+++ b/src/app/hooks/useSpaceHierarchy.ts
@@ -7,7 +7,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import type { MSpaceChildContent } from '$types/matrix/room';
import { roomToParentsAtom } from '$state/room/roomToParents';
-import { getAllParents, getStateEvents, isValidChild } from '$utils/room';
+import { getStateEvents, hasRecursiveParent, isValidChild } from '$utils/room';
import { isRoomId } from '$utils/matrix';
import type { SortFunc } from '$utils/sort';
import { byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort';
@@ -216,7 +216,7 @@ export const useSpaceHierarchy = (
const eventRoomId = mEvent.getRoomId();
if (!eventRoomId) return;
- if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+ if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) {
setHierarchy(
profileHierarchyBuild('space-hierarchy:event', () =>
getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)
@@ -367,7 +367,7 @@ export const useSpaceJoinedHierarchy = (
const eventRoomId = mEvent.getRoomId();
if (!eventRoomId) return;
- if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+ if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) {
setHierarchy(
profileHierarchyBuild('space-joined-hierarchy:event', () =>
getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)
diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts
index f1bc6dc07..3c1793c97 100644
--- a/src/app/state/hooks/roomList.ts
+++ b/src/app/state/hooks/roomList.ts
@@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { MatrixClient } from '$types/matrix-sdk';
import { useCallback, useMemo } from 'react';
-import { getAllParents, isRoom, isSpace, isUnsupportedRoom } from '$utils/room';
+import { hasRecursiveParent, isRoom, isSpace, isUnsupportedRoom } from '$utils/room';
import type { RoomToParents } from '$types/matrix/room';
import { compareRoomsEqual } from '$state/room-list/utils';
@@ -31,7 +31,7 @@ export const useRecursiveChildScopeFactory = (
(parentId: string) => (roomId) =>
isRoom(mx.getRoom(roomId)) &&
roomToParents.has(roomId) &&
- getAllParents(roomToParents, roomId).has(parentId),
+ hasRecursiveParent(roomToParents, roomId, parentId),
[mx, roomToParents]
);
@@ -53,7 +53,7 @@ export const useRecursiveChildSpaceScopeFactory = (
(parentId: string) => (roomId) =>
isSpace(mx.getRoom(roomId)) &&
roomToParents.has(roomId) &&
- getAllParents(roomToParents, roomId).has(parentId),
+ hasRecursiveParent(roomToParents, roomId, parentId),
[mx, roomToParents]
);
@@ -80,7 +80,7 @@ export const useRecursiveChildRoomScopeFactory = (
isRoom(mx.getRoom(roomId)) &&
!mDirects.has(roomId) &&
roomToParents.has(roomId) &&
- getAllParents(roomToParents, roomId).has(parentId),
+ hasRecursiveParent(roomToParents, roomId, parentId),
[mx, mDirects, roomToParents]
);
@@ -107,7 +107,7 @@ export const useRecursiveChildDirectScopeFactory = (
isRoom(mx.getRoom(roomId)) &&
mDirects.has(roomId) &&
roomToParents.has(roomId) &&
- getAllParents(roomToParents, roomId).has(parentId),
+ hasRecursiveParent(roomToParents, roomId, parentId),
[mx, mDirects, roomToParents]
);
diff --git a/src/app/utils/room.parents.test.ts b/src/app/utils/room.parents.test.ts
new file mode 100644
index 000000000..57ea75100
--- /dev/null
+++ b/src/app/utils/room.parents.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import type { RoomToParents } from '$types/matrix/room';
+import { getAllParents, hasRecursiveParent } from './room';
+
+describe('hasRecursiveParent', () => {
+ it('resolves recursive ancestry', () => {
+ const roomToParents: RoomToParents = new Map([
+ ['!room:example.org', new Set(['!space-a:example.org'])],
+ ['!space-a:example.org', new Set(['!space-root:example.org'])],
+ ]);
+
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe(
+ true
+ );
+ expect(
+ hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org')
+ ).toBe(true);
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!unknown:example.org')).toBe(
+ false
+ );
+ });
+
+ it('handles cyclic parent graphs safely', () => {
+ const roomToParents: RoomToParents = new Map([
+ ['!room:example.org', new Set(['!space-a:example.org'])],
+ ['!space-a:example.org', new Set(['!space-b:example.org'])],
+ ['!space-b:example.org', new Set(['!space-a:example.org'])],
+ ]);
+
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe(
+ true
+ );
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-b:example.org')).toBe(
+ true
+ );
+ });
+
+ it('matches getAllParents semantics', () => {
+ const roomToParents: RoomToParents = new Map([
+ ['!room:example.org', new Set(['!space-a:example.org', '!space-b:example.org'])],
+ ['!space-a:example.org', new Set(['!space-root:example.org'])],
+ ]);
+
+ const allParents = getAllParents(roomToParents, '!room:example.org');
+ Array.from(allParents).forEach((parentId) => {
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', parentId)).toBe(true);
+ });
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org')).toBe(
+ false
+ );
+ });
+});
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index e15630c79..2d5b12227 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -139,6 +139,56 @@ export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set
return allParents;
};
+const roomParentsClosureCache = new WeakMap>>();
+
+const getOrCreateParentsClosure = (roomToParents: RoomToParents): Map> => {
+ const cached = roomParentsClosureCache.get(roomToParents);
+ if (cached) return cached;
+
+ const nextCache = new Map>();
+ roomParentsClosureCache.set(roomToParents, nextCache);
+ return nextCache;
+};
+
+const getAllParentsMemoized = (roomToParents: RoomToParents, roomId: string): Set => {
+ const closureCache = getOrCreateParentsClosure(roomToParents);
+ const cached = closureCache.get(roomId);
+ if (cached) return cached;
+
+ const visited = new Set();
+ const allParents = new Set();
+
+ const visit = (rId: string) => {
+ if (visited.has(rId)) return;
+ visited.add(rId);
+
+ const parents = roomToParents.get(rId);
+ if (!parents) return;
+
+ parents.forEach((parentId) => {
+ allParents.add(parentId);
+
+ const parentCached = closureCache.get(parentId);
+ if (parentCached) {
+ parentCached.forEach((ancestorId) => allParents.add(ancestorId));
+ return;
+ }
+
+ visit(parentId);
+ });
+ };
+
+ visit(roomId);
+ closureCache.set(roomId, allParents);
+ return allParents;
+};
+
+export const hasRecursiveParent = (
+ roomToParents: RoomToParents,
+ roomId: string,
+ parentId: string
+): boolean => getAllParentsMemoized(roomToParents, roomId).has(parentId);
+
export const getSpaceChildren = (room: Room) =>
getStateEvents(room, EventType.SpaceChild).reduce((filtered, mEvent) => {
const stateKey = mEvent.getStateKey();
From 33e52989fdad91314293b200bf20afd51bce5b35 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 23:00:02 -0500
Subject: [PATCH 12/28] cache search room parents
---
src/app/features/search/Search.test.ts | 27 ++++++++++++++++++++++++++
src/app/features/search/Search.tsx | 17 +++++++++-------
src/app/features/search/searchUtils.ts | 22 +++++++++++++++++++++
src/app/hooks/useSpaceHierarchy.ts | 2 +-
src/app/utils/room.parents.test.ts | 12 ++++++------
src/instrument.ts | 8 ++++----
6 files changed, 70 insertions(+), 18 deletions(-)
create mode 100644 src/app/features/search/Search.test.ts
create mode 100644 src/app/features/search/searchUtils.ts
diff --git a/src/app/features/search/Search.test.ts b/src/app/features/search/Search.test.ts
new file mode 100644
index 000000000..4e6284839
--- /dev/null
+++ b/src/app/features/search/Search.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import type { RoomToParents } from '$types/matrix/room';
+import { sortRoomsBySelectedSpace } from './searchUtils';
+
+describe('sortRoomsBySelectedSpace', () => {
+ it('returns original list when no space is selected', () => {
+ const roomToParents: RoomToParents = new Map();
+ const items = ['!a:example.org', '!b:example.org', '!c:example.org'];
+
+ expect(sortRoomsBySelectedSpace(items, undefined, roomToParents)).toEqual(items);
+ });
+
+ it('prioritizes rooms in selected space', () => {
+ const roomToParents: RoomToParents = new Map([
+ ['!room-a:example.org', new Set(['!space-1:example.org'])],
+ ['!room-b:example.org', new Set(['!space-2:example.org'])],
+ ['!room-c:example.org', new Set(['!space-1:example.org'])],
+ ]);
+ const items = ['!room-b:example.org', '!room-a:example.org', '!room-c:example.org'];
+
+ expect(sortRoomsBySelectedSpace(items, '!space-1:example.org', roomToParents)).toEqual([
+ '!room-a:example.org',
+ '!room-c:example.org',
+ '!room-b:example.org',
+ ]);
+ });
+});
diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx
index 3ca25fbb6..eae55c6af 100644
--- a/src/app/features/search/Search.tsx
+++ b/src/app/features/search/Search.tsx
@@ -50,6 +50,7 @@ import { KeySymbol } from '$utils/key-symbol';
import { isMacOS } from '$utils/user-agent';
import { useSelectedSpace } from '$hooks/router/useSelectedSpace';
import { getMxIdServer } from '$utils/mxIdHelper';
+import { sortRoomsBySelectedSpace } from './searchUtils';
enum SearchRoomType {
Rooms = '#',
@@ -166,14 +167,16 @@ export function Search({ requestClose }: SearchProps) {
const roomsToRender = useMemo(() => {
const items = result ? result.items : topActiveRooms;
- if (!selectedSpaceId) return items;
+ return sortRoomsBySelectedSpace(items, selectedSpaceId, roomToParents);
+ }, [result, topActiveRooms, selectedSpaceId, roomToParents]);
- return [...items].toSorted((a, b) => {
- const aInSpace = getAllParents(roomToParents, a)?.has(selectedSpaceId) ? 1 : 0;
- const bInSpace = getAllParents(roomToParents, b)?.has(selectedSpaceId) ? 1 : 0;
- return bInSpace - aInSpace;
+ const roomParentsCache = useMemo(() => {
+ const cache = new Map>();
+ roomsToRender.forEach((roomId) => {
+ cache.set(roomId, getAllParents(roomToParents, roomId));
});
- }, [result, topActiveRooms, selectedSpaceId, roomToParents]);
+ return cache;
+ }, [roomsToRender, roomToParents]);
const listFocus = useListFocusIndex(roomsToRender.length, 0);
@@ -315,7 +318,7 @@ export function Search({ requestClose }: SearchProps) {
const dmUsername = dmUserId && getMxIdLocalPart(dmUserId);
const dmUserServer = dmUserId && getMxIdServer(dmUserId);
- const allParents = getAllParents(roomToParents, roomId);
+ const allParents = roomParentsCache.get(roomId);
const orphanParents =
allParents && orphanSpaces.filter((o) => allParents.has(o));
const perfectOrphanParent =
diff --git a/src/app/features/search/searchUtils.ts b/src/app/features/search/searchUtils.ts
new file mode 100644
index 000000000..5e79e1fa4
--- /dev/null
+++ b/src/app/features/search/searchUtils.ts
@@ -0,0 +1,22 @@
+import type { RoomToParents } from '$types/matrix/room';
+import { hasRecursiveParent } from '$utils/room';
+
+export const sortRoomsBySelectedSpace = (
+ items: string[],
+ selectedSpaceId: string | undefined,
+ roomToParents: RoomToParents
+): string[] => {
+ if (!selectedSpaceId) return items;
+
+ const inSelectedSpaceCache = new Map();
+ const getInSelectedSpaceScore = (roomId: string): number => {
+ const cached = inSelectedSpaceCache.get(roomId);
+ if (cached !== undefined) return cached;
+
+ const score = hasRecursiveParent(roomToParents, roomId, selectedSpaceId) ? 1 : 0;
+ inSelectedSpaceCache.set(roomId, score);
+ return score;
+ };
+
+ return [...items].toSorted((a, b) => getInSelectedSpaceScore(b) - getInSelectedSpaceScore(a));
+};
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
index d48206f75..639849271 100644
--- a/src/app/hooks/useSpaceHierarchy.ts
+++ b/src/app/hooks/useSpaceHierarchy.ts
@@ -51,7 +51,7 @@ const childEventOrderThenTs: SortFunc = (a, b) =>
childEventByOrder(a, b) || childEventTs(a, b);
const spaceHierarchyProfiler = createLogger('space-hierarchy-profiler');
-const profileHierarchyBuild = (label: string, build: () => T): T => {
+const profileHierarchyBuild = (label: string, build: () => T): T => {
if (!isDebug()) return build();
const start = performance.now();
diff --git a/src/app/utils/room.parents.test.ts b/src/app/utils/room.parents.test.ts
index 57ea75100..c6c9cb95d 100644
--- a/src/app/utils/room.parents.test.ts
+++ b/src/app/utils/room.parents.test.ts
@@ -12,9 +12,9 @@ describe('hasRecursiveParent', () => {
expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-a:example.org')).toBe(
true
);
- expect(
- hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org')
- ).toBe(true);
+ expect(hasRecursiveParent(roomToParents, '!room:example.org', '!space-root:example.org')).toBe(
+ true
+ );
expect(hasRecursiveParent(roomToParents, '!room:example.org', '!unknown:example.org')).toBe(
false
);
@@ -45,8 +45,8 @@ describe('hasRecursiveParent', () => {
Array.from(allParents).forEach((parentId) => {
expect(hasRecursiveParent(roomToParents, '!room:example.org', parentId)).toBe(true);
});
- expect(hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org')).toBe(
- false
- );
+ expect(
+ hasRecursiveParent(roomToParents, '!room:example.org', '!not-a-parent:example.org')
+ ).toBe(false);
});
});
diff --git a/src/instrument.ts b/src/instrument.ts
index 118e7b244..320fd75ba 100644
--- a/src/instrument.ts
+++ b/src/instrument.ts
@@ -1,11 +1,11 @@
/* oxlint-disable no-console */
const dsn = import.meta.env.VITE_SENTRY_DSN;
-const sentryEnabled = localStorage.getItem("sable_sentry_enabled") === "true";
+const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true';
if (dsn && sentryEnabled) {
- void import("./instrument-runtime");
+ void import('./instrument-runtime');
} else if (!sentryEnabled) {
- console.info("[Sentry] Disabled by user preference");
+ console.info('[Sentry] Disabled by user preference');
} else {
- console.info("[Sentry] Disabled - no DSN provided");
+ console.info('[Sentry] Disabled - no DSN provided');
}
From 060d38d929467e4b7a99a323f0767963d3b748db Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 23:03:01 -0500
Subject: [PATCH 13/28] combine cache with other places
---
.../room-settings/abbreviations/RoomAbbreviations.tsx | 4 ++--
src/app/hooks/useRoomAbbreviations.ts | 7 ++-----
src/app/pages/client/space/RoomProvider.tsx | 4 ++--
3 files changed, 6 insertions(+), 9 deletions(-)
diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx
index 913e6dbaf..9bf3195ad 100644
--- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx
+++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx
@@ -29,7 +29,7 @@ import { useForceUpdate } from '$hooks/useForceUpdate';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import type { MatrixError } from '$types/matrix-sdk';
import type { AbbreviationEntry, RoomAbbreviationsContent } from '$utils/abbreviations';
-import { getAllParents, getStateEvent } from '$utils/room';
+import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { SequenceCardStyle } from '$features/common-settings/styles.css';
import { CustomStateEvent } from '$types/matrix/room';
@@ -61,7 +61,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps)
(event) => {
if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return;
const eventRoomId = event.getRoomId();
- if (eventRoomId && getAllParents(roomToParents, room.roomId).has(eventRoomId)) {
+ if (eventRoomId && hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) {
forceAncestorUpdate();
}
},
diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts
index b6bd30ba1..65e79dae5 100644
--- a/src/app/hooks/useRoomAbbreviations.ts
+++ b/src/app/hooks/useRoomAbbreviations.ts
@@ -4,7 +4,7 @@ import type { Room } from '$types/matrix-sdk';
import type { RoomAbbreviationsContent } from '$utils/abbreviations';
import { buildAbbreviationsMap } from '$utils/abbreviations';
-import { getAllParents, getStateEvent } from '$utils/room';
+import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { useMatrixClient } from './useMatrixClient';
import { useStateEvent } from './useStateEvent';
@@ -44,10 +44,7 @@ export const useMergedAbbreviations = (room: Room): Map => {
if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return;
const eventRoomId = event.getRoomId();
if (!eventRoomId) return;
- if (
- eventRoomId === room.roomId ||
- getAllParents(roomToParents, room.roomId).has(eventRoomId)
- ) {
+ if (eventRoomId === room.roomId || hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) {
forceUpdate();
}
},
diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx
index 8f672bd1f..bc9e897ed 100644
--- a/src/app/pages/client/space/RoomProvider.tsx
+++ b/src/app/pages/client/space/RoomProvider.tsx
@@ -6,7 +6,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { JoinBeforeNavigate } from '$features/join-before-navigate';
import { useSpace } from '$hooks/useSpace';
-import { getAllParents, getSpaceChildren } from '$utils/room';
+import { getSpaceChildren, hasRecursiveParent } from '$utils/room';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { allRoomsAtom } from '$state/room-list/roomList';
import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers';
@@ -49,7 +49,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
- if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
+ if (!hasRecursiveParent(roomToParents, room.roomId, space.roomId)) {
if (getSpaceChildren(space).includes(room.roomId)) {
// fill missing roomToParent mapping
setRoomToParents({
From 3a0df9aac0ab00acbd6966f6cc14c39893dc629a Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 23:10:02 -0500
Subject: [PATCH 14/28] random router memo and fallbacks
doubt these ones make much difference if any
---
src/app/pages/App.tsx | 8 ++++++--
src/app/pages/Router.tsx | 36 +++++++++++++++++++-----------------
2 files changed, 25 insertions(+), 19 deletions(-)
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index e4bf0d773..29ca548d3 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useRef } from 'react';
+import { lazy, Suspense, useMemo, useRef } from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { createStore } from 'jotai/vanilla';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
@@ -39,12 +39,16 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell
}
bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults);
const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled();
+ const router = useMemo(
+ () => createRouter(clientConfig, screenSize),
+ [clientConfig, screenSize]
+ );
return (
-
+
{reactQueryDevtoolsEnabled && (
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 1e9ff19ef..9d715c48f 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -76,6 +76,7 @@ import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { HomeCreateRoom } from './client/home/CreateRoom';
import { CallStatusRenderer } from './CallStatusRenderer';
+import { ConfigConfigLoading } from './ConfigConfig';
const SettingsRoute = lazy(async () => {
const mod = await import('$features/settings/SettingsRoute');
@@ -117,6 +118,7 @@ const ToRoomEvent = lazy(async () => {
const mod = await import('./client/ToRoomEvent');
return { default: mod.ToRoomEvent };
});
+const routeFallback = ;
/**
* Returns true if there is at least one stored session.
@@ -361,14 +363,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={}
/>
)}
-
-
-
- }
- />
+
+
+
+ }
+ />
} />
-
+
@@ -405,7 +407,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -413,7 +415,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -422,7 +424,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -430,7 +432,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -441,7 +443,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
-
+
@@ -461,7 +463,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -469,7 +471,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
@@ -478,7 +480,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
}
From 855079220ce3cb458092652bbd78378d1a563c8a Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 23:36:34 -0500
Subject: [PATCH 15/28] add prefetching on hover to some tabs
---
src/app/hooks/useRoomAbbreviations.ts | 5 +-
src/app/pages/App.tsx | 5 +-
src/app/pages/Router.tsx | 16 ++---
src/app/pages/client/SidebarNav.tsx | 7 +-
src/app/pages/client/sidebar/CreateTab.tsx | 6 ++
src/app/pages/client/sidebar/ExploreTab.tsx | 13 +++-
src/app/pages/client/sidebar/InboxTab.tsx | 13 +++-
src/app/pages/client/sidebar/SpaceTabs.tsx | 6 ++
src/app/pages/routePrefetch.test.ts | 48 +++++++++++++
src/app/pages/routePrefetch.ts | 76 +++++++++++++++++++++
10 files changed, 179 insertions(+), 16 deletions(-)
create mode 100644 src/app/pages/routePrefetch.test.ts
create mode 100644 src/app/pages/routePrefetch.ts
diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts
index 65e79dae5..0e0029a1d 100644
--- a/src/app/hooks/useRoomAbbreviations.ts
+++ b/src/app/hooks/useRoomAbbreviations.ts
@@ -44,7 +44,10 @@ export const useMergedAbbreviations = (room: Room): Map => {
if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return;
const eventRoomId = event.getRoomId();
if (!eventRoomId) return;
- if (eventRoomId === room.roomId || hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) {
+ if (
+ eventRoomId === room.roomId ||
+ hasRecursiveParent(roomToParents, room.roomId, eventRoomId)
+ ) {
forceUpdate();
}
},
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 29ca548d3..cbaf3523d 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -39,10 +39,7 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell
}
bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults);
const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled();
- const router = useMemo(
- () => createRouter(clientConfig, screenSize),
- [clientConfig, screenSize]
- );
+ const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]);
return (
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 9d715c48f..ef5629a63 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -363,14 +363,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={}
/>
)}
-
-
-
- }
- />
+
+
+
+ }
+ />
} />
(null);
@@ -28,6 +29,10 @@ export function SidebarNav() {
const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly');
const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts');
+ useEffect(() => {
+ scheduleInitialRoutePrefetch();
+ }, []);
+
const handleContextMenu: MouseEventHandler = (evt) => {
const target = evt.target as HTMLElement;
if (target.closest('button, a, [role="button"]')) return;
diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx
index 850db9456..cd8e6339d 100644
--- a/src/app/pages/client/sidebar/CreateTab.tsx
+++ b/src/app/pages/client/sidebar/CreateTab.tsx
@@ -17,6 +17,7 @@ import {
} from '$pages/pathUtils';
import { useCreateSelected } from '$hooks/router/useCreateSelected';
import { JoinAddressPrompt } from '$components/join-address-prompt';
+import { prefetchCreateRoute } from '../../routePrefetch';
export function CreateTab() {
const createSelected = useCreateSelected();
@@ -24,6 +25,9 @@ export function CreateTab() {
const navigate = useNavigate();
const [menuCords, setMenuCords] = useState();
const [joinAddress, setJoinAddress] = useState(false);
+ const handlePrefetch = () => {
+ void prefetchCreateRoute();
+ };
const handleMenu: MouseEventHandler = (evt) => {
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
@@ -108,6 +112,8 @@ export function CreateTab() {
ref={triggerRef}
outlined
onClick={handleMenu}
+ onMouseEnter={handlePrefetch}
+ onFocus={handlePrefetch}
>
diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx
index e255aeac9..3092b5a8a 100644
--- a/src/app/pages/client/sidebar/ExploreTab.tsx
+++ b/src/app/pages/client/sidebar/ExploreTab.tsx
@@ -14,6 +14,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { getMxIdServer } from '$utils/mxIdHelper';
+import { prefetchExploreRoute } from '../../routePrefetch';
export function ExploreTab() {
const mx = useMatrixClient();
@@ -23,6 +24,9 @@ export function ExploreTab() {
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const exploreSelected = useExploreSelected();
+ const handlePrefetch = () => {
+ void prefetchExploreRoute();
+ };
const handleExploreClick = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -53,7 +57,14 @@ export function ExploreTab() {
{(triggerRef) => (
-
+
)}
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
index c632335b0..208283a45 100644
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ b/src/app/pages/client/sidebar/InboxTab.tsx
@@ -17,6 +17,7 @@ import {
import { useInboxSelected } from '$hooks/router/useInbox';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
+import { prefetchInboxRoute } from '../../routePrefetch';
export function InboxTab() {
const screenSize = useScreenSizeContext();
@@ -25,6 +26,9 @@ export function InboxTab() {
const inboxSelected = useInboxSelected();
const allInvites = useAtomValue(allInvitesAtom);
const inviteCount = allInvites.length;
+ const handlePrefetch = () => {
+ void prefetchInboxRoute();
+ };
const handleInboxClick = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -45,7 +49,14 @@ export function InboxTab() {
{(triggerRef) => (
-
+
)}
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index 4ffd0b160..7cf53217b 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -82,6 +82,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { InviteUserPrompt } from '$components/invite-user-prompt';
import { CustomAccountDataEvent } from '$types/matrix/accountData';
+import { prefetchSpaceLobbyRoute } from '../../routePrefetch';
type SpaceMenuProps = {
room: Room;
@@ -419,6 +420,9 @@ function SpaceTab({
return cords;
});
};
+ const handlePrefetch = () => {
+ void prefetchSpaceLobbyRoute();
+ };
return (
@@ -440,6 +444,8 @@ function SpaceTab({
ref={triggerRef}
size={folder ? '300' : '400'}
onClick={onClick}
+ onMouseEnter={handlePrefetch}
+ onFocus={handlePrefetch}
onContextMenu={handleContextMenu}
>
{
+ it('deduplicates concurrent prefetches by key', async () => {
+ __resetRoutePrefetchForTests();
+ const importer = vi.fn<() => Promise<{ ok: boolean }>>(async () => ({ ok: true }));
+
+ await Promise.all([
+ prefetchRouteChunks('shared-key', [importer]),
+ prefetchRouteChunks('shared-key', [importer]),
+ prefetchRouteChunks('shared-key', [importer]),
+ ]);
+
+ expect(importer).toHaveBeenCalledTimes(1);
+ });
+
+ it('schedules only one initial idle prefetch run', () => {
+ __resetRoutePrefetchForTests();
+ const runPrefetch = vi.fn<() => void>();
+ const requestIdleCallback =
+ vi.fn<
+ (cb: (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void) => number
+ >();
+ const setTimeout = vi.fn<(cb: () => void, delay: number) => number>();
+ const win = { requestIdleCallback, setTimeout } as unknown as Window &
+ typeof globalThis & {
+ requestIdleCallback: (cb: () => void, options: { timeout: number }) => number;
+ };
+
+ scheduleInitialRoutePrefetch(runPrefetch, win);
+ scheduleInitialRoutePrefetch(runPrefetch, win);
+
+ expect(requestIdleCallback).toHaveBeenCalledTimes(1);
+ expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 1200 });
+ expect(setTimeout).not.toHaveBeenCalled();
+
+ const firstCall = requestIdleCallback.mock.calls[0];
+ if (!firstCall) throw new Error('Expected requestIdleCallback to have been called');
+ const callback = firstCall[0] as () => void;
+ callback();
+ expect(runPrefetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/pages/routePrefetch.ts b/src/app/pages/routePrefetch.ts
new file mode 100644
index 000000000..c1af887cb
--- /dev/null
+++ b/src/app/pages/routePrefetch.ts
@@ -0,0 +1,76 @@
+type Importer = () => Promise;
+
+const prefetchCache = new Map>();
+
+export const prefetchRouteChunks = (key: string, importers: Importer[]): Promise => {
+ const cached = prefetchCache.get(key);
+ if (cached) return cached;
+
+ const task = Promise.all(importers.map((importer) => importer()))
+ .then(() => undefined)
+ .catch(() => undefined);
+ prefetchCache.set(key, task);
+ return task;
+};
+
+export const prefetchExploreRoute = (): Promise =>
+ prefetchRouteChunks('explore', [
+ () => import('./client/explore/Explore'),
+ () => import('./client/explore/Featured'),
+ () => import('./client/explore/Server'),
+ ]);
+
+export const prefetchInboxRoute = (): Promise =>
+ prefetchRouteChunks('inbox', [
+ () => import('./client/inbox/Inbox'),
+ () => import('./client/inbox/Notifications'),
+ () => import('./client/inbox/Invites'),
+ ]);
+
+export const prefetchSettingsRoute = (): Promise =>
+ prefetchRouteChunks('settings', [() => import('$features/settings/SettingsRoute')]);
+
+export const prefetchCreateRoute = (): Promise =>
+ prefetchRouteChunks('create', [() => import('./client/create/Create')]);
+
+export const prefetchSpaceLobbyRoute = (): Promise =>
+ prefetchRouteChunks('space-lobby', [() => import('$features/lobby/Lobby')]);
+
+type IdleDeadline = { didTimeout: boolean; timeRemaining: () => number };
+type IdleRequestCallback = (deadline: IdleDeadline) => void;
+type IdleRequestWindow = Window &
+ typeof globalThis & {
+ requestIdleCallback?: (callback: IdleRequestCallback, options?: { timeout: number }) => number;
+ cancelIdleCallback?: (id: number) => void;
+ };
+
+let initialPrefetchScheduled = false;
+
+export const runInitialRoutePrefetch = (): void => {
+ void prefetchExploreRoute();
+ void prefetchInboxRoute();
+ void prefetchSpaceLobbyRoute();
+ void prefetchSettingsRoute();
+ void prefetchCreateRoute();
+};
+
+export const scheduleInitialRoutePrefetch = (
+ runPrefetch: () => void = runInitialRoutePrefetch,
+ winOverride?: IdleRequestWindow
+): void => {
+ if (initialPrefetchScheduled) return;
+ initialPrefetchScheduled = true;
+
+ const win = winOverride ?? (window as IdleRequestWindow);
+ if (typeof win.requestIdleCallback === 'function') {
+ win.requestIdleCallback(() => runPrefetch(), { timeout: 1200 });
+ return;
+ }
+
+ win.setTimeout(runPrefetch, 0);
+};
+
+export const __resetRoutePrefetchForTests = (): void => {
+ prefetchCache.clear();
+ initialPrefetchScheduled = false;
+};
From 27efca1411450b1296245f4cb6ecd85ae2f6d76e Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sat, 9 May 2026 23:56:09 -0500
Subject: [PATCH 16/28] more prefetching
---
src/app/features/lobby/HierarchyItemMenu.tsx | 26 ++++++++++++++++++-
src/app/features/lobby/LobbyHeader.tsx | 11 ++++++++
src/app/features/room-nav/RoomNavItem.tsx | 11 ++++++++
src/app/features/room-nav/RoomNavUser.tsx | 11 +++++++-
src/app/features/room/MembersDrawer.tsx | 7 +++++
src/app/features/room/RoomViewHeader.tsx | 11 ++++++++
.../features/search/SearchModalRenderer.tsx | 2 ++
src/app/pages/client/sidebar/SearchTab.tsx | 18 +++++++++++--
src/app/pages/client/sidebar/SpaceTabs.tsx | 7 ++++-
src/app/pages/client/space/Space.tsx | 17 +++++++++++-
src/app/pages/routePrefetch.ts | 15 +++++++++++
src/app/state/hooks/roomSettings.ts | 2 ++
src/app/state/hooks/spaceSettings.ts | 2 ++
src/app/state/hooks/userRoomProfile.ts | 2 ++
14 files changed, 136 insertions(+), 6 deletions(-)
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index 165343c82..20d17a0ad 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -37,6 +37,7 @@ import { getCanonicalAliasOrRoomId } from '$utils/matrix';
import { useNavigate } from 'react-router-dom';
import { getSpaceLobbyPath } from '$pages/pathUtils';
import { EventType } from '$types/matrix-sdk';
+import { prefetchRoomSettingsModal, prefetchSpaceSettingsModal } from '$pages/routePrefetch';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
@@ -195,9 +196,23 @@ function SettingsMenuItem({
}
requestClose();
};
+ const handleSettingsPrefetch = () => {
+ if ('space' in item) {
+ void prefetchSpaceSettingsModal();
+ return;
+ }
+ void prefetchRoomSettingsModal();
+ };
return (
-
}
radii="300"
@@ -320,6 +326,9 @@ export function RoomNavItem({
const handleOpenMenu: MouseEventHandler = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
+ const handleSettingsPrefetch = () => {
+ void prefetchRoomSettingsModal();
+ };
const handleNavItemClick: MouseEventHandler = (evt) => {
if (room.isCallRoom()) {
@@ -547,6 +556,8 @@ export function RoomNavItem({
>
= (evt) => {
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
};
+ const handlePrefetch = () => {
+ void prefetchUserProfileModal();
+ };
const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name;
return (
-
+
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index f751bcf31..813e79990 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -55,6 +55,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '$hooks/useMembe
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { formatCompactNumber } from '$utils/formatCompactNumber';
+import { prefetchUserProfileModal } from '$pages/routePrefetch';
import * as css from './MembersDrawer.css';
type MemberDrawerHeaderProps = {
@@ -116,6 +117,10 @@ function MemberItem({
pressed,
typing,
}: MemberItemProps) {
+ const handlePrefetch = () => {
+ void prefetchUserProfileModal();
+ };
+
const nicknames = useAtomValue(nicknamesAtom);
const name =
getMemberDisplayName(room, member.userId, nicknames) ??
@@ -139,6 +144,8 @@ function MemberItem({
variant="Background"
radii="400"
onClick={onClick}
+ onMouseEnter={handlePrefetch}
+ onFocus={handlePrefetch}
before={
(({ room, requestClose
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
+ const handleSettingsPrefetch = () => {
+ void prefetchRoomSettingsModal();
+ };
return (
@@ -274,6 +278,8 @@ const RoomMenu = forwardRef(({ room, requestClose
}
radii="300"
@@ -563,6 +569,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {
const handleOpenMenu: MouseEventHandler = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
+ const handleRoomSettingsPrefetch = () => {
+ void prefetchRoomSettingsModal();
+ };
const handleOpenPinMenu: MouseEventHandler = (evt) => {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
@@ -898,6 +907,8 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {
diff --git a/src/app/features/search/SearchModalRenderer.tsx b/src/app/features/search/SearchModalRenderer.tsx
index e68a3bac4..6687fe7b0 100644
--- a/src/app/features/search/SearchModalRenderer.tsx
+++ b/src/app/features/search/SearchModalRenderer.tsx
@@ -3,6 +3,7 @@ import { isKeyHotkey } from 'is-hotkey';
import { useAtom } from 'jotai';
import { useKeyDown } from '$hooks/useKeyDown';
import { searchModalAtom } from '$state/searchModal';
+import { prefetchSearchModal } from '$pages/routePrefetch';
const Search = lazy(async () => {
const mod = await import('./Search');
@@ -18,6 +19,7 @@ export function SearchModalRenderer() {
(event) => {
if (isKeyHotkey('mod+k', event) || isKeyHotkey('mod+f', event)) {
event.preventDefault();
+ void prefetchSearchModal();
if (opened) {
setOpen(false);
return;
diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx
index c3ef38c59..05fb8bfd7 100644
--- a/src/app/pages/client/sidebar/SearchTab.tsx
+++ b/src/app/pages/client/sidebar/SearchTab.tsx
@@ -2,17 +2,31 @@ import { Icon, Icons } from 'folds';
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar';
import { searchModalAtom } from '$state/searchModal';
+import { prefetchSearchModal } from '$pages/routePrefetch';
export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom);
- const open = () => setOpen(true);
+ const open = () => {
+ void prefetchSearchModal();
+ setOpen(true);
+ };
+ const handlePrefetch = () => {
+ void prefetchSearchModal();
+ };
return (
{(triggerRef) => (
-
+
)}
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index 7cf53217b..423ada3f5 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -82,7 +82,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { InviteUserPrompt } from '$components/invite-user-prompt';
import { CustomAccountDataEvent } from '$types/matrix/accountData';
-import { prefetchSpaceLobbyRoute } from '../../routePrefetch';
+import { prefetchSpaceLobbyRoute, prefetchSpaceSettingsModal } from '../../routePrefetch';
type SpaceMenuProps = {
room: Room;
@@ -135,6 +135,9 @@ const SpaceMenu = forwardRef(
openSpaceSettings(room.roomId);
requestClose();
};
+ const handleSettingsPrefetch = () => {
+ void prefetchSpaceSettingsModal();
+ };
return (
@@ -200,6 +203,8 @@ const SpaceMenu = forwardRef(
}
radii="300"
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index 3914073e3..2062cad6c 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -80,6 +80,7 @@ import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper';
import { useCallEmbed } from '$hooks/useCallEmbed';
import { createDebugLogger } from '$utils/debugLogger';
+import { prefetchSpaceSettingsModal } from '$pages/routePrefetch';
const debugLog = createDebugLogger('Space');
@@ -130,6 +131,9 @@ const SpaceMenu = forwardRef(({ room, requestClo
openSpaceSettings(room.roomId);
requestClose();
};
+ const handleSettingsPrefetch = () => {
+ void prefetchSpaceSettingsModal();
+ };
const handleOpenTimeline = () => {
debugLog.info('ui', 'Space timeline opened', { roomId: room.roomId });
@@ -189,6 +193,8 @@ const SpaceMenu = forwardRef(({ room, requestClo
}
radii="300"
@@ -260,6 +266,9 @@ function SpaceHeader() {
return cords;
});
};
+ const handleSettingsPrefetch = () => {
+ void prefetchSpaceSettingsModal();
+ };
return (
<>
@@ -272,7 +281,13 @@ function SpaceHeader() {
{joinRules?.join_rule !== JoinRule.Public && }
-
+
diff --git a/src/app/pages/routePrefetch.ts b/src/app/pages/routePrefetch.ts
index c1af887cb..d01cca395 100644
--- a/src/app/pages/routePrefetch.ts
+++ b/src/app/pages/routePrefetch.ts
@@ -36,6 +36,20 @@ export const prefetchCreateRoute = (): Promise =>
export const prefetchSpaceLobbyRoute = (): Promise =>
prefetchRouteChunks('space-lobby', [() => import('$features/lobby/Lobby')]);
+export const prefetchSearchModal = (): Promise =>
+ prefetchRouteChunks('search-modal', [() => import('$features/search/Search')]);
+
+export const prefetchUserProfileModal = (): Promise =>
+ prefetchRouteChunks('user-profile-modal', [() => import('$components/user-profile')]);
+
+export const prefetchRoomSettingsModal = (): Promise =>
+ prefetchRouteChunks('room-settings-modal', [() => import('$features/room-settings/RoomSettings')]);
+
+export const prefetchSpaceSettingsModal = (): Promise =>
+ prefetchRouteChunks('space-settings-modal', [
+ () => import('$features/space-settings/SpaceSettings'),
+ ]);
+
type IdleDeadline = { didTimeout: boolean; timeRemaining: () => number };
type IdleRequestCallback = (deadline: IdleDeadline) => void;
type IdleRequestWindow = Window &
@@ -52,6 +66,7 @@ export const runInitialRoutePrefetch = (): void => {
void prefetchSpaceLobbyRoute();
void prefetchSettingsRoute();
void prefetchCreateRoute();
+ void prefetchSearchModal();
};
export const scheduleInitialRoutePrefetch = (
diff --git a/src/app/state/hooks/roomSettings.ts b/src/app/state/hooks/roomSettings.ts
index 82204e9d7..b65dc3d61 100644
--- a/src/app/state/hooks/roomSettings.ts
+++ b/src/app/state/hooks/roomSettings.ts
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import type { RoomSettingsPage, RoomSettingsState } from '$state/roomSettings';
import { roomSettingsAtom } from '$state/roomSettings';
+import { prefetchRoomSettingsModal } from '$pages/routePrefetch';
export const useRoomSettingsState = (): RoomSettingsState | undefined => {
const data = useAtomValue(roomSettingsAtom);
@@ -26,6 +27,7 @@ export const useOpenRoomSettings = (): OpenCallback => {
const open: OpenCallback = useCallback(
(roomId, spaceId, page) => {
+ void prefetchRoomSettingsModal();
setSettings({ roomId, spaceId, page });
},
[setSettings]
diff --git a/src/app/state/hooks/spaceSettings.ts b/src/app/state/hooks/spaceSettings.ts
index aa153bdd1..92ac38775 100644
--- a/src/app/state/hooks/spaceSettings.ts
+++ b/src/app/state/hooks/spaceSettings.ts
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import type { SpaceSettingsPage, SpaceSettingsState } from '$state/spaceSettings';
import { spaceSettingsAtom } from '$state/spaceSettings';
+import { prefetchSpaceSettingsModal } from '$pages/routePrefetch';
export const useSpaceSettingsState = (): SpaceSettingsState | undefined => {
const data = useAtomValue(spaceSettingsAtom);
@@ -26,6 +27,7 @@ export const useOpenSpaceSettings = (): OpenCallback => {
const open: OpenCallback = useCallback(
(roomId, spaceId, page) => {
+ void prefetchSpaceSettingsModal();
setSettings({ roomId, spaceId, page });
},
[setSettings]
diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts
index 092823877..8e08f6e1c 100644
--- a/src/app/state/hooks/userRoomProfile.ts
+++ b/src/app/state/hooks/userRoomProfile.ts
@@ -4,6 +4,7 @@ import type { Position, RectCords } from 'folds';
import type { UserProfile } from '$hooks/useUserProfile';
import type { UserRoomProfileState } from '$state/userRoomProfile';
import { userRoomProfileAtom } from '$state/userRoomProfile';
+import { prefetchUserProfileModal } from '$pages/routePrefetch';
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
const data = useAtomValue(userRoomProfileAtom);
@@ -35,6 +36,7 @@ export const useOpenUserRoomProfile = (): OpenCallback => {
const open: OpenCallback = useCallback(
(roomId, spaceId, userId, cords, position, initialProfile) => {
+ void prefetchUserProfileModal();
setUserRoomProfile({
roomId,
spaceId,
From 60183a9bd35260745263b3213e3d171cdd19eba8 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 00:10:18 -0500
Subject: [PATCH 17/28] more lazy loads
---
src/app/components/DefaultErrorPage.tsx | 5 +++--
src/app/pages/Router.tsx | 14 ++++++++++++--
src/app/pages/client/home/CreateRoom.tsx | 2 +-
3 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx
index 62042cef1..045b1ea50 100644
--- a/src/app/components/DefaultErrorPage.tsx
+++ b/src/app/components/DefaultErrorPage.tsx
@@ -1,7 +1,6 @@
import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds';
import * as Sentry from '@sentry/react';
import { SplashScreen } from '$components/splash-screen';
-import { buildGitHubUrl } from '$features/bug-report/BugReportModal';
type ErrorPageProps = {
error: Error;
@@ -25,7 +24,9 @@ ${error.message}
${stacktrace}
\`\`\``;
- return buildGitHubUrl('bug', `Error: ${error.message}`, { context: automatedBugReport });
+ const title = encodeURIComponent(`Error: ${error.message}`);
+ const body = encodeURIComponent(automatedBugReport);
+ return `https://github.com/SableClient/Sable/issues/new?template=bug_report.yml&title=${title}&description=${body}`;
}
// This component is used as the fallback for the ErrorBoundary in App.tsx, which means it will be rendered whenever an uncaught error is thrown in any of the child components and not handled locally.
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index ef5629a63..82bff3949 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -74,7 +74,6 @@ import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
-import { HomeCreateRoom } from './client/home/CreateRoom';
import { CallStatusRenderer } from './CallStatusRenderer';
import { ConfigConfigLoading } from './ConfigConfig';
@@ -118,6 +117,10 @@ const ToRoomEvent = lazy(async () => {
const mod = await import('./client/ToRoomEvent');
return { default: mod.ToRoomEvent };
});
+const HomeCreateRoom = lazy(async () => {
+ const mod = await import('./client/home/CreateRoom');
+ return { default: mod.HomeCreateRoom };
+});
const routeFallback = ;
/**
@@ -294,7 +297,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
{mobile ? null : } />}
- } />
+
+
+
+ }
+ />
join} />
} />
Date: Sun, 10 May 2026 00:27:04 -0500
Subject: [PATCH 18/28] defer some essential features?
---
src/app/pages/client/ClientNonUIFeatures.tsx | 685 ++----------------
.../client/DeferredNotificationFeatures.tsx | 430 +++++++++++
.../scheduleDeferredFeatureMount.test.ts | 62 ++
.../client/scheduleDeferredFeatureMount.ts | 28 +
src/app/pages/routePrefetch.ts | 4 +-
5 files changed, 567 insertions(+), 642 deletions(-)
create mode 100644 src/app/pages/client/DeferredNotificationFeatures.tsx
create mode 100644 src/app/pages/client/scheduleDeferredFeatureMount.test.ts
create mode 100644 src/app/pages/client/scheduleDeferredFeatureMount.ts
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index b089cf8f8..82a090ae6 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,104 +1,49 @@
import { useAtomValue, useSetAtom } from 'jotai';
import * as Sentry from '@sentry/react';
-import type { ReactNode } from 'react';
-import { useCallback, useEffect, useRef } from 'react';
+import { lazy, Suspense, type ReactNode, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import type { RoomEventHandlerMap } from '$types/matrix-sdk';
-import {
- MatrixEvent,
- MatrixEventEvent,
- PushProcessor,
- RoomEvent,
- SetPresence,
- SyncState,
- EventType,
-} from '$types/matrix-sdk';
-import parse from 'html-react-parser';
-import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser';
-import { sanitizeCustomHtml } from '$utils/sanitize';
+import { SetPresence } from '$types/matrix-sdk';
import { roomToUnreadAtom } from '$state/room/roomToUnread';
import LogoSVG from '$public/res/svg/cinny-logo.svg';
import LogoUnreadSVG from '$public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '$public/res/svg/cinny-highlight.svg';
-import NotificationSound from '$public/sound/notification.ogg';
-import InviteSound from '$public/sound/invite.ogg';
-import { notificationPermission, setFavicon } from '$utils/dom';
+import { setFavicon } from '$utils/dom';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { nicknamesAtom } from '$state/nicknames';
import { mDirectAtom } from '$state/mDirectList';
-import { allInvitesAtom } from '$state/room-list/inviteList';
-import { usePreviousValue } from '$hooks/usePreviousValue';
import { useMatrixClient } from '$hooks/useMatrixClient';
-import {
- getMemberDisplayName,
- getNotificationType,
- getStateEvent,
- isDMRoom,
- isNotificationEvent,
-} from '$utils/room';
-import { NotificationType } from '$types/matrix/room';
-import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
-import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
-import { useInboxNotificationsSelected } from '$hooks/router/useInbox';
-import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
-import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl';
import { registrationAtom } from '$state/serviceWorkerRegistration';
-import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions';
-import {
- buildRoomMessageNotification,
- resolveNotificationPreviewText,
-} from '$utils/notificationStyle';
-import { mobileOrTablet } from '$utils/user-agent';
-import { createDebugLogger } from '$utils/debugLogger';
+import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions';
import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
import { getSlidingSyncManager } from '$client/initMatrix';
-import { NotificationBanner } from '$components/notification-banner';
-import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner';
-import { TelemetryConsentBanner } from '$components/telemetry-consent';
import { useCallSignaling } from '$hooks/useCallSignaling';
-import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
import { getInboxInvitesPath } from '../pathUtils';
-import { BackgroundNotifications } from './BackgroundNotifications';
-
-const pushRelayLog = createDebugLogger('push-relay');
-
-function clearMediaSessionQuickly(): void {
- if (!('mediaSession' in navigator)) return;
- // iOS registers the lock screen media player as a side-effect of
- // HTMLAudioElement.play(). We delay slightly so iOS has finished updating
- // the media session before we clear it — clearing too early is a no-op.
- // We only clear if no real in-app media (video/audio in a room) has since
- // registered meaningful metadata; if it has, leave it alone.
- setTimeout(() => {
- if (navigator.mediaSession.metadata !== null) return;
- navigator.mediaSession.playbackState = 'none';
- }, 500);
-}
+import { scheduleDeferredFeatureMount } from './scheduleDeferredFeatureMount';
+
+const DeferredNotificationFeatures = lazy(async () => {
+ const mod = await import('./DeferredNotificationFeatures');
+ return { default: mod.DeferredNotificationFeatures };
+});
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
-
if (twitterEmoji) {
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
} else {
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
}
-
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
-
if (pageZoom === 100) {
document.documentElement.style.removeProperty('font-size');
} else {
document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`);
}
-
return null;
}
@@ -118,490 +63,38 @@ function FaviconUpdater() {
total += unread.total;
highlightTotal += unread.highlight;
}
- if (unread.total > 0) {
- notification = true;
- }
- if (unread.highlight > 0) {
- highlight = true;
- }
+ if (unread.total > 0) notification = true;
+ if (unread.highlight > 0) highlight = true;
});
- if (highlight) {
- setFavicon(LogoHighlightSVG);
- } else if (!faviconForMentionsOnly && notification) {
- setFavicon(LogoUnreadSVG);
- } else {
- setFavicon(LogoSVG);
- }
+ if (highlight) setFavicon(LogoHighlightSVG);
+ else if (!faviconForMentionsOnly && notification) setFavicon(LogoUnreadSVG);
+ else setFavicon(LogoSVG);
+
try {
- // Only badge with highlight (mention) counts — total unread is too noisy
- // for an OS-level app badge.
- if (highlightTotal > 0) {
- navigator.setAppBadge(highlightTotal);
- } else {
- navigator.clearAppBadge();
- }
+ if (highlightTotal > 0) navigator.setAppBadge(highlightTotal);
+ else navigator.clearAppBadge();
+
if (usePushNotifications && registration) {
if (total === 0) {
- // All rooms read — clear every notification.
registration.getNotifications().then((notifs) => notifs.forEach((n) => n.close()));
} else {
- // Dismiss notifications for individual rooms that are now fully read.
registration.getNotifications().then((notifs) => {
notifs.forEach((n) => {
const notifRoomId = n.data?.room_id;
if (!notifRoomId) return;
const roomUnread = roomToUnread.get(notifRoomId);
- if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) {
- n.close();
- }
+ if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) n.close();
});
});
}
}
- } catch {
- // Likely Firefox/Gecko-based and doesn't support badging API
- }
+ } catch {}
}, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]);
return null;
}
-function InviteNotifications() {
- const audioRef = useRef(null);
- const invites = useAtomValue(allInvitesAtom);
- const perviousInviteLen = usePreviousValue(invites.length, 0);
- const mx = useMatrixClient();
-
- const navigate = useNavigate();
- const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications');
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
-
- const notify = useCallback(
- (count: number) => {
- const noti = new window.Notification('Invitation', {
- icon: LogoSVG,
- badge: LogoSVG,
- body: `You have ${count} new invitation request.`,
- silent: true,
- });
-
- noti.addEventListener('click', () => {
- if (!window.closed) navigate(getInboxInvitesPath());
- noti.close();
- });
- },
- [navigate]
- );
-
- const playSound = useCallback(() => {
- const audioElement = audioRef.current;
- audioElement?.play();
- clearMediaSessionQuickly();
- }, []);
-
- useEffect(() => {
- if (invites.length <= perviousInviteLen || mx.getSyncState() !== SyncState.Syncing) return;
-
- // SW push (via Sygnal) handles invite notifications when the app is backgrounded.
- if (document.visibilityState !== 'visible' && usePushNotifications) return;
-
- // OS notification for invites — desktop only.
- if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
- try {
- notify(invites.length - perviousInviteLen);
- } catch {
- // window.Notification may be unavailable in sandboxed environments.
- }
- }
- // Audio API requires a visible document; skip when hidden.
- if (document.visibilityState === 'visible' && notificationSound) {
- playSound();
- }
- }, [
- mx,
- invites,
- perviousInviteLen,
- showSystemNotifications,
- usePushNotifications,
- notificationSound,
- notify,
- playSound,
- ]);
-
- return (
- // oxlint-disable-next-line jsx-a11y/media-has-caption
-
- );
-}
-
-function MessageNotifications() {
- const audioRef = useRef(null);
- const notifiedEventsRef = useRef(new Set());
- // Record mount time so we can distinguish live events from historical backfill
- // on sliding sync proxies that don't set num_live (which causes liveEvent=false
- // for all events, including actually-new messages).
- const clientStartTimeRef = useRef(Date.now());
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const appBaseUrl = useSettingsLinkBaseUrl();
- const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications');
- const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications');
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
- const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
- const [showEncryptedMessageContent] = useSetting(
- settingsAtom,
- 'showMessageContentInEncryptedNotifications'
- );
- const nicknames = useAtomValue(nicknamesAtom);
- const nicknamesRef = useRef(nicknames);
- nicknamesRef.current = nicknames;
- const mDirects = useAtomValue(mDirectAtom);
- const mDirectsRef = useRef(mDirects);
- mDirectsRef.current = mDirects;
-
- const setPending = useSetAtom(pendingNotificationAtom);
- const setInAppBanner = useSetAtom(inAppBannerAtom);
- const selectedRoomId = useSelectedRoom();
- const notificationSelected = useInboxNotificationsSelected();
-
- const playSound = useCallback(() => {
- const audioElement = audioRef.current;
- audioElement?.play();
- clearMediaSessionQuickly();
- }, []);
-
- useEffect(() => {
- const pushProcessor = new PushProcessor(mx);
- // Track encrypted events that should skip focus check when decrypted (because we
- // already checked focus when the encrypted event arrived, and want to use that
- // original state rather than re-checking after decryption completes).
- const skipFocusCheckEvents = new Set();
- // Tracks when each event first arrived so we can measure notification delivery latency
- const notifyTimerMap = new Map();
-
- const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
- mEvent,
- room,
- _toStartOfTimeline,
- _removed,
- data
- ) => {
- if (mx.getSyncState() !== SyncState.Syncing) return;
-
- const eventId = mEvent.getId();
- // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it)
- if (eventId && !notifyTimerMap.has(eventId)) {
- notifyTimerMap.set(eventId, performance.now());
- }
- const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId);
- if (!shouldSkipFocusCheck) {
- if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected))
- return;
- }
-
- // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live,
- // which causes every event to arrive with fromCache=true and therefore
- // liveEvent=false — silently blocking all notifications. Fall back to an
- // age check: treat the event as potentially live only when it was sent
- // within 60 s of this component mounting (tight enough to avoid phantom
- // notifications for pre-existing unread messages, generous enough for
- // messages that arrived during a brief offline window).
- // Additionally, skip the event if the user already has a read receipt
- // covering it (message was read on another device before this session).
- const isHistoricalEvent =
- !data.liveEvent &&
- (mEvent.getTs() < clientStartTimeRef.current - 60 * 1000 ||
- (!!room && room.hasUserReadEvent(mx.getSafeUserId(), mEvent.getId()!)));
-
- // For encrypted events that haven't been decrypted yet, wait for decryption
- // before processing the notification. The SDK's Timeline re-emission after
- // decryption comes with data.liveEvent=false which would wrongly block it.
- if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) {
- if (eventId) {
- // Mark this event to skip focus check when decrypted, so we use the focus
- // state from when the encrypted event originally arrived, not when it decrypts.
- skipFocusCheckEvents.add(eventId);
- }
-
- const handleDecrypted = () => {
- // After decryption, run the notification logic with the decrypted event
- handleTimelineEvent(mEvent, room, undefined, true, data);
- // Clean up the skip-focus marker
- if (eventId) {
- skipFocusCheckEvents.delete(eventId);
- }
- };
- mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted);
- return;
- }
-
- if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) {
- return;
- }
-
- const notificationType = getNotificationType(mx, room.roomId);
- if (notificationType === NotificationType.Mute) {
- return;
- }
-
- const sender = mEvent.getSender();
- if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
-
- // Deduplicate: don't show a second banner if this event fires twice
- // (e.g., decrypted events re-emitted by the SDK).
- if (notifiedEventsRef.current.has(eventId)) return;
-
- // Check if this is a DM using multiple signals for robustness
- const isDM = isDMRoom(room, mDirectsRef.current);
-
- // Measure total notification delivery latency (includes decryption wait for E2EE events)
- const arrivalMs = notifyTimerMap.get(eventId);
- if (arrivalMs !== undefined) {
- Sentry.metrics.distribution(
- 'sable.notification.delivery_ms',
- performance.now() - arrivalMs,
- {
- attributes: {
- encrypted: String(mEvent.isEncrypted()),
- dm: String(isDM),
- },
- }
- );
- notifyTimerMap.delete(eventId);
- }
- const pushActions = pushProcessor.actionsForEvent(mEvent);
-
- // For DMs with "All Messages" or "Default" notification settings:
- // Always notify even if push rules fail to match due to sliding sync limitations.
- // For "Mention & Keywords": respect the push rule (only notify if it matches).
- const shouldForceDMNotification =
- isDM && notificationType !== NotificationType.MentionsAndKeywords;
- const shouldNotify = pushActions?.notify || shouldForceDMNotification;
-
- // If we shouldn't notify based on rules/settings, skip everything
- if (!shouldNotify) return;
-
- const loudByRule = Boolean(pushActions.tweaks?.sound);
- const isHighlightByRule = Boolean(pushActions.tweaks?.highlight);
-
- // With sliding sync we only load m.room.member/$ME in required_state, so
- // PushProcessor cannot evaluate the room_member_count == 2 condition on
- // .m.rule.room_one_to_one. That rule therefore fails to match, and DM
- // messages fall through to .m.rule.message which carries no sound tweak —
- // leaving loudByRule=false. Treat known DMs as inherently loud so that
- // the OS notification and badge are consistent with the DM context.
- const isLoud = loudByRule || isDM;
-
- // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
- notifiedEventsRef.current.add(eventId);
- if (notifiedEventsRef.current.size > 200) {
- const first = notifiedEventsRef.current.values().next().value;
- if (first) notifiedEventsRef.current.delete(first);
- }
-
- // On desktop: fire an OS notification whenever system notifications are
- // enabled and permission is granted — regardless of whether the window is
- // focused. When the window is also visible the in-app banner fires too,
- // mirroring the behaviour of apps like Discord.
- // The whole block is wrapped in try/catch: window.Notification() can throw
- // in sandboxed environments, browsers with DnD active, or Electron — and
- // an uncaught exception here would abort the handler before setInAppBanner
- // is reached, causing in-app notifications to silently vanish too.
- if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
- try {
- const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption);
- const avatarMxc =
- room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
- const osPayload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
- roomAvatar: avatarMxc
- ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined,
- username:
- getMemberDisplayName(room, sender, nicknamesRef.current) ??
- getMxIdLocalPart(sender) ??
- sender,
- previewText: resolveNotificationPreviewText({
- content: mEvent.getContent(),
- eventType: mEvent.getType(),
- isEncryptedRoom,
- showMessageContent,
- showEncryptedMessageContent,
- }),
- silent: !notificationSound || !isLoud,
- eventId,
- });
- const noti = new window.Notification(osPayload.title, osPayload.options);
- const { roomId } = room;
- noti.addEventListener('click', () => {
- window.focus();
- setPending({
- roomId,
- eventId,
- targetSessionId: mx.getUserId() ?? undefined,
- });
- noti.close();
- });
- } catch {
- // window.Notification unavailable or blocked (sandboxed context, DnD, etc.)
- }
- }
-
- // Everything below requires the page to be visible (in-app UI + audio).
- if (document.visibilityState !== 'visible') return;
-
- // Page is visible — show the themed in-app notification banner.
- // For non-DM rooms, only show banner for highlighted messages (mentions/keywords).
- // For DMs, show banner for all messages.
- if (showNotifications && (isHighlightByRule || isDM)) {
- const avatarMxc =
- room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
- const roomAvatar = avatarMxc
- ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined;
- const resolvedSenderName =
- getMemberDisplayName(room, sender, nicknamesRef.current) ??
- getMxIdLocalPart(sender) ??
- sender;
- const content = mEvent.getContent();
- // Events reaching here are already decrypted (m.room.encrypted is skipped
- // above). Pass isEncryptedRoom:false so the preview always shows the actual
- // message body when showMessageContent is enabled.
- const previewText = resolveNotificationPreviewText({
- content: mEvent.getContent(),
- eventType: mEvent.getType(),
- isEncryptedRoom: false,
- showMessageContent,
- showEncryptedMessageContent,
- });
-
- // Build a rich ReactNode body using the same HTML parser as the room
- // timeline — mxc images, mention pills, linkify, spoilers, code blocks.
- let bodyNode: ReactNode;
- if (
- showMessageContent &&
- content.format === 'org.matrix.custom.html' &&
- content.formatted_body
- ) {
- const htmlParserOpts = getReactCustomHtmlParser(mx, room.roomId, {
- settingsLinkBaseUrl: appBaseUrl,
- linkifyOpts: LINKIFY_OPTS,
- useAuthentication,
- nicknames: nicknamesRef.current,
- });
- bodyNode = parse(sanitizeCustomHtml(content.formatted_body), htmlParserOpts) as ReactNode;
- }
-
- const payload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
- roomAvatar,
- username: resolvedSenderName,
- previewText,
- silent: !notificationSound || !isLoud,
- eventId,
- });
- const { roomId } = room;
- const capturedEventId = eventId;
- const capturedUserId = mx.getUserId() ?? undefined;
- const canonicalAlias = room.getCanonicalAlias();
- const serverName = canonicalAlias?.split(':')[1] ?? room.roomId.split(':')[1] ?? undefined;
- setInAppBanner({
- id: eventId,
- title: payload.title,
- roomName: room.name ?? undefined,
- serverName,
- senderName: resolvedSenderName,
- body: previewText,
- bodyNode,
- icon: roomAvatar,
- onClick: () => {
- window.focus();
- setPending({
- roomId,
- eventId: capturedEventId,
- targetSessionId: capturedUserId,
- });
- },
- });
- }
-
- // In-app audio: play when notification sounds are enabled AND this notification is loud.
- if (notificationSound && isLoud) {
- playSound();
- }
- };
- mx.on(RoomEvent.Timeline, handleTimelineEvent);
- return () => {
- mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
- };
- }, [
- mx,
- notificationSound,
- notificationSelected,
- showNotifications,
- showSystemNotifications,
- showMessageContent,
- showEncryptedMessageContent,
- usePushNotifications,
- playSound,
- setInAppBanner,
- setPending,
- selectedRoomId,
- appBaseUrl,
- useAuthentication,
- ]);
-
- return (
- // oxlint-disable-next-line jsx-a11y/media-has-caption
-
- );
-}
-
-function PrivacyBlurFeature() {
- const [blurMedia] = useSetting(settingsAtom, 'privacyBlur');
- const [blurAvatars] = useSetting(settingsAtom, 'privacyBlurAvatars');
- const [blurEmotes] = useSetting(settingsAtom, 'privacyBlurEmotes');
-
- useEffect(() => {
- document.body.classList.toggle('sable-blur-media', blurMedia);
- document.body.classList.toggle('sable-blur-avatars', blurAvatars);
- document.body.classList.toggle('sable-blur-emotes', blurEmotes);
- }, [blurMedia, blurAvatars, blurEmotes]);
-
- return null;
-}
-
-// Periodically emits memory-health gauges so Sentry dashboards can surface
-// unbounded growth (e.g. blob cache never evicted, stale inflight requests).
-function HealthMonitor() {
- useEffect(() => {
- const id = window.setInterval(() => {
- const { cacheSize, inflightCount } = getBlobCacheStats();
- Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize);
- if (inflightCount > 0) {
- Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount);
- if (inflightCount >= 10) {
- Sentry.addBreadcrumb({
- category: 'media',
- message: `High inflight request count: ${inflightCount}`,
- level: 'warning',
- data: { inflight_count: inflightCount },
- });
- }
- }
- }, 60_000);
- return () => window.clearInterval(id);
- }, []);
- return null;
-}
-
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -613,25 +106,20 @@ export function HandleNotificationClick() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return undefined;
-
const handleMessage = (ev: MessageEvent) => {
const { data } = ev;
if (!data || data.type !== 'notificationClick') return;
-
const { userId, roomId, eventId, isInvite } = data as {
userId?: string;
roomId?: string;
eventId?: string;
isInvite?: boolean;
};
-
if (userId) setActiveSessionId(userId);
-
if (isInvite) {
navigate(getInboxInvitesPath());
return;
}
-
if (!roomId) return;
setPending({ roomId, eventId, targetSessionId: userId });
};
@@ -653,15 +141,12 @@ function SyncNotificationSettingsWithServiceWorker() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return undefined;
-
const postVisibility = () => {
const visible = document.visibilityState === 'visible';
const msg = { type: 'setAppVisible', visible };
navigator.serviceWorker.controller?.postMessage(msg);
navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
};
-
- // Report initial visibility immediately, then track changes.
postVisibility();
document.addEventListener('visibilitychange', postVisibility);
return () => document.removeEventListener('visibilitychange', postVisibility);
@@ -669,20 +154,14 @@ function SyncNotificationSettingsWithServiceWorker() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
- // notificationSoundEnabled is intentionally excluded: push notification sound
- // is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
- // The in-app sound setting only controls the in-page
)}
- {backPagination}
{dividers}
{renderedEvent}
diff --git a/src/app/features/room/TimelinePaginationStatus.test.tsx b/src/app/features/room/TimelinePaginationStatus.test.tsx
new file mode 100644
index 000000000..79991b701
--- /dev/null
+++ b/src/app/features/room/TimelinePaginationStatus.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { TimelinePaginationStatusRow } from './TimelinePaginationStatus';
+
+describe('TimelinePaginationStatusRow', () => {
+ it('does not render when hidden', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders retry UI on error', () => {
+ const onRetry = vi.fn();
+ render(
+
+ );
+
+ expect(screen.getByText('Failed to load history.')).toBeInTheDocument();
+ screen.getByText('Retry').click();
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/features/room/TimelinePaginationStatus.tsx b/src/app/features/room/TimelinePaginationStatus.tsx
index 138fa46ec..744931cbc 100644
--- a/src/app/features/room/TimelinePaginationStatus.tsx
+++ b/src/app/features/room/TimelinePaginationStatus.tsx
@@ -8,6 +8,7 @@ export type TimelinePaginationStatusRowProps = {
hasMore: boolean;
status: TimelinePaginationStatus;
onRetry: () => void;
+ hidden?: boolean;
};
export function TimelinePaginationStatusRow({
@@ -16,7 +17,9 @@ export function TimelinePaginationStatusRow({
hasMore,
status,
onRetry,
+ hidden,
}: Readonly) {
+ if (hidden) return null;
if (!hasMore && status === 'idle') return null;
if (status === 'error') {
diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx
index 2a2bdf3f4..5e9ae5f13 100644
--- a/src/app/features/room/TimelineViewport.tsx
+++ b/src/app/features/room/TimelineViewport.tsx
@@ -40,6 +40,7 @@ export type TimelineViewportProps = {
backPagination: ReactNode;
frontPagination: ReactNode;
onScroll: (offset: number) => void;
+ onUserScrollIntent: () => void;
onJumpLatest: () => void;
renderMatrixEvent: (
eventType: string,
@@ -69,15 +70,36 @@ export function TimelineViewport({
backPagination,
frontPagination,
onScroll,
+ onUserScrollIntent,
onJumpLatest,
renderMatrixEvent,
}: Readonly) {
return (
{unreadBanner}
+ {backPagination && (
+
+ {backPagination}
+
+ )}
)}
diff --git a/src/app/features/room/timelineViewportModel.test.ts b/src/app/features/room/timelineViewportModel.test.ts
index 9b9eb2308..5be227185 100644
--- a/src/app/features/room/timelineViewportModel.test.ts
+++ b/src/app/features/room/timelineViewportModel.test.ts
@@ -54,6 +54,28 @@ describe('timelineViewportModel', () => {
).toBe('bottom');
});
+ it('keeps bottom anchor pinned for minor upward drift near latest', () => {
+ expect(
+ releaseAnchorOnScroll('bottom', {
+ offset: 2100,
+ previousOffset: 2110,
+ scrollSize: 3000,
+ viewportSize: 800,
+ })
+ ).toBe('bottom');
+ });
+
+ it('releases bottom anchor after a deliberate upward move away from latest', () => {
+ expect(
+ releaseAnchorOnScroll('bottom', {
+ offset: 1800,
+ previousOffset: 2200,
+ scrollSize: 3000,
+ viewportSize: 800,
+ })
+ ).toBe('free');
+ });
+
it('releases a center anchor after manual downward scroll', () => {
expect(
releaseAnchorOnScroll('center', {
diff --git a/src/app/features/room/timelineViewportModel.ts b/src/app/features/room/timelineViewportModel.ts
index 3a5b69015..1a08595b9 100644
--- a/src/app/features/room/timelineViewportModel.ts
+++ b/src/app/features/room/timelineViewportModel.ts
@@ -37,6 +37,8 @@ export type TimelineScrollDecision = {
export const TIMELINE_BOTTOM_THRESHOLD_PX = 100;
export const TIMELINE_PAGINATION_THRESHOLD_PX = 500;
export const TIMELINE_PAGINATION_REARM_THRESHOLD_PX = 700;
+export const TIMELINE_SCROLL_INTENT_DELTA_PX = 8;
+export const TIMELINE_BOTTOM_RELEASE_DISTANCE_PX = 200;
export const getDistanceFromBottom = (
scrollSize: number,
@@ -70,15 +72,32 @@ export const releaseAnchorOnScroll = (
anchorMode: TimelineAnchorMode,
snapshot: TimelineScrollSnapshot
): TimelineAnchorMode => {
+ const distanceFromBottom = getDistanceFromBottom(
+ snapshot.scrollSize,
+ snapshot.offset,
+ snapshot.viewportSize
+ );
+ const userScrollingUp =
+ snapshot.offset + TIMELINE_SCROLL_INTENT_DELTA_PX < snapshot.previousOffset;
+ const userScrollingDown =
+ snapshot.offset > snapshot.previousOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
+
if (anchorMode === 'center') {
- if (snapshot.offset + 2 < snapshot.previousOffset) return 'free';
- if (snapshot.offset > snapshot.previousOffset + 2) return 'free';
+ if (userScrollingUp) return 'free';
+ if (userScrollingDown) return 'free';
return 'center';
}
+ if (anchorMode === 'bottom') {
+ if (isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize))
+ return 'bottom';
+ if (userScrollingUp && distanceFromBottom > TIMELINE_BOTTOM_RELEASE_DISTANCE_PX) return 'free';
+ return 'bottom';
+ }
+
if (isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize))
return 'bottom';
- if (snapshot.offset + 2 < snapshot.previousOffset) return 'free';
+ if (userScrollingUp) return 'free';
return anchorMode;
};
@@ -89,8 +108,10 @@ export const getTimelineScrollDecision = (
): TimelineScrollDecision => {
const nextAnchorMode = releaseAnchorOnScroll(anchorMode, snapshot);
const atBottom = isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize);
- const userScrollingUp = snapshot.offset + 2 < snapshot.previousOffset;
- const userScrollingDown = snapshot.offset > snapshot.previousOffset + 2;
+ const userScrollingUp =
+ snapshot.offset + TIMELINE_SCROLL_INTENT_DELTA_PX < snapshot.previousOffset;
+ const userScrollingDown =
+ snapshot.offset > snapshot.previousOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
const distanceFromBottom = getDistanceFromBottom(
snapshot.scrollSize,
snapshot.offset,
diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx
index 3dc27f817..98d713b5a 100644
--- a/src/app/features/room/useTimelineViewportController.test.tsx
+++ b/src/app/features/room/useTimelineViewportController.test.tsx
@@ -65,6 +65,20 @@ const createRefs = (vList = createVList()) => {
};
};
+const createEmptyProcessedRefs = (vList = createVList()) => {
+ const processedEventsRef: MutableRefObject = {
+ current: [],
+ };
+
+ return {
+ vList,
+ vListRef: { current: vList } as RefObject,
+ messageListRef: { current: document.createElement('div') } as RefObject,
+ processedEventsRef,
+ atBottomRef: { current: true },
+ };
+};
+
const renderController = ({
eventId,
timelineSync = createTimelineSync(),
@@ -128,10 +142,90 @@ describe('useTimelineViewportController', () => {
expect(result.current.isReady).toBe(true);
});
+ it('re-anchors to latest when first renderable rows appear after an all-state initial slice', () => {
+ const refs = createEmptyProcessedRefs();
+ const timelineSync = createTimelineSync({ eventsLength: 20 });
+ const { rerender } = renderController({ timelineSync, refs });
+
+ expect(refs.vList.scrollToIndex).not.toHaveBeenCalled();
+ expect(refs.vList.scrollTo).toHaveBeenCalledWith(refs.vList.scrollSize);
+
+ refs.processedEventsRef.current = [
+ createProcessedEvent('$x', 10),
+ createProcessedEvent('$y', 11),
+ ];
+
+ rerender({ sync: timelineSync, roomEventId: undefined });
+
+ expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' });
+ });
+
+ it('delays viewport reveal while bootstrap prefill is in progress for underfilled rooms', () => {
+ const vList = createVList() as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ scrollTo: ReturnType;
+ scrollBy: ReturnType;
+ scrollToIndex: ReturnType;
+ };
+ vList.scrollSize = 800;
+ vList.viewportSize = 800;
+ const refs = createRefs(vList as unknown as VListHandle);
+ const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
+
+ const { result } = renderController({ timelineSync, refs });
+
+ expect(result.current.isReady).toBe(false);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ });
+
+ it('does not trigger ready fallback timeout while bootstrap reveal is gated', () => {
+ vi.useFakeTimers();
+ const vList = createVList() as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ vList.scrollSize = 700;
+ vList.viewportSize = 700;
+ const refs = createRefs(vList as unknown as VListHandle);
+ const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
+
+ const { result } = renderController({ timelineSync, refs });
+ expect(result.current.isReady).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.isReady).toBe(false);
+ });
+
+ it('waits for a measured viewport before bootstrap reveal or backfill', () => {
+ const vList = createVList() as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ vList.scrollSize = 120;
+ vList.viewportSize = 0;
+ const refs = createRefs(vList as unknown as VListHandle);
+ const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
+
+ const { result } = renderController({ timelineSync, refs });
+
+ expect(result.current.isReady).toBe(false);
+ expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+ });
+
it('paginates older history at the top edge', () => {
const timelineSync = createTimelineSync();
const { result } = renderController({ timelineSync });
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
act(() => {
result.current.handleVListScroll(300);
});
@@ -142,6 +236,105 @@ describe('useTimelineViewportController', () => {
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
+ it('keeps bottom anchor pinned during loading-driven offset shifts', () => {
+ const refs = createRefs();
+ const setAtBottom = vi.fn((val: boolean) => {
+ refs.atBottomRef.current = val;
+ });
+ const timelineSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync, refs, setAtBottom });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+
+ mutableVList.scrollOffset = 2200;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+
+ const loadingTimelineSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingTimelineSync, roomEventId: undefined });
+ setAtBottom.mockClear();
+
+ mutableVList.scrollOffset = 1900;
+ act(() => {
+ result.current.handleVListScroll(1900);
+ });
+
+ expect(setAtBottom).not.toHaveBeenCalledWith(false);
+ expect(loadingTimelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+ });
+
+ it('releases bottom anchor on an intentional upward scroll when idle', () => {
+ const refs = createRefs();
+ const setAtBottom = vi.fn((val: boolean) => {
+ refs.atBottomRef.current = val;
+ });
+ const timelineSync = createTimelineSync();
+ const { result } = renderController({ timelineSync, refs, setAtBottom });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+
+ mutableVList.scrollOffset = 2200;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 1900;
+ act(() => {
+ result.current.handleVListScroll(1900);
+ });
+
+ expect(setAtBottom).toHaveBeenCalledWith(false);
+ });
+
+ it('does not release bottom anchor before any user scroll intent', () => {
+ const refs = createRefs();
+ const setAtBottom = vi.fn((val: boolean) => {
+ refs.atBottomRef.current = val;
+ });
+ const timelineSync = createTimelineSync();
+ const { result } = renderController({ timelineSync, refs, setAtBottom });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+
+ mutableVList.scrollOffset = 2200;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+
+ setAtBottom.mockClear();
+ mutableVList.scrollOffset = 1900;
+ act(() => {
+ result.current.handleVListScroll(1900);
+ });
+
+ expect(setAtBottom).not.toHaveBeenCalledWith(false);
+ });
+
it('does not paginate while an event jump is still loading', () => {
let resolveLoad: () => void = () => {};
const timelineSync = createTimelineSync({
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index 9f77fedd2..e0b401d09 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -13,7 +13,8 @@ import {
type TimelineAnchor,
} from './timelineViewportModel';
-const INITIAL_BACKFILL_PAGE_BUDGET = 2;
+const INITIAL_BACKFILL_PAGE_BUDGET = 6;
+const BOOTSTRAP_FILL_TARGET_SLACK_PX = 32;
type TimelineSyncController = ReturnType;
@@ -46,6 +47,7 @@ export function useTimelineViewportController({
const [topSpacerHeight, setTopSpacerHeight] = useState(0);
const [isReady, setIsReady] = useState(false);
const [jumpInFlight, setJumpInFlight] = useState(false);
+ const [bootstrapViewportTick, setBootstrapViewportTick] = useState(0);
const topSpacerHeightRef = useRef(0);
const hasInitialScrolledRef = useRef(false);
@@ -56,6 +58,9 @@ export function useTimelineViewportController({
const backwardEdgeArmedRef = useRef(true);
const forwardEdgeArmedRef = useRef(true);
const suppressNextCenterScrollRef = useRef(false);
+ const userScrollIntentRef = useRef(false);
+ const pendingBootstrapRevealRef = useRef(false);
+ const bootstrapViewportRetryFrameRef = useRef(undefined);
const settleAnchorFrameRef = useRef(undefined);
const readyFallbackTimerRef = useRef | undefined>(undefined);
const currentRoomIdRef = useRef(roomId);
@@ -82,6 +87,12 @@ export function useTimelineViewportController({
backwardEdgeArmedRef.current = true;
forwardEdgeArmedRef.current = true;
suppressNextCenterScrollRef.current = false;
+ userScrollIntentRef.current = false;
+ pendingBootstrapRevealRef.current = false;
+ if (bootstrapViewportRetryFrameRef.current !== undefined) {
+ cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
+ bootstrapViewportRetryFrameRef.current = undefined;
+ }
if (settleAnchorFrameRef.current !== undefined) {
cancelAnimationFrame(settleAnchorFrameRef.current);
settleAnchorFrameRef.current = undefined;
@@ -181,12 +192,16 @@ export function useTimelineViewportController({
if (readyFallbackTimerRef.current !== undefined) {
clearTimeout(readyFallbackTimerRef.current);
}
+ if (bootstrapViewportRetryFrameRef.current !== undefined) {
+ cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
+ }
},
[]
);
useEffect(() => {
if (isReady) return;
+ if (pendingBootstrapRevealRef.current) return;
if (readyFallbackTimerRef.current !== undefined) {
clearTimeout(readyFallbackTimerRef.current);
}
@@ -211,13 +226,18 @@ export function useTimelineViewportController({
) {
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) {
- pendingReadyRef.current = false;
+ // Some rooms initially hydrate mostly state/hidden events. Keep a pending
+ // first-visible-row anchor so we re-land on latest once a renderable row appears.
+ pendingReadyRef.current = true;
settleTimelineAnchor({ kind: 'bottom' }, true);
hasInitialScrolledRef.current = true;
return;
}
vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
- settleTimelineAnchor({ kind: 'bottom' }, true);
+ const contentHeight = Math.max(0, vListRef.current.scrollSize - topSpacerHeightRef.current);
+ const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack;
+ pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal;
+ settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal);
hasInitialScrolledRef.current = true;
}
}, [
@@ -257,8 +277,34 @@ export function useTimelineViewportController({
} else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') {
setShift(false);
if (wasAtBottomBeforePaginationRef.current) settleTimelineAnchor({ kind: 'bottom' });
+
+ if (pendingBootstrapRevealRef.current) {
+ const v = vListRef.current;
+ if (!v) return;
+ if (v.viewportSize <= 0) return;
+ const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
+ const isFilled = contentHeight > v.viewportSize + BOOTSTRAP_FILL_TARGET_SLACK_PX;
+ const done =
+ isFilled ||
+ !timelineSync.canPaginateBack ||
+ remainingInitialBackfillPagesRef.current <= 0;
+ if (done) {
+ pendingBootstrapRevealRef.current = false;
+ setIsReady(true);
+ }
+ }
+ } else if (timelineSync.backwardStatus === 'error' && pendingBootstrapRevealRef.current) {
+ pendingBootstrapRevealRef.current = false;
+ setIsReady(true);
}
- }, [timelineSync.backwardStatus, atBottomRef, settleTimelineAnchor]);
+ }, [
+ roomId,
+ timelineSync.backwardStatus,
+ timelineSync.canPaginateBack,
+ atBottomRef,
+ settleTimelineAnchor,
+ vListRef,
+ ]);
useEffect(() => {
let timeoutId: ReturnType | undefined;
@@ -369,12 +415,19 @@ export function useTimelineViewportController({
pendingReadyRef.current = false;
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
settleTimelineAnchor({ kind: 'bottom' }, true);
- }, [processedEventsRef.current.length, settleTimelineAnchor, vListRef, processedEventsRef]);
+ }, [
+ roomId,
+ timelineSync.eventsLength,
+ processedEventsRef.current.length,
+ settleTimelineAnchor,
+ vListRef,
+ processedEventsRef,
+ ]);
useEffect(() => {
const v = vListRef.current;
if (!v) return;
- if (!isReady) return;
+ if (!isReady && !pendingBootstrapRevealRef.current) return;
if (jumpInFlight) return;
if (timelineSync.focusItem) return;
if (anchorRef.current.kind === 'message-center') return;
@@ -383,16 +436,33 @@ export function useTimelineViewportController({
if (!canPaginateBackRef.current || backwardStatusRef.current !== 'idle') return;
const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
- if (contentHeight > v.viewportSize + 32) return;
+ if (v.viewportSize <= 0) {
+ if (bootstrapViewportRetryFrameRef.current === undefined) {
+ bootstrapViewportRetryFrameRef.current = requestAnimationFrame(() => {
+ bootstrapViewportRetryFrameRef.current = undefined;
+ setBootstrapViewportTick((prev) => prev + 1);
+ });
+ }
+ return;
+ }
+ if (contentHeight > v.viewportSize + BOOTSTRAP_FILL_TARGET_SLACK_PX) {
+ if (pendingBootstrapRevealRef.current) {
+ pendingBootstrapRevealRef.current = false;
+ setIsReady(true);
+ }
+ return;
+ }
remainingInitialBackfillPagesRef.current -= 1;
timelineSyncRef.current.handleTimelinePagination(true);
}, [
+ roomId,
isReady,
jumpInFlight,
timelineSync.focusItem,
timelineSync.eventsLength,
timelineSync.backwardStatus,
+ bootstrapViewportTick,
timelineSyncRef,
vListRef,
]);
@@ -434,23 +504,50 @@ export function useTimelineViewportController({
}
);
+ const suppressBottomReleaseWhileLoading =
+ anchorRef.current.kind === 'bottom' &&
+ decision.anchorMode === 'free' &&
+ (backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading');
+ const suppressBottomReleaseWithoutIntent =
+ anchorRef.current.kind === 'bottom' &&
+ decision.anchorMode === 'free' &&
+ !userScrollIntentRef.current;
+ const suppressBottomRelease =
+ suppressBottomReleaseWhileLoading || suppressBottomReleaseWithoutIntent;
+ const nextAnchorMode = suppressBottomRelease ? 'bottom' : decision.anchorMode;
+ const nextAtBottom = suppressBottomRelease ? true : decision.atBottom;
+ const paginateBackward = suppressBottomRelease ? false : decision.paginateBackward;
+ const paginateForward = suppressBottomRelease ? false : decision.paginateForward;
+
backwardEdgeArmedRef.current = decision.nextBackwardArmed;
forwardEdgeArmedRef.current = decision.nextForwardArmed;
- if (decision.atBottom !== atBottomRef.current) setAtBottom(decision.atBottom);
+ if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
- if (decision.anchorMode === 'bottom') {
+ if (nextAnchorMode === 'bottom') {
anchorRef.current = { kind: 'bottom' };
- } else if (decision.anchorMode === 'free') {
+ } else if (nextAnchorMode === 'free') {
anchorRef.current = { kind: 'none' };
}
- if (decision.paginateBackward) timelineSyncRef.current.handleTimelinePagination(true);
- if (decision.paginateForward) timelineSyncRef.current.handleTimelinePagination(false);
+ if (paginateBackward) timelineSyncRef.current.handleTimelinePagination(true);
+ if (paginateForward) timelineSyncRef.current.handleTimelinePagination(false);
},
- [atBottomRef, jumpInFlight, setAtBottom, timelineSync.focusItem, timelineSyncRef, vListRef]
+ [
+ roomId,
+ atBottomRef,
+ jumpInFlight,
+ setAtBottom,
+ timelineSync.focusItem,
+ timelineSyncRef,
+ vListRef,
+ ]
);
+ const markUserScrollIntent = useCallback(() => {
+ userScrollIntentRef.current = true;
+ }, []);
+
return {
shift,
topSpacerHeight,
@@ -458,5 +555,6 @@ export function useTimelineViewportController({
beginJumpLoad,
settleTimelineAnchor,
handleVListScroll,
+ markUserScrollIntent,
};
}
From 9d876afecffd32edff3d4ec388f0a13cff12e6fc Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 15:11:16 -0500
Subject: [PATCH 22/28] lint
---
.../message/content/ImageContent.tsx | 4 +--
.../room/TimelinePaginationStatus.test.tsx | 4 +--
src/app/features/room/TimelineViewport.tsx | 2 +-
.../useTimelineViewportController.test.tsx | 33 ++++++++++---------
.../room/useTimelineViewportController.ts | 17 +++-------
.../features/settings/SettingsRoute.test.tsx | 3 ++
.../client/DeferredNotificationFeatures.tsx | 2 ++
.../scheduleDeferredFeatureMount.test.ts | 8 ++---
src/app/plugins/arborium/runtime.test.ts | 2 +-
9 files changed, 37 insertions(+), 38 deletions(-)
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index 13868c4bf..310af5833 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -141,7 +141,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}, [mx, url, useAuthentication, mimeType, encInfo, matrixThumbnailMaxEdge, info?.w, info?.h])
);
- useEffect(() => {
+ useEffect((): void | (() => void) => {
if (!viewer) {
setViewerFullSrc(null);
return;
@@ -200,7 +200,7 @@ export const ImageContent = as<'div', ImageContentProps>(
: isContained
? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined }
: hasDimensions
- ? { aspectRatio: `${info!.w} / ${info!.h}` }
+ ? { aspectRatio: `${info.w} / ${info.h}` }
: { minHeight: '150px' };
const fillPreviewSlotStyle = fillsSlot
diff --git a/src/app/features/room/TimelinePaginationStatus.test.tsx b/src/app/features/room/TimelinePaginationStatus.test.tsx
index 79991b701..3ff5fc488 100644
--- a/src/app/features/room/TimelinePaginationStatus.test.tsx
+++ b/src/app/features/room/TimelinePaginationStatus.test.tsx
@@ -10,7 +10,7 @@ describe('TimelinePaginationStatusRow', () => {
eventsLength={10}
hasMore
status="loading"
- onRetry={vi.fn()}
+ onRetry={vi.fn<() => void>()}
hidden
/>
);
@@ -19,7 +19,7 @@ describe('TimelinePaginationStatusRow', () => {
});
it('renders retry UI on error', () => {
- const onRetry = vi.fn();
+ const onRetry = vi.fn<() => void>();
render(
;
const nativeRequestAnimationFrame = globalThis.requestAnimationFrame;
const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame;
+const noop = () => {};
const createVList = (): VListHandle =>
({
scrollOffset: 0,
scrollSize: 3000,
viewportSize: 800,
- scrollTo: vi.fn(),
- scrollBy: vi.fn(),
- scrollToIndex: vi.fn(),
+ scrollTo: vi.fn<(offset: number) => void>(),
+ scrollBy: vi.fn<(offset: number) => void>(),
+ scrollToIndex: vi.fn<(index: number, options?: { align?: string }) => void>(),
}) as unknown as VListHandle;
const createProcessedEvent = (id: string, itemIndex: number): ProcessedEvent =>
@@ -38,16 +39,16 @@ const createTimelineSync = (
): TimelineSyncController =>
({
timeline: { linkedTimelines: [] },
- setTimeline: vi.fn(),
+ setTimeline: vi.fn<(next: unknown) => void>(),
eventsLength: 2,
liveTimelineLinked: true,
canPaginateBack: true,
backwardStatus: 'idle',
forwardStatus: 'idle',
- handleTimelinePagination: vi.fn(),
- loadEventTimeline: vi.fn(() => Promise.resolve()),
+ handleTimelinePagination: vi.fn<(backward: boolean) => void>(),
+ loadEventTimeline: vi.fn<(eventId: string) => Promise>(() => Promise.resolve()),
focusItem: undefined,
- setFocusItem: vi.fn(),
+ setFocusItem: vi.fn<(next: unknown) => void>(),
...overrides,
}) as unknown as TimelineSyncController;
@@ -83,7 +84,7 @@ const renderController = ({
eventId,
timelineSync = createTimelineSync(),
refs = createRefs(),
- setAtBottom = vi.fn((val: boolean) => {
+ setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
refs.atBottomRef.current = val;
}),
}: {
@@ -125,7 +126,9 @@ describe('useTimelineViewportController', () => {
cb(0);
return 1;
}) as typeof globalThis.requestAnimationFrame;
- globalThis.cancelAnimationFrame = vi.fn() as unknown as typeof globalThis.cancelAnimationFrame;
+ globalThis.cancelAnimationFrame = vi.fn<
+ (id: number) => void
+ >() as unknown as typeof globalThis.cancelAnimationFrame;
});
afterEach(() => {
@@ -238,7 +241,7 @@ describe('useTimelineViewportController', () => {
it('keeps bottom anchor pinned during loading-driven offset shifts', () => {
const refs = createRefs();
- const setAtBottom = vi.fn((val: boolean) => {
+ const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
refs.atBottomRef.current = val;
});
const timelineSync = createTimelineSync();
@@ -275,7 +278,7 @@ describe('useTimelineViewportController', () => {
it('releases bottom anchor on an intentional upward scroll when idle', () => {
const refs = createRefs();
- const setAtBottom = vi.fn((val: boolean) => {
+ const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
refs.atBottomRef.current = val;
});
const timelineSync = createTimelineSync();
@@ -307,7 +310,7 @@ describe('useTimelineViewportController', () => {
it('does not release bottom anchor before any user scroll intent', () => {
const refs = createRefs();
- const setAtBottom = vi.fn((val: boolean) => {
+ const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
refs.atBottomRef.current = val;
});
const timelineSync = createTimelineSync();
@@ -336,9 +339,9 @@ describe('useTimelineViewportController', () => {
});
it('does not paginate while an event jump is still loading', () => {
- let resolveLoad: () => void = () => {};
+ let resolveLoad: () => void = noop;
const timelineSync = createTimelineSync({
- loadEventTimeline: vi.fn(
+ loadEventTimeline: vi.fn<(eventId: string) => Promise>(
() =>
new Promise((resolve) => {
resolveLoad = resolve;
@@ -360,7 +363,7 @@ describe('useTimelineViewportController', () => {
});
it('centers a loaded focus item and consumes the scroll intent', () => {
- const setFocusItem = vi.fn();
+ const setFocusItem = vi.fn<(next: unknown) => void>();
const timelineSync = createTimelineSync({
focusItem: { index: 1, scrollTo: true, highlight: true },
setFocusItem,
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index e0b401d09..c84f4694f 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -199,7 +199,7 @@ export function useTimelineViewportController({
[]
);
- useEffect(() => {
+ useEffect((): void | (() => void) => {
if (isReady) return;
if (pendingBootstrapRevealRef.current) return;
if (readyFallbackTimerRef.current !== undefined) {
@@ -234,7 +234,6 @@ export function useTimelineViewportController({
return;
}
vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
- const contentHeight = Math.max(0, vListRef.current.scrollSize - topSpacerHeightRef.current);
const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack;
pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal;
settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal);
@@ -242,6 +241,7 @@ export function useTimelineViewportController({
}
}, [
timelineSync.eventsLength,
+ timelineSync.canPaginateBack,
eventId,
roomId,
processedEventsRef,
@@ -306,7 +306,7 @@ export function useTimelineViewportController({
vListRef,
]);
- useEffect(() => {
+ useEffect((): void | (() => void) => {
let timeoutId: ReturnType | undefined;
if (timelineSync.focusItem) {
if (timelineSync.focusItem.scrollTo && vListRef.current) {
@@ -416,7 +416,6 @@ export function useTimelineViewportController({
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
settleTimelineAnchor({ kind: 'bottom' }, true);
}, [
- roomId,
timelineSync.eventsLength,
processedEventsRef.current.length,
settleTimelineAnchor,
@@ -533,15 +532,7 @@ export function useTimelineViewportController({
if (paginateBackward) timelineSyncRef.current.handleTimelinePagination(true);
if (paginateForward) timelineSyncRef.current.handleTimelinePagination(false);
},
- [
- roomId,
- atBottomRef,
- jumpInFlight,
- setAtBottom,
- timelineSync.focusItem,
- timelineSyncRef,
- vListRef,
- ]
+ [atBottomRef, jumpInFlight, setAtBottom, timelineSync.focusItem, timelineSyncRef, vListRef]
);
const markUserScrollIntent = useCallback(() => {
diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx
index 5a991494d..505784d12 100644
--- a/src/app/features/settings/SettingsRoute.test.tsx
+++ b/src/app/features/settings/SettingsRoute.test.tsx
@@ -655,6 +655,9 @@ describe('Settings shallow route shell', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open focused general settings' }));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument();
expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-layout');
diff --git a/src/app/pages/client/DeferredNotificationFeatures.tsx b/src/app/pages/client/DeferredNotificationFeatures.tsx
index 34d684575..59d2752d5 100644
--- a/src/app/pages/client/DeferredNotificationFeatures.tsx
+++ b/src/app/pages/client/DeferredNotificationFeatures.tsx
@@ -122,6 +122,7 @@ function InviteNotifications() {
return (
+
);
}
@@ -340,6 +341,7 @@ function MessageNotifications() {
return (
+
);
}
diff --git a/src/app/pages/client/scheduleDeferredFeatureMount.test.ts b/src/app/pages/client/scheduleDeferredFeatureMount.test.ts
index f81ade54b..ed906745b 100644
--- a/src/app/pages/client/scheduleDeferredFeatureMount.test.ts
+++ b/src/app/pages/client/scheduleDeferredFeatureMount.test.ts
@@ -3,12 +3,12 @@ import { scheduleDeferredFeatureMount } from './scheduleDeferredFeatureMount';
describe('scheduleDeferredFeatureMount', () => {
it('uses requestIdleCallback when available', () => {
- const mount = vi.fn();
- const requestIdleCallback = vi.fn((cb: IdleRequestCallback) => {
+ const mount = vi.fn<() => void>();
+ const requestIdleCallback = vi.fn<(cb: IdleRequestCallback) => number>((cb) => {
cb({ didTimeout: false, timeRemaining: () => 0 } as IdleDeadline);
return 1;
});
- const cancelIdleCallback = vi.fn();
+ const cancelIdleCallback = vi.fn<(id: number) => void>();
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
const win = window as unknown as {
@@ -38,7 +38,7 @@ describe('scheduleDeferredFeatureMount', () => {
it('falls back to setTimeout when requestIdleCallback is unavailable', () => {
vi.useFakeTimers();
- const mount = vi.fn();
+ const mount = vi.fn<() => void>();
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts
index 960ae8550..afa7037b7 100644
--- a/src/app/plugins/arborium/runtime.test.ts
+++ b/src/app/plugins/arborium/runtime.test.ts
@@ -44,7 +44,7 @@ describe('highlightCode', () => {
expect(normalizeLanguage).toHaveBeenCalledWith('ts');
expect(detectLanguage).not.toHaveBeenCalled();
expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;');
- });
+ }, 15000);
it('maps jsx to tsx when Arborium supports tsx', async () => {
const normalizeLanguage = vi.fn<(language: string) => string>((language: string) => language);
From a6189acb9c27c1598e9657d1e5ec8e59b142fc4f Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 19:24:31 -0500
Subject: [PATCH 23/28] refactor again: timeline is somewhat working now
---
.../RoomNotificationSwitcher.test.tsx | 2 +-
src/app/features/room/TimelineViewport.tsx | 63 ++++-
src/app/features/room/timelineJumpDebug.ts | 48 ++++
.../room/timelineViewportModel.test.ts | 170 ------------
.../features/room/timelineViewportModel.ts | 117 +--------
.../useTimelineViewportController.test.tsx | 192 +++++++++++++-
.../room/useTimelineViewportController.ts | 241 +++++++++++++-----
src/app/features/settings/Settings.test.tsx | 22 +-
.../settings/cosmetics/Themes.test.tsx | 2 +-
.../hooks/timeline/useTimelineSync.test.tsx | 85 +++++-
src/app/hooks/timeline/useTimelineSync.ts | 94 ++++---
11 files changed, 614 insertions(+), 422 deletions(-)
create mode 100644 src/app/features/room/timelineJumpDebug.ts
diff --git a/src/app/components/RoomNotificationSwitcher.test.tsx b/src/app/components/RoomNotificationSwitcher.test.tsx
index bd0b7c04c..28bf44808 100644
--- a/src/app/components/RoomNotificationSwitcher.test.tsx
+++ b/src/app/components/RoomNotificationSwitcher.test.tsx
@@ -56,7 +56,7 @@ describe('RoomNotificationModeSwitcher', () => {
RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Unset
);
- });
+ }, 15000);
it('disables interaction while the room mode is changing', () => {
modeStateStatus.current = 'loading';
diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx
index 96e401753..a1bc8958d 100644
--- a/src/app/features/room/TimelineViewport.tsx
+++ b/src/app/features/room/TimelineViewport.tsx
@@ -1,4 +1,11 @@
-import type { ReactNode, RefObject } from 'react';
+import {
+ useRef,
+ type KeyboardEvent,
+ type ReactNode,
+ type RefObject,
+ type TouchEvent,
+ type WheelEvent,
+} from 'react';
import type { Room } from '$types/matrix-sdk';
import type { VListHandle } from 'virtua';
import { VList } from 'virtua';
@@ -6,6 +13,7 @@ import classNames from 'classnames';
import { as, Box, Chip, Icon, Icons, Text, config } from 'folds';
import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline';
import type { MessageLayout, MessageSpacing } from '$state/settings';
+import type { TimelineScrollDirection } from './timelineViewportModel';
import * as css from './RoomTimeline.css';
import { TimelineEventRow } from './TimelineEventRow';
import { TimelineLoadingPlaceholders } from './TimelineLoadingPlaceholders';
@@ -23,6 +31,12 @@ const TimelineFloat = as<'div', css.TimelineFloatVariants>(
)
);
+const getDirectionFromDelta = (deltaY: number): TimelineScrollDirection | undefined => {
+ if (deltaY > 0) return 'forward';
+ if (deltaY < 0) return 'backward';
+ return undefined;
+};
+
export type TimelineViewportProps = {
room: Room;
isReady: boolean;
@@ -40,7 +54,7 @@ export type TimelineViewportProps = {
backPagination: ReactNode;
frontPagination: ReactNode;
onScroll: (offset: number) => void;
- onUserScrollIntent: () => void;
+ onUserScrollIntent: (direction?: TimelineScrollDirection) => void;
onJumpLatest: () => void;
renderMatrixEvent: (
eventType: string,
@@ -74,6 +88,43 @@ export function TimelineViewport({
onJumpLatest,
renderMatrixEvent,
}: Readonly) {
+ const lastTouchYRef = useRef(undefined);
+
+ const handleWheelIntent = (event: WheelEvent) => {
+ onUserScrollIntent(getDirectionFromDelta(event.deltaY));
+ };
+
+ const handleTouchStart = (event: TouchEvent) => {
+ lastTouchYRef.current = event.touches[0]?.clientY;
+ };
+
+ const handleTouchMoveIntent = (event: TouchEvent) => {
+ const nextY = event.touches[0]?.clientY;
+ const prevY = lastTouchYRef.current;
+ lastTouchYRef.current = nextY;
+ if (nextY === undefined || prevY === undefined) {
+ onUserScrollIntent();
+ return;
+ }
+ onUserScrollIntent(getDirectionFromDelta(prevY - nextY));
+ };
+
+ const handleKeyboardScrollIntent = (event: KeyboardEvent) => {
+ if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') {
+ onUserScrollIntent('backward');
+ return;
+ }
+ if (
+ event.key === 'ArrowDown' ||
+ event.key === 'PageDown' ||
+ event.key === 'End' ||
+ event.key === ' ' ||
+ event.key === 'Spacebar'
+ ) {
+ onUserScrollIntent('forward');
+ }
+ };
+
return (
{unreadBanner}
@@ -96,10 +147,10 @@ export function TimelineViewport({
;
+};
+
+const MAX_ENTRIES = 400;
+const store: TimelineJumpDebugEntry[] = [];
+
+export const pushTimelineJumpDebug = (
+ source: TimelineJumpDebugEntry['source'],
+ event: string,
+ data?: Record
+): void => {
+ store.push({ ts: Date.now(), source, event, data });
+ if (store.length > MAX_ENTRIES) store.splice(0, store.length - MAX_ENTRIES);
+};
+
+export const dumpTimelineJumpDebug = (): string =>
+ JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ count: store.length,
+ entries: store.map((entry) => ({
+ ...entry,
+ ts: new Date(entry.ts).toISOString(),
+ })),
+ },
+ null,
+ 2
+ );
+
+export const clearTimelineJumpDebug = (): void => {
+ store.length = 0;
+};
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __sableDumpTimelineJumpDebug: (() => string) | undefined;
+ // eslint-disable-next-line no-var
+ var __sableClearTimelineJumpDebug: (() => void) | undefined;
+}
+
+if (typeof globalThis !== 'undefined') {
+ globalThis.__sableDumpTimelineJumpDebug = dumpTimelineJumpDebug;
+ globalThis.__sableClearTimelineJumpDebug = clearTimelineJumpDebug;
+}
diff --git a/src/app/features/room/timelineViewportModel.test.ts b/src/app/features/room/timelineViewportModel.test.ts
index 5be227185..f74a4f1e1 100644
--- a/src/app/features/room/timelineViewportModel.test.ts
+++ b/src/app/features/room/timelineViewportModel.test.ts
@@ -3,22 +3,9 @@ import {
getBottomClampSpacer,
getCenterAnchorAdjustment,
getDistanceFromBottom,
- getTimelineScrollDecision,
isTimelineAtBottom,
- releaseAnchorOnScroll,
} from './timelineViewportModel';
-const basePagination = {
- canPaginateBack: true,
- backwardIdle: true,
- backwardArmed: true,
- liveTimelineLinked: true,
- forwardIdle: true,
- forwardArmed: true,
- jumpPending: false,
- focusPending: false,
-};
-
describe('timelineViewportModel', () => {
it('calculates bottom distance and bottom threshold', () => {
expect(getDistanceFromBottom(1200, 500, 600)).toBe(100);
@@ -42,161 +29,4 @@ describe('timelineViewportModel', () => {
-100
);
});
-
- it('pins to bottom when the scroll snapshot is near latest', () => {
- expect(
- releaseAnchorOnScroll('free', {
- offset: 520,
- previousOffset: 300,
- scrollSize: 1200,
- viewportSize: 600,
- })
- ).toBe('bottom');
- });
-
- it('keeps bottom anchor pinned for minor upward drift near latest', () => {
- expect(
- releaseAnchorOnScroll('bottom', {
- offset: 2100,
- previousOffset: 2110,
- scrollSize: 3000,
- viewportSize: 800,
- })
- ).toBe('bottom');
- });
-
- it('releases bottom anchor after a deliberate upward move away from latest', () => {
- expect(
- releaseAnchorOnScroll('bottom', {
- offset: 1800,
- previousOffset: 2200,
- scrollSize: 3000,
- viewportSize: 800,
- })
- ).toBe('free');
- });
-
- it('releases a center anchor after manual downward scroll', () => {
- expect(
- releaseAnchorOnScroll('center', {
- offset: 340,
- previousOffset: 300,
- scrollSize: 2000,
- viewportSize: 600,
- })
- ).toBe('free');
- });
-
- it('keeps a center anchor pinned even when the detached jump window is bottom-sized', () => {
- expect(
- releaseAnchorOnScroll('center', {
- offset: 400,
- previousOffset: 400,
- scrollSize: 1200,
- viewportSize: 800,
- })
- ).toBe('center');
- });
-
- it('requests backward pagination at the top edge', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 120, previousOffset: 180, scrollSize: 3000, viewportSize: 700 },
- basePagination
- )
- ).toMatchObject({ anchorMode: 'free', paginateBackward: true, paginateForward: false });
- });
-
- it('disarms backward pagination after firing until the viewport leaves the edge', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 120, previousOffset: 180, scrollSize: 3000, viewportSize: 700 },
- basePagination
- )
- ).toMatchObject({ paginateBackward: true, nextBackwardArmed: false });
-
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 110, previousOffset: 120, scrollSize: 3000, viewportSize: 700 },
- { ...basePagination, backwardArmed: false }
- )
- ).toMatchObject({ paginateBackward: false, nextBackwardArmed: false });
-
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 900, previousOffset: 110, scrollSize: 3000, viewportSize: 700 },
- { ...basePagination, backwardArmed: false }
- )
- ).toMatchObject({ paginateBackward: false, nextBackwardArmed: true });
- });
-
- it('does not request backward pagination for a stationary top-edge scroll event', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 120, previousOffset: 120, scrollSize: 3000, viewportSize: 700 },
- basePagination
- )
- ).toMatchObject({ paginateBackward: false, paginateForward: false });
- });
-
- it('requests forward pagination at the bottom edge of a historical window', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 1750, previousOffset: 1600, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false }
- )
- ).toMatchObject({ paginateBackward: false, paginateForward: true });
- });
-
- it('disarms forward pagination after firing until the viewport leaves the edge', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 1750, previousOffset: 1600, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false }
- )
- ).toMatchObject({ paginateForward: true, nextForwardArmed: false });
-
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 1780, previousOffset: 1750, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false, forwardArmed: false }
- )
- ).toMatchObject({ paginateForward: false, nextForwardArmed: false });
-
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 900, previousOffset: 1780, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false, forwardArmed: false }
- )
- ).toMatchObject({ paginateForward: false, nextForwardArmed: true });
- });
-
- it('does not request forward pagination for a stationary bottom-edge scroll event', () => {
- expect(
- getTimelineScrollDecision(
- 'free',
- { offset: 1750, previousOffset: 1750, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false }
- )
- ).toMatchObject({ paginateBackward: false, paginateForward: false });
- });
-
- it('blocks edge pagination while a jump is pending', () => {
- expect(
- getTimelineScrollDecision(
- 'center',
- { offset: 100, previousOffset: 100, scrollSize: 3000, viewportSize: 800 },
- { ...basePagination, liveTimelineLinked: false, jumpPending: true }
- )
- ).toMatchObject({ paginateBackward: false, paginateForward: false });
- });
});
diff --git a/src/app/features/room/timelineViewportModel.ts b/src/app/features/room/timelineViewportModel.ts
index 1a08595b9..635932311 100644
--- a/src/app/features/room/timelineViewportModel.ts
+++ b/src/app/features/room/timelineViewportModel.ts
@@ -1,44 +1,13 @@
-export type TimelineAnchorMode = 'bottom' | 'center' | 'free';
-
export type TimelineAnchor =
| { kind: 'none' }
| { kind: 'bottom' }
| { kind: 'message-center'; eventId: string };
-export type RectLike = Pick;
-
-export type TimelineScrollSnapshot = {
- offset: number;
- previousOffset: number;
- scrollSize: number;
- viewportSize: number;
-};
-
-export type TimelinePaginationState = {
- canPaginateBack: boolean;
- backwardIdle: boolean;
- backwardArmed: boolean;
- liveTimelineLinked: boolean;
- forwardIdle: boolean;
- forwardArmed: boolean;
- jumpPending: boolean;
- focusPending: boolean;
-};
+export type TimelineScrollDirection = 'backward' | 'forward';
-export type TimelineScrollDecision = {
- anchorMode: TimelineAnchorMode;
- atBottom: boolean;
- paginateBackward: boolean;
- paginateForward: boolean;
- nextBackwardArmed: boolean;
- nextForwardArmed: boolean;
-};
+export type RectLike = Pick;
export const TIMELINE_BOTTOM_THRESHOLD_PX = 100;
-export const TIMELINE_PAGINATION_THRESHOLD_PX = 500;
-export const TIMELINE_PAGINATION_REARM_THRESHOLD_PX = 700;
-export const TIMELINE_SCROLL_INTENT_DELTA_PX = 8;
-export const TIMELINE_BOTTOM_RELEASE_DISTANCE_PX = 200;
export const getDistanceFromBottom = (
scrollSize: number,
@@ -67,85 +36,3 @@ export const getCenterAnchorAdjustment = (targetRect: RectLike, viewportRect: Re
const viewportCenter = viewportRect.top + viewportRect.height / 2;
return targetCenter - viewportCenter;
};
-
-export const releaseAnchorOnScroll = (
- anchorMode: TimelineAnchorMode,
- snapshot: TimelineScrollSnapshot
-): TimelineAnchorMode => {
- const distanceFromBottom = getDistanceFromBottom(
- snapshot.scrollSize,
- snapshot.offset,
- snapshot.viewportSize
- );
- const userScrollingUp =
- snapshot.offset + TIMELINE_SCROLL_INTENT_DELTA_PX < snapshot.previousOffset;
- const userScrollingDown =
- snapshot.offset > snapshot.previousOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
-
- if (anchorMode === 'center') {
- if (userScrollingUp) return 'free';
- if (userScrollingDown) return 'free';
- return 'center';
- }
-
- if (anchorMode === 'bottom') {
- if (isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize))
- return 'bottom';
- if (userScrollingUp && distanceFromBottom > TIMELINE_BOTTOM_RELEASE_DISTANCE_PX) return 'free';
- return 'bottom';
- }
-
- if (isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize))
- return 'bottom';
- if (userScrollingUp) return 'free';
- return anchorMode;
-};
-
-export const getTimelineScrollDecision = (
- anchorMode: TimelineAnchorMode,
- snapshot: TimelineScrollSnapshot,
- pagination: TimelinePaginationState
-): TimelineScrollDecision => {
- const nextAnchorMode = releaseAnchorOnScroll(anchorMode, snapshot);
- const atBottom = isTimelineAtBottom(snapshot.scrollSize, snapshot.offset, snapshot.viewportSize);
- const userScrollingUp =
- snapshot.offset + TIMELINE_SCROLL_INTENT_DELTA_PX < snapshot.previousOffset;
- const userScrollingDown =
- snapshot.offset > snapshot.previousOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
- const distanceFromBottom = getDistanceFromBottom(
- snapshot.scrollSize,
- snapshot.offset,
- snapshot.viewportSize
- );
- const nearBackwardEdge = snapshot.offset < TIMELINE_PAGINATION_THRESHOLD_PX;
- const nearForwardEdge = distanceFromBottom < TIMELINE_PAGINATION_THRESHOLD_PX;
- const backwardArmed =
- pagination.backwardArmed || snapshot.offset > TIMELINE_PAGINATION_REARM_THRESHOLD_PX;
- const forwardArmed =
- pagination.forwardArmed || distanceFromBottom > TIMELINE_PAGINATION_REARM_THRESHOLD_PX;
- const paginationBlocked =
- pagination.jumpPending || pagination.focusPending || nextAnchorMode === 'center';
- const paginateBackward =
- !paginationBlocked &&
- backwardArmed &&
- userScrollingUp &&
- nearBackwardEdge &&
- pagination.canPaginateBack &&
- pagination.backwardIdle;
- const paginateForward =
- !paginationBlocked &&
- forwardArmed &&
- userScrollingDown &&
- nearForwardEdge &&
- !pagination.liveTimelineLinked &&
- pagination.forwardIdle;
-
- return {
- anchorMode: nextAnchorMode,
- atBottom,
- paginateBackward,
- paginateForward,
- nextBackwardArmed: paginateBackward ? false : backwardArmed,
- nextForwardArmed: paginateForward ? false : forwardArmed,
- };
-};
diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx
index 431b9687f..a8c89b16b 100644
--- a/src/app/features/room/useTimelineViewportController.test.tsx
+++ b/src/app/features/room/useTimelineViewportController.test.tsx
@@ -239,6 +239,31 @@ describe('useTimelineViewportController', () => {
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
+ it('paginates older history from user input while already clamped at the top edge', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(0);
+ });
+ expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+
+ act(() => {
+ result.current.markUserScrollIntent('backward');
+ });
+
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ });
+
it('keeps bottom anchor pinned during loading-driven offset shifts', () => {
const refs = createRefs();
const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
@@ -272,7 +297,6 @@ describe('useTimelineViewportController', () => {
result.current.handleVListScroll(1900);
});
- expect(setAtBottom).not.toHaveBeenCalledWith(false);
expect(loadingTimelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
@@ -336,6 +360,7 @@ describe('useTimelineViewportController', () => {
});
expect(setAtBottom).not.toHaveBeenCalledWith(false);
+ expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
it('does not paginate while an event jump is still loading', () => {
@@ -435,6 +460,9 @@ describe('useTimelineViewportController', () => {
act(() => {
result.current.handleVListScroll(2200);
});
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
act(() => {
result.current.handleVListScroll(2260);
});
@@ -442,7 +470,33 @@ describe('useTimelineViewportController', () => {
expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
});
- it('does not keep paginating forward when a new page lands near the bottom edge', () => {
+ it('paginates forward from user input while already clamped at the bottom edge', () => {
+ const timelineSync = createTimelineSync({
+ liveTimelineLinked: false,
+ });
+ const { result, refs } = renderController({ timelineSync });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+ mutableVList.scrollOffset = 2200;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+ expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+
+ act(() => {
+ result.current.markUserScrollIntent('forward');
+ });
+
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
+ });
+
+ it('does not repeat forward pagination from layout scrolls after a landed jump', () => {
const timelineSync = createTimelineSync({
liveTimelineLinked: false,
focusItem: { index: 1, scrollTo: true, highlight: true },
@@ -461,30 +515,91 @@ describe('useTimelineViewportController', () => {
scrollSize: number;
viewportSize: number;
};
- mutableVList.scrollOffset = 2200;
mutableVList.scrollSize = 3000;
mutableVList.viewportSize = 800;
+ mutableVList.scrollOffset = 2200;
act(() => {
result.current.handleVListScroll(2200);
});
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 2260;
act(() => {
result.current.handleVListScroll(2260);
});
+ mutableVList.scrollOffset = 2280;
+ act(() => {
+ result.current.handleVListScroll(2280);
+ });
expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
act(() => {
- result.current.handleVListScroll(2270);
+ result.current.markUserScrollIntent();
});
+ mutableVList.scrollOffset = 2290;
act(() => {
- result.current.handleVListScroll(2280);
+ result.current.handleVListScroll(2290);
+ });
+
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not repeat backward pagination from layout scrolls after a landed jump', () => {
+ const timelineSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
+ });
+ const { result, refs, rerender } = renderController({ timelineSync });
+ const landedTimelineSync = createTimelineSync({
+ ...timelineSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
+ });
+
+ rerender({ sync: landedTimelineSync, roomEventId: undefined });
+
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+ mutableVList.scrollOffset = 700;
+
+ act(() => {
+ result.current.handleVListScroll(700);
+ });
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 120;
+ act(() => {
+ result.current.handleVListScroll(120);
+ });
+ mutableVList.scrollOffset = 80;
+ act(() => {
+ result.current.handleVListScroll(80);
});
expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 60;
+ act(() => {
+ result.current.handleVListScroll(60);
+ });
+
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
});
- it('rearams forward pagination only after leaving the bottom edge', () => {
+ it('continues forward pagination after leaving and returning to the bottom edge', () => {
const timelineSync = createTimelineSync({
liveTimelineLinked: false,
focusItem: { index: 1, scrollTo: true, highlight: true },
@@ -510,6 +625,9 @@ describe('useTimelineViewportController', () => {
act(() => {
result.current.handleVListScroll(2200);
});
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
mutableVList.scrollOffset = 2260;
act(() => {
result.current.handleVListScroll(2260);
@@ -520,6 +638,9 @@ describe('useTimelineViewportController', () => {
act(() => {
result.current.handleVListScroll(1200);
});
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
mutableVList.scrollOffset = 2260;
act(() => {
result.current.handleVListScroll(2260);
@@ -527,4 +648,63 @@ describe('useTimelineViewportController', () => {
expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
});
+
+ it('rearms forward pagination after a forward page settles while staying near bottom', () => {
+ const timelineSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
+ forwardStatus: 'idle',
+ });
+ const { result, refs, rerender } = renderController({ timelineSync });
+ const landedTimelineSync = createTimelineSync({
+ ...timelineSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
+ forwardStatus: 'idle',
+ });
+
+ rerender({ sync: landedTimelineSync, roomEventId: undefined });
+
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ mutableVList.scrollOffset = 2200;
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 2260;
+ act(() => {
+ result.current.handleVListScroll(2260);
+ });
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ const forwardLoadingSync = createTimelineSync({
+ ...landedTimelineSync,
+ forwardStatus: 'loading',
+ });
+ rerender({ sync: forwardLoadingSync, roomEventId: undefined });
+ const forwardIdleSync = createTimelineSync({
+ ...landedTimelineSync,
+ forwardStatus: 'idle',
+ });
+ rerender({ sync: forwardIdleSync, roomEventId: undefined });
+
+ act(() => {
+ result.current.markUserScrollIntent();
+ });
+ mutableVList.scrollOffset = 2290;
+ act(() => {
+ result.current.handleVListScroll(2290);
+ });
+
+ expect(forwardIdleSync.handleTimelinePagination).toHaveBeenCalledWith(false);
+ });
});
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index c84f4694f..f4265968a 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -8,16 +8,27 @@ import {
import {
getBottomClampSpacer,
getCenterAnchorAdjustment,
- getTimelineScrollDecision,
isTimelineAtBottom,
+ TIMELINE_BOTTOM_THRESHOLD_PX,
type TimelineAnchor,
+ type TimelineScrollDirection,
} from './timelineViewportModel';
+import { pushTimelineJumpDebug } from './timelineJumpDebug';
const INITIAL_BACKFILL_PAGE_BUDGET = 6;
const BOOTSTRAP_FILL_TARGET_SLACK_PX = 32;
+const TIMELINE_PAGINATION_THRESHOLD_PX = 500;
+const TIMELINE_SCROLL_INTENT_DELTA_PX = 8;
+const TIMELINE_SCROLL_INTENT_TTL_MS = 250;
type TimelineSyncController = ReturnType;
+type PendingScrollIntent = {
+ id: number;
+ direction?: TimelineScrollDirection;
+ expiresAt: number;
+};
+
export type UseTimelineViewportControllerOptions = {
roomId: string;
eventId?: string;
@@ -55,10 +66,9 @@ export function useTimelineViewportController({
const anchorRef = useRef({ kind: 'bottom' });
const remainingInitialBackfillPagesRef = useRef(INITIAL_BACKFILL_PAGE_BUDGET);
const lastScrollOffsetRef = useRef(0);
- const backwardEdgeArmedRef = useRef(true);
- const forwardEdgeArmedRef = useRef(true);
const suppressNextCenterScrollRef = useRef(false);
- const userScrollIntentRef = useRef(false);
+ const scrollIntentCounterRef = useRef(0);
+ const pendingScrollIntentRef = useRef(undefined);
const pendingBootstrapRevealRef = useRef(false);
const bootstrapViewportRetryFrameRef = useRef(undefined);
const settleAnchorFrameRef = useRef(undefined);
@@ -77,6 +87,66 @@ export function useTimelineViewportController({
const forwardStatusRef = useRef(timelineSync.forwardStatus);
forwardStatusRef.current = timelineSync.forwardStatus;
+ const clearPendingScrollIntent = useCallback(() => {
+ pendingScrollIntentRef.current = undefined;
+ }, []);
+
+ const getActiveScrollIntent = useCallback((): PendingScrollIntent | undefined => {
+ const intent = pendingScrollIntentRef.current;
+ if (!intent) return undefined;
+ if (Date.now() <= intent.expiresAt) return intent;
+ pendingScrollIntentRef.current = undefined;
+ return undefined;
+ }, []);
+
+ const getScrollEdges = useCallback(
+ (offset = vListRef.current?.scrollOffset) => {
+ const v = vListRef.current;
+ if (!v || offset === undefined) return undefined;
+ const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
+ return {
+ offset,
+ distanceFromBottom,
+ isAtBottom: distanceFromBottom < TIMELINE_BOTTOM_THRESHOLD_PX,
+ isAtBackwardEdge: offset < TIMELINE_PAGINATION_THRESHOLD_PX,
+ isAtForwardEdge: distanceFromBottom < TIMELINE_PAGINATION_THRESHOLD_PX,
+ };
+ },
+ [vListRef]
+ );
+
+ const requestPaginationFromScroll = useCallback(
+ (
+ direction: TimelineScrollDirection,
+ intent: PendingScrollIntent | undefined,
+ offset?: number
+ ) => {
+ if (!intent) return;
+ if (jumpInFlight || timelineSyncRef.current.focusItem?.scrollTo) return;
+
+ const edges = getScrollEdges(offset);
+ if (!edges) return;
+ if (
+ direction === 'backward' &&
+ (!edges.isAtBackwardEdge ||
+ !canPaginateBackRef.current ||
+ backwardStatusRef.current !== 'idle')
+ )
+ return;
+ if (
+ direction === 'forward' &&
+ (!edges.isAtForwardEdge ||
+ liveTimelineLinkedRef.current ||
+ forwardStatusRef.current !== 'idle')
+ )
+ return;
+
+ pendingScrollIntentRef.current = undefined;
+ timelineSyncRef.current.handleTimelinePagination(direction === 'backward');
+ },
+ [getScrollEdges, jumpInFlight, timelineSyncRef]
+ );
+
if (currentRoomIdRef.current !== roomId) {
hasInitialScrolledRef.current = false;
currentRoomIdRef.current = roomId;
@@ -84,10 +154,8 @@ export function useTimelineViewportController({
anchorRef.current = eventId ? { kind: 'none' } : { kind: 'bottom' };
remainingInitialBackfillPagesRef.current = INITIAL_BACKFILL_PAGE_BUDGET;
lastScrollOffsetRef.current = 0;
- backwardEdgeArmedRef.current = true;
- forwardEdgeArmedRef.current = true;
suppressNextCenterScrollRef.current = false;
- userScrollIntentRef.current = false;
+ pendingScrollIntentRef.current = undefined;
pendingBootstrapRevealRef.current = false;
if (bootstrapViewportRetryFrameRef.current !== undefined) {
cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
@@ -159,6 +227,7 @@ export function useTimelineViewportController({
anchorRef.current = anchor;
if (anchor.kind === 'message-center') {
suppressNextCenterScrollRef.current = true;
+ pendingScrollIntentRef.current = undefined;
}
if (settleAnchorFrameRef.current !== undefined) {
cancelAnimationFrame(settleAnchorFrameRef.current);
@@ -176,12 +245,21 @@ export function useTimelineViewportController({
const beginJumpLoad = useCallback(
(targetEventId: string) => {
+ pendingScrollIntentRef.current = undefined;
setJumpInFlight(true);
+ pushTimelineJumpDebug('viewport', 'begin_jump_load', {
+ roomId,
+ targetEventId,
+ });
void Promise.resolve(timelineSyncRef.current.loadEventTimeline(targetEventId)).finally(() => {
setJumpInFlight(false);
+ pushTimelineJumpDebug('viewport', 'jump_load_settled', {
+ roomId,
+ targetEventId,
+ });
});
},
- [timelineSyncRef]
+ [roomId, timelineSyncRef]
);
useEffect(
@@ -266,11 +344,18 @@ export function useTimelineViewportController({
]);
const prevBackwardStatusRef = useRef(timelineSync.backwardStatus);
+ const prevForwardStatusRef = useRef(timelineSync.forwardStatus);
const wasAtBottomBeforePaginationRef = useRef(false);
useLayoutEffect(() => {
const prev = prevBackwardStatusRef.current;
prevBackwardStatusRef.current = timelineSync.backwardStatus;
+ if (
+ prev !== timelineSync.backwardStatus &&
+ (prev === 'loading' || timelineSync.backwardStatus === 'loading')
+ ) {
+ clearPendingScrollIntent();
+ }
if (timelineSync.backwardStatus === 'loading') {
wasAtBottomBeforePaginationRef.current = atBottomRef.current;
if (anchorRef.current.kind !== 'bottom') setShift(true);
@@ -302,10 +387,22 @@ export function useTimelineViewportController({
timelineSync.backwardStatus,
timelineSync.canPaginateBack,
atBottomRef,
+ clearPendingScrollIntent,
settleTimelineAnchor,
vListRef,
]);
+ useLayoutEffect(() => {
+ const prev = prevForwardStatusRef.current;
+ prevForwardStatusRef.current = timelineSync.forwardStatus;
+ if (
+ prev !== timelineSync.forwardStatus &&
+ (prev === 'loading' || timelineSync.forwardStatus === 'loading')
+ ) {
+ clearPendingScrollIntent();
+ }
+ }, [clearPendingScrollIntent, timelineSync.forwardStatus]);
+
useEffect((): void | (() => void) => {
let timeoutId: ReturnType | undefined;
if (timelineSync.focusItem) {
@@ -327,6 +424,12 @@ export function useTimelineViewportController({
const focusEventId = processedEventsRef.current[processedIndex]?.id;
if (focusEventId) {
settleTimelineAnchor({ kind: 'message-center', eventId: focusEventId }, true);
+ pushTimelineJumpDebug('viewport', 'focus_center_anchor_set', {
+ roomId,
+ focusEventId,
+ processedIndex,
+ focusRawIndex,
+ });
}
timelineSync.setFocusItem((prev) =>
prev ? { ...prev, index: focusRawIndex, scrollTo: false } : undefined
@@ -345,6 +448,7 @@ export function useTimelineViewportController({
timelineSync.focusItem,
getRawIndexToProcessedIndex,
processedEventsRef,
+ roomId,
settleTimelineAnchor,
vListRef,
]);
@@ -371,7 +475,11 @@ export function useTimelineViewportController({
if (!timelineSync.focusItem) return;
if (timelineSync.focusItem.scrollTo) return;
setJumpInFlight(false);
- }, [jumpInFlight, timelineSync.focusItem]);
+ pushTimelineJumpDebug('viewport', 'jump_inflight_cleared_by_focus_settle', {
+ roomId,
+ focusIndex: timelineSync.focusItem.index,
+ });
+ }, [jumpInFlight, roomId, timelineSync.focusItem]);
useLayoutEffect(() => {
if (!isReady) return;
@@ -473,71 +581,84 @@ export function useTimelineViewportController({
const prevOffset = lastScrollOffsetRef.current;
lastScrollOffsetRef.current = offset;
+ const activeIntent = getActiveScrollIntent();
+ const hasScrollIntent = activeIntent !== undefined;
+ const userScrollingUp = offset + TIMELINE_SCROLL_INTENT_DELTA_PX < prevOffset;
+ const userScrollingDown = offset > prevOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
+ const intentTowardBackward =
+ hasScrollIntent &&
+ (activeIntent.direction === 'backward' ||
+ (activeIntent.direction === undefined && userScrollingUp));
+ const intentTowardForward =
+ hasScrollIntent &&
+ (activeIntent.direction === 'forward' ||
+ (activeIntent.direction === undefined && userScrollingDown));
+
+ const edges = getScrollEdges(offset);
+ if (!edges) return;
if (anchorRef.current.kind === 'message-center' && suppressNextCenterScrollRef.current) {
suppressNextCenterScrollRef.current = false;
return;
}
- const decision = getTimelineScrollDecision(
- anchorRef.current.kind === 'message-center'
- ? 'center'
- : anchorRef.current.kind === 'bottom'
- ? 'bottom'
- : 'free',
- {
+ if (anchorRef.current.kind === 'message-center') {
+ if (!hasScrollIntent) return;
+ anchorRef.current = { kind: 'none' };
+ pushTimelineJumpDebug('viewport', 'anchor_released_during_jump_context', {
+ roomId,
+ jumpInFlight,
+ hasFocusItem: Boolean(timelineSync.focusItem),
+ focusScrollTo: timelineSync.focusItem?.scrollTo,
offset,
- previousOffset: prevOffset,
- scrollSize: v.scrollSize,
- viewportSize: v.viewportSize,
- },
- {
- canPaginateBack: canPaginateBackRef.current,
- backwardIdle: backwardStatusRef.current === 'idle',
- backwardArmed: backwardEdgeArmedRef.current,
- liveTimelineLinked: liveTimelineLinkedRef.current,
- forwardIdle: forwardStatusRef.current === 'idle',
- forwardArmed: forwardEdgeArmedRef.current,
- jumpPending: jumpInFlight,
- focusPending: Boolean(timelineSync.focusItem?.scrollTo),
+ prevOffset,
+ atBottom: edges.isAtBottom,
+ });
+ } else if (anchorRef.current.kind === 'bottom') {
+ const paginationLoading =
+ backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading';
+ if (intentTowardBackward && !edges.isAtBottom && !paginationLoading) {
+ anchorRef.current = { kind: 'none' };
}
- );
-
- const suppressBottomReleaseWhileLoading =
- anchorRef.current.kind === 'bottom' &&
- decision.anchorMode === 'free' &&
- (backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading');
- const suppressBottomReleaseWithoutIntent =
- anchorRef.current.kind === 'bottom' &&
- decision.anchorMode === 'free' &&
- !userScrollIntentRef.current;
- const suppressBottomRelease =
- suppressBottomReleaseWhileLoading || suppressBottomReleaseWithoutIntent;
- const nextAnchorMode = suppressBottomRelease ? 'bottom' : decision.anchorMode;
- const nextAtBottom = suppressBottomRelease ? true : decision.atBottom;
- const paginateBackward = suppressBottomRelease ? false : decision.paginateBackward;
- const paginateForward = suppressBottomRelease ? false : decision.paginateForward;
-
- backwardEdgeArmedRef.current = decision.nextBackwardArmed;
- forwardEdgeArmedRef.current = decision.nextForwardArmed;
-
- if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
-
- if (nextAnchorMode === 'bottom') {
+ } else if (edges.isAtBottom) {
anchorRef.current = { kind: 'bottom' };
- } else if (nextAnchorMode === 'free') {
- anchorRef.current = { kind: 'none' };
}
- if (paginateBackward) timelineSyncRef.current.handleTimelinePagination(true);
- if (paginateForward) timelineSyncRef.current.handleTimelinePagination(false);
+ const nextAtBottom =
+ anchorRef.current.kind === 'bottom' && !hasScrollIntent && !edges.isAtBottom
+ ? true
+ : edges.isAtBottom;
+ if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
+
+ if (intentTowardBackward) requestPaginationFromScroll('backward', activeIntent, offset);
+ if (intentTowardForward) requestPaginationFromScroll('forward', activeIntent, offset);
},
- [atBottomRef, jumpInFlight, setAtBottom, timelineSync.focusItem, timelineSyncRef, vListRef]
+ [
+ atBottomRef,
+ getActiveScrollIntent,
+ getScrollEdges,
+ jumpInFlight,
+ requestPaginationFromScroll,
+ roomId,
+ setAtBottom,
+ timelineSync.focusItem,
+ vListRef,
+ ]
);
- const markUserScrollIntent = useCallback(() => {
- userScrollIntentRef.current = true;
- }, []);
+ const markUserScrollIntent = useCallback(
+ (direction?: TimelineScrollDirection) => {
+ scrollIntentCounterRef.current += 1;
+ const intent = {
+ id: scrollIntentCounterRef.current,
+ direction,
+ expiresAt: Date.now() + TIMELINE_SCROLL_INTENT_TTL_MS,
+ };
+ pendingScrollIntentRef.current = intent;
+ if (direction) requestPaginationFromScroll(direction, intent);
+ },
+ [requestPaginationFromScroll]
+ );
return {
shift,
diff --git a/src/app/features/settings/Settings.test.tsx b/src/app/features/settings/Settings.test.tsx
index abc8fa997..4b9eb5e7a 100644
--- a/src/app/features/settings/Settings.test.tsx
+++ b/src/app/features/settings/Settings.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { ClientConfigProvider } from '$hooks/useClientConfig';
import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize';
@@ -150,12 +150,10 @@ describe('Settings', () => {
);
fireEvent.click(screen.getByRole('button', { name: /copy settings link/i }));
-
- await waitFor(() => {
- expect(writeText).toHaveBeenCalledWith(
- 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings'
- );
- });
+ await Promise.resolve();
+ expect(writeText).toHaveBeenCalledWith(
+ 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings'
+ );
});
it('preserves the configured hash-router basename in copied settings links', async () => {
@@ -170,11 +168,9 @@ describe('Settings', () => {
);
fireEvent.click(screen.getByRole('button', { name: /copy settings link/i }));
-
- await waitFor(() => {
- expect(writeText).toHaveBeenCalledWith(
- 'https://app.example/#/app/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings'
- );
- });
+ await Promise.resolve();
+ expect(writeText).toHaveBeenCalledWith(
+ 'https://app.example/#/app/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings'
+ );
});
});
diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx
index 9e769f74b..42780e25a 100644
--- a/src/app/features/settings/cosmetics/Themes.test.tsx
+++ b/src/app/features/settings/cosmetics/Themes.test.tsx
@@ -138,7 +138,7 @@ describe('Appearance settings', () => {
expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Dracula' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Dracula' }).at(-1)).toBeDisabled();
- });
+ }, 15000);
it('updates the manual app and code block theme settings when system theme is disabled', () => {
currentSettings = {
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index b9d253c6a..b0fdcd1f5 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Room } from '$types/matrix-sdk';
-import { RoomEvent } from '$types/matrix-sdk';
+import { Direction, RoomEvent } from '$types/matrix-sdk';
import { useTimelineSync } from './useTimelineSync';
vi.mock('@sentry/react', () => ({
@@ -17,8 +17,8 @@ vi.mock('@sentry/react', () => ({
type FakeTimeline = {
getEvents: () => unknown[];
- getNeighbouringTimeline: () => undefined;
- getPaginationToken: () => undefined;
+ getNeighbouringTimeline: (direction: Direction) => FakeTimeline | undefined;
+ getPaginationToken: (direction: Direction) => string | undefined;
getRoomId: () => string;
};
@@ -32,25 +32,31 @@ type FakeRoom = Room &
emit: EventEmitter['emit'];
};
-function createTimeline(events: unknown[] = [{}]): FakeTimeline {
+function createTimeline(
+ events: unknown[] = [{}],
+ paginationToken?: string,
+ neighbour?: FakeTimeline
+): FakeTimeline {
return {
getEvents: () => events,
- getNeighbouringTimeline: () => undefined,
- getPaginationToken: () => undefined,
+ getNeighbouringTimeline: (direction: Direction) =>
+ direction === Direction.Forward ? neighbour : undefined,
+ getPaginationToken: () => paginationToken,
getRoomId: () => '!room:test',
};
}
function createRoom(
roomId = '!room:test',
- events: unknown[] = [{}]
+ events: unknown[] = [{}],
+ paginationToken?: string
): {
room: FakeRoom;
timelineSet: FakeTimelineSet;
events: unknown[];
} {
const timeline = {
- ...createTimeline(events),
+ ...createTimeline(events, paginationToken),
getRoomId: () => roomId,
};
const timelineSet = new EventEmitter() as FakeTimelineSet;
@@ -238,4 +244,67 @@ describe('useTimelineSync', () => {
expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline());
});
+
+ it('does not auto-scroll to bottom while a jump focus item is active', async () => {
+ const { room, events } = createRoom();
+ const scrollToBottom = vi.fn<() => void>();
+
+ const { result } = renderHook(() =>
+ useTimelineSync({
+ room: room as Room,
+ mx: { getUserId: () => '@alice:test' } as never,
+ isAtBottom: true,
+ isAtBottomRef: { current: true },
+ scrollToBottom,
+ unreadInfo: undefined,
+ setUnreadInfo: vi.fn<() => void>(),
+ hideReadsRef: { current: false },
+ readUptoEventIdRef: { current: undefined },
+ })
+ );
+
+ await act(async () => {
+ result.current.setFocusItem({ index: 0, scrollTo: true, highlight: true });
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ events.push({});
+ room.emit(RoomEvent.LocalEchoUpdated, {}, room);
+ await Promise.resolve();
+ });
+
+ expect(scrollToBottom).not.toHaveBeenCalled();
+ });
+
+ it('does not recursively paginate sparse pages from a single pagination request', async () => {
+ const { room, events } = createRoom('!room:test', [{}], 'token');
+ const paginateEventTimeline = vi.fn<() => Promise>(async () => {
+ events.push({});
+ });
+
+ const { result } = renderHook(() =>
+ useTimelineSync({
+ room: room as Room,
+ mx: {
+ getUserId: () => '@alice:test',
+ paginateEventTimeline,
+ getRoom: () => null,
+ } as never,
+ isAtBottom: false,
+ isAtBottomRef: { current: false },
+ scrollToBottom: vi.fn<() => void>(),
+ unreadInfo: undefined,
+ setUnreadInfo: vi.fn<() => void>(),
+ hideReadsRef: { current: false },
+ readUptoEventIdRef: { current: undefined },
+ })
+ );
+
+ await act(async () => {
+ await result.current.handleTimelinePagination(true);
+ });
+
+ expect(paginateEventTimeline).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index c10b762b8..360d10f79 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -26,6 +26,7 @@ import {
getRoomUnreadInfo,
PAGINATION_LIMIT,
} from '$utils/timeline';
+import { pushTimelineJumpDebug } from '$features/room/timelineJumpDebug';
export const EVENT_TIMELINE_LOAD_TIMEOUT_MS = 12000;
@@ -147,16 +148,7 @@ const useTimelinePagination = (
(backwards ? setBackwardStatus : setForwardStatus)('loading');
}
- // `continuing` tracks whether we hand the fetchingRef lock to a recursive
- // continuation call below. The finally block must NOT reset the lock if
- // the recursive call has already claimed it, otherwise there is a brief
- // window where fetchingRef is false while the recursive paginate is in
- // flight, allowing a third overlapping call to start on sparse pages.
- let continuing = false;
-
try {
- const countBefore = getTimelinesEventsCount(lTimelines);
-
const [err] = await to(mx.paginateEventTimeline(timelineToPaginate, { backwards, limit }));
if (err) {
@@ -189,38 +181,9 @@ const useTimelinePagination = (
if (!firstTimeline) return;
recalibratePagination(freshLTimelines);
(backwards ? setBackwardStatus : setForwardStatus)('idle');
-
- const countAfter = getTimelinesEventsCount(getLinkedTimelines(firstTimeline));
- const fetched = countAfter - countBefore;
-
- if (fetched > 0 && fetched < 5) {
- const checkTimeline = backwards
- ? freshLTimelines[0]
- : freshLTimelines[freshLTimelines.length - 1];
- if (!checkTimeline) return;
- const checkDirection = backwards ? Direction.Backward : Direction.Forward;
- const stillHasToken =
- typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken(checkDirection) ===
- 'string';
- if (stillHasToken) {
- // Release lock so inner paginate can claim it, then mark continuing
- // so the finally block below does NOT reset it after inner claims.
- fetchingRef.current[directionKey] = false;
- continuing = true;
- paginate(backwards);
- // At this point the inner paginate has synchronously set
- // fetchingRef.current[directionKey] = true before hitting its own
- // await. The finally below will skip the reset.
- }
- }
}
} finally {
- // Only release the lock if we did NOT hand it to a recursive continuation.
- // If `continuing` is true the recursive call owns the lock and will release
- // it in its own finally block.
- if (!continuing) {
- fetchingRef.current[directionKey] = false;
- }
+ fetchingRef.current[directionKey] = false;
}
};
}, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus]);
@@ -447,6 +410,12 @@ export function useTimelineSync({
useCallback(
(evtId, lTimelines, evtAbsIndex) => {
if (!alive()) return;
+ pushTimelineJumpDebug('sync', 'jump_load_resolved', {
+ roomId: room.roomId,
+ eventId: evtId,
+ absIndex: evtAbsIndex,
+ linkedTimelines: lTimelines.length,
+ });
setTimeline({ linkedTimelines: lTimelines });
@@ -456,7 +425,7 @@ export function useTimelineSync({
highlight: evtId !== readUptoEventIdRef.current,
});
},
- [alive, readUptoEventIdRef]
+ [alive, readUptoEventIdRef, room.roomId]
),
useCallback(() => {
if (!alive()) return;
@@ -474,6 +443,13 @@ export function useTimelineSync({
room,
useCallback(
(mEvt: MatrixEvent) => {
+ if (focusItem?.scrollTo) {
+ pushTimelineJumpDebug('sync', 'live_event_ignored_while_jump_landing', {
+ roomId: room.roomId,
+ eventId: mEvt.getId() ?? undefined,
+ });
+ return;
+ }
const { threadRootId } = mEvt;
if (threadRootId !== undefined) return;
@@ -490,6 +466,11 @@ export function useTimelineSync({
}
scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth');
+ pushTimelineJumpDebug('sync', 'live_event_autoscroll_bottom', {
+ roomId: room.roomId,
+ eventId: mEvt.getId() ?? undefined,
+ mode: mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth',
+ });
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -501,7 +482,16 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
},
- [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef]
+ [
+ focusItem?.scrollTo,
+ mx,
+ room,
+ isAtBottomRef,
+ unreadInfo,
+ scrollToBottom,
+ setUnreadInfo,
+ hideReadsRef,
+ ]
)
);
@@ -547,6 +537,12 @@ export function useTimelineSync({
);
useEffect(() => {
+ if (focusItem?.scrollTo) {
+ pushTimelineJumpDebug('sync', 'auto_follow_blocked_jump_landing', {
+ roomId: room.roomId,
+ });
+ return;
+ }
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
@@ -565,7 +561,21 @@ export function useTimelineSync({
lastScrolledAtEventsLengthRef.current = eventsLength;
scrollToBottom('instant');
- }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
+ pushTimelineJumpDebug('sync', 'auto_follow_scroll_bottom', {
+ roomId: room.roomId,
+ eventsLength,
+ resetAutoScrollPending,
+ isAtBottom,
+ liveTimelineLinked,
+ });
+ }, [
+ focusItem?.scrollTo,
+ isAtBottom,
+ liveTimelineLinked,
+ eventsLength,
+ scrollToBottom,
+ room.roomId,
+ ]);
useEffect(() => {
if (eventId) return;
From 8e0ba226b463dfafbab04244072952c8f5ff2cae Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 21:01:06 -0500
Subject: [PATCH 24/28] fix: scrolling after jumping
hopefully the last fix...
---
.../features/room/TimelineEventRow.test.tsx | 43 ++++++++++
src/app/features/room/TimelineEventRow.tsx | 6 +-
src/app/features/room/TimelineViewport.tsx | 17 +---
.../useTimelineViewportController.test.tsx | 82 +++++++++++++++++++
.../room/useTimelineViewportController.ts | 4 +-
5 files changed, 133 insertions(+), 19 deletions(-)
create mode 100644 src/app/features/room/TimelineEventRow.test.tsx
diff --git a/src/app/features/room/TimelineEventRow.test.tsx b/src/app/features/room/TimelineEventRow.test.tsx
new file mode 100644
index 000000000..9371e52a1
--- /dev/null
+++ b/src/app/features/room/TimelineEventRow.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import type { Room } from '$types/matrix-sdk';
+import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline';
+import { MessageLayout } from '$state/settings';
+import { TimelineEventRow } from './TimelineEventRow';
+
+const createProcessedEvent = (): ProcessedEvent =>
+ ({
+ id: '$event',
+ itemIndex: 0,
+ mEvent: {
+ getType: () => 'm.room.message',
+ getStateKey: () => undefined,
+ getTs: () => Date.now(),
+ },
+ timelineSet: {},
+ eventSender: null,
+ collapsed: false,
+ willRenderNewDivider: false,
+ willRenderDayDivider: false,
+ }) as unknown as ProcessedEvent;
+
+describe('TimelineEventRow', () => {
+ it('keeps backward pagination inside the first virtual row before the first event', () => {
+ const { container } = render(
+ Loading older messages }
+ renderMatrixEvent={() => First message
}
+ />
+ );
+
+ expect(screen.getByText('Loading older messages')).toBeInTheDocument();
+ expect(screen.getByText('First message')).toBeInTheDocument();
+ expect(container.textContent).toBe('Loading older messagesFirst message');
+ });
+});
diff --git a/src/app/features/room/TimelineEventRow.tsx b/src/app/features/room/TimelineEventRow.tsx
index 64a175c2a..08cdc5ed8 100644
--- a/src/app/features/room/TimelineEventRow.tsx
+++ b/src/app/features/room/TimelineEventRow.tsx
@@ -32,6 +32,7 @@ export type TimelineEventRowProps = {
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
canPaginateBack: boolean;
+ backPagination?: ReactNode;
renderMatrixEvent: (
eventType: string,
isStateEvent: boolean,
@@ -50,6 +51,7 @@ export function TimelineEventRow({
messageLayout,
messageSpacing,
canPaginateBack,
+ backPagination,
renderMatrixEvent,
}: Readonly) {
const introPadding = `${toRem(28)} ${toRem(16)} ${toRem(24)} ${
@@ -60,13 +62,14 @@ export function TimelineEventRow({
if (index === 0 && !canPaginateBack) {
return (
+ {backPagination}
);
}
- if (index === 0) return ;
+ if (index === 0) return {backPagination};
return ;
}
@@ -111,6 +114,7 @@ export function TimelineEventRow({
)}
+ {backPagination}
{dividers}
{renderedEvent}
diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx
index a1bc8958d..9eca30b1b 100644
--- a/src/app/features/room/TimelineViewport.tsx
+++ b/src/app/features/room/TimelineViewport.tsx
@@ -128,22 +128,6 @@ export function TimelineViewport({
return (
{unreadBanner}
- {backPagination && (
-
- {backPagination}
-
- )}
)}
diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx
index a8c89b16b..66ab85f8b 100644
--- a/src/app/features/room/useTimelineViewportController.test.tsx
+++ b/src/app/features/room/useTimelineViewportController.test.tsx
@@ -300,6 +300,46 @@ describe('useTimelineViewportController', () => {
expect(loadingTimelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
+ it('enables virtual-list shift while backward pagination loads away from bottom', () => {
+ const timelineSync = createTimelineSync();
+ const { result, refs, rerender } = renderController({ timelineSync });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 2200;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+ act(() => {
+ result.current.markUserScrollIntent('backward');
+ });
+ mutableVList.scrollOffset = 1900;
+ act(() => {
+ result.current.handleVListScroll(1900);
+ });
+
+ const loadingTimelineSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingTimelineSync, roomEventId: undefined });
+
+ expect(result.current.shift).toBe(true);
+
+ const idleTimelineSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ });
+ rerender({ sync: idleTimelineSync, roomEventId: undefined });
+
+ expect(result.current.shift).toBe(false);
+ });
+
it('releases bottom anchor on an intentional upward scroll when idle', () => {
const refs = createRefs();
const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
@@ -496,6 +536,48 @@ describe('useTimelineViewportController', () => {
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
});
+ it('does not bottom-anchor a detached jump window when forward pagination loads', () => {
+ const timelineSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
+ });
+ const { result, refs, rerender } = renderController({ timelineSync });
+ const landedTimelineSync = createTimelineSync({
+ ...timelineSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
+ forwardStatus: 'idle',
+ });
+
+ rerender({ sync: landedTimelineSync, roomEventId: undefined });
+
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ scrollTo: ReturnType;
+ };
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+ mutableVList.scrollOffset = 2200;
+ refs.vList.scrollTo = vi.fn<(offset: number) => void>();
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+ act(() => {
+ result.current.markUserScrollIntent('forward');
+ });
+
+ const forwardLoadingSync = createTimelineSync({
+ ...landedTimelineSync,
+ forwardStatus: 'loading',
+ });
+ rerender({ sync: forwardLoadingSync, roomEventId: undefined });
+
+ expect(refs.vList.scrollTo).not.toHaveBeenCalled();
+ });
+
it('does not repeat forward pagination from layout scrolls after a landed jump', () => {
const timelineSync = createTimelineSync({
liveTimelineLinked: false,
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index f4265968a..92b6f6eca 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -620,14 +620,14 @@ export function useTimelineViewportController({
if (intentTowardBackward && !edges.isAtBottom && !paginationLoading) {
anchorRef.current = { kind: 'none' };
}
- } else if (edges.isAtBottom) {
+ } else if (edges.isAtBottom && liveTimelineLinkedRef.current) {
anchorRef.current = { kind: 'bottom' };
}
const nextAtBottom =
anchorRef.current.kind === 'bottom' && !hasScrollIntent && !edges.isAtBottom
? true
- : edges.isAtBottom;
+ : edges.isAtBottom && liveTimelineLinkedRef.current;
if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
if (intentTowardBackward) requestPaginationFromScroll('backward', activeIntent, offset);
From 271307401b0a02e449fd88eaca75fc482f570fd1 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 22:12:45 -0500
Subject: [PATCH 25/28] fix: sort of finally working now
---
src/app/features/room/TimelineViewport.tsx | 30 +-
.../useTimelineViewportController.test.tsx | 271 ++++++++++++++++-
.../room/useTimelineViewportController.ts | 284 +++++++++++++++++-
src/app/hooks/timeline/useTimelineSync.ts | 11 +-
4 files changed, 570 insertions(+), 26 deletions(-)
diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx
index 9eca30b1b..3b0cee149 100644
--- a/src/app/features/room/TimelineViewport.tsx
+++ b/src/app/features/room/TimelineViewport.tsx
@@ -1,10 +1,10 @@
import {
+ useEffect,
useRef,
type KeyboardEvent,
type ReactNode,
type RefObject,
type TouchEvent,
- type WheelEvent,
} from 'react';
import type { Room } from '$types/matrix-sdk';
import type { VListHandle } from 'virtua';
@@ -54,7 +54,7 @@ export type TimelineViewportProps = {
backPagination: ReactNode;
frontPagination: ReactNode;
onScroll: (offset: number) => void;
- onUserScrollIntent: (direction?: TimelineScrollDirection) => void;
+ onUserScrollIntent: (direction?: TimelineScrollDirection, deltaPx?: number) => boolean;
onJumpLatest: () => void;
renderMatrixEvent: (
eventType: string,
@@ -90,9 +90,21 @@ export function TimelineViewport({
}: Readonly) {
const lastTouchYRef = useRef(undefined);
- const handleWheelIntent = (event: WheelEvent) => {
- onUserScrollIntent(getDirectionFromDelta(event.deltaY));
- };
+ useEffect(() => {
+ const element = messageListRef.current;
+ if (!element) return undefined;
+
+ const handleNativeWheel = (event: WheelEvent) => {
+ const consumed = onUserScrollIntent(
+ getDirectionFromDelta(event.deltaY),
+ Math.abs(event.deltaY)
+ );
+ if (consumed && event.cancelable) event.preventDefault();
+ };
+
+ element.addEventListener('wheel', handleNativeWheel, { passive: false });
+ return () => element.removeEventListener('wheel', handleNativeWheel);
+ }, [messageListRef, onUserScrollIntent]);
const handleTouchStart = (event: TouchEvent) => {
lastTouchYRef.current = event.touches[0]?.clientY;
@@ -106,12 +118,13 @@ export function TimelineViewport({
onUserScrollIntent();
return;
}
- onUserScrollIntent(getDirectionFromDelta(prevY - nextY));
+ const delta = prevY - nextY;
+ onUserScrollIntent(getDirectionFromDelta(delta), Math.abs(delta));
};
const handleKeyboardScrollIntent = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') {
- onUserScrollIntent('backward');
+ onUserScrollIntent('backward', event.key === 'ArrowUp' ? 40 : 600);
return;
}
if (
@@ -121,7 +134,7 @@ export function TimelineViewport({
event.key === ' ' ||
event.key === 'Spacebar'
) {
- onUserScrollIntent('forward');
+ onUserScrollIntent('forward', event.key === 'ArrowDown' ? 40 : 600);
}
};
@@ -131,7 +144,6 @@ export function TimelineViewport({
{
result.current.handleVListScroll(120);
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
});
it('paginates older history from user input while already clamped at the top edge', () => {
@@ -261,7 +261,210 @@ describe('useTimelineViewportController', () => {
result.current.markUserScrollIntent('backward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
+ });
+
+ it('applies top-edge recoil so upward scroll can continue while near offset 0', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ scrollTo: ReturnType
;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+ refs.vList.scrollTo = vi.fn<(offset: number) => void>();
+
+ act(() => {
+ result.current.markUserScrollIntent('backward', 180);
+ });
+
+ expect(refs.vList.scrollTo).toHaveBeenCalled();
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
+ });
+
+ it('scales pagination limit with scroll intent strength', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(0);
+ });
+ act(() => {
+ result.current.markUserScrollIntent('backward', 20);
+ });
+ const paginationMock = timelineSync.handleTimelinePagination as unknown as ReturnType<
+ typeof vi.fn
+ >;
+ const firstLimit = paginationMock.mock.calls.at(-1)?.[1] as number | undefined;
+ expect(firstLimit).toBeDefined();
+ expect(firstLimit).toBeGreaterThanOrEqual(40);
+ expect(firstLimit).toBeLessThanOrEqual(100);
+
+ paginationMock.mockClear();
+ act(() => {
+ result.current.markUserScrollIntent('backward', 500);
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, 160);
+ });
+
+ it('queues another backward page when user keeps scrolling at the top while loading', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.handleVListScroll(0);
+ });
+ act(() => {
+ result.current.markUserScrollIntent('backward');
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+ act(() => {
+ result.current.markUserScrollIntent('backward');
+ });
+ expect(loadingSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, expect.any(Number));
+ });
+
+ it('continues backward pagination after load settles when still near top without new input', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.markUserScrollIntent('backward');
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, expect.any(Number));
+ });
+
+ it('continues backward pagination from preserved scroll pressure even after offset moves away from the edge', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.markUserScrollIntent('backward', 500);
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, 160);
+
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+
+ mutableVList.scrollOffset = 1500;
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, 160);
+ });
+
+ it('bounds backward scroll pressure so pagination does not continue forever', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync, refs });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollOffset = 0;
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+
+ act(() => {
+ result.current.markUserScrollIntent('backward', 500);
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ for (let i = 0; i < 6; i += 1) {
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+
+ mutableVList.scrollOffset = 1500;
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+ }
+
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(3);
});
it('keeps bottom anchor pinned during loading-driven offset shifts', () => {
@@ -372,7 +575,7 @@ describe('useTimelineViewportController', () => {
expect(setAtBottom).toHaveBeenCalledWith(false);
});
- it('does not release bottom anchor before any user scroll intent', () => {
+ it('does not trigger pagination before any user scroll intent', () => {
const refs = createRefs();
const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
refs.atBottomRef.current = val;
@@ -399,7 +602,6 @@ describe('useTimelineViewportController', () => {
result.current.handleVListScroll(1900);
});
- expect(setAtBottom).not.toHaveBeenCalledWith(false);
expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
@@ -507,7 +709,10 @@ describe('useTimelineViewportController', () => {
result.current.handleVListScroll(2260);
});
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(
+ false,
+ expect.any(Number)
+ );
});
it('paginates forward from user input while already clamped at the bottom edge', () => {
@@ -533,7 +738,51 @@ describe('useTimelineViewportController', () => {
result.current.markUserScrollIntent('forward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false, expect.any(Number));
+ });
+
+ it('queues another forward page when user keeps scrolling at detached bottom while loading', () => {
+ const timelineSync = createTimelineSync({
+ liveTimelineLinked: false,
+ });
+ const { result, refs, rerender } = renderController({ timelineSync });
+ const mutableVList = refs.vList as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ mutableVList.scrollSize = 3000;
+ mutableVList.viewportSize = 800;
+ mutableVList.scrollOffset = 2200;
+
+ act(() => {
+ result.current.handleVListScroll(2200);
+ });
+ act(() => {
+ result.current.markUserScrollIntent('forward');
+ });
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false, expect.any(Number));
+
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ liveTimelineLinked: false,
+ forwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+ act(() => {
+ result.current.markUserScrollIntent('forward');
+ });
+ expect(loadingSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ liveTimelineLinked: false,
+ forwardStatus: 'idle',
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(false, expect.any(Number));
});
it('does not bottom-anchor a detached jump window when forward pagination loads', () => {
@@ -668,7 +917,10 @@ describe('useTimelineViewportController', () => {
});
expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(
+ true,
+ expect.any(Number)
+ );
act(() => {
result.current.markUserScrollIntent();
@@ -787,6 +1039,9 @@ describe('useTimelineViewportController', () => {
result.current.handleVListScroll(2290);
});
- expect(forwardIdleSync.handleTimelinePagination).toHaveBeenCalledWith(false);
+ expect(forwardIdleSync.handleTimelinePagination).toHaveBeenCalledWith(
+ false,
+ expect.any(Number)
+ );
});
});
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index 92b6f6eca..e8b1538af 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -18,14 +18,33 @@ import { pushTimelineJumpDebug } from './timelineJumpDebug';
const INITIAL_BACKFILL_PAGE_BUDGET = 6;
const BOOTSTRAP_FILL_TARGET_SLACK_PX = 32;
const TIMELINE_PAGINATION_THRESHOLD_PX = 500;
+const TIMELINE_PAGINATION_LEAD_THRESHOLD_PX = 1000;
const TIMELINE_SCROLL_INTENT_DELTA_PX = 8;
-const TIMELINE_SCROLL_INTENT_TTL_MS = 250;
+const TIMELINE_SCROLL_INTENT_TTL_MS = 1200;
+const MIN_PAGINATION_LIMIT = 40;
+const MEDIUM_SCROLL_LIMIT = 100;
+const FAST_SCROLL_LIMIT = 160;
+const SCROLL_PRESSURE_TTL_MS = 1200;
+const MAX_SCROLL_PRESSURE_PAGES = 3;
+const TOP_EDGE_RECOIL_TRIGGER_PX = 28;
+const TOP_EDGE_RECOIL_STEP_MIN_PX = 90;
+const TOP_EDGE_RECOIL_STEP_MAX_PX = 260;
+const TOP_EDGE_RECOIL_COOLDOWN_MS = 60;
type TimelineSyncController = ReturnType;
type PendingScrollIntent = {
id: number;
direction?: TimelineScrollDirection;
+ limit?: number;
+ expiresAt: number;
+};
+
+type ScrollPressure = {
+ backward: number;
+ forward: number;
+ backwardLimit: number;
+ forwardLimit: number;
expiresAt: number;
};
@@ -69,10 +88,21 @@ export function useTimelineViewportController({
const suppressNextCenterScrollRef = useRef(false);
const scrollIntentCounterRef = useRef(0);
const pendingScrollIntentRef = useRef(undefined);
+ const queuedPaginationAfterLoadRef = useRef({ backward: false, forward: false });
+ const scrollPressureRef = useRef({
+ backward: 0,
+ forward: 0,
+ backwardLimit: MIN_PAGINATION_LIMIT,
+ forwardLimit: MIN_PAGINATION_LIMIT,
+ expiresAt: 0,
+ });
+ const lastIntentSampleRef = useRef<{ at: number; deltaPx: number }>({ at: 0, deltaPx: 0 });
const pendingBootstrapRevealRef = useRef(false);
const bootstrapViewportRetryFrameRef = useRef(undefined);
const settleAnchorFrameRef = useRef(undefined);
const readyFallbackTimerRef = useRef | undefined>(undefined);
+ const lastTopEdgeRecoilAtRef = useRef(0);
+ const lastBackwardIntentAtRef = useRef(0);
const currentRoomIdRef = useRef(roomId);
const canPaginateBackRef = useRef(timelineSync.canPaginateBack);
@@ -91,6 +121,51 @@ export function useTimelineViewportController({
pendingScrollIntentRef.current = undefined;
}, []);
+ const applyTopEdgeRecoil = useCallback(
+ (deltaPx?: number) => {
+ const v = vListRef.current;
+ if (!v) return false;
+ if (v.scrollOffset > TOP_EDGE_RECOIL_TRIGGER_PX) return false;
+ const now = Date.now();
+ if (now - lastTopEdgeRecoilAtRef.current < TOP_EDGE_RECOIL_COOLDOWN_MS) return false;
+ lastTopEdgeRecoilAtRef.current = now;
+ const recoilPx = Math.max(
+ TOP_EDGE_RECOIL_STEP_MIN_PX,
+ Math.min(TOP_EDGE_RECOIL_STEP_MAX_PX, Math.round((deltaPx ?? 120) * 1.2))
+ );
+ const maxOffset = Math.max(0, v.scrollSize - v.viewportSize);
+ v.scrollTo(Math.min(maxOffset, v.scrollOffset + recoilPx));
+ return true;
+ },
+ [vListRef]
+ );
+
+ const readScrollPressure = useCallback((): ScrollPressure => {
+ const pressure = scrollPressureRef.current;
+ if (Date.now() <= pressure.expiresAt) return pressure;
+ pressure.backward = 0;
+ pressure.forward = 0;
+ pressure.backwardLimit = MIN_PAGINATION_LIMIT;
+ pressure.forwardLimit = MIN_PAGINATION_LIMIT;
+ return pressure;
+ }, []);
+
+ const consumeScrollPressure = useCallback(
+ (direction: TimelineScrollDirection): number | undefined => {
+ const pressure = readScrollPressure();
+ if (direction === 'backward' && pressure.backward > 0) {
+ pressure.backward -= 1;
+ return pressure.backwardLimit;
+ }
+ if (direction === 'forward' && pressure.forward > 0) {
+ pressure.forward -= 1;
+ return pressure.forwardLimit;
+ }
+ return undefined;
+ },
+ [readScrollPressure]
+ );
+
const getActiveScrollIntent = useCallback((): PendingScrollIntent | undefined => {
const intent = pendingScrollIntentRef.current;
if (!intent) return undefined;
@@ -115,6 +190,32 @@ export function useTimelineViewportController({
[vListRef]
);
+ const isNearBackwardEdge = useCallback(
+ (edges: ReturnType) =>
+ Boolean(edges && edges.offset < TIMELINE_PAGINATION_LEAD_THRESHOLD_PX),
+ []
+ );
+
+ const isNearForwardEdge = useCallback(
+ (edges: ReturnType) =>
+ Boolean(edges && edges.distanceFromBottom < TIMELINE_PAGINATION_LEAD_THRESHOLD_PX),
+ []
+ );
+
+ const getDynamicPaginationLimit = useCallback((deltaPx?: number): number => {
+ const now = Date.now();
+ const previous = lastIntentSampleRef.current;
+ const sampleDelta = typeof deltaPx === 'number' ? deltaPx : previous.deltaPx;
+ const elapsed = previous.at === 0 ? 16 : Math.max(16, now - previous.at);
+ const speedPxPerSec = (sampleDelta / elapsed) * 1000;
+
+ lastIntentSampleRef.current = { at: now, deltaPx: sampleDelta };
+
+ if (sampleDelta > 320 || speedPxPerSec > 2200) return FAST_SCROLL_LIMIT;
+ if (sampleDelta > 120 || speedPxPerSec > 900) return MEDIUM_SCROLL_LIMIT;
+ return MIN_PAGINATION_LIMIT;
+ }, []);
+
const requestPaginationFromScroll = useCallback(
(
direction: TimelineScrollDirection,
@@ -142,9 +243,13 @@ export function useTimelineViewportController({
return;
pendingScrollIntentRef.current = undefined;
- timelineSyncRef.current.handleTimelinePagination(direction === 'backward');
+ const pressureLimit = consumeScrollPressure(direction);
+ timelineSyncRef.current.handleTimelinePagination(
+ direction === 'backward',
+ Math.max(intent.limit ?? MIN_PAGINATION_LIMIT, pressureLimit ?? MIN_PAGINATION_LIMIT)
+ );
},
- [getScrollEdges, jumpInFlight, timelineSyncRef]
+ [consumeScrollPressure, getScrollEdges, jumpInFlight, timelineSyncRef]
);
if (currentRoomIdRef.current !== roomId) {
@@ -156,6 +261,15 @@ export function useTimelineViewportController({
lastScrollOffsetRef.current = 0;
suppressNextCenterScrollRef.current = false;
pendingScrollIntentRef.current = undefined;
+ queuedPaginationAfterLoadRef.current = { backward: false, forward: false };
+ scrollPressureRef.current = {
+ backward: 0,
+ forward: 0,
+ backwardLimit: MIN_PAGINATION_LIMIT,
+ forwardLimit: MIN_PAGINATION_LIMIT,
+ expiresAt: 0,
+ };
+ lastIntentSampleRef.current = { at: 0, deltaPx: 0 };
pendingBootstrapRevealRef.current = false;
if (bootstrapViewportRetryFrameRef.current !== undefined) {
cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
@@ -362,6 +476,29 @@ export function useTimelineViewportController({
} else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') {
setShift(false);
if (wasAtBottomBeforePaginationRef.current) settleTimelineAnchor({ kind: 'bottom' });
+ const edges = getScrollEdges();
+ const pressure = readScrollPressure();
+ const shouldContinueBackward =
+ isNearBackwardEdge(edges) &&
+ canPaginateBackRef.current &&
+ !jumpInFlight &&
+ !timelineSync.focusItem?.scrollTo;
+ const shouldContinueBackwardWithPressure =
+ pressure.backward > 0 &&
+ canPaginateBackRef.current &&
+ !jumpInFlight &&
+ !timelineSync.focusItem?.scrollTo;
+ if (
+ queuedPaginationAfterLoadRef.current.backward ||
+ shouldContinueBackward ||
+ shouldContinueBackwardWithPressure
+ ) {
+ queuedPaginationAfterLoadRef.current.backward = false;
+ if (shouldContinueBackward || shouldContinueBackwardWithPressure) {
+ const pressureLimit = consumeScrollPressure('backward') ?? MIN_PAGINATION_LIMIT;
+ timelineSyncRef.current.handleTimelinePagination(true, pressureLimit);
+ }
+ }
if (pendingBootstrapRevealRef.current) {
const v = vListRef.current;
@@ -388,6 +525,13 @@ export function useTimelineViewportController({
timelineSync.canPaginateBack,
atBottomRef,
clearPendingScrollIntent,
+ consumeScrollPressure,
+ getScrollEdges,
+ isNearBackwardEdge,
+ jumpInFlight,
+ readScrollPressure,
+ timelineSync.focusItem?.scrollTo,
+ timelineSyncRef,
settleTimelineAnchor,
vListRef,
]);
@@ -401,7 +545,32 @@ export function useTimelineViewportController({
) {
clearPendingScrollIntent();
}
- }, [clearPendingScrollIntent, timelineSync.forwardStatus]);
+ if (prev === 'loading' && timelineSync.forwardStatus === 'idle') {
+ if (queuedPaginationAfterLoadRef.current.forward) {
+ queuedPaginationAfterLoadRef.current.forward = false;
+ const edges = getScrollEdges();
+ const pressure = readScrollPressure();
+ const shouldContinueForwardWithPressure = pressure.forward > 0 && !jumpInFlight;
+ if (
+ (isNearForwardEdge(edges) || shouldContinueForwardWithPressure) &&
+ !liveTimelineLinkedRef.current &&
+ !jumpInFlight
+ ) {
+ const pressureLimit = consumeScrollPressure('forward') ?? MIN_PAGINATION_LIMIT;
+ timelineSyncRef.current.handleTimelinePagination(false, pressureLimit);
+ }
+ }
+ }
+ }, [
+ clearPendingScrollIntent,
+ consumeScrollPressure,
+ getScrollEdges,
+ isNearForwardEdge,
+ jumpInFlight,
+ readScrollPressure,
+ timelineSync.forwardStatus,
+ timelineSyncRef,
+ ]);
useEffect((): void | (() => void) => {
let timeoutId: ReturnType | undefined;
@@ -597,6 +766,15 @@ export function useTimelineViewportController({
const edges = getScrollEdges(offset);
if (!edges) return;
+ if (
+ edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX &&
+ canPaginateBackRef.current &&
+ backwardStatusRef.current === 'loading' &&
+ Date.now() - lastBackwardIntentAtRef.current < 1500
+ ) {
+ applyTopEdgeRecoil(120);
+ }
+
if (anchorRef.current.kind === 'message-center' && suppressNextCenterScrollRef.current) {
suppressNextCenterScrollRef.current = false;
return;
@@ -624,8 +802,13 @@ export function useTimelineViewportController({
anchorRef.current = { kind: 'bottom' };
}
+ const paginationLoading =
+ backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading';
const nextAtBottom =
- anchorRef.current.kind === 'bottom' && !hasScrollIntent && !edges.isAtBottom
+ anchorRef.current.kind === 'bottom' &&
+ paginationLoading &&
+ !hasScrollIntent &&
+ !edges.isAtBottom
? true
: edges.isAtBottom && liveTimelineLinkedRef.current;
if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
@@ -634,6 +817,7 @@ export function useTimelineViewportController({
if (intentTowardForward) requestPaginationFromScroll('forward', activeIntent, offset);
},
[
+ applyTopEdgeRecoil,
atBottomRef,
getActiveScrollIntent,
getScrollEdges,
@@ -647,17 +831,103 @@ export function useTimelineViewportController({
);
const markUserScrollIntent = useCallback(
- (direction?: TimelineScrollDirection) => {
+ (direction?: TimelineScrollDirection, deltaPx?: number): boolean => {
+ if (direction && pendingBootstrapRevealRef.current) {
+ pendingBootstrapRevealRef.current = false;
+ setIsReady(true);
+ }
+ let consumed = false;
scrollIntentCounterRef.current += 1;
+ const dynamicLimit = getDynamicPaginationLimit(deltaPx);
+ const pressurePages = Math.max(
+ 1,
+ Math.min(MAX_SCROLL_PRESSURE_PAGES, Math.ceil(dynamicLimit / MIN_PAGINATION_LIMIT))
+ );
+ if (direction) {
+ const pressure = readScrollPressure();
+ if (direction === 'backward') {
+ lastBackwardIntentAtRef.current = Date.now();
+ pressure.backward = Math.min(MAX_SCROLL_PRESSURE_PAGES, pressure.backward + pressurePages);
+ pressure.backwardLimit = Math.max(pressure.backwardLimit, dynamicLimit);
+ } else {
+ pressure.forward = Math.min(MAX_SCROLL_PRESSURE_PAGES, pressure.forward + pressurePages);
+ pressure.forwardLimit = Math.max(pressure.forwardLimit, dynamicLimit);
+ }
+ pressure.expiresAt = Date.now() + SCROLL_PRESSURE_TTL_MS;
+ }
const intent = {
id: scrollIntentCounterRef.current,
direction,
+ limit: dynamicLimit,
expiresAt: Date.now() + TIMELINE_SCROLL_INTENT_TTL_MS,
};
pendingScrollIntentRef.current = intent;
+ if (
+ direction === 'backward' &&
+ anchorRef.current.kind === 'bottom' &&
+ backwardStatusRef.current === 'idle'
+ ) {
+ anchorRef.current = { kind: 'none' };
+ if (atBottomRef.current) setAtBottom(false);
+ }
+ const edges = direction ? getScrollEdges() : undefined;
+ if (
+ direction === 'backward' &&
+ canPaginateBackRef.current &&
+ edges &&
+ edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX
+ ) {
+ consumed = applyTopEdgeRecoil(deltaPx) || consumed;
+ }
+ if (
+ direction === 'backward' &&
+ backwardStatusRef.current === 'loading' &&
+ isNearBackwardEdge(edges)
+ ) {
+ queuedPaginationAfterLoadRef.current.backward = true;
+ }
+ if (
+ direction === 'forward' &&
+ forwardStatusRef.current === 'loading' &&
+ isNearForwardEdge(edges)
+ ) {
+ queuedPaginationAfterLoadRef.current.forward = true;
+ }
+ if (
+ direction === 'backward' &&
+ backwardStatusRef.current === 'idle' &&
+ canPaginateBackRef.current &&
+ isNearBackwardEdge(edges)
+ ) {
+ if (edges && edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX) {
+ consumed = applyTopEdgeRecoil(deltaPx) || consumed;
+ }
+ requestPaginationFromScroll('backward', intent, edges?.offset);
+ return consumed;
+ }
+ if (
+ direction === 'forward' &&
+ forwardStatusRef.current === 'idle' &&
+ !liveTimelineLinkedRef.current &&
+ isNearForwardEdge(edges)
+ ) {
+ requestPaginationFromScroll('forward', intent, edges?.offset);
+ return consumed;
+ }
if (direction) requestPaginationFromScroll(direction, intent);
+ return consumed;
},
- [requestPaginationFromScroll]
+ [
+ atBottomRef,
+ applyTopEdgeRecoil,
+ getDynamicPaginationLimit,
+ getScrollEdges,
+ isNearBackwardEdge,
+ isNearForwardEdge,
+ readScrollPressure,
+ requestPaginationFromScroll,
+ setAtBottom,
+ ]
);
return {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 360d10f79..e4eea37bf 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -122,7 +122,7 @@ const useTimelinePagination = (
setTimeline(() => ({ linkedTimelines: newLTimelines }));
};
- return async (backwards: boolean) => {
+ return async (backwards: boolean, limitOverride?: number) => {
const directionKey = backwards ? 'backward' : 'forward';
if (fetchingRef.current[directionKey]) return;
@@ -148,8 +148,15 @@ const useTimelinePagination = (
(backwards ? setBackwardStatus : setForwardStatus)('loading');
}
+ const requestLimit =
+ typeof limitOverride === 'number' && Number.isFinite(limitOverride)
+ ? Math.max(10, Math.min(300, Math.floor(limitOverride)))
+ : limit;
+
try {
- const [err] = await to(mx.paginateEventTimeline(timelineToPaginate, { backwards, limit }));
+ const [err] = await to(
+ mx.paginateEventTimeline(timelineToPaginate, { backwards, limit: requestLimit })
+ );
if (err) {
if (alive()) {
From 58d614f8b945c06483a2309361f7f863fb6457c0 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 22:50:52 -0500
Subject: [PATCH 26/28] refactor: try to offload more back to virtua
stuff broke time for more patches!
---
src/app/features/room/TimelineViewport.tsx | 34 +-
.../room/timelineViewportModel.test.ts | 49 +-
.../features/room/timelineViewportModel.ts | 65 +-
.../useTimelineViewportController.test.tsx | 920 ++++--------------
.../room/useTimelineViewportController.ts | 854 ++++++----------
5 files changed, 553 insertions(+), 1369 deletions(-)
diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx
index 3b0cee149..5d130c529 100644
--- a/src/app/features/room/TimelineViewport.tsx
+++ b/src/app/features/room/TimelineViewport.tsx
@@ -1,11 +1,4 @@
-import {
- useEffect,
- useRef,
- type KeyboardEvent,
- type ReactNode,
- type RefObject,
- type TouchEvent,
-} from 'react';
+import { useRef, type KeyboardEvent, type ReactNode, type RefObject, type TouchEvent } from 'react';
import type { Room } from '$types/matrix-sdk';
import type { VListHandle } from 'virtua';
import { VList } from 'virtua';
@@ -54,7 +47,7 @@ export type TimelineViewportProps = {
backPagination: ReactNode;
frontPagination: ReactNode;
onScroll: (offset: number) => void;
- onUserScrollIntent: (direction?: TimelineScrollDirection, deltaPx?: number) => boolean;
+ onUserScrollIntent: (direction?: TimelineScrollDirection) => void;
onJumpLatest: () => void;
renderMatrixEvent: (
eventType: string,
@@ -90,22 +83,6 @@ export function TimelineViewport({
}: Readonly) {
const lastTouchYRef = useRef(undefined);
- useEffect(() => {
- const element = messageListRef.current;
- if (!element) return undefined;
-
- const handleNativeWheel = (event: WheelEvent) => {
- const consumed = onUserScrollIntent(
- getDirectionFromDelta(event.deltaY),
- Math.abs(event.deltaY)
- );
- if (consumed && event.cancelable) event.preventDefault();
- };
-
- element.addEventListener('wheel', handleNativeWheel, { passive: false });
- return () => element.removeEventListener('wheel', handleNativeWheel);
- }, [messageListRef, onUserScrollIntent]);
-
const handleTouchStart = (event: TouchEvent) => {
lastTouchYRef.current = event.touches[0]?.clientY;
};
@@ -119,12 +96,12 @@ export function TimelineViewport({
return;
}
const delta = prevY - nextY;
- onUserScrollIntent(getDirectionFromDelta(delta), Math.abs(delta));
+ onUserScrollIntent(getDirectionFromDelta(delta));
};
const handleKeyboardScrollIntent = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') {
- onUserScrollIntent('backward', event.key === 'ArrowUp' ? 40 : 600);
+ onUserScrollIntent('backward');
return;
}
if (
@@ -134,7 +111,7 @@ export function TimelineViewport({
event.key === ' ' ||
event.key === 'Spacebar'
) {
- onUserScrollIntent('forward', event.key === 'ArrowDown' ? 40 : 600);
+ onUserScrollIntent('forward');
}
};
@@ -144,6 +121,7 @@ export function TimelineViewport({
onUserScrollIntent(getDirectionFromDelta(event.deltaY))}
onTouchStartCapture={handleTouchStart}
onTouchMoveCapture={handleTouchMoveIntent}
onKeyDownCapture={handleKeyboardScrollIntent}
diff --git a/src/app/features/room/timelineViewportModel.test.ts b/src/app/features/room/timelineViewportModel.test.ts
index f74a4f1e1..68a0147f2 100644
--- a/src/app/features/room/timelineViewportModel.test.ts
+++ b/src/app/features/room/timelineViewportModel.test.ts
@@ -2,16 +2,51 @@ import { describe, expect, it } from 'vitest';
import {
getBottomClampSpacer,
getCenterAnchorAdjustment,
- getDistanceFromBottom,
- isTimelineAtBottom,
+ getTimelineVisibleRange,
+ type TimelineViewportGeometry,
} from './timelineViewportModel';
+const createGeometry = (
+ offset: number,
+ viewportSize: number,
+ itemSize: number,
+ itemCount: number
+): TimelineViewportGeometry => ({
+ scrollOffset: offset,
+ scrollSize: itemSize * itemCount,
+ viewportSize,
+ findItemIndex: (itemOffset) =>
+ Math.max(0, Math.min(itemCount - 1, Math.floor(itemOffset / itemSize))),
+ getItemOffset: (index) => index * itemSize,
+});
+
describe('timelineViewportModel', () => {
- it('calculates bottom distance and bottom threshold', () => {
- expect(getDistanceFromBottom(1200, 500, 600)).toBe(100);
- expect(getDistanceFromBottom(600, 0, 800)).toBe(0);
- expect(isTimelineAtBottom(1200, 520, 600, 100)).toBe(true);
- expect(isTimelineAtBottom(1200, 499, 600, 100)).toBe(false);
+ it('derives visible virtual-list edges from item geometry', () => {
+ expect(getTimelineVisibleRange(createGeometry(0, 200, 100, 5), 5)).toEqual({
+ firstIndex: 0,
+ lastIndex: 1,
+ atStart: true,
+ atEnd: false,
+ atScrollEnd: false,
+ });
+
+ expect(getTimelineVisibleRange(createGeometry(300, 200, 100, 5), 5)).toEqual({
+ firstIndex: 3,
+ lastIndex: 4,
+ atStart: false,
+ atEnd: true,
+ atScrollEnd: true,
+ });
+ });
+
+ it('treats an empty list as both edges without inventing scroll distance', () => {
+ expect(getTimelineVisibleRange(createGeometry(0, 800, 100, 0), 0)).toEqual({
+ firstIndex: -1,
+ lastIndex: -1,
+ atStart: true,
+ atEnd: true,
+ atScrollEnd: true,
+ });
});
it('bottom-clamps short content while excluding the existing spacer', () => {
diff --git a/src/app/features/room/timelineViewportModel.ts b/src/app/features/room/timelineViewportModel.ts
index 635932311..424ea76e5 100644
--- a/src/app/features/room/timelineViewportModel.ts
+++ b/src/app/features/room/timelineViewportModel.ts
@@ -7,20 +7,61 @@ export type TimelineScrollDirection = 'backward' | 'forward';
export type RectLike = Pick
;
-export const TIMELINE_BOTTOM_THRESHOLD_PX = 100;
+export type TimelineViewportGeometry = {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ findItemIndex: (offset: number) => number;
+ getItemOffset: (index: number) => number;
+};
-export const getDistanceFromBottom = (
- scrollSize: number,
- scrollOffset: number,
- viewportSize: number
-): number => Math.max(0, scrollSize - scrollOffset - viewportSize);
+export type TimelineVisibleRange = {
+ firstIndex: number;
+ lastIndex: number;
+ atStart: boolean;
+ atEnd: boolean;
+ atScrollEnd: boolean;
+};
-export const isTimelineAtBottom = (
- scrollSize: number,
- scrollOffset: number,
- viewportSize: number,
- threshold = TIMELINE_BOTTOM_THRESHOLD_PX
-): boolean => getDistanceFromBottom(scrollSize, scrollOffset, viewportSize) < threshold;
+const clamp = (value: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, value));
+
+export const getTimelineVisibleRange = (
+ viewport: TimelineViewportGeometry,
+ itemCount: number,
+ offset = viewport.scrollOffset
+): TimelineVisibleRange => {
+ if (itemCount <= 0) {
+ return {
+ firstIndex: -1,
+ lastIndex: -1,
+ atStart: true,
+ atEnd: true,
+ atScrollEnd: true,
+ };
+ }
+
+ const maxIndex = itemCount - 1;
+ const scrollEnd = offset + viewport.viewportSize;
+ const maxScrollOffset = Math.max(0, viewport.scrollSize - viewport.viewportSize);
+ const firstIndex = clamp(viewport.findItemIndex(offset), 0, maxIndex);
+ let lastIndex = clamp(
+ viewport.findItemIndex(Math.min(scrollEnd, viewport.scrollSize)),
+ firstIndex,
+ maxIndex
+ );
+ while (lastIndex > firstIndex && viewport.getItemOffset(lastIndex) >= scrollEnd) {
+ lastIndex -= 1;
+ }
+
+ return {
+ firstIndex,
+ lastIndex,
+ atStart: firstIndex === 0,
+ atEnd: lastIndex === maxIndex,
+ atScrollEnd: offset >= maxScrollOffset,
+ };
+};
export const getBottomClampSpacer = (
viewportSize: number,
diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx
index 0a4bb4d02..f090e339b 100644
--- a/src/app/features/room/useTimelineViewportController.test.tsx
+++ b/src/app/features/room/useTimelineViewportController.test.tsx
@@ -17,6 +17,10 @@ const createVList = (): VListHandle =>
scrollOffset: 0,
scrollSize: 3000,
viewportSize: 800,
+ findItemIndex: vi.fn<(offset: number) => number>((offset) =>
+ Math.max(0, Math.min(29, Math.floor(offset / 100)))
+ ),
+ getItemOffset: vi.fn<(index: number) => number>((index) => index * 100),
scrollTo: vi.fn<(offset: number) => void>(),
scrollBy: vi.fn<(offset: number) => void>(),
scrollToIndex: vi.fn<(index: number, options?: { align?: string }) => void>(),
@@ -34,13 +38,16 @@ const createProcessedEvent = (id: string, itemIndex: number): ProcessedEvent =>
willRenderDayDivider: false,
}) as unknown as ProcessedEvent;
+const createProcessedEvents = (count: number): ProcessedEvent[] =>
+ Array.from({ length: count }, (_, index) => createProcessedEvent(`$${index}`, index));
+
const createTimelineSync = (
overrides: Partial = {}
): TimelineSyncController =>
({
timeline: { linkedTimelines: [] },
setTimeline: vi.fn<(next: unknown) => void>(),
- eventsLength: 2,
+ eventsLength: 3,
liveTimelineLinked: true,
canPaginateBack: true,
backwardStatus: 'idle',
@@ -54,7 +61,11 @@ const createTimelineSync = (
const createRefs = (vList = createVList()) => {
const processedEventsRef: MutableRefObject = {
- current: [createProcessedEvent('$a', 0), createProcessedEvent('$b', 1)],
+ current: [
+ createProcessedEvent('$a', 0),
+ createProcessedEvent('$b', 1),
+ createProcessedEvent('$c', 2),
+ ],
};
return {
@@ -67,17 +78,9 @@ const createRefs = (vList = createVList()) => {
};
const createEmptyProcessedRefs = (vList = createVList()) => {
- const processedEventsRef: MutableRefObject = {
- current: [],
- };
-
- return {
- vList,
- vListRef: { current: vList } as RefObject,
- messageListRef: { current: document.createElement('div') } as RefObject,
- processedEventsRef,
- atBottomRef: { current: true },
- };
+ const refs = createRefs(vList);
+ refs.processedEventsRef.current = [];
+ return refs;
};
const renderController = ({
@@ -94,9 +97,6 @@ const renderController = ({
setAtBottom?: (val: boolean) => void;
} = {}) => {
const timelineSyncRef: MutableRefObject = { current: timelineSync };
- const indexByRaw = new Map(
- refs.processedEventsRef.current.map((event, index) => [event.itemIndex, index])
- );
const hook = renderHook(
({ sync, roomEventId }: { sync: TimelineSyncController; roomEventId?: string }) => {
@@ -111,7 +111,12 @@ const renderController = ({
processedEventsRef: refs.processedEventsRef,
atBottomRef: refs.atBottomRef,
setAtBottom,
- getRawIndexToProcessedIndex: (rawIndex) => indexByRaw.get(rawIndex),
+ getRawIndexToProcessedIndex: (rawIndex) => {
+ const index = refs.processedEventsRef.current.findIndex(
+ (event) => event.itemIndex === rawIndex
+ );
+ return index < 0 ? undefined : index;
+ },
});
},
{ initialProps: { sync: timelineSync, roomEventId: eventId } }
@@ -140,18 +145,18 @@ describe('useTimelineViewportController', () => {
it('lands the initial live timeline at the latest event and reveals the viewport', () => {
const { result, refs } = renderController();
- expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' });
+ expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(2, { align: 'end' });
expect(refs.vList.scrollTo).toHaveBeenCalledWith(refs.vList.scrollSize);
expect(result.current.isReady).toBe(true);
});
- it('re-anchors to latest when first renderable rows appear after an all-state initial slice', () => {
+ it('re-anchors to latest when renderable rows appear after an all-state initial slice', () => {
const refs = createEmptyProcessedRefs();
const timelineSync = createTimelineSync({ eventsLength: 20 });
- const { rerender } = renderController({ timelineSync, refs });
+ const { rerender, refs: renderedRefs } = renderController({ timelineSync, refs });
- expect(refs.vList.scrollToIndex).not.toHaveBeenCalled();
- expect(refs.vList.scrollTo).toHaveBeenCalledWith(refs.vList.scrollSize);
+ expect(renderedRefs.vList.scrollToIndex).not.toHaveBeenCalled();
+ expect(renderedRefs.vList.scrollTo).toHaveBeenCalledWith(renderedRefs.vList.scrollSize);
refs.processedEventsRef.current = [
createProcessedEvent('$x', 10),
@@ -160,259 +165,180 @@ describe('useTimelineViewportController', () => {
rerender({ sync: timelineSync, roomEventId: undefined });
- expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' });
+ expect(renderedRefs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' });
});
- it('delays viewport reveal while bootstrap prefill is in progress for underfilled rooms', () => {
+ it('prefills underfilled live rooms without hiding the revealed timeline', () => {
const vList = createVList() as unknown as {
scrollOffset: number;
scrollSize: number;
viewportSize: number;
- scrollTo: ReturnType;
- scrollBy: ReturnType;
- scrollToIndex: ReturnType;
};
- vList.scrollSize = 800;
+ vList.scrollSize = 200;
vList.viewportSize = 800;
const refs = createRefs(vList as unknown as VListHandle);
const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
const { result } = renderController({ timelineSync, refs });
- expect(result.current.isReady).toBe(false);
+ expect(result.current.isReady).toBe(true);
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
- it('does not trigger ready fallback timeout while bootstrap reveal is gated', () => {
- vi.useFakeTimers();
- const vList = createVList() as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- vList.scrollSize = 700;
- vList.viewportSize = 700;
- const refs = createRefs(vList as unknown as VListHandle);
- const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
-
+ it('does not paginate from layout-only scroll changes', () => {
+ const refs = createRefs();
+ const timelineSync = createTimelineSync();
const { result } = renderController({ timelineSync, refs });
- expect(result.current.isReady).toBe(false);
+ vi.mocked(timelineSync.handleTimelinePagination).mockClear();
act(() => {
- vi.advanceTimersByTime(2000);
+ result.current.handleVListScroll(0);
});
- expect(result.current.isReady).toBe(false);
- });
-
- it('waits for a measured viewport before bootstrap reveal or backfill', () => {
- const vList = createVList() as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- vList.scrollSize = 120;
- vList.viewportSize = 0;
- const refs = createRefs(vList as unknown as VListHandle);
- const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
-
- const { result } = renderController({ timelineSync, refs });
-
- expect(result.current.isReady).toBe(false);
expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
- it('paginates older history at the top edge', () => {
- const timelineSync = createTimelineSync();
- const { result } = renderController({ timelineSync });
-
- act(() => {
- result.current.markUserScrollIntent();
- });
- act(() => {
- result.current.handleVListScroll(300);
+ it('treats the live timeline as bottom when the latest row is visible', () => {
+ const refs = createRefs();
+ refs.atBottomRef.current = false;
+ refs.processedEventsRef.current = createProcessedEvents(30);
+ const setAtBottom = vi.fn<(val: boolean) => void>((val) => {
+ refs.atBottomRef.current = val;
});
+ const timelineSync = createTimelineSync({ eventsLength: 30, liveTimelineLinked: true });
+ const { result } = renderController({ timelineSync, refs, setAtBottom });
+ refs.atBottomRef.current = false;
+ setAtBottom.mockClear();
+
act(() => {
- result.current.handleVListScroll(120);
+ result.current.handleVListScroll(2101);
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
+ expect(setAtBottom).toHaveBeenCalledWith(true);
});
- it('paginates older history from user input while already clamped at the top edge', () => {
+ it('paginates older history when the user scrolls at the start edge', () => {
const refs = createRefs();
const timelineSync = createTimelineSync();
const { result } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(0);
- });
- expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
act(() => {
result.current.markUserScrollIntent('backward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
- it('applies top-edge recoil so upward scroll can continue while near offset 0', () => {
+ it('paginates newer history when a detached jump window reaches the end edge', () => {
const refs = createRefs();
- const timelineSync = createTimelineSync();
+ const mutableVList = refs.vList as unknown as { scrollOffset: number };
+ mutableVList.scrollOffset = 2200;
+ const timelineSync = createTimelineSync({ liveTimelineLinked: false });
const { result } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- scrollTo: ReturnType;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- refs.vList.scrollTo = vi.fn<(offset: number) => void>();
act(() => {
- result.current.markUserScrollIntent('backward', 180);
+ result.current.markUserScrollIntent('forward');
});
- expect(refs.vList.scrollTo).toHaveBeenCalled();
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, expect.any(Number));
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
});
- it('scales pagination limit with scroll intent strength', () => {
+ it('requests one page per edge crossing from scroll events', () => {
const refs = createRefs();
- const timelineSync = createTimelineSync();
+ const mutableVList = refs.vList as unknown as { scrollOffset: number };
+ mutableVList.scrollOffset = 1200;
+ const timelineSync = createTimelineSync({ liveTimelineLinked: false });
const { result } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
act(() => {
- result.current.handleVListScroll(0);
+ result.current.markUserScrollIntent('forward');
});
+ mutableVList.scrollOffset = 2200;
act(() => {
- result.current.markUserScrollIntent('backward', 20);
+ result.current.handleVListScroll(2200);
});
- const paginationMock = timelineSync.handleTimelinePagination as unknown as ReturnType<
- typeof vi.fn
- >;
- const firstLimit = paginationMock.mock.calls.at(-1)?.[1] as number | undefined;
- expect(firstLimit).toBeDefined();
- expect(firstLimit).toBeGreaterThanOrEqual(40);
- expect(firstLimit).toBeLessThanOrEqual(100);
-
- paginationMock.mockClear();
act(() => {
- result.current.markUserScrollIntent('backward', 500);
+ result.current.handleVListScroll(2200);
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, 160);
+
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false);
});
- it('queues another backward page when user keeps scrolling at the top while loading', () => {
+ it('paginates as soon as scrolling lands on the start edge', () => {
const refs = createRefs();
+ const mutableVList = refs.vList as unknown as { scrollOffset: number };
+ mutableVList.scrollOffset = 1200;
const timelineSync = createTimelineSync();
- const { result, rerender } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
+ const { result } = renderController({ timelineSync, refs });
+ vi.mocked(timelineSync.handleTimelinePagination).mockClear();
act(() => {
- result.current.handleVListScroll(0);
+ result.current.handleVListScroll(1200);
});
+ mutableVList.scrollOffset = 0;
act(() => {
- result.current.markUserScrollIntent('backward');
+ result.current.handleVListScroll(0);
});
+
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
+ });
- const loadingSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'loading',
+ it('does not treat a programmatic jump landing as edge pagination', () => {
+ const refs = createRefs();
+ const mutableVList = refs.vList as unknown as { scrollOffset: number };
+ mutableVList.scrollOffset = 1200;
+ const initialSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
});
- rerender({ sync: loadingSync, roomEventId: undefined });
- act(() => {
- result.current.markUserScrollIntent('backward');
+ const { result, rerender } = renderController({ timelineSync: initialSync, refs });
+ const landedSync = createTimelineSync({
+ ...initialSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
});
- expect(loadingSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ rerender({ sync: landedSync, roomEventId: undefined });
- const idleSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'idle',
+ vi.mocked(landedSync.handleTimelinePagination).mockClear();
+ act(() => {
+ result.current.handleVListScroll(1200);
+ });
+ mutableVList.scrollOffset = 2200;
+ act(() => {
+ result.current.handleVListScroll(2200);
});
- rerender({ sync: idleSync, roomEventId: undefined });
- expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, expect.any(Number));
+ expect(landedSync.handleTimelinePagination).not.toHaveBeenCalled();
});
- it('continues backward pagination after load settles when still near top without new input', () => {
+ it('queues one additional page while loading if the user keeps scrolling at an edge', () => {
const refs = createRefs();
- const timelineSync = createTimelineSync();
- const { result, rerender } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
+ const loadingSync = createTimelineSync({ backwardStatus: 'loading' });
+ const { result, rerender } = renderController({ timelineSync: loadingSync, refs });
act(() => {
result.current.markUserScrollIntent('backward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- const loadingSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'loading',
- });
- rerender({ sync: loadingSync, roomEventId: undefined });
+ expect(loadingSync.handleTimelinePagination).not.toHaveBeenCalled();
- const idleSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'idle',
- });
+ const idleSync = createTimelineSync({ ...loadingSync, backwardStatus: 'idle' });
rerender({ sync: idleSync, roomEventId: undefined });
- expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, expect.any(Number));
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ expect(idleSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
- it('continues backward pagination from preserved scroll pressure even after offset moves away from the edge', () => {
+ it('rearms edge pagination after a page settles at the same edge', () => {
const refs = createRefs();
- const timelineSync = createTimelineSync();
+ const timelineSync = createTimelineSync({ backwardStatus: 'idle' });
const { result, rerender } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
act(() => {
- result.current.markUserScrollIntent('backward', 500);
+ result.current.markUserScrollIntent('backward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true, 160);
+ expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
const loadingSync = createTimelineSync({
...timelineSync,
@@ -420,192 +346,100 @@ describe('useTimelineViewportController', () => {
});
rerender({ sync: loadingSync, roomEventId: undefined });
- mutableVList.scrollOffset = 1500;
- const idleSync = createTimelineSync({
+ const settledSync = createTimelineSync({
...timelineSync,
backwardStatus: 'idle',
});
- rerender({ sync: idleSync, roomEventId: undefined });
-
- expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(true, 160);
- });
-
- it('bounds backward scroll pressure so pagination does not continue forever', () => {
- const refs = createRefs();
- const timelineSync = createTimelineSync();
- const { result, rerender } = renderController({ timelineSync, refs });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 0;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
+ rerender({ sync: settledSync, roomEventId: undefined });
act(() => {
- result.current.markUserScrollIntent('backward', 500);
+ result.current.markUserScrollIntent('backward');
});
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- for (let i = 0; i < 6; i += 1) {
- const loadingSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'loading',
- });
- rerender({ sync: loadingSync, roomEventId: undefined });
-
- mutableVList.scrollOffset = 1500;
- const idleSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'idle',
- });
- rerender({ sync: idleSync, roomEventId: undefined });
- }
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(3);
+ expect(settledSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
+ expect(settledSync.handleTimelinePagination).toHaveBeenLastCalledWith(true);
});
- it('keeps bottom anchor pinned during loading-driven offset shifts', () => {
+ it('enables virtua shift only while backward pagination loads away from bottom', () => {
const refs = createRefs();
- const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
- refs.atBottomRef.current = val;
- });
- const timelineSync = createTimelineSync();
- const { result, rerender } = renderController({ timelineSync, refs, setAtBottom });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
-
- mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
-
- const loadingTimelineSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'loading',
- });
- rerender({ sync: loadingTimelineSync, roomEventId: undefined });
- setAtBottom.mockClear();
+ refs.atBottomRef.current = false;
+ const idleSync = createTimelineSync();
+ const { result, rerender } = renderController({ timelineSync: idleSync, refs });
+ refs.atBottomRef.current = false;
- mutableVList.scrollOffset = 1900;
- act(() => {
- result.current.handleVListScroll(1900);
- });
+ const loadingSync = createTimelineSync({ ...idleSync, backwardStatus: 'loading' });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+ expect(result.current.shift).toBe(true);
- expect(loadingTimelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+ const settledSync = createTimelineSync({ ...idleSync, backwardStatus: 'idle' });
+ rerender({ sync: settledSync, roomEventId: undefined });
+ expect(result.current.shift).toBe(false);
});
- it('enables virtual-list shift while backward pagination loads away from bottom', () => {
- const timelineSync = createTimelineSync();
- const { result, refs, rerender } = renderController({ timelineSync });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent('backward');
- });
- mutableVList.scrollOffset = 1900;
- act(() => {
- result.current.handleVListScroll(1900);
- });
-
- const loadingTimelineSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'loading',
- });
- rerender({ sync: loadingTimelineSync, roomEventId: undefined });
-
- expect(result.current.shift).toBe(true);
-
- const idleTimelineSync = createTimelineSync({
- ...timelineSync,
- backwardStatus: 'idle',
+ it('centers a loaded focus item and releases bottom following', () => {
+ const setAtBottom = vi.fn<(val: boolean) => void>();
+ const setFocusItem = vi.fn<(next: unknown) => void>();
+ const timelineSync = createTimelineSync({
+ focusItem: { index: 1, scrollTo: true, highlight: true },
+ setFocusItem,
});
- rerender({ sync: idleTimelineSync, roomEventId: undefined });
+ const { refs } = renderController({ timelineSync, setAtBottom });
- expect(result.current.shift).toBe(false);
+ expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'center' });
+ expect(setAtBottom).toHaveBeenCalledWith(false);
+ expect(setFocusItem).toHaveBeenCalledWith(expect.any(Function));
});
- it('releases bottom anchor on an intentional upward scroll when idle', () => {
+ it('does not paginate from the programmatic landing scroll after a jump', () => {
const refs = createRefs();
- const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
- refs.atBottomRef.current = val;
+ const initialSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
});
- const timelineSync = createTimelineSync();
- const { result } = renderController({ timelineSync, refs, setAtBottom });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
-
- mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(2200);
+ const { result, rerender } = renderController({ timelineSync: initialSync, refs });
+ const landedSync = createTimelineSync({
+ ...initialSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
});
+ rerender({ sync: landedSync, roomEventId: undefined });
+ vi.mocked(landedSync.handleTimelinePagination).mockClear();
act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 1900;
- act(() => {
- result.current.handleVListScroll(1900);
+ result.current.handleVListScroll(2200);
});
- expect(setAtBottom).toHaveBeenCalledWith(false);
+ expect(landedSync.handleTimelinePagination).not.toHaveBeenCalled();
});
- it('does not trigger pagination before any user scroll intent', () => {
+ it('allows normal forward pagination after the user scrolls away from a landed jump', () => {
const refs = createRefs();
- const setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => {
- refs.atBottomRef.current = val;
- });
- const timelineSync = createTimelineSync();
- const { result } = renderController({ timelineSync, refs, setAtBottom });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
-
+ const mutableVList = refs.vList as unknown as { scrollOffset: number };
mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
+ const initialSync = createTimelineSync({
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: true, highlight: true },
+ });
+ const { result, rerender } = renderController({ timelineSync: initialSync, refs });
+ const landedSync = createTimelineSync({
+ ...initialSync,
+ liveTimelineLinked: false,
+ focusItem: { index: 1, scrollTo: false, highlight: true },
+ });
+ rerender({ sync: landedSync, roomEventId: undefined });
act(() => {
- result.current.handleVListScroll(2200);
+ result.current.markUserScrollIntent('forward');
});
-
- setAtBottom.mockClear();
- mutableVList.scrollOffset = 1900;
act(() => {
- result.current.handleVListScroll(1900);
+ result.current.handleVListScroll(2200);
});
- expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
+ expect(landedSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
+ expect(landedSync.handleTimelinePagination).toHaveBeenCalledWith(false);
});
- it('does not paginate while an event jump is still loading', () => {
+ it('loads a missing jump target and suppresses pagination until it resolves', () => {
let resolveLoad: () => void = noop;
const timelineSync = createTimelineSync({
loadEventTimeline: vi.fn<(eventId: string) => Promise>(
@@ -618,7 +452,7 @@ describe('useTimelineViewportController', () => {
const { result } = renderController({ eventId: '$target', timelineSync });
act(() => {
- result.current.handleVListScroll(120);
+ result.current.markUserScrollIntent('backward');
});
expect(timelineSync.loadEventTimeline).toHaveBeenCalledWith('$target');
@@ -628,420 +462,4 @@ describe('useTimelineViewportController', () => {
resolveLoad();
});
});
-
- it('centers a loaded focus item and consumes the scroll intent', () => {
- const setFocusItem = vi.fn<(next: unknown) => void>();
- const timelineSync = createTimelineSync({
- focusItem: { index: 1, scrollTo: true, highlight: true },
- setFocusItem,
- });
- const { refs } = renderController({ timelineSync });
-
- expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'center' });
- expect(setFocusItem).toHaveBeenCalledWith(expect.any(Function));
-
- const updater = setFocusItem.mock.calls[0]?.[0] as (
- prev: typeof timelineSync.focusItem
- ) => typeof timelineSync.focusItem;
- expect(updater(timelineSync.focusItem)).toEqual({ index: 1, scrollTo: false, highlight: true });
- });
-
- it('blocks the programmatic landing scroll after a jump focus item has landed', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
-
- expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
- });
-
- it('allows edge pagination after the user scrolls away from a landed jump', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollOffset = 2200;
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- act(() => {
- result.current.handleVListScroll(2260);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(
- false,
- expect.any(Number)
- );
- });
-
- it('paginates forward from user input while already clamped at the bottom edge', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- });
- const { result, refs } = renderController({ timelineSync });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- mutableVList.scrollOffset = 2200;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
-
- act(() => {
- result.current.markUserScrollIntent('forward');
- });
-
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false, expect.any(Number));
- });
-
- it('queues another forward page when user keeps scrolling at detached bottom while loading', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- mutableVList.scrollOffset = 2200;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent('forward');
- });
- expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false, expect.any(Number));
-
- const loadingSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- forwardStatus: 'loading',
- });
- rerender({ sync: loadingSync, roomEventId: undefined });
- act(() => {
- result.current.markUserScrollIntent('forward');
- });
- expect(loadingSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- const idleSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- forwardStatus: 'idle',
- });
- rerender({ sync: idleSync, roomEventId: undefined });
-
- expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- expect(idleSync.handleTimelinePagination).toHaveBeenLastCalledWith(false, expect.any(Number));
- });
-
- it('does not bottom-anchor a detached jump window when forward pagination loads', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- forwardStatus: 'idle',
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- scrollTo: ReturnType;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- mutableVList.scrollOffset = 2200;
- refs.vList.scrollTo = vi.fn<(offset: number) => void>();
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent('forward');
- });
-
- const forwardLoadingSync = createTimelineSync({
- ...landedTimelineSync,
- forwardStatus: 'loading',
- });
- rerender({ sync: forwardLoadingSync, roomEventId: undefined });
-
- expect(refs.vList.scrollTo).not.toHaveBeenCalled();
- });
-
- it('does not repeat forward pagination from layout scrolls after a landed jump', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- mutableVList.scrollOffset = 2200;
-
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2260;
- act(() => {
- result.current.handleVListScroll(2260);
- });
- mutableVList.scrollOffset = 2280;
- act(() => {
- result.current.handleVListScroll(2280);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2290;
- act(() => {
- result.current.handleVListScroll(2290);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- });
-
- it('does not repeat backward pagination from layout scrolls after a landed jump', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
- mutableVList.scrollOffset = 700;
-
- act(() => {
- result.current.handleVListScroll(700);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 120;
- act(() => {
- result.current.handleVListScroll(120);
- });
- mutableVList.scrollOffset = 80;
- act(() => {
- result.current.handleVListScroll(80);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledWith(
- true,
- expect.any(Number)
- );
-
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 60;
- act(() => {
- result.current.handleVListScroll(60);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- });
-
- it('continues forward pagination after leaving and returning to the bottom edge', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- mutableVList.scrollOffset = 2200;
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2260;
- act(() => {
- result.current.handleVListScroll(2260);
- });
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- mutableVList.scrollOffset = 1200;
- act(() => {
- result.current.handleVListScroll(1200);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2260;
- act(() => {
- result.current.handleVListScroll(2260);
- });
-
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(2);
- });
-
- it('rearms forward pagination after a forward page settles while staying near bottom', () => {
- const timelineSync = createTimelineSync({
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: true, highlight: true },
- forwardStatus: 'idle',
- });
- const { result, refs, rerender } = renderController({ timelineSync });
- const landedTimelineSync = createTimelineSync({
- ...timelineSync,
- liveTimelineLinked: false,
- focusItem: { index: 1, scrollTo: false, highlight: true },
- forwardStatus: 'idle',
- });
-
- rerender({ sync: landedTimelineSync, roomEventId: undefined });
-
- const mutableVList = refs.vList as unknown as {
- scrollOffset: number;
- scrollSize: number;
- viewportSize: number;
- };
- mutableVList.scrollSize = 3000;
- mutableVList.viewportSize = 800;
-
- mutableVList.scrollOffset = 2200;
- act(() => {
- result.current.handleVListScroll(2200);
- });
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2260;
- act(() => {
- result.current.handleVListScroll(2260);
- });
- expect(landedTimelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1);
-
- const forwardLoadingSync = createTimelineSync({
- ...landedTimelineSync,
- forwardStatus: 'loading',
- });
- rerender({ sync: forwardLoadingSync, roomEventId: undefined });
- const forwardIdleSync = createTimelineSync({
- ...landedTimelineSync,
- forwardStatus: 'idle',
- });
- rerender({ sync: forwardIdleSync, roomEventId: undefined });
-
- act(() => {
- result.current.markUserScrollIntent();
- });
- mutableVList.scrollOffset = 2290;
- act(() => {
- result.current.handleVListScroll(2290);
- });
-
- expect(forwardIdleSync.handleTimelinePagination).toHaveBeenCalledWith(
- false,
- expect.any(Number)
- );
- });
});
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index e8b1538af..0bcaf010d 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -1,4 +1,12 @@
-import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type MutableRefObject,
+ type RefObject,
+} from 'react';
import type { VListHandle } from 'virtua';
import type { useTimelineSync } from '$hooks/timeline/useTimelineSync';
import {
@@ -8,59 +16,41 @@ import {
import {
getBottomClampSpacer,
getCenterAnchorAdjustment,
- isTimelineAtBottom,
- TIMELINE_BOTTOM_THRESHOLD_PX,
+ getTimelineVisibleRange,
type TimelineAnchor,
type TimelineScrollDirection,
+ type TimelineVisibleRange,
} from './timelineViewportModel';
import { pushTimelineJumpDebug } from './timelineJumpDebug';
const INITIAL_BACKFILL_PAGE_BUDGET = 6;
-const BOOTSTRAP_FILL_TARGET_SLACK_PX = 32;
-const TIMELINE_PAGINATION_THRESHOLD_PX = 500;
-const TIMELINE_PAGINATION_LEAD_THRESHOLD_PX = 1000;
-const TIMELINE_SCROLL_INTENT_DELTA_PX = 8;
-const TIMELINE_SCROLL_INTENT_TTL_MS = 1200;
-const MIN_PAGINATION_LIMIT = 40;
-const MEDIUM_SCROLL_LIMIT = 100;
-const FAST_SCROLL_LIMIT = 160;
-const SCROLL_PRESSURE_TTL_MS = 1200;
-const MAX_SCROLL_PRESSURE_PAGES = 3;
-const TOP_EDGE_RECOIL_TRIGGER_PX = 28;
-const TOP_EDGE_RECOIL_STEP_MIN_PX = 90;
-const TOP_EDGE_RECOIL_STEP_MAX_PX = 260;
-const TOP_EDGE_RECOIL_COOLDOWN_MS = 60;
+const READY_FALLBACK_MS = 1500;
+const FOCUS_HIGHLIGHT_MS = 2000;
type TimelineSyncController = ReturnType;
-type PendingScrollIntent = {
- id: number;
- direction?: TimelineScrollDirection;
- limit?: number;
- expiresAt: number;
-};
-
-type ScrollPressure = {
- backward: number;
- forward: number;
- backwardLimit: number;
- forwardLimit: number;
- expiresAt: number;
-};
-
export type UseTimelineViewportControllerOptions = {
roomId: string;
eventId?: string;
timelineSync: TimelineSyncController;
- timelineSyncRef: React.MutableRefObject;
+ timelineSyncRef: MutableRefObject;
vListRef: RefObject;
messageListRef: RefObject;
- processedEventsRef: React.MutableRefObject;
- atBottomRef: React.MutableRefObject;
+ processedEventsRef: MutableRefObject;
+ atBottomRef: MutableRefObject;
setAtBottom: (val: boolean) => void;
getRawIndexToProcessedIndex: (rawIndex: number) => number | undefined;
};
+const getScrollDirection = (
+ previousOffset: number,
+ nextOffset: number
+): TimelineScrollDirection | undefined => {
+ if (nextOffset < previousOffset) return 'backward';
+ if (nextOffset > previousOffset) return 'forward';
+ return undefined;
+};
+
export function useTimelineViewportController({
roomId,
eventId,
@@ -77,33 +67,23 @@ export function useTimelineViewportController({
const [topSpacerHeight, setTopSpacerHeight] = useState(0);
const [isReady, setIsReady] = useState(false);
const [jumpInFlight, setJumpInFlight] = useState(false);
- const [bootstrapViewportTick, setBootstrapViewportTick] = useState(0);
const topSpacerHeightRef = useRef(0);
const hasInitialScrolledRef = useRef(false);
const pendingReadyRef = useRef(false);
const anchorRef = useRef({ kind: 'bottom' });
+ const edgeArmedRef = useRef({ backward: true, forward: true });
+ const queuedPaginationRef = useRef({ backward: false, forward: false });
+ const pendingUserDirectionRef = useRef(undefined);
+ const pendingUserScrollRef = useRef(false);
const remainingInitialBackfillPagesRef = useRef(INITIAL_BACKFILL_PAGE_BUDGET);
const lastScrollOffsetRef = useRef(0);
- const suppressNextCenterScrollRef = useRef(false);
- const scrollIntentCounterRef = useRef(0);
- const pendingScrollIntentRef = useRef(undefined);
- const queuedPaginationAfterLoadRef = useRef({ backward: false, forward: false });
- const scrollPressureRef = useRef({
- backward: 0,
- forward: 0,
- backwardLimit: MIN_PAGINATION_LIMIT,
- forwardLimit: MIN_PAGINATION_LIMIT,
- expiresAt: 0,
- });
- const lastIntentSampleRef = useRef<{ at: number; deltaPx: number }>({ at: 0, deltaPx: 0 });
- const pendingBootstrapRevealRef = useRef(false);
- const bootstrapViewportRetryFrameRef = useRef(undefined);
+ const pendingBootstrapFrameRef = useRef(undefined);
const settleAnchorFrameRef = useRef(undefined);
const readyFallbackTimerRef = useRef | undefined>(undefined);
- const lastTopEdgeRecoilAtRef = useRef(0);
- const lastBackwardIntentAtRef = useRef(0);
const currentRoomIdRef = useRef(roomId);
+ const jumpInFlightRef = useRef(jumpInFlight);
+ jumpInFlightRef.current = jumpInFlight;
const canPaginateBackRef = useRef(timelineSync.canPaginateBack);
canPaginateBackRef.current = timelineSync.canPaginateBack;
@@ -117,163 +97,24 @@ export function useTimelineViewportController({
const forwardStatusRef = useRef(timelineSync.forwardStatus);
forwardStatusRef.current = timelineSync.forwardStatus;
- const clearPendingScrollIntent = useCallback(() => {
- pendingScrollIntentRef.current = undefined;
- }, []);
-
- const applyTopEdgeRecoil = useCallback(
- (deltaPx?: number) => {
- const v = vListRef.current;
- if (!v) return false;
- if (v.scrollOffset > TOP_EDGE_RECOIL_TRIGGER_PX) return false;
- const now = Date.now();
- if (now - lastTopEdgeRecoilAtRef.current < TOP_EDGE_RECOIL_COOLDOWN_MS) return false;
- lastTopEdgeRecoilAtRef.current = now;
- const recoilPx = Math.max(
- TOP_EDGE_RECOIL_STEP_MIN_PX,
- Math.min(TOP_EDGE_RECOIL_STEP_MAX_PX, Math.round((deltaPx ?? 120) * 1.2))
- );
- const maxOffset = Math.max(0, v.scrollSize - v.viewportSize);
- v.scrollTo(Math.min(maxOffset, v.scrollOffset + recoilPx));
- return true;
- },
- [vListRef]
- );
-
- const readScrollPressure = useCallback((): ScrollPressure => {
- const pressure = scrollPressureRef.current;
- if (Date.now() <= pressure.expiresAt) return pressure;
- pressure.backward = 0;
- pressure.forward = 0;
- pressure.backwardLimit = MIN_PAGINATION_LIMIT;
- pressure.forwardLimit = MIN_PAGINATION_LIMIT;
- return pressure;
- }, []);
-
- const consumeScrollPressure = useCallback(
- (direction: TimelineScrollDirection): number | undefined => {
- const pressure = readScrollPressure();
- if (direction === 'backward' && pressure.backward > 0) {
- pressure.backward -= 1;
- return pressure.backwardLimit;
- }
- if (direction === 'forward' && pressure.forward > 0) {
- pressure.forward -= 1;
- return pressure.forwardLimit;
- }
- return undefined;
- },
- [readScrollPressure]
- );
-
- const getActiveScrollIntent = useCallback((): PendingScrollIntent | undefined => {
- const intent = pendingScrollIntentRef.current;
- if (!intent) return undefined;
- if (Date.now() <= intent.expiresAt) return intent;
- pendingScrollIntentRef.current = undefined;
- return undefined;
- }, []);
-
- const getScrollEdges = useCallback(
- (offset = vListRef.current?.scrollOffset) => {
- const v = vListRef.current;
- if (!v || offset === undefined) return undefined;
- const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
- return {
- offset,
- distanceFromBottom,
- isAtBottom: distanceFromBottom < TIMELINE_BOTTOM_THRESHOLD_PX,
- isAtBackwardEdge: offset < TIMELINE_PAGINATION_THRESHOLD_PX,
- isAtForwardEdge: distanceFromBottom < TIMELINE_PAGINATION_THRESHOLD_PX,
- };
- },
- [vListRef]
- );
-
- const isNearBackwardEdge = useCallback(
- (edges: ReturnType) =>
- Boolean(edges && edges.offset < TIMELINE_PAGINATION_LEAD_THRESHOLD_PX),
- []
- );
-
- const isNearForwardEdge = useCallback(
- (edges: ReturnType) =>
- Boolean(edges && edges.distanceFromBottom < TIMELINE_PAGINATION_LEAD_THRESHOLD_PX),
- []
- );
-
- const getDynamicPaginationLimit = useCallback((deltaPx?: number): number => {
- const now = Date.now();
- const previous = lastIntentSampleRef.current;
- const sampleDelta = typeof deltaPx === 'number' ? deltaPx : previous.deltaPx;
- const elapsed = previous.at === 0 ? 16 : Math.max(16, now - previous.at);
- const speedPxPerSec = (sampleDelta / elapsed) * 1000;
-
- lastIntentSampleRef.current = { at: now, deltaPx: sampleDelta };
-
- if (sampleDelta > 320 || speedPxPerSec > 2200) return FAST_SCROLL_LIMIT;
- if (sampleDelta > 120 || speedPxPerSec > 900) return MEDIUM_SCROLL_LIMIT;
- return MIN_PAGINATION_LIMIT;
- }, []);
-
- const requestPaginationFromScroll = useCallback(
- (
- direction: TimelineScrollDirection,
- intent: PendingScrollIntent | undefined,
- offset?: number
- ) => {
- if (!intent) return;
- if (jumpInFlight || timelineSyncRef.current.focusItem?.scrollTo) return;
-
- const edges = getScrollEdges(offset);
- if (!edges) return;
- if (
- direction === 'backward' &&
- (!edges.isAtBackwardEdge ||
- !canPaginateBackRef.current ||
- backwardStatusRef.current !== 'idle')
- )
- return;
- if (
- direction === 'forward' &&
- (!edges.isAtForwardEdge ||
- liveTimelineLinkedRef.current ||
- forwardStatusRef.current !== 'idle')
- )
- return;
-
- pendingScrollIntentRef.current = undefined;
- const pressureLimit = consumeScrollPressure(direction);
- timelineSyncRef.current.handleTimelinePagination(
- direction === 'backward',
- Math.max(intent.limit ?? MIN_PAGINATION_LIMIT, pressureLimit ?? MIN_PAGINATION_LIMIT)
- );
- },
- [consumeScrollPressure, getScrollEdges, jumpInFlight, timelineSyncRef]
- );
+ useLayoutEffect(() => {
+ if (currentRoomIdRef.current === roomId) return;
- if (currentRoomIdRef.current !== roomId) {
- hasInitialScrolledRef.current = false;
currentRoomIdRef.current = roomId;
+ hasInitialScrolledRef.current = false;
pendingReadyRef.current = false;
anchorRef.current = eventId ? { kind: 'none' } : { kind: 'bottom' };
+ edgeArmedRef.current = { backward: true, forward: true };
+ queuedPaginationRef.current = { backward: false, forward: false };
+ pendingUserDirectionRef.current = undefined;
+ pendingUserScrollRef.current = false;
remainingInitialBackfillPagesRef.current = INITIAL_BACKFILL_PAGE_BUDGET;
lastScrollOffsetRef.current = 0;
- suppressNextCenterScrollRef.current = false;
- pendingScrollIntentRef.current = undefined;
- queuedPaginationAfterLoadRef.current = { backward: false, forward: false };
- scrollPressureRef.current = {
- backward: 0,
- forward: 0,
- backwardLimit: MIN_PAGINATION_LIMIT,
- forwardLimit: MIN_PAGINATION_LIMIT,
- expiresAt: 0,
- };
- lastIntentSampleRef.current = { at: 0, deltaPx: 0 };
- pendingBootstrapRevealRef.current = false;
- if (bootstrapViewportRetryFrameRef.current !== undefined) {
- cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
- bootstrapViewportRetryFrameRef.current = undefined;
+ topSpacerHeightRef.current = 0;
+
+ if (pendingBootstrapFrameRef.current !== undefined) {
+ cancelAnimationFrame(pendingBootstrapFrameRef.current);
+ pendingBootstrapFrameRef.current = undefined;
}
if (settleAnchorFrameRef.current !== undefined) {
cancelAnimationFrame(settleAnchorFrameRef.current);
@@ -283,18 +124,49 @@ export function useTimelineViewportController({
clearTimeout(readyFallbackTimerRef.current);
readyFallbackTimerRef.current = undefined;
}
+
+ setShift(false);
+ setTopSpacerHeight(0);
setJumpInFlight(false);
setIsReady(false);
- }
+ }, [eventId, roomId]);
+
+ const getRenderedItemCount = useCallback(() => {
+ const processedCount = processedEventsRef.current.length;
+ if (processedCount > 0) return processedCount;
+ return timelineSyncRef.current.eventsLength > 0 ? 1 : 0;
+ }, [processedEventsRef, timelineSyncRef]);
+
+ const getVisibleRange = useCallback(
+ (offset?: number): TimelineVisibleRange | undefined => {
+ const v = vListRef.current;
+ if (!v) return undefined;
+ return getTimelineVisibleRange(v, getRenderedItemCount(), offset);
+ },
+ [getRenderedItemCount, vListRef]
+ );
+
+ const setBottomStateFromRange = useCallback(
+ (range: TimelineVisibleRange) => {
+ const nextAtBottom = range.atEnd && liveTimelineLinkedRef.current;
+ if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
+ },
+ [atBottomRef, setAtBottom]
+ );
+
+ const updateEdgeArming = useCallback((range: TimelineVisibleRange) => {
+ if (!range.atStart) edgeArmedRef.current.backward = true;
+ if (!range.atEnd) edgeArmedRef.current.forward = true;
+ }, []);
const recalcTopSpacer = useCallback(() => {
const v = vListRef.current;
if (!v) return;
const prev = topSpacerHeightRef.current;
- const newH = getBottomClampSpacer(v.viewportSize, v.scrollSize, prev);
- if (Math.abs(prev - newH) > 2) {
- topSpacerHeightRef.current = newH;
- setTopSpacerHeight(newH);
+ const next = getBottomClampSpacer(v.viewportSize, v.scrollSize, prev);
+ if (prev !== next) {
+ topSpacerHeightRef.current = next;
+ setTopSpacerHeight(next);
}
}, [vListRef]);
@@ -315,6 +187,7 @@ export function useTimelineViewportController({
if (anchor.kind === 'bottom') {
v.scrollTo(v.scrollSize);
+ if (liveTimelineLinkedRef.current && !atBottomRef.current) setAtBottom(true);
return true;
}
@@ -327,22 +200,18 @@ export function useTimelineViewportController({
target.getBoundingClientRect(),
viewport.getBoundingClientRect()
);
- if (Math.abs(adjustment) > 1) v.scrollBy(adjustment);
+ if (adjustment !== 0) v.scrollBy(adjustment);
return true;
}
return false;
},
- [findMessageElement, messageListRef, vListRef]
+ [atBottomRef, findMessageElement, messageListRef, setAtBottom, vListRef]
);
const settleTimelineAnchor = useCallback(
(anchor: TimelineAnchor, reveal = false) => {
anchorRef.current = anchor;
- if (anchor.kind === 'message-center') {
- suppressNextCenterScrollRef.current = true;
- pendingScrollIntentRef.current = undefined;
- }
if (settleAnchorFrameRef.current !== undefined) {
cancelAnimationFrame(settleAnchorFrameRef.current);
}
@@ -357,9 +226,77 @@ export function useTimelineViewportController({
[applyTimelineAnchor, recalcTopSpacer]
);
+ const requestPagination = useCallback(
+ (direction: TimelineScrollDirection, fromQueuedIntent = false): boolean => {
+ if (jumpInFlightRef.current || timelineSyncRef.current.focusItem?.scrollTo) return false;
+
+ if (direction === 'backward') {
+ if (!canPaginateBackRef.current) return false;
+ if (backwardStatusRef.current === 'loading') {
+ if (!fromQueuedIntent) queuedPaginationRef.current.backward = true;
+ return false;
+ }
+ if (backwardStatusRef.current !== 'idle') return false;
+ edgeArmedRef.current.backward = false;
+ timelineSyncRef.current.handleTimelinePagination(true);
+ return true;
+ }
+
+ if (liveTimelineLinkedRef.current) return false;
+ if (forwardStatusRef.current === 'loading') {
+ if (!fromQueuedIntent) queuedPaginationRef.current.forward = true;
+ return false;
+ }
+ if (forwardStatusRef.current !== 'idle') return false;
+ edgeArmedRef.current.forward = false;
+ timelineSyncRef.current.handleTimelinePagination(false);
+ return true;
+ },
+ [timelineSyncRef]
+ );
+
+ const requestPaginationAtVisibleEdge = useCallback(
+ (direction: TimelineScrollDirection, range = getVisibleRange()) => {
+ if (!range) return false;
+ if (direction === 'backward' && range.atStart && edgeArmedRef.current.backward) {
+ return requestPagination('backward');
+ }
+ if (direction === 'forward' && range.atEnd && edgeArmedRef.current.forward) {
+ return requestPagination('forward');
+ }
+ return false;
+ },
+ [getVisibleRange, requestPagination]
+ );
+
+ const releaseAnchorsForUserScroll = useCallback(
+ (direction: TimelineScrollDirection | undefined) => {
+ if (!direction) return;
+
+ if (anchorRef.current.kind === 'message-center') {
+ anchorRef.current = { kind: 'none' };
+ pushTimelineJumpDebug('viewport', 'anchor_released_during_jump_context', {
+ roomId,
+ direction,
+ jumpInFlight: jumpInFlightRef.current,
+ hasFocusItem: Boolean(timelineSyncRef.current.focusItem),
+ focusScrollTo: timelineSyncRef.current.focusItem?.scrollTo,
+ });
+ }
+
+ if (anchorRef.current.kind === 'bottom' && direction === 'backward') {
+ anchorRef.current = { kind: 'none' };
+ if (atBottomRef.current) setAtBottom(false);
+ }
+ },
+ [atBottomRef, roomId, setAtBottom, timelineSyncRef]
+ );
+
const beginJumpLoad = useCallback(
(targetEventId: string) => {
- pendingScrollIntentRef.current = undefined;
+ pendingUserDirectionRef.current = undefined;
+ pendingUserScrollRef.current = false;
+ anchorRef.current = { kind: 'none' };
setJumpInFlight(true);
pushTimelineJumpDebug('viewport', 'begin_jump_load', {
roomId,
@@ -381,61 +318,48 @@ export function useTimelineViewportController({
if (settleAnchorFrameRef.current !== undefined) {
cancelAnimationFrame(settleAnchorFrameRef.current);
}
+ if (pendingBootstrapFrameRef.current !== undefined) {
+ cancelAnimationFrame(pendingBootstrapFrameRef.current);
+ }
if (readyFallbackTimerRef.current !== undefined) {
clearTimeout(readyFallbackTimerRef.current);
}
- if (bootstrapViewportRetryFrameRef.current !== undefined) {
- cancelAnimationFrame(bootstrapViewportRetryFrameRef.current);
- }
},
[]
);
- useEffect((): void | (() => void) => {
- if (isReady) return;
- if (pendingBootstrapRevealRef.current) return;
- if (readyFallbackTimerRef.current !== undefined) {
- clearTimeout(readyFallbackTimerRef.current);
- }
+ useEffect(() => {
+ if (isReady) return undefined;
+ if (eventId || jumpInFlight) return undefined;
readyFallbackTimerRef.current = setTimeout(() => {
setIsReady(true);
readyFallbackTimerRef.current = undefined;
- }, 1500);
+ }, READY_FALLBACK_MS);
return () => {
if (readyFallbackTimerRef.current !== undefined) {
clearTimeout(readyFallbackTimerRef.current);
readyFallbackTimerRef.current = undefined;
}
};
- }, [isReady, roomId]);
+ }, [eventId, isReady, jumpInFlight, roomId]);
useLayoutEffect(() => {
- if (
- !eventId &&
- !hasInitialScrolledRef.current &&
- timelineSync.eventsLength > 0 &&
- vListRef.current
- ) {
- const lastIndex = processedEventsRef.current.length - 1;
- if (lastIndex < 0) {
- // Some rooms initially hydrate mostly state/hidden events. Keep a pending
- // first-visible-row anchor so we re-land on latest once a renderable row appears.
- pendingReadyRef.current = true;
- settleTimelineAnchor({ kind: 'bottom' }, true);
- hasInitialScrolledRef.current = true;
- return;
- }
+ if (eventId || hasInitialScrolledRef.current || timelineSync.eventsLength === 0) return;
+ if (!timelineSync.liveTimelineLinked || !vListRef.current) return;
+
+ const lastIndex = processedEventsRef.current.length - 1;
+ if (lastIndex >= 0) {
vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
- const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack;
- pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal;
- settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal);
- hasInitialScrolledRef.current = true;
+ } else {
+ pendingReadyRef.current = true;
}
+
+ settleTimelineAnchor({ kind: 'bottom' }, true);
+ hasInitialScrolledRef.current = true;
}, [
- timelineSync.eventsLength,
- timelineSync.canPaginateBack,
eventId,
- roomId,
+ timelineSync.eventsLength,
+ timelineSync.liveTimelineLinked,
processedEventsRef,
settleTimelineAnchor,
vListRef,
@@ -462,118 +386,43 @@ export function useTimelineViewportController({
const wasAtBottomBeforePaginationRef = useRef(false);
useLayoutEffect(() => {
- const prev = prevBackwardStatusRef.current;
+ const previous = prevBackwardStatusRef.current;
prevBackwardStatusRef.current = timelineSync.backwardStatus;
- if (
- prev !== timelineSync.backwardStatus &&
- (prev === 'loading' || timelineSync.backwardStatus === 'loading')
- ) {
- clearPendingScrollIntent();
- }
+
if (timelineSync.backwardStatus === 'loading') {
wasAtBottomBeforePaginationRef.current = atBottomRef.current;
- if (anchorRef.current.kind !== 'bottom') setShift(true);
- } else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') {
+ if (!atBottomRef.current) setShift(true);
+ return;
+ }
+
+ if (previous === 'loading') {
setShift(false);
+ edgeArmedRef.current.backward = true;
if (wasAtBottomBeforePaginationRef.current) settleTimelineAnchor({ kind: 'bottom' });
- const edges = getScrollEdges();
- const pressure = readScrollPressure();
- const shouldContinueBackward =
- isNearBackwardEdge(edges) &&
- canPaginateBackRef.current &&
- !jumpInFlight &&
- !timelineSync.focusItem?.scrollTo;
- const shouldContinueBackwardWithPressure =
- pressure.backward > 0 &&
- canPaginateBackRef.current &&
- !jumpInFlight &&
- !timelineSync.focusItem?.scrollTo;
- if (
- queuedPaginationAfterLoadRef.current.backward ||
- shouldContinueBackward ||
- shouldContinueBackwardWithPressure
- ) {
- queuedPaginationAfterLoadRef.current.backward = false;
- if (shouldContinueBackward || shouldContinueBackwardWithPressure) {
- const pressureLimit = consumeScrollPressure('backward') ?? MIN_PAGINATION_LIMIT;
- timelineSyncRef.current.handleTimelinePagination(true, pressureLimit);
- }
- }
- if (pendingBootstrapRevealRef.current) {
- const v = vListRef.current;
- if (!v) return;
- if (v.viewportSize <= 0) return;
- const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
- const isFilled = contentHeight > v.viewportSize + BOOTSTRAP_FILL_TARGET_SLACK_PX;
- const done =
- isFilled ||
- !timelineSync.canPaginateBack ||
- remainingInitialBackfillPagesRef.current <= 0;
- if (done) {
- pendingBootstrapRevealRef.current = false;
- setIsReady(true);
- }
+ if (queuedPaginationRef.current.backward) {
+ queuedPaginationRef.current.backward = false;
+ requestPagination('backward', true);
}
- } else if (timelineSync.backwardStatus === 'error' && pendingBootstrapRevealRef.current) {
- pendingBootstrapRevealRef.current = false;
- setIsReady(true);
}
- }, [
- roomId,
- timelineSync.backwardStatus,
- timelineSync.canPaginateBack,
- atBottomRef,
- clearPendingScrollIntent,
- consumeScrollPressure,
- getScrollEdges,
- isNearBackwardEdge,
- jumpInFlight,
- readScrollPressure,
- timelineSync.focusItem?.scrollTo,
- timelineSyncRef,
- settleTimelineAnchor,
- vListRef,
- ]);
+ }, [atBottomRef, requestPagination, settleTimelineAnchor, timelineSync.backwardStatus]);
useLayoutEffect(() => {
- const prev = prevForwardStatusRef.current;
+ const previous = prevForwardStatusRef.current;
prevForwardStatusRef.current = timelineSync.forwardStatus;
- if (
- prev !== timelineSync.forwardStatus &&
- (prev === 'loading' || timelineSync.forwardStatus === 'loading')
- ) {
- clearPendingScrollIntent();
- }
- if (prev === 'loading' && timelineSync.forwardStatus === 'idle') {
- if (queuedPaginationAfterLoadRef.current.forward) {
- queuedPaginationAfterLoadRef.current.forward = false;
- const edges = getScrollEdges();
- const pressure = readScrollPressure();
- const shouldContinueForwardWithPressure = pressure.forward > 0 && !jumpInFlight;
- if (
- (isNearForwardEdge(edges) || shouldContinueForwardWithPressure) &&
- !liveTimelineLinkedRef.current &&
- !jumpInFlight
- ) {
- const pressureLimit = consumeScrollPressure('forward') ?? MIN_PAGINATION_LIMIT;
- timelineSyncRef.current.handleTimelinePagination(false, pressureLimit);
- }
+
+ if (previous === 'loading' && timelineSync.forwardStatus !== 'loading') {
+ edgeArmedRef.current.forward = true;
+ if (queuedPaginationRef.current.forward) {
+ queuedPaginationRef.current.forward = false;
+ requestPagination('forward', true);
}
}
- }, [
- clearPendingScrollIntent,
- consumeScrollPressure,
- getScrollEdges,
- isNearForwardEdge,
- jumpInFlight,
- readScrollPressure,
- timelineSync.forwardStatus,
- timelineSyncRef,
- ]);
+ }, [requestPagination, timelineSync.forwardStatus]);
- useEffect((): void | (() => void) => {
+ useEffect(() => {
let timeoutId: ReturnType | undefined;
+
if (timelineSync.focusItem) {
if (timelineSync.focusItem.scrollTo && vListRef.current) {
let processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index);
@@ -588,6 +437,7 @@ export function useTimelineViewportController({
focusRawIndex = nearest.focusRawIndex;
}
}
+
if (processedIndex !== undefined) {
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
const focusEventId = processedEventsRef.current[processedIndex]?.id;
@@ -599,76 +449,62 @@ export function useTimelineViewportController({
processedIndex,
focusRawIndex,
});
+ } else {
+ setIsReady(true);
}
+ if (atBottomRef.current) setAtBottom(false);
timelineSync.setFocusItem((prev) =>
prev ? { ...prev, index: focusRawIndex, scrollTo: false } : undefined
);
}
}
+
timeoutId = setTimeout(() => {
timelineSync.setFocusItem(undefined);
- }, 2000);
+ }, FOCUS_HIGHLIGHT_MS);
}
+
return () => {
if (timeoutId !== undefined) clearTimeout(timeoutId);
};
}, [
- timelineSync,
- timelineSync.focusItem,
+ atBottomRef,
getRawIndexToProcessedIndex,
processedEventsRef,
roomId,
+ setAtBottom,
settleTimelineAnchor,
+ timelineSync,
+ timelineSync.focusItem,
vListRef,
]);
useEffect(() => {
- if (
- timelineSync.focusItem &&
- !timelineSync.focusItem.scrollTo &&
- (isReady || anchorRef.current.kind !== 'message-center')
- ) {
- setIsReady(true);
- }
- }, [timelineSync.focusItem, isReady]);
+ if (timelineSync.focusItem) setIsReady(true);
+ }, [timelineSync.focusItem]);
useEffect(() => {
if (!eventId) return;
- anchorRef.current = { kind: 'none' };
setIsReady(false);
beginJumpLoad(eventId);
- }, [eventId, roomId, beginJumpLoad]);
-
- useEffect(() => {
- if (!jumpInFlight) return;
- if (!timelineSync.focusItem) return;
- if (timelineSync.focusItem.scrollTo) return;
- setJumpInFlight(false);
- pushTimelineJumpDebug('viewport', 'jump_inflight_cleared_by_focus_settle', {
- roomId,
- focusIndex: timelineSync.focusItem.index,
- });
- }, [jumpInFlight, roomId, timelineSync.focusItem]);
+ }, [beginJumpLoad, eventId, roomId]);
useLayoutEffect(() => {
if (!isReady) return;
recalcTopSpacer();
applyTimelineAnchor();
- const v = vListRef.current;
- if (!v || anchorRef.current.kind !== 'bottom') return;
- const nextAtBottom = isTimelineAtBottom(v.scrollSize, v.scrollOffset, v.viewportSize);
- if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
+ const range = getVisibleRange();
+ if (range) setBottomStateFromRange(range);
}, [
+ applyTimelineAnchor,
+ getVisibleRange,
isReady,
- timelineSync.eventsLength,
+ recalcTopSpacer,
+ setBottomStateFromRange,
timelineSync.backwardStatus,
+ timelineSync.eventsLength,
timelineSync.forwardStatus,
- recalcTopSpacer,
- applyTimelineAnchor,
- setAtBottom,
- atBottomRef,
- vListRef,
]);
useEffect(() => {
@@ -684,7 +520,7 @@ export function useTimelineViewportController({
observer.observe(viewport);
return () => observer.disconnect();
- }, [isReady, messageListRef, recalcTopSpacer, applyTimelineAnchor]);
+ }, [applyTimelineAnchor, isReady, messageListRef, recalcTopSpacer]);
useLayoutEffect(() => {
if (!pendingReadyRef.current) return;
@@ -693,53 +529,42 @@ export function useTimelineViewportController({
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
settleTimelineAnchor({ kind: 'bottom' }, true);
}, [
- timelineSync.eventsLength,
+ processedEventsRef,
processedEventsRef.current.length,
settleTimelineAnchor,
+ timelineSync.eventsLength,
vListRef,
- processedEventsRef,
]);
useEffect(() => {
const v = vListRef.current;
- if (!v) return;
- if (!isReady && !pendingBootstrapRevealRef.current) return;
- if (jumpInFlight) return;
- if (timelineSync.focusItem) return;
- if (anchorRef.current.kind === 'message-center') return;
- if (anchorRef.current.kind !== 'bottom') return;
+ if (!v || eventId || jumpInFlight) return;
+ if (!isReady || anchorRef.current.kind !== 'bottom') return;
if (remainingInitialBackfillPagesRef.current <= 0) return;
if (!canPaginateBackRef.current || backwardStatusRef.current !== 'idle') return;
-
- const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
if (v.viewportSize <= 0) {
- if (bootstrapViewportRetryFrameRef.current === undefined) {
- bootstrapViewportRetryFrameRef.current = requestAnimationFrame(() => {
- bootstrapViewportRetryFrameRef.current = undefined;
- setBootstrapViewportTick((prev) => prev + 1);
+ if (pendingBootstrapFrameRef.current === undefined) {
+ pendingBootstrapFrameRef.current = requestAnimationFrame(() => {
+ pendingBootstrapFrameRef.current = undefined;
+ recalcTopSpacer();
});
}
return;
}
- if (contentHeight > v.viewportSize + BOOTSTRAP_FILL_TARGET_SLACK_PX) {
- if (pendingBootstrapRevealRef.current) {
- pendingBootstrapRevealRef.current = false;
- setIsReady(true);
- }
- return;
- }
+
+ const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
+ if (contentHeight > v.viewportSize) return;
remainingInitialBackfillPagesRef.current -= 1;
- timelineSyncRef.current.handleTimelinePagination(true);
+ requestPagination('backward');
}, [
- roomId,
+ eventId,
isReady,
jumpInFlight,
- timelineSync.focusItem,
- timelineSync.eventsLength,
+ recalcTopSpacer,
+ requestPagination,
timelineSync.backwardStatus,
- bootstrapViewportTick,
- timelineSyncRef,
+ timelineSync.eventsLength,
vListRef,
]);
@@ -748,186 +573,73 @@ export function useTimelineViewportController({
const v = vListRef.current;
if (!v) return;
- const prevOffset = lastScrollOffsetRef.current;
+ const previousOffset = lastScrollOffsetRef.current;
lastScrollOffsetRef.current = offset;
- const activeIntent = getActiveScrollIntent();
- const hasScrollIntent = activeIntent !== undefined;
- const userScrollingUp = offset + TIMELINE_SCROLL_INTENT_DELTA_PX < prevOffset;
- const userScrollingDown = offset > prevOffset + TIMELINE_SCROLL_INTENT_DELTA_PX;
- const intentTowardBackward =
- hasScrollIntent &&
- (activeIntent.direction === 'backward' ||
- (activeIntent.direction === undefined && userScrollingUp));
- const intentTowardForward =
- hasScrollIntent &&
- (activeIntent.direction === 'forward' ||
- (activeIntent.direction === undefined && userScrollingDown));
-
- const edges = getScrollEdges(offset);
- if (!edges) return;
-
- if (
- edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX &&
- canPaginateBackRef.current &&
- backwardStatusRef.current === 'loading' &&
- Date.now() - lastBackwardIntentAtRef.current < 1500
- ) {
- applyTopEdgeRecoil(120);
- }
- if (anchorRef.current.kind === 'message-center' && suppressNextCenterScrollRef.current) {
- suppressNextCenterScrollRef.current = false;
+ const range = getVisibleRange(offset);
+ if (!range) return;
+ const previousRange = getVisibleRange(previousOffset);
+ updateEdgeArming(range);
+ setBottomStateFromRange(range);
+
+ const userDirection = pendingUserDirectionRef.current;
+ const userScroll = pendingUserScrollRef.current;
+ pendingUserDirectionRef.current = undefined;
+ pendingUserScrollRef.current = false;
+
+ const inferredDirection = getScrollDirection(previousOffset, offset);
+ const direction = userDirection ?? inferredDirection;
+ const isUserInitiated = userScroll || userDirection !== undefined;
+ const arrivedAtEdge =
+ Boolean(previousRange) &&
+ ((direction === 'backward' && !previousRange?.atStart && range.atStart) ||
+ (direction === 'forward' && !previousRange?.atEnd && range.atEnd));
+
+ if (!direction) {
+ if (anchorRef.current.kind === 'none' && range.atEnd && liveTimelineLinkedRef.current) {
+ anchorRef.current = { kind: 'bottom' };
+ }
return;
}
- if (anchorRef.current.kind === 'message-center') {
- if (!hasScrollIntent) return;
- anchorRef.current = { kind: 'none' };
- pushTimelineJumpDebug('viewport', 'anchor_released_during_jump_context', {
- roomId,
- jumpInFlight,
- hasFocusItem: Boolean(timelineSync.focusItem),
- focusScrollTo: timelineSync.focusItem?.scrollTo,
- offset,
- prevOffset,
- atBottom: edges.isAtBottom,
- });
- } else if (anchorRef.current.kind === 'bottom') {
- const paginationLoading =
- backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading';
- if (intentTowardBackward && !edges.isAtBottom && !paginationLoading) {
- anchorRef.current = { kind: 'none' };
- }
- } else if (edges.isAtBottom && liveTimelineLinkedRef.current) {
- anchorRef.current = { kind: 'bottom' };
+ if (isUserInitiated) releaseAnchorsForUserScroll(direction);
+ if (isUserInitiated || anchorRef.current.kind !== 'message-center') {
+ if (isUserInitiated || arrivedAtEdge) requestPaginationAtVisibleEdge(direction, range);
}
- const paginationLoading =
- backwardStatusRef.current === 'loading' || forwardStatusRef.current === 'loading';
- const nextAtBottom =
- anchorRef.current.kind === 'bottom' &&
- paginationLoading &&
- !hasScrollIntent &&
- !edges.isAtBottom
- ? true
- : edges.isAtBottom && liveTimelineLinkedRef.current;
- if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom);
-
- if (intentTowardBackward) requestPaginationFromScroll('backward', activeIntent, offset);
- if (intentTowardForward) requestPaginationFromScroll('forward', activeIntent, offset);
+ if (anchorRef.current.kind === 'none' && range.atEnd && liveTimelineLinkedRef.current) {
+ anchorRef.current = { kind: 'bottom' };
+ }
},
[
- applyTopEdgeRecoil,
- atBottomRef,
- getActiveScrollIntent,
- getScrollEdges,
- jumpInFlight,
- requestPaginationFromScroll,
- roomId,
- setAtBottom,
- timelineSync.focusItem,
+ getVisibleRange,
+ releaseAnchorsForUserScroll,
+ requestPaginationAtVisibleEdge,
+ setBottomStateFromRange,
+ updateEdgeArming,
vListRef,
]
);
const markUserScrollIntent = useCallback(
- (direction?: TimelineScrollDirection, deltaPx?: number): boolean => {
- if (direction && pendingBootstrapRevealRef.current) {
- pendingBootstrapRevealRef.current = false;
- setIsReady(true);
- }
- let consumed = false;
- scrollIntentCounterRef.current += 1;
- const dynamicLimit = getDynamicPaginationLimit(deltaPx);
- const pressurePages = Math.max(
- 1,
- Math.min(MAX_SCROLL_PRESSURE_PAGES, Math.ceil(dynamicLimit / MIN_PAGINATION_LIMIT))
- );
- if (direction) {
- const pressure = readScrollPressure();
- if (direction === 'backward') {
- lastBackwardIntentAtRef.current = Date.now();
- pressure.backward = Math.min(MAX_SCROLL_PRESSURE_PAGES, pressure.backward + pressurePages);
- pressure.backwardLimit = Math.max(pressure.backwardLimit, dynamicLimit);
- } else {
- pressure.forward = Math.min(MAX_SCROLL_PRESSURE_PAGES, pressure.forward + pressurePages);
- pressure.forwardLimit = Math.max(pressure.forwardLimit, dynamicLimit);
- }
- pressure.expiresAt = Date.now() + SCROLL_PRESSURE_TTL_MS;
- }
- const intent = {
- id: scrollIntentCounterRef.current,
- direction,
- limit: dynamicLimit,
- expiresAt: Date.now() + TIMELINE_SCROLL_INTENT_TTL_MS,
- };
- pendingScrollIntentRef.current = intent;
- if (
- direction === 'backward' &&
- anchorRef.current.kind === 'bottom' &&
- backwardStatusRef.current === 'idle'
- ) {
- anchorRef.current = { kind: 'none' };
- if (atBottomRef.current) setAtBottom(false);
- }
- const edges = direction ? getScrollEdges() : undefined;
- if (
- direction === 'backward' &&
- canPaginateBackRef.current &&
- edges &&
- edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX
- ) {
- consumed = applyTopEdgeRecoil(deltaPx) || consumed;
- }
- if (
- direction === 'backward' &&
- backwardStatusRef.current === 'loading' &&
- isNearBackwardEdge(edges)
- ) {
- queuedPaginationAfterLoadRef.current.backward = true;
- }
- if (
- direction === 'forward' &&
- forwardStatusRef.current === 'loading' &&
- isNearForwardEdge(edges)
- ) {
- queuedPaginationAfterLoadRef.current.forward = true;
- }
- if (
- direction === 'backward' &&
- backwardStatusRef.current === 'idle' &&
- canPaginateBackRef.current &&
- isNearBackwardEdge(edges)
- ) {
- if (edges && edges.offset < TOP_EDGE_RECOIL_TRIGGER_PX) {
- consumed = applyTopEdgeRecoil(deltaPx) || consumed;
- }
- requestPaginationFromScroll('backward', intent, edges?.offset);
- return consumed;
- }
- if (
- direction === 'forward' &&
- forwardStatusRef.current === 'idle' &&
- !liveTimelineLinkedRef.current &&
- isNearForwardEdge(edges)
- ) {
- requestPaginationFromScroll('forward', intent, edges?.offset);
- return consumed;
+ (direction?: TimelineScrollDirection) => {
+ pendingUserScrollRef.current = true;
+ pendingUserDirectionRef.current = direction;
+
+ releaseAnchorsForUserScroll(direction);
+ if (!direction) return;
+
+ const range = getVisibleRange();
+ if (!range) return;
+ updateEdgeArming(range);
+
+ if (direction === 'backward' && range.atStart) {
+ requestPagination('backward');
+ } else if (direction === 'forward' && range.atEnd) {
+ requestPagination('forward');
}
- if (direction) requestPaginationFromScroll(direction, intent);
- return consumed;
},
- [
- atBottomRef,
- applyTopEdgeRecoil,
- getDynamicPaginationLimit,
- getScrollEdges,
- isNearBackwardEdge,
- isNearForwardEdge,
- readScrollPressure,
- requestPaginationFromScroll,
- setAtBottom,
- ]
+ [getVisibleRange, releaseAnchorsForUserScroll, requestPagination, updateEdgeArming]
);
return {
From 279d9bd4b9ba9a1a4eec06d1756de330a6eb3e5e Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 22:59:26 -0500
Subject: [PATCH 27/28] fix: readd the patch for room load bottom clamping
---
.../useTimelineViewportController.test.tsx | 35 +++++++++++-
.../room/useTimelineViewportController.ts | 54 ++++++++++++++++---
2 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx
index f090e339b..2149ff96c 100644
--- a/src/app/features/room/useTimelineViewportController.test.tsx
+++ b/src/app/features/room/useTimelineViewportController.test.tsx
@@ -168,7 +168,7 @@ describe('useTimelineViewportController', () => {
expect(renderedRefs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' });
});
- it('prefills underfilled live rooms without hiding the revealed timeline', () => {
+ it('keeps underfilled live rooms hidden while bootstrap prefill starts', () => {
const vList = createVList() as unknown as {
scrollOffset: number;
scrollSize: number;
@@ -181,10 +181,41 @@ describe('useTimelineViewportController', () => {
const { result } = renderController({ timelineSync, refs });
- expect(result.current.isReady).toBe(true);
+ expect(result.current.isReady).toBe(false);
expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true);
});
+ it('reveals an initially underfilled live room once bootstrap prefill fills the viewport', () => {
+ const vList = createVList() as unknown as {
+ scrollOffset: number;
+ scrollSize: number;
+ viewportSize: number;
+ };
+ vList.scrollSize = 200;
+ vList.viewportSize = 800;
+ const refs = createRefs(vList as unknown as VListHandle);
+ const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' });
+
+ const { result, rerender } = renderController({ timelineSync, refs });
+ expect(result.current.isReady).toBe(false);
+
+ const loadingSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'loading',
+ });
+ rerender({ sync: loadingSync, roomEventId: undefined });
+
+ vList.scrollSize = 1500;
+ const idleSync = createTimelineSync({
+ ...timelineSync,
+ backwardStatus: 'idle',
+ eventsLength: 10,
+ });
+ rerender({ sync: idleSync, roomEventId: undefined });
+
+ expect(result.current.isReady).toBe(true);
+ });
+
it('does not paginate from layout-only scroll changes', () => {
const refs = createRefs();
const timelineSync = createTimelineSync();
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index 0bcaf010d..ee1e10199 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -67,6 +67,7 @@ export function useTimelineViewportController({
const [topSpacerHeight, setTopSpacerHeight] = useState(0);
const [isReady, setIsReady] = useState(false);
const [jumpInFlight, setJumpInFlight] = useState(false);
+ const [bootstrapViewportTick, setBootstrapViewportTick] = useState(0);
const topSpacerHeightRef = useRef(0);
const hasInitialScrolledRef = useRef(false);
@@ -76,6 +77,7 @@ export function useTimelineViewportController({
const queuedPaginationRef = useRef({ backward: false, forward: false });
const pendingUserDirectionRef = useRef(undefined);
const pendingUserScrollRef = useRef(false);
+ const pendingBootstrapRevealRef = useRef(false);
const remainingInitialBackfillPagesRef = useRef(INITIAL_BACKFILL_PAGE_BUDGET);
const lastScrollOffsetRef = useRef(0);
const pendingBootstrapFrameRef = useRef(undefined);
@@ -108,6 +110,7 @@ export function useTimelineViewportController({
queuedPaginationRef.current = { backward: false, forward: false };
pendingUserDirectionRef.current = undefined;
pendingUserScrollRef.current = false;
+ pendingBootstrapRevealRef.current = false;
remainingInitialBackfillPagesRef.current = INITIAL_BACKFILL_PAGE_BUDGET;
lastScrollOffsetRef.current = 0;
topSpacerHeightRef.current = 0;
@@ -170,6 +173,25 @@ export function useTimelineViewportController({
}
}, [vListRef]);
+ const revealBootstrapIfReady = useCallback((): boolean => {
+ if (!pendingBootstrapRevealRef.current) return false;
+
+ const v = vListRef.current;
+ if (!v || v.viewportSize <= 0) return false;
+
+ const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
+ const done =
+ contentHeight > v.viewportSize ||
+ !canPaginateBackRef.current ||
+ remainingInitialBackfillPagesRef.current <= 0;
+
+ if (!done) return false;
+
+ pendingBootstrapRevealRef.current = false;
+ setIsReady(true);
+ return true;
+ }, [vListRef]);
+
const findMessageElement = useCallback(
(eventIdToFind: string): HTMLElement | undefined => {
const root = messageListRef.current;
@@ -330,6 +352,7 @@ export function useTimelineViewportController({
useEffect(() => {
if (isReady) return undefined;
+ if (pendingBootstrapRevealRef.current) return undefined;
if (eventId || jumpInFlight) return undefined;
readyFallbackTimerRef.current = setTimeout(() => {
setIsReady(true);
@@ -354,12 +377,15 @@ export function useTimelineViewportController({
pendingReadyRef.current = true;
}
- settleTimelineAnchor({ kind: 'bottom' }, true);
+ const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack;
+ pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal;
+ settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal);
hasInitialScrolledRef.current = true;
}, [
eventId,
timelineSync.eventsLength,
timelineSync.liveTimelineLinked,
+ timelineSync.canPaginateBack,
processedEventsRef,
settleTimelineAnchor,
vListRef,
@@ -399,13 +425,20 @@ export function useTimelineViewportController({
setShift(false);
edgeArmedRef.current.backward = true;
if (wasAtBottomBeforePaginationRef.current) settleTimelineAnchor({ kind: 'bottom' });
+ revealBootstrapIfReady();
if (queuedPaginationRef.current.backward) {
queuedPaginationRef.current.backward = false;
requestPagination('backward', true);
}
}
- }, [atBottomRef, requestPagination, settleTimelineAnchor, timelineSync.backwardStatus]);
+ }, [
+ atBottomRef,
+ requestPagination,
+ revealBootstrapIfReady,
+ settleTimelineAnchor,
+ timelineSync.backwardStatus,
+ ]);
useLayoutEffect(() => {
const previous = prevForwardStatusRef.current;
@@ -527,11 +560,14 @@ export function useTimelineViewportController({
if (processedEventsRef.current.length === 0) return;
pendingReadyRef.current = false;
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
- settleTimelineAnchor({ kind: 'bottom' }, true);
+ const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack;
+ pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal;
+ settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal);
}, [
processedEventsRef,
processedEventsRef.current.length,
settleTimelineAnchor,
+ timelineSync.canPaginateBack,
timelineSync.eventsLength,
vListRef,
]);
@@ -539,19 +575,22 @@ export function useTimelineViewportController({
useEffect(() => {
const v = vListRef.current;
if (!v || eventId || jumpInFlight) return;
- if (!isReady || anchorRef.current.kind !== 'bottom') return;
+ if (!isReady && !pendingBootstrapRevealRef.current) return;
+ if (anchorRef.current.kind !== 'bottom') return;
if (remainingInitialBackfillPagesRef.current <= 0) return;
- if (!canPaginateBackRef.current || backwardStatusRef.current !== 'idle') return;
if (v.viewportSize <= 0) {
if (pendingBootstrapFrameRef.current === undefined) {
pendingBootstrapFrameRef.current = requestAnimationFrame(() => {
pendingBootstrapFrameRef.current = undefined;
- recalcTopSpacer();
+ setBootstrapViewportTick((prev) => prev + 1);
});
}
return;
}
+ if (revealBootstrapIfReady()) return;
+ if (!canPaginateBackRef.current || backwardStatusRef.current !== 'idle') return;
+
const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current);
if (contentHeight > v.viewportSize) return;
@@ -561,10 +600,11 @@ export function useTimelineViewportController({
eventId,
isReady,
jumpInFlight,
- recalcTopSpacer,
+ revealBootstrapIfReady,
requestPagination,
timelineSync.backwardStatus,
timelineSync.eventsLength,
+ bootstrapViewportTick,
vListRef,
]);
From 58bc4145b973e08da777842930b33a0f07897f4e Mon Sep 17 00:00:00 2001
From: 7w1
Date: Sun, 10 May 2026 23:20:10 -0500
Subject: [PATCH 28/28] cleanup: remove debug stuff and knip complaints
---
src/app/features/bug-report/index.ts | 1 -
src/app/features/create-room/index.ts | 2 -
src/app/features/lobby/index.ts | 1 -
src/app/features/room-settings/index.ts | 2 -
src/app/features/room/timelineJumpDebug.ts | 48 -------------------
.../room/useTimelineViewportController.ts | 27 +----------
src/app/features/search/index.ts | 2 -
src/app/features/space-settings/index.ts | 2 -
src/app/hooks/timeline/useTimelineSync.ts | 29 +----------
src/app/pages/client/create/index.ts | 1 -
src/app/pages/client/explore/index.ts | 3 --
src/app/pages/client/inbox/index.ts | 3 --
12 files changed, 3 insertions(+), 118 deletions(-)
delete mode 100644 src/app/features/bug-report/index.ts
delete mode 100644 src/app/features/create-room/index.ts
delete mode 100644 src/app/features/lobby/index.ts
delete mode 100644 src/app/features/room-settings/index.ts
delete mode 100644 src/app/features/room/timelineJumpDebug.ts
delete mode 100644 src/app/features/search/index.ts
delete mode 100644 src/app/features/space-settings/index.ts
delete mode 100644 src/app/pages/client/create/index.ts
delete mode 100644 src/app/pages/client/explore/index.ts
delete mode 100644 src/app/pages/client/inbox/index.ts
diff --git a/src/app/features/bug-report/index.ts b/src/app/features/bug-report/index.ts
deleted file mode 100644
index 2fb17d031..000000000
--- a/src/app/features/bug-report/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { BugReportModalRenderer } from './BugReportModalRenderer';
diff --git a/src/app/features/create-room/index.ts b/src/app/features/create-room/index.ts
deleted file mode 100644
index f60c94b37..000000000
--- a/src/app/features/create-room/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './CreateRoom';
-export * from './CreateRoomModal';
diff --git a/src/app/features/lobby/index.ts b/src/app/features/lobby/index.ts
deleted file mode 100644
index 08355c79a..000000000
--- a/src/app/features/lobby/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Lobby';
diff --git a/src/app/features/room-settings/index.ts b/src/app/features/room-settings/index.ts
deleted file mode 100644
index d94968453..000000000
--- a/src/app/features/room-settings/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './RoomSettings';
-export * from './RoomSettingsRenderer';
diff --git a/src/app/features/room/timelineJumpDebug.ts b/src/app/features/room/timelineJumpDebug.ts
deleted file mode 100644
index d17d19964..000000000
--- a/src/app/features/room/timelineJumpDebug.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-type TimelineJumpDebugEntry = {
- ts: number;
- source: 'sync' | 'viewport';
- event: string;
- data?: Record;
-};
-
-const MAX_ENTRIES = 400;
-const store: TimelineJumpDebugEntry[] = [];
-
-export const pushTimelineJumpDebug = (
- source: TimelineJumpDebugEntry['source'],
- event: string,
- data?: Record
-): void => {
- store.push({ ts: Date.now(), source, event, data });
- if (store.length > MAX_ENTRIES) store.splice(0, store.length - MAX_ENTRIES);
-};
-
-export const dumpTimelineJumpDebug = (): string =>
- JSON.stringify(
- {
- exportedAt: new Date().toISOString(),
- count: store.length,
- entries: store.map((entry) => ({
- ...entry,
- ts: new Date(entry.ts).toISOString(),
- })),
- },
- null,
- 2
- );
-
-export const clearTimelineJumpDebug = (): void => {
- store.length = 0;
-};
-
-declare global {
- // eslint-disable-next-line no-var
- var __sableDumpTimelineJumpDebug: (() => string) | undefined;
- // eslint-disable-next-line no-var
- var __sableClearTimelineJumpDebug: (() => void) | undefined;
-}
-
-if (typeof globalThis !== 'undefined') {
- globalThis.__sableDumpTimelineJumpDebug = dumpTimelineJumpDebug;
- globalThis.__sableClearTimelineJumpDebug = clearTimelineJumpDebug;
-}
diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts
index ee1e10199..fc6ff14da 100644
--- a/src/app/features/room/useTimelineViewportController.ts
+++ b/src/app/features/room/useTimelineViewportController.ts
@@ -21,7 +21,6 @@ import {
type TimelineScrollDirection,
type TimelineVisibleRange,
} from './timelineViewportModel';
-import { pushTimelineJumpDebug } from './timelineJumpDebug';
const INITIAL_BACKFILL_PAGE_BUDGET = 6;
const READY_FALLBACK_MS = 1500;
@@ -297,13 +296,6 @@ export function useTimelineViewportController({
if (anchorRef.current.kind === 'message-center') {
anchorRef.current = { kind: 'none' };
- pushTimelineJumpDebug('viewport', 'anchor_released_during_jump_context', {
- roomId,
- direction,
- jumpInFlight: jumpInFlightRef.current,
- hasFocusItem: Boolean(timelineSyncRef.current.focusItem),
- focusScrollTo: timelineSyncRef.current.focusItem?.scrollTo,
- });
}
if (anchorRef.current.kind === 'bottom' && direction === 'backward') {
@@ -311,7 +303,7 @@ export function useTimelineViewportController({
if (atBottomRef.current) setAtBottom(false);
}
},
- [atBottomRef, roomId, setAtBottom, timelineSyncRef]
+ [atBottomRef, setAtBottom]
);
const beginJumpLoad = useCallback(
@@ -320,19 +312,11 @@ export function useTimelineViewportController({
pendingUserScrollRef.current = false;
anchorRef.current = { kind: 'none' };
setJumpInFlight(true);
- pushTimelineJumpDebug('viewport', 'begin_jump_load', {
- roomId,
- targetEventId,
- });
void Promise.resolve(timelineSyncRef.current.loadEventTimeline(targetEventId)).finally(() => {
setJumpInFlight(false);
- pushTimelineJumpDebug('viewport', 'jump_load_settled', {
- roomId,
- targetEventId,
- });
});
},
- [roomId, timelineSyncRef]
+ [timelineSyncRef]
);
useEffect(
@@ -476,12 +460,6 @@ export function useTimelineViewportController({
const focusEventId = processedEventsRef.current[processedIndex]?.id;
if (focusEventId) {
settleTimelineAnchor({ kind: 'message-center', eventId: focusEventId }, true);
- pushTimelineJumpDebug('viewport', 'focus_center_anchor_set', {
- roomId,
- focusEventId,
- processedIndex,
- focusRawIndex,
- });
} else {
setIsReady(true);
}
@@ -504,7 +482,6 @@ export function useTimelineViewportController({
atBottomRef,
getRawIndexToProcessedIndex,
processedEventsRef,
- roomId,
setAtBottom,
settleTimelineAnchor,
timelineSync,
diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts
deleted file mode 100644
index 319f77d62..000000000
--- a/src/app/features/search/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Search } from './Search';
-export * from './SearchModalRenderer';
diff --git a/src/app/features/space-settings/index.ts b/src/app/features/space-settings/index.ts
deleted file mode 100644
index e01a1b268..000000000
--- a/src/app/features/space-settings/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './SpaceSettings';
-export * from './SpaceSettingsRenderer';
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index e4eea37bf..503ad7886 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -26,7 +26,6 @@ import {
getRoomUnreadInfo,
PAGINATION_LIMIT,
} from '$utils/timeline';
-import { pushTimelineJumpDebug } from '$features/room/timelineJumpDebug';
export const EVENT_TIMELINE_LOAD_TIMEOUT_MS = 12000;
@@ -417,13 +416,6 @@ export function useTimelineSync({
useCallback(
(evtId, lTimelines, evtAbsIndex) => {
if (!alive()) return;
- pushTimelineJumpDebug('sync', 'jump_load_resolved', {
- roomId: room.roomId,
- eventId: evtId,
- absIndex: evtAbsIndex,
- linkedTimelines: lTimelines.length,
- });
-
setTimeline({ linkedTimelines: lTimelines });
setFocusItem({
@@ -432,7 +424,7 @@ export function useTimelineSync({
highlight: evtId !== readUptoEventIdRef.current,
});
},
- [alive, readUptoEventIdRef, room.roomId]
+ [alive, readUptoEventIdRef]
),
useCallback(() => {
if (!alive()) return;
@@ -451,10 +443,6 @@ export function useTimelineSync({
useCallback(
(mEvt: MatrixEvent) => {
if (focusItem?.scrollTo) {
- pushTimelineJumpDebug('sync', 'live_event_ignored_while_jump_landing', {
- roomId: room.roomId,
- eventId: mEvt.getId() ?? undefined,
- });
return;
}
const { threadRootId } = mEvt;
@@ -473,11 +461,6 @@ export function useTimelineSync({
}
scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth');
- pushTimelineJumpDebug('sync', 'live_event_autoscroll_bottom', {
- roomId: room.roomId,
- eventId: mEvt.getId() ?? undefined,
- mode: mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth',
- });
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -545,9 +528,6 @@ export function useTimelineSync({
useEffect(() => {
if (focusItem?.scrollTo) {
- pushTimelineJumpDebug('sync', 'auto_follow_blocked_jump_landing', {
- roomId: room.roomId,
- });
return;
}
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
@@ -568,13 +548,6 @@ export function useTimelineSync({
lastScrolledAtEventsLengthRef.current = eventsLength;
scrollToBottom('instant');
- pushTimelineJumpDebug('sync', 'auto_follow_scroll_bottom', {
- roomId: room.roomId,
- eventsLength,
- resetAutoScrollPending,
- isAtBottom,
- liveTimelineLinked,
- });
}, [
focusItem?.scrollTo,
isAtBottom,
diff --git a/src/app/pages/client/create/index.ts b/src/app/pages/client/create/index.ts
deleted file mode 100644
index 48cba6e76..000000000
--- a/src/app/pages/client/create/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Create';
diff --git a/src/app/pages/client/explore/index.ts b/src/app/pages/client/explore/index.ts
deleted file mode 100644
index 1149a10d4..000000000
--- a/src/app/pages/client/explore/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './Explore';
-export * from './Server';
-export * from './Featured';
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
deleted file mode 100644
index c8036b471..000000000
--- a/src/app/pages/client/inbox/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './Inbox';
-export * from './Notifications';
-export * from './Invites';