From aeea53dfde0777d1a97e4ca31853952744c7b362 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Jul 2026 10:51:38 +0100 Subject: [PATCH] Expose SDKContext on the window & avoid using singleton store `.instance` (#34098) * Expose SDKContextClass via window for debugging * Remove stores from window if they are exposed via sdkContext * Avoid usages of global store instance where React context is accessible * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Iterate * Discard changes to apps/web/src/components/views/elements/AppTile.tsx --- apps/web/src/@types/global.d.ts | 12 +- .../src/components/structures/MatrixChat.tsx | 4 +- .../components/structures/MessagePanel.tsx | 8 +- .../components/structures/PipContainer.tsx | 94 ++-- .../structures/grouper/CreationGrouper.tsx | 8 +- .../structures/grouper/MainGrouper.tsx | 8 +- .../dialogs/MessageEditHistoryDialog.tsx | 3 +- .../views/rooms/BasicMessageComposer.tsx | 14 +- .../rooms/RoomListPanel/RoomListView.tsx | 15 +- .../views/rooms/SearchResultTile.tsx | 8 +- .../views/spaces/QuickSettingsButton.tsx | 7 +- .../src/components/views/voip/CallView.tsx | 11 +- apps/web/src/contexts/SDKContextClass.ts | 1 - apps/web/src/hooks/room/useRoomCall.tsx | 9 +- apps/web/src/hooks/useRoomCall.test.ts | 5 +- apps/web/src/stores/WidgetStore.ts | 2 - .../src/stores/right-panel/RightPanelStore.ts | 2 - apps/web/src/stores/spaces/SpaceStore.ts | 2 - .../src/stores/widgets/WidgetLayoutStore.ts | 2 - apps/web/src/utils/exportUtils/HtmlExport.tsx | 1 + .../viewmodels/room-list/RoomListViewModel.ts | 16 +- .../room/timeline/DateSeparatorViewModel.tsx | 10 +- apps/web/test/test-utils/client.ts | 1 + .../components/structures/ThreadView-test.tsx | 7 +- .../views/elements/AppTile-test.tsx | 25 +- .../ExtensionsCard-test.tsx.snap | 8 + .../views/rooms/BasicMessageComposer-test.tsx | 49 +- .../views/rooms/EditMessageComposer-test.tsx | 10 +- .../components/views/rooms/EventTile-test.tsx | 8 +- .../views/rooms/MessageComposer-test.tsx | 18 +- .../rooms/RoomHeader/RoomHeader-test.tsx | 9 +- .../views/rooms/SearchResultTile-test.tsx | 5 +- .../views/rooms/SendMessageComposer-test.tsx | 12 +- .../views/spaces/QuickSettingsButton-test.tsx | 13 +- .../room-list/RoomListViewModel-test.tsx | 432 +++++++++++++++--- .../timeline/DateSeparatorViewModel-test.tsx | 6 +- 36 files changed, 613 insertions(+), 232 deletions(-) diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index 606ed57eb4..d66c160f8e 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -19,13 +19,8 @@ import { type PlatformPeg } from "../PlatformPeg"; import { type IntegrationManagers } from "../integrations/IntegrationManagers"; import { type ModalManager } from "../Modal"; import type SettingsStore from "../settings/SettingsStore"; -import type RightPanelStore from "../stores/right-panel/RightPanelStore"; -import type WidgetStore from "../stores/WidgetStore"; import type UserActivity from "../UserActivity"; import { type ModalWidgetStore } from "../stores/ModalWidgetStore"; -import { type WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; -import { type SpaceStoreClass } from "../stores/spaces/SpaceStore"; -import type TypingStore from "../stores/TypingStore"; import { type EventIndexPeg } from "../indexing/EventIndexPeg"; import { type VoiceRecordingStore } from "../stores/VoiceRecordingStore"; import type PerformanceMonitor from "../performance"; @@ -41,6 +36,7 @@ import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; import { type ModuleApiType } from "../modules/Api.ts"; import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV3.ts"; +import { type SDKContextClass } from "../contexts/SDKContextClass.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -95,14 +91,9 @@ declare global { mxIntegrationManagers: typeof IntegrationManagers; singletonModalManager: ModalManager; mxSettingsStore: SettingsStore; - mxRightPanelStore: RightPanelStore; - mxWidgetStore: WidgetStore; - mxWidgetLayoutStore: WidgetLayoutStore; mxUserActivity: UserActivity; mxModalWidgetStore: ModalWidgetStore; - mxSpaceStore: SpaceStoreClass; mxVoiceRecordingStore: VoiceRecordingStore; - mxTypingStore: TypingStore; mxEventIndexPeg: EventIndexPeg; mxPerformanceMonitor: PerformanceMonitor; mxPerformanceEntryNames: any; @@ -114,6 +105,7 @@ declare global { mxOnRecaptchaLoaded?: () => void; mxModuleLoader: ModuleLoader; mxModuleApi: ModuleApiType; + mxSdkContext: SDKContextClass; // electron-only electron?: Electron; diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 04c7fb2286..a424b40a61 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -93,7 +93,6 @@ import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from "./auth/SoftLogout"; import { copyPlaintext } from "../../utils/strings"; -import { PosthogAnalytics } from "../../PosthogAnalytics"; import { initSentry } from "../../sentry"; import { showSpaceInvite } from "../../utils/space"; import { type ButtonEvent } from "../views/elements/AccessibleButton"; @@ -245,6 +244,7 @@ export default class MatrixChat extends React.PureComponent { super(props); this.stores = SDKContextClass.instance; this.stores.constructEagerStores(); + window.mxSdkContext = this.stores; this.state = { view: Views.LOADING, @@ -1792,7 +1792,7 @@ export default class MatrixChat extends React.PureComponent { // Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client // Will be moved to a pre-login flow as well - if (PosthogAnalytics.instance.isEnabled() && SettingsStore.isLevelSupported(SettingLevel.ACCOUNT)) { + if (this.stores.posthogAnalytics.isEnabled() && SettingsStore.isLevelSupported(SettingLevel.ACCOUNT)) { this.initPosthogAnalyticsToast(); } diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index 73136b16c2..a22d829de7 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef, type ReactNode, type TransitionEventHandler } from "react"; +import React, { type JSX, createRef, type ReactNode, type TransitionEventHandler, useContext } from "react"; import classNames from "classnames"; import { type Room, @@ -58,6 +58,7 @@ import { _t } from "../../languageHandler"; import { getLateEventInfo } from "./grouper/LateEventGrouper"; import { DateSeparatorViewModel } from "../../viewmodels/room/timeline/DateSeparatorViewModel"; import { isEligibleForSpecialReceipt } from "../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; +import { SDKContext } from "../../contexts/SDKContext.ts"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -66,7 +67,10 @@ const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; * Creates and auto-disposes the DateSeparatorViewModel for message panel rendering. */ function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + const sdkContext = useContext(SDKContext); + const vm = useCreateAutoDisposedViewModel( + () => new DateSeparatorViewModel({ roomId, ts, roomViewStore: sdkContext.roomViewStore }), + ); return ; } diff --git a/apps/web/src/components/structures/PipContainer.tsx b/apps/web/src/components/structures/PipContainer.tsx index b5ffde6194..fc3d80268b 100644 --- a/apps/web/src/components/structures/PipContainer.tsx +++ b/apps/web/src/components/structures/PipContainer.tsx @@ -12,7 +12,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import { useCreateAutoDisposedViewModel, WidgetPipView } from "@element-hq/web-shared-components"; import LegacyCallView from "../views/voip/LegacyCallView"; -import type LegacyCallHandler from "../../LegacyCallHandler"; import { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import PictureInPictureDragger, { type CreatePipChildren } from "./PictureInPictureDragger"; import dis from "../../dispatcher/dispatcher"; @@ -54,44 +53,6 @@ interface IState { showWidgetInPip: boolean; } -// Splits a list of calls into one 'primary' one and a list -// (which should be a single element) of other calls. -// The primary will be the one not on hold, or an arbitrary one -// if they're all on hold) -function getPrimarySecondaryCallsForPip( - legacyCallHandler: LegacyCallHandler, - roomId: string | null, -): [MatrixCall | null, MatrixCall[]] { - if (!roomId) return [null, []]; - - const calls = legacyCallHandler.getAllActiveCallsForPip(roomId); - - let primary: MatrixCall | null = null; - let secondaries: MatrixCall[] = []; - - for (const call of calls) { - if (!SHOW_CALL_IN_STATES.includes(call.state)) continue; - - if (!call.isRemoteOnHold() && primary === null) { - primary = call; - } else { - secondaries.push(call); - } - } - - if (primary === null && secondaries.length > 0) { - primary = secondaries[0]; - secondaries = secondaries.slice(1); - } - - if (secondaries.length > 1) { - // We should never be in more than two calls so this shouldn't happen - logger.log("Found more than 1 secondary call! Other calls will not be shown."); - } - - return [primary, secondaries]; -} - /** * PipContainer shows a small version of the LegacyCallView or a sticky widget hovering over the UI in * 'picture-in-picture' (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing @@ -103,11 +64,11 @@ class PipContainerInner extends React.Component { declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { - super(props); + super(props, context); - const roomId = context.roomViewStore.getRoomId(); + const roomId = this.context.roomViewStore.getRoomId(); - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(context.legacyCallHandler, roomId); + const [primaryCall, secondaryCalls] = this.getPrimarySecondaryCallsForPip(roomId); this.state = { viewedRoomId: roomId || undefined, @@ -147,6 +108,43 @@ class PipContainerInner extends React.Component { ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges); } + /** + * Splits a list of calls into one 'primary' one and a list + * (which should be a single element) of other calls. + * The primary will be the one not on hold, or an arbitrary one + * if they're all on hold) + */ + private getPrimarySecondaryCallsForPip(roomId: string | null): [MatrixCall | null, MatrixCall[]] { + if (!roomId) return [null, []]; + + const calls = this.context.legacyCallHandler.getAllActiveCallsForPip(roomId); + + let primary: MatrixCall | null = null; + let secondaries: MatrixCall[] = []; + + for (const call of calls) { + if (!SHOW_CALL_IN_STATES.includes(call.state)) continue; + + if (!call.isRemoteOnHold() && primary === null) { + primary = call; + } else { + secondaries.push(call); + } + } + + if (primary === null && secondaries.length > 0) { + primary = secondaries[0]; + secondaries = secondaries.slice(1); + } + + if (secondaries.length > 1) { + // We should never be in more than two calls so this shouldn't happen + logger.log("Found more than 1 secondary call! Other calls will not be shown."); + } + + return [primary, secondaries]; + } + private onMove = (): void => this.props.movePersistedElement.current?.(); private onRoomViewStoreUpdate = (): void => { @@ -165,7 +163,7 @@ class PipContainerInner extends React.Component { } if (!newRoomId) return; - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.context.legacyCallHandler, newRoomId); + const [primaryCall, secondaryCalls] = this.getPrimarySecondaryCallsForPip(newRoomId); this.setState({ viewedRoomId: newRoomId, primaryCall: primaryCall, @@ -184,10 +182,7 @@ class PipContainerInner extends React.Component { private updateCalls = (): void => { if (!this.state.viewedRoomId) return; - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip( - this.context.legacyCallHandler, - this.state.viewedRoomId, - ); + const [primaryCall, secondaryCalls] = this.getPrimarySecondaryCallsForPip(this.state.viewedRoomId); this.setState({ primaryCall: primaryCall, @@ -198,10 +193,7 @@ class PipContainerInner extends React.Component { private onCallRemoteHold = (): void => { if (!this.state.viewedRoomId) return; - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip( - this.context.legacyCallHandler, - this.state.viewedRoomId, - ); + const [primaryCall, secondaryCalls] = this.getPrimarySecondaryCallsForPip(this.state.viewedRoomId); this.setState({ primaryCall: primaryCall, diff --git a/apps/web/src/components/structures/grouper/CreationGrouper.tsx b/apps/web/src/components/structures/grouper/CreationGrouper.tsx index 04dc689b8b..15eba7e8c8 100644 --- a/apps/web/src/components/structures/grouper/CreationGrouper.tsx +++ b/apps/web/src/components/structures/grouper/CreationGrouper.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ReactNode } from "react"; +import React, { type ReactNode, useContext } from "react"; import { EventType, M_BEACON_INFO, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; @@ -19,6 +19,7 @@ import { _t } from "../../../languageHandler"; import NewRoomIntro from "../../views/rooms/NewRoomIntro"; import GenericEventListSummary from "../../views/elements/GenericEventListSummary"; import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; // Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until @@ -28,7 +29,10 @@ import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSe * Creates and auto-disposes the DateSeparatorViewModel for creation-group rendering. */ function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { - const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + const sdkContext = useContext(SDKContext); + const vm = useCreateAutoDisposedViewModel( + () => new DateSeparatorViewModel({ roomId, ts, roomViewStore: sdkContext.roomViewStore }), + ); return ; } diff --git a/apps/web/src/components/structures/grouper/MainGrouper.tsx b/apps/web/src/components/structures/grouper/MainGrouper.tsx index 8245a9e323..3c6398927d 100644 --- a/apps/web/src/components/structures/grouper/MainGrouper.tsx +++ b/apps/web/src/components/structures/grouper/MainGrouper.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ReactNode } from "react"; +import React, { type ReactNode, useContext } from "react"; import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; @@ -18,6 +18,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import HistoryTile from "../../views/rooms/HistoryTile"; import EventListSummary from "../../views/elements/EventListSummary"; import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; const groupedStateEvents = [ EventType.RoomMember, @@ -30,7 +31,10 @@ const groupedStateEvents = [ * Creates and auto-disposes the DateSeparatorViewModel for grouped timeline rendering. */ function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { - const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + const sdkContext = useContext(SDKContext); + const vm = useCreateAutoDisposedViewModel( + () => new DateSeparatorViewModel({ roomId, ts, roomViewStore: sdkContext.roomViewStore }), + ); return ; } diff --git a/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx index d54bc548aa..c6bf0e4e77 100644 --- a/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -20,6 +20,7 @@ import ScrollPanel from "../../structures/ScrollPanel"; import Spinner from "../elements/Spinner"; import EditHistoryMessage from "../messages/EditHistoryMessage"; import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { SDKContextClass } from "../../../contexts/SDKContextClass.ts"; interface IProps { mxEvent: MatrixEvent; @@ -54,7 +55,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { + public static contextType = SDKContext; + declare public context: React.ContextType; + public readonly editorRef = createRef(); private autocompleteRef = createRef(); private formatBarRef = createRef(); @@ -245,17 +247,13 @@ export default class BasicMessageEditor extends React.Component const { cmd } = parseCommandString(this.props.model.parts[0].text); const command = CommandMap.get(cmd!); if ( - !command?.isEnabled(MatrixClientPeg.get(), this.props.room.roomId) || + !command?.isEnabled(this.context.client!, this.props.room.roomId) || command.category !== CommandCategories.messages ) { isTyping = false; } } - SDKContextClass.instance.typingStore.setSelfTyping( - this.props.room.roomId, - this.props.threadId ?? null, - isTyping, - ); + this.context.typingStore.setSelfTyping(this.props.room.roomId, this.props.threadId ?? null, isTyping); this.props.onChange?.(selection, inputType, diff); }; diff --git a/apps/web/src/components/views/rooms/RoomListPanel/RoomListView.tsx b/apps/web/src/components/views/rooms/RoomListPanel/RoomListView.tsx index 6c4cad44ec..00aa0ef9cc 100644 --- a/apps/web/src/components/views/rooms/RoomListPanel/RoomListView.tsx +++ b/apps/web/src/components/views/rooms/RoomListPanel/RoomListView.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, type JSX, type ReactNode } from "react"; +import React, { useCallback, type JSX, type ReactNode, useContext } from "react"; import { RoomListView as SharedRoomListView, useCreateAutoDisposedViewModel, @@ -13,21 +13,28 @@ import { } from "@element-hq/web-shared-components"; import { type Room } from "matrix-js-sdk/src/matrix"; -import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { RoomAvatarView } from "../../avatars/RoomAvatarView"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; import { RoomListViewModel } from "../../../../viewmodels/room-list/RoomListViewModel"; +import { SDKContext } from "../../../../contexts/SDKContext.ts"; /** * RoomListView component using shared components with proper MVVM pattern. */ export function RoomListView(): JSX.Element { - const matrixClient = useMatrixClientContext(); + const sdkContext = useContext(SDKContext); // Create and auto-dispose ViewModel instance - const vm = useCreateAutoDisposedViewModel(() => new RoomListViewModel({ client: matrixClient })); + const vm = useCreateAutoDisposedViewModel( + () => + new RoomListViewModel({ + client: sdkContext.client!, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }), + ); // Render avatar for each room - memoized to prevent re-renders const renderAvatar = useCallback((room: SharedRoom): ReactNode => { diff --git a/apps/web/src/components/views/rooms/SearchResultTile.tsx b/apps/web/src/components/views/rooms/SearchResultTile.tsx index 59cc1f7fb7..8b37f47b59 100644 --- a/apps/web/src/components/views/rooms/SearchResultTile.tsx +++ b/apps/web/src/components/views/rooms/SearchResultTile.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useContext } from "react"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; @@ -22,6 +22,7 @@ import { buildLegacyCallEventGroupers } from "../../structures/LegacyCallEventGr import { haveRendererForEvent } from "../../../events/EventTileFactory"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; interface IProps { // a list of strings to be highlighted in the results @@ -39,7 +40,10 @@ interface IProps { * Creates and auto-disposes the DateSeparatorViewModel for search result rendering. */ function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + const sdkContext = useContext(SDKContext); + const vm = useCreateAutoDisposedViewModel( + () => new DateSeparatorViewModel({ roomId, ts, roomViewStore: sdkContext.roomViewStore }), + ); return ; } diff --git a/apps/web/src/components/views/spaces/QuickSettingsButton.tsx b/apps/web/src/components/views/spaces/QuickSettingsButton.tsx index 0f530a1426..7d50f908fe 100644 --- a/apps/web/src/components/views/spaces/QuickSettingsButton.tsx +++ b/apps/web/src/components/views/spaces/QuickSettingsButton.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useContext } from "react"; import classNames from "classnames"; import { SettingsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; @@ -20,14 +20,15 @@ import { Action } from "../../../dispatcher/actions"; import QuickThemeSwitcher from "./QuickThemeSwitcher"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; -import { SDKContextClass } from "../../../contexts/SDKContextClass"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; const QuickSettingsButton: React.FC<{ isPanelCollapsed: boolean; }> = ({ isPanelCollapsed = false }) => { + const sdkContext = useContext(SDKContext); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId(); + const currentRoomId = sdkContext.roomViewStore.getRoomId(); const developerModeEnabled = useSettingValue("developerMode"); let contextMenu: JSX.Element | undefined; diff --git a/apps/web/src/components/views/voip/CallView.tsx b/apps/web/src/components/views/voip/CallView.tsx index ac05b06c08..4863e61b71 100644 --- a/apps/web/src/components/views/voip/CallView.tsx +++ b/apps/web/src/components/views/voip/CallView.tsx @@ -10,12 +10,11 @@ import React, { type FC, useContext, useEffect, type AriaRole, useCallback } fro import type { Room } from "matrix-js-sdk/src/matrix"; import { type Call, CallEvent } from "../../../models/Call"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { CallStore } from "../../../stores/CallStore"; -import { SDKContextClass } from "../../../contexts/SDKContextClass"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useCall } from "../../../hooks/useCall"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; interface JoinCallViewProps { room: Room; @@ -26,7 +25,7 @@ interface JoinCallViewProps { } const JoinCallView: FC = ({ room, resizing, call, role, onClose }) => { - const cli = useContext(MatrixClientContext); + const sdkContext = useContext(SDKContext); useTypedEventEmitter(call, CallEvent.Close, onClose); useEffect(() => { @@ -38,17 +37,17 @@ const JoinCallView: FC = ({ room, resizing, call, role, onClo // The stickyPromise has to resolve before the widget actually becomes sticky. // We only let the widget become sticky after disconnecting all other active calls. const calls = [...CallStore.instance.connectedCalls].filter( - (call) => SDKContextClass.instance.roomViewStore.getRoomId() !== call.roomId, + (call) => sdkContext.roomViewStore.getRoomId() !== call.roomId, ); await Promise.all(calls.map(async (call) => await call.disconnect())); - }, []); + }, [sdkContext.roomViewStore]); return (
{ + const sdkContext = useContext(SDKContext); const roomViewStore = useScopedRoomContext("roomViewStore").roomViewStore; // settings const widgetsFeatureEnabled = useSettingValue(UIFeature.Widgets); @@ -133,9 +134,9 @@ export const useRoomCall = ( }, [useElementCallExclusively, serverIsConfiguredForElementCall]); const hasLegacyCall = useEventEmitterState( - SDKContextClass.instance.legacyCallHandler, + sdkContext.legacyCallHandler, LegacyCallHandlerEvent.CallsChanged, - () => SDKContextClass.instance.legacyCallHandler.getCallForRoom(room.roomId) !== null, + () => sdkContext.legacyCallHandler.getCallForRoom(room.roomId) !== null, ); // settings const widgets = useWidgets(room); diff --git a/apps/web/src/hooks/useRoomCall.test.ts b/apps/web/src/hooks/useRoomCall.test.ts index 638301011d..4c583c72f7 100644 --- a/apps/web/src/hooks/useRoomCall.test.ts +++ b/apps/web/src/hooks/useRoomCall.test.ts @@ -69,7 +69,10 @@ describe("useRoomCall", () => { }); function render() { - return renderHook(() => useRoomCall(room), withContexts({ matrixClient: client, roomContext })); + return renderHook( + () => useRoomCall(room), + withContexts({ matrixClient: client, roomContext, sdkContext: SDKContextClass.instance }), + ); } describe("Element Call focus detection", () => { diff --git a/apps/web/src/stores/WidgetStore.ts b/apps/web/src/stores/WidgetStore.ts index fbae950dbc..b41c101ac6 100644 --- a/apps/web/src/stores/WidgetStore.ts +++ b/apps/web/src/stores/WidgetStore.ts @@ -202,5 +202,3 @@ export default class WidgetStore extends AsyncStoreWithClient { this.emit(UPDATE_EVENT, roomId); } } - -window.mxWidgetStore = WidgetStore.instance; diff --git a/apps/web/src/stores/right-panel/RightPanelStore.ts b/apps/web/src/stores/right-panel/RightPanelStore.ts index 18db90a9eb..1e7812db48 100644 --- a/apps/web/src/stores/right-panel/RightPanelStore.ts +++ b/apps/web/src/stores/right-panel/RightPanelStore.ts @@ -454,5 +454,3 @@ export default class RightPanelStore extends ReadyWatchingStore { return this.internalInstance; } } - -window.mxRightPanelStore = RightPanelStore.instance; diff --git a/apps/web/src/stores/spaces/SpaceStore.ts b/apps/web/src/stores/spaces/SpaceStore.ts index 56a25ee229..88d65a2d4b 100644 --- a/apps/web/src/stores/spaces/SpaceStore.ts +++ b/apps/web/src/stores/spaces/SpaceStore.ts @@ -1370,5 +1370,3 @@ export default class SpaceStore { return store; } } - -window.mxSpaceStore = SpaceStore.instance; diff --git a/apps/web/src/stores/widgets/WidgetLayoutStore.ts b/apps/web/src/stores/widgets/WidgetLayoutStore.ts index 0beeba830b..77063e26bb 100644 --- a/apps/web/src/stores/widgets/WidgetLayoutStore.ts +++ b/apps/web/src/stores/widgets/WidgetLayoutStore.ts @@ -514,5 +514,3 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.recalculateRoom(room); // call to try local echo on changes (the catch above undoes any errors) } } - -window.mxWidgetLayoutStore = WidgetLayoutStore.instance; diff --git a/apps/web/src/utils/exportUtils/HtmlExport.tsx b/apps/web/src/utils/exportUtils/HtmlExport.tsx index 1d005c4618..adf5f17abe 100644 --- a/apps/web/src/utils/exportUtils/HtmlExport.tsx +++ b/apps/web/src/utils/exportUtils/HtmlExport.tsx @@ -262,6 +262,7 @@ export default class HTMLExporter extends Exporter { roomId: event.getRoomId()!, ts, forExport: true, + roomViewStore: SDKContextClass.instance.roomViewStore, }); try { const dateSeparator = ( diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 206d0b7c06..a4abe130f7 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -22,7 +22,7 @@ import dispatcher from "../../dispatcher/dispatcher"; import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload"; -import SpaceStore from "../../stores/spaces/SpaceStore"; +import { type SpaceStoreClass } from "../../stores/spaces/SpaceStore"; import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult, @@ -34,7 +34,6 @@ import { UPDATE_STATUS_INDICATOR, } from "../../stores/notifications/RoomNotificationStateStore"; import { RoomListItemViewModel } from "./RoomListItemViewModel"; -import { SDKContextClass } from "../../contexts/SDKContextClass"; import { hasCreateRoomRights } from "./utils"; import { keepIfSame } from "../../utils/keepIfSame"; import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; @@ -43,6 +42,7 @@ import { getCustomSectionData, isCustomSectionTag, CHATS_TAG } from "../../store import { tagRoom } from "../../utils/room/tagRoom"; import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom"; import SettingsStore from "../../settings/SettingsStore"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; /** * Tracks the position of the active room within a specific section. @@ -58,6 +58,8 @@ interface StickyRoomPosition { interface RoomListViewModelProps { client: MatrixClient; + roomViewStore: RoomViewStore; + spaceStore: SpaceStoreClass; } const filterKeyToIdMap: Map = new Map([ @@ -158,7 +160,7 @@ export class RoomListViewModel private scrollToIndex?: (index: number) => void; public constructor(props: RoomListViewModelProps) { - const activeSpace = SpaceStore.instance.activeSpaceRoom; + const activeSpace = props.spaceStore.activeSpaceRoom; // Get initial rooms const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined); @@ -575,7 +577,7 @@ export class RoomListViewModel * Migrated from useRoomListNavigation hook. */ private handleViewRoomDelta(payload: ViewRoomDeltaPayload): void { - const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId(); + const currentRoomId = this.props.roomViewStore.getRoomId(); if (!currentRoomId) return; const { delta, unread } = payload; @@ -646,7 +648,7 @@ export class RoomListViewModel } // Space changed - get the last selected room for the new space to prevent flicker - const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId); + const lastSelectedRoom = this.props.spaceStore.getLastSelectedRoomIdForSpace(newSpaceId); this.updateRoomListData(true, lastSelectedRoom); return; @@ -753,7 +755,7 @@ export class RoomListViewModel ): Promise { // Determine the room ID to use for calculations // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore - const roomId = roomIdOverride ?? SDKContextClass.instance.roomViewStore.getRoomId(); + const roomId = roomIdOverride ?? this.props.roomViewStore.getRoomId(); // Apply sticky room logic to keep selected room at same position within its section const stickySections = this.applyStickyRoom(isRoomChange, roomId); @@ -858,7 +860,7 @@ export class RoomListViewModel }; public createRoom = (): void => { - const activeSpace = SpaceStore.instance.activeSpaceRoom; + const activeSpace = this.props.spaceStore.activeSpaceRoom; if (activeSpace) { dispatcher.dispatch({ action: Action.CreateRoom, diff --git a/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx b/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx index ea05e86d16..7e7517c40f 100644 --- a/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx +++ b/apps/web/src/viewmodels/room/timeline/DateSeparatorViewModel.tsx @@ -28,7 +28,7 @@ import { UIFeature } from "../../../settings/UIFeature"; import ErrorDialog from "../../../components/views/dialogs/ErrorDialog"; import BugReportDialog from "../../../components/views/dialogs/BugReportDialog"; import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { SDKContextClass } from "../../../contexts/SDKContextClass"; +import { type RoomViewStore } from "../../../stores/RoomViewStore.tsx"; export interface DateSeparatorViewModelProps { /** @@ -43,6 +43,10 @@ export interface DateSeparatorViewModelProps { * Export mode disables relative date labels and jump-to-date menu UI. */ forExport?: boolean; + /** + * The room view store instance to use + */ + roomViewStore: RoomViewStore; } /** @@ -159,7 +163,7 @@ export class DateSeparatorViewModel // Only try to navigate to the room if the user is still viewing the same // room. We don't want to jump someone back to a room after a slow request // if they've already navigated away to another room. - const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId(); + const currentRoomId = this.props.roomViewStore.getRoomId(); if (currentRoomId === roomIdForJumpRequest) { dispatcher.dispatch({ action: Action.ViewRoom, @@ -185,7 +189,7 @@ export class DateSeparatorViewModel // don't want to worry someone about an error in a room they no longer care // about after a slow request if they've already navigated away to another // room. - const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId(); + const currentRoomId = this.props.roomViewStore.getRoomId(); if (currentRoomId === roomIdForJumpRequest) { let friendlyErrorMessage = "An error occured while trying to find and jump to the given date."; let submitDebugLogsContent: React.ReactElement = <>; diff --git a/apps/web/test/test-utils/client.ts b/apps/web/test/test-utils/client.ts index a700a09d25..8cd204a579 100644 --- a/apps/web/test/test-utils/client.ts +++ b/apps/web/test/test-utils/client.ts @@ -188,6 +188,7 @@ export const mockClientMethodsCrypto = (): Partial< }); export const mockClientMethodsRooms = (rooms: Room[] = []): Partial, unknown>> => ({ + getVisibleRooms: vi.fn().mockReturnValue(rooms), getRooms: vi.fn().mockReturnValue(rooms), getRoom: vi.fn((roomId) => rooms.find((r) => r.roomId === roomId) ?? null), isRoomEncrypted: vi.fn(), diff --git a/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx b/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx index ea32700a2b..20ff75d44e 100644 --- a/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx @@ -37,6 +37,7 @@ import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomCo import { untilDispatch } from "../../../test-utils/utilities.ts"; import { TimelineRenderingType } from "../../../../src/contexts/RoomContext.ts"; import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts"; +import { SDKContext } from "../../../../src/contexts/SDKContext.ts"; describe("ThreadView", () => { const ROOM_ID = "!roomId:example.org"; @@ -73,7 +74,11 @@ describe("ThreadView", () => { } async function getComponent(initialEvent?: MatrixEvent): Promise { - const renderResult = render(); + const renderResult = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); await waitFor(() => { expect(() => getByTestId(renderResult.container, "spinner")).toThrow(); diff --git a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx index b7489ee78a..7737c4df79 100644 --- a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -24,7 +24,6 @@ import { clientAndSDKContextRenderOptions, stubClient } from "../../../../test-u import { Action } from "../../../../../src/dispatcher/actions"; import dis from "../../../../../src/dispatcher/dispatcher"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; @@ -194,13 +193,11 @@ describe("AppTile", () => { ); renderResult.rerender( - - - , + , ); expect(renderResult.queryByText("Example 1")).not.toBeInTheDocument(); @@ -278,13 +275,11 @@ describe("AppTile", () => { }), ); renderResult.rerender( - - - , + , ); await waitFor(() => { diff --git a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap index 36f3dc76c5..ed65faa013 100644 --- a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap @@ -283,6 +283,14 @@ exports[` should render widgets 1`] = `
+ + Set layout for everyone + diff --git a/apps/web/test/unit-tests/components/views/rooms/BasicMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/BasicMessageComposer-test.tsx index 848fa3ca45..caedd8cede 100644 --- a/apps/web/test/unit-tests/components/views/rooms/BasicMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/BasicMessageComposer-test.tsx @@ -20,6 +20,7 @@ import { CommandPartCreator } from "../../../../../src/editor/parts"; import DocumentOffset from "../../../../../src/editor/offset"; import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; describe("BasicMessageComposer", () => { const renderer = createRenderer(); @@ -35,7 +36,11 @@ describe("BasicMessageComposer", () => { it("should allow a user to paste a URL without it being mangled", async () => { const model = new EditorModel([], pc, renderer); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const testUrl = "https://element.io"; const mockDataTransfer = generateMockDataTransferForString(testUrl); await userEvent.paste(mockDataTransfer); @@ -51,7 +56,11 @@ describe("BasicMessageComposer", () => { }); userEvent.setup(); const model = new EditorModel([], pc, renderer); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const tranformations = [ { before: "4:3 video", after: "4:3 video" }, @@ -81,7 +90,11 @@ describe("BasicMessageComposer", () => { it("should not mangle shift-enter when the autocomplete is open", async () => { const model = new EditorModel([], pc, renderer); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const input = screen.getByRole("textbox"); @@ -93,7 +106,11 @@ describe("BasicMessageComposer", () => { it("should escape single quote in placeholder", async () => { const model = new EditorModel([], pc, renderer); - const composer = render(); + const composer = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const input = composer.queryAllByRole("textbox"); const placeholder = input[0].style.getPropertyValue("--placeholder"); expect(placeholder).toMatch("'Don\\'t'"); @@ -101,7 +118,11 @@ describe("BasicMessageComposer", () => { it("should escape backslash in placeholder", async () => { const model = new EditorModel([], pc, renderer); - const composer = render(); + const composer = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const input = composer.queryAllByRole("textbox"); const placeholder = input[0].style.getPropertyValue("--placeholder"); expect(placeholder).toMatch("'w\\\\e'"); @@ -116,7 +137,11 @@ describe("BasicMessageComposer", () => { // spy on typingStore.setSelfTyping const spy = jest.spyOn(SDKContextClass.instance.typingStore, "setSelfTyping"); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); // simulate typing by updating the model - this will call the component's update callback await model.update(commandPart.text, "insertText", new DocumentOffset(commandPart.text.length, true)); @@ -128,7 +153,11 @@ describe("BasicMessageComposer", () => { it("should ignore keydown events during IME composition", () => { const model = new EditorModel([], pc, renderer); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const input = screen.getByRole("textbox"); // Start IME composition @@ -167,7 +196,11 @@ describe("BasicMessageComposer", () => { it("should handle keydown events normally when not composing", () => { const model = new EditorModel([], pc, renderer); - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const input = screen.getByRole("textbox"); // Simulate Tab key when NOT composing diff --git a/apps/web/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx index 39ae0a4b6f..84338e7904 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx @@ -33,6 +33,8 @@ import NotifProvider from "../../../../../src/autocomplete/NotifProvider"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; +import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass.ts"; describe("", () => { const userId = "@alice:server.org"; @@ -78,9 +80,11 @@ describe("", () => { const getComponent = (editState: EditorStateTransfer, roomContext: RoomContextType = defaultRoomContext) => render(, { wrapper: ({ children }) => ( - - {children} - + + + {children} + + ), }); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index f7ea551ecd..e9633efd56 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -51,6 +51,8 @@ import SettingsStore from "../../../../../src/settings/SettingsStore"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import PlatformPeg from "../../../../../src/PlatformPeg"; +import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass.ts"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; function getTile(container: HTMLElement): HTMLElement { const tile = container.querySelector(".mx_EventTile"); @@ -1652,7 +1654,11 @@ describe("EventTile", () => { return Element.prototype.matches.call(this, selector); }); - const { container, rerender } = render(); + const { container, rerender } = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); const editingTile = container.querySelector(".mx_EventTile_isEditing"); expect(editingTile).not.toBeNull(); diff --git a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index 7a470e3bf3..2014a820d7 100644 --- a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -37,6 +37,8 @@ import { Action } from "../../../../../src/dispatcher/actions"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { TimelineRenderingType, type RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; +import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass.ts"; const openStickerPicker = async (): Promise => { await userEvent.click(screen.getByLabelText("More options")); @@ -469,13 +471,15 @@ function wrapAndRender( }; const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( - - - - - - - + + + + + + + + + ); return { rawComponent: getRawComponent(props, roomContext, mockClient), diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index cba7aab747..0a558e71ea 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -59,6 +59,7 @@ import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore import { UIFeature } from "../../../../../../src/settings/UIFeature"; import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; import { ElementCallMemberEventType } from "../../../../../../src/call-types"; +import { SDKContext } from "../../../../../../src/contexts/SDKContext"; import { SDKContextClass } from "../../../../../../src/contexts/SDKContextClass.ts"; jest.mock("../../../../../../src/utils/ShieldUtils"); @@ -93,9 +94,11 @@ describe("RoomHeader", () => { function getWrapper(): RenderOptions { return { wrapper: ({ children }) => ( - - {children} - + + + {children} + + ), }; } diff --git a/apps/web/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx index a605650269..085abab201 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx @@ -10,9 +10,10 @@ import React from "react"; import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix"; import { render, type RenderResult } from "jest-matrix-react"; -import { stubClient, withClientContextRenderOptions } from "../../../../test-utils"; +import { clientAndSDKContextRenderOptions, stubClient } from "../../../../test-utils"; import SearchResultTile from "../../../../../src/components/views/rooms/SearchResultTile"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass.ts"; const ROOM_ID = "!qPewotXpIctQySfjSy:localhost"; @@ -30,7 +31,7 @@ describe("SearchResultTile", () => { function renderComponent(props: Partial): RenderResult { return render( , - withClientContextRenderOptions(MatrixClientPeg.safeGet()), + clientAndSDKContextRenderOptions(MatrixClientPeg.safeGet(), SDKContextClass.instance), ); } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index e51fc0c095..ef8d77e433 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -35,6 +35,7 @@ import { addTextToComposer } from "../../../../test-utils/composer"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass"; import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -199,7 +200,11 @@ describe("", () => { ); const getComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => { - return render(getRawComponent(props, roomContext, client)); + return render(getRawComponent(props, roomContext, client), { + wrapper: ({ children }) => ( + {children} + ), + }); }; it("renders text and placeholder correctly", () => { @@ -447,6 +452,11 @@ describe("", () => { , + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const composer = container.querySelector(".mx_BasicMessageComposer_input")!; diff --git a/apps/web/test/unit-tests/components/views/spaces/QuickSettingsButton-test.tsx b/apps/web/test/unit-tests/components/views/spaces/QuickSettingsButton-test.tsx index e6ed934681..2d1350216a 100644 --- a/apps/web/test/unit-tests/components/views/spaces/QuickSettingsButton-test.tsx +++ b/apps/web/test/unit-tests/components/views/spaces/QuickSettingsButton-test.tsx @@ -14,12 +14,17 @@ import { mocked } from "jest-mock"; import QuickSettingsButton from "../../../../../src/components/views/spaces/QuickSettingsButton"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass"; +import { SDKContext } from "../../../../../src/contexts/SDKContext.ts"; describe("QuickSettingsButton", () => { const roomId = "!room:example.com"; const renderQuickSettingsButton = () => { - render(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); }; const getQuickSettingsButton = () => { @@ -37,7 +42,11 @@ describe("QuickSettingsButton", () => { }); it("should render the quick settings button in expanded mode", () => { - const { asFragment } = render(); + const { asFragment } = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); expect(asFragment()).toMatchSnapshot(); }); diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 98e754f0bb..abafda7dc5 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -9,7 +9,14 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import { waitFor } from "jest-matrix-react"; -import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils"; +import { + createTestClient, + flushPromises, + flushPromisesWithFakeTimers, + mkStubRoom, + stubClient, + TestSDKContext, +} from "../../test-utils"; import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; import SpaceStore from "../../../src/stores/spaces/SpaceStore"; import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters"; @@ -44,6 +51,7 @@ jest.mock("../../../src/viewmodels/room-list/utils", () => ({ describe("RoomListViewModel", () => { let matrixClient: MatrixClient; + let sdkContext: TestSDKContext; let room1: Room; let room2: Room; let room3: Room; @@ -51,6 +59,8 @@ describe("RoomListViewModel", () => { beforeEach(() => { matrixClient = createTestClient(); + sdkContext = new TestSDKContext(); + sdkContext._client = matrixClient; room1 = mkStubRoom("!room1:server", "Room 1", matrixClient); room2 = mkStubRoom("!room2:server", "Room 2", matrixClient); room3 = mkStubRoom("!room3:server", "Room 3", matrixClient); @@ -80,7 +90,11 @@ describe("RoomListViewModel", () => { describe("Initialization", () => { it("should initialize with correct snapshot", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const snapshot = viewModel.getSnapshot(); expect(snapshot.sections[0].roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]); @@ -97,7 +111,11 @@ describe("RoomListViewModel", () => { sections: [{ tag: CHATS_TAG, rooms: [] }], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().sections).toEqual([]); expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true); @@ -106,7 +124,11 @@ describe("RoomListViewModel", () => { it("should set canCreateRoom based on user rights", () => { mocked(hasCreateRoomRights).mockReturnValue(true); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().canCreateRoom).toBe(true); }); @@ -114,7 +136,11 @@ describe("RoomListViewModel", () => { describe("Room list updates", () => { it("should update room list when ListsUpdate event fires", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient); jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ @@ -134,7 +160,11 @@ describe("RoomListViewModel", () => { it("should update loading state when ListsLoaded event fires", () => { jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().isLoadingRooms).toBe(true); @@ -145,7 +175,11 @@ describe("RoomListViewModel", () => { // This test ensures that the room list item vms are preserved when the room list is changing it("should keep existing view model when ListsUpdate event fires", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Create view model for room1 const room1VM = viewModel.getRoomItemViewModel("!room1:server"); @@ -160,7 +194,11 @@ describe("RoomListViewModel", () => { describe("Space switching", () => { it("should update room list when space changes", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const spaceRoomList = [room1, room2]; @@ -178,7 +216,11 @@ describe("RoomListViewModel", () => { }); it("should clear view models when space changes", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Get view models for visible rooms const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; @@ -200,7 +242,11 @@ describe("RoomListViewModel", () => { }); it("should clear roomsMap when space changes and repopulate with new rooms", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const newSpaceRoom = mkStubRoom("!spaceroom:server", "Space Room", matrixClient); @@ -221,7 +267,11 @@ describe("RoomListViewModel", () => { describe("Active room tracking", () => { it("should update active room index when room is selected", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); @@ -237,7 +287,11 @@ describe("RoomListViewModel", () => { }); it("should return undefined active room index when no room is selected", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); @@ -255,7 +309,11 @@ describe("RoomListViewModel", () => { describe("Sticky room behavior", () => { it("should keep selected room at same index when room list updates", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Select room at index 1 jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); @@ -281,7 +339,11 @@ describe("RoomListViewModel", () => { }); it("should not apply sticky behavior when user changes rooms", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Select room at index 1 jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); @@ -307,7 +369,11 @@ describe("RoomListViewModel", () => { describe("Filters", () => { it("should toggle filter on", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().activeFilterId).toBeUndefined(); @@ -324,7 +390,11 @@ describe("RoomListViewModel", () => { }); it("should toggle filter off", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Turn filter on jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ @@ -363,7 +433,11 @@ describe("RoomListViewModel", () => { it("hides the Favourites and Low Priority filters when sections are enabled", () => { mockShowSections(true); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); const { filterIds } = viewModel.getSnapshot(); expect(filterIds).not.toContain("favourite"); @@ -372,7 +446,11 @@ describe("RoomListViewModel", () => { it("shows the Favourites and Low Priority filters when sections are disabled", () => { mockShowSections(false); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); const { filterIds } = viewModel.getSnapshot(); expect(filterIds).toContain("favourite"); @@ -393,7 +471,11 @@ describe("RoomListViewModel", () => { return "watcher-id"; }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); expect(viewModel.getSnapshot().filterIds).toContain("favourite"); // Activate the Favourites filter @@ -423,7 +505,11 @@ describe("RoomListViewModel", () => { describe("Room item view models", () => { it("should create room item view model on demand", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const itemViewModel = viewModel.getRoomItemViewModel("!room1:server"); @@ -432,7 +518,11 @@ describe("RoomListViewModel", () => { }); it("should reuse existing room item view model", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server"); const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server"); @@ -441,13 +531,21 @@ describe("RoomListViewModel", () => { }); it("should return undefined for non-existent room", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getRoomItemViewModel("!nonexistent:server")).toBeUndefined(); }); it("should not throw when requesting view model for a room removed from the list but still in roomsMap", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Normal list update removes room2 from the list jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ @@ -461,7 +559,11 @@ describe("RoomListViewModel", () => { }); it("should return undefined for a room from old space after space change", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const spaceRoom = mkStubRoom("!newroom:server", "New Room", matrixClient); @@ -478,7 +580,11 @@ describe("RoomListViewModel", () => { }); it("should recover when roomsMap is stale but roomsResult has the room", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Manually clear roomsMap to simulate stale cache, but keep roomsResult intact (viewModel as any).roomsMap.clear(); @@ -488,7 +594,11 @@ describe("RoomListViewModel", () => { }); it("should dispose view models for rooms no longer visible", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; const vm2 = viewModel.getRoomItemViewModel("!room2:server")!; @@ -511,7 +621,11 @@ describe("RoomListViewModel", () => { describe("Room creation", () => { it("should dispatch CreateChat action when createChatRoom is called", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const dispatchSpy = jest.spyOn(dispatcher, "fire"); @@ -521,7 +635,11 @@ describe("RoomListViewModel", () => { }); it("should dispatch CreateRoom action without parent space", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); @@ -536,7 +654,11 @@ describe("RoomListViewModel", () => { const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient); jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); @@ -556,7 +678,11 @@ describe("RoomListViewModel", () => { }); it("should navigate to next room when delta is 1", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); @@ -579,7 +705,11 @@ describe("RoomListViewModel", () => { }); it("should navigate to previous room when delta is -1", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); @@ -602,7 +732,11 @@ describe("RoomListViewModel", () => { }); it("should wrap around to last room when navigating backwards from first room", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); @@ -625,7 +759,11 @@ describe("RoomListViewModel", () => { }); it("should not navigate when current room is not found", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server"); @@ -649,7 +787,11 @@ describe("RoomListViewModel", () => { }); it("should not navigate when no room is selected", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); @@ -682,7 +824,11 @@ describe("RoomListViewModel", () => { }); it("should dispose all room item view models on dispose", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; const vm2 = viewModel.getRoomItemViewModel("!room2:server")!; @@ -698,19 +844,31 @@ describe("RoomListViewModel", () => { describe("Toast", () => { it("should show toast when SectionCreated event fires", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated); expect(viewModel.getSnapshot().toast).toBe("section_created"); }); it("should show toast when RoomTagged event fires", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged); expect(viewModel.getSnapshot().toast).toBe("chat_moved"); }); it("should clear toast when closeToast is called", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated); expect(viewModel.getSnapshot().toast).toBe("section_created"); @@ -720,7 +878,11 @@ describe("RoomListViewModel", () => { }); it("should auto-close toast after 15 seconds", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated); expect(viewModel.getSnapshot().toast).toBe("section_created"); @@ -730,7 +892,11 @@ describe("RoomListViewModel", () => { }); it("should reset the auto-close timer when a new section is created", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated); jest.advanceTimersByTime(10 * 1000); @@ -755,7 +921,11 @@ describe("RoomListViewModel", () => { it("should show the unread-activity toast when an unread room is below the fold", () => { mockRoom3Unread(); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // room1/room2 visible, room3 (unread) scrolled below the fold. viewModel.updateVisibleFold(1); @@ -765,7 +935,11 @@ describe("RoomListViewModel", () => { it("should prefer the event toast over the unread-activity toast, restoring it on auto-close", () => { mockRoom3Unread(); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); viewModel.updateVisibleFold(1); expect(viewModel.getSnapshot().toast).toBe("unread_activity"); @@ -804,7 +978,11 @@ describe("RoomListViewModel", () => { }); it("should initialize with multiple sections", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const snapshot = viewModel.getSnapshot(); expect(snapshot.sections).toHaveLength(3); @@ -817,7 +995,11 @@ describe("RoomListViewModel", () => { }); it("should not be a flat list when multiple sections exist", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().isFlatList).toBe(false); }); @@ -832,7 +1014,11 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().isFlatList).toBe(true); expect(viewModel.getSnapshot().sections).toHaveLength(1); @@ -849,14 +1035,22 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().isFlatList).toBe(true); expect(viewModel.getSnapshot().sections).toHaveLength(0); }); it("should exclude favourite and low_priority from filter list", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const snapshot = viewModel.getSnapshot(); expect(snapshot.filterIds).not.toContain("favourite"); @@ -876,7 +1070,11 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const snapshot = viewModel.getSnapshot(); expect(snapshot.sections).toHaveLength(1); @@ -884,7 +1082,11 @@ describe("RoomListViewModel", () => { }); it("should create section header view models on demand", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const headerVM = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); expect(headerVM).toBeDefined(); @@ -893,7 +1095,11 @@ describe("RoomListViewModel", () => { }); it("should reuse section header view models", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const headerVM1 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); const headerVM2 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -901,7 +1107,11 @@ describe("RoomListViewModel", () => { }); it("should hide room IDs when a section is collapsed", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse the favourite section const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -920,7 +1130,11 @@ describe("RoomListViewModel", () => { }); it("should compute activeRoomIndex relative to visible rooms when a section is collapsed", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse the favourite section (which has 2 rooms: fav1, fav2) const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -943,7 +1157,11 @@ describe("RoomListViewModel", () => { }); it("should restore room IDs when a section is re-expanded", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -958,7 +1176,11 @@ describe("RoomListViewModel", () => { }); it("should update sections when room list changes", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const newFav = mkStubRoom("!fav3:server", "Fav 3", matrixClient); @@ -978,7 +1200,11 @@ describe("RoomListViewModel", () => { }); it("should preserve section collapse state across list updates", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse favourites const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -993,7 +1219,11 @@ describe("RoomListViewModel", () => { }); it("should track section collapse state per space", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse favourites in the home space const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -1048,7 +1278,11 @@ describe("RoomListViewModel", () => { }); it("should apply filters across all sections", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Only favRoom1 is unread jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ @@ -1102,7 +1336,11 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true); }); @@ -1116,7 +1354,11 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(false); }); @@ -1130,7 +1372,11 @@ describe("RoomListViewModel", () => { ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true); }); @@ -1138,7 +1384,11 @@ describe("RoomListViewModel", () => { describe("Collapse/expand all sections", () => { it("should collapse all sections when Action.RoomListCollapseAllSections is dispatched", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); const chatsHeader = viewModel.getSectionHeaderViewModel(CHATS_TAG); @@ -1156,7 +1406,11 @@ describe("RoomListViewModel", () => { }); it("should expand all sections when Action.RoomListExpandAllSections is dispatched", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse first const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); @@ -1177,7 +1431,11 @@ describe("RoomListViewModel", () => { describe("notifyCollapseState", () => { it("should dispatch collapseSections=expand when all sections are expanded (default)", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -1189,7 +1447,11 @@ describe("RoomListViewModel", () => { }); it("should dispatch collapseSection=collapse when all sections are collapsed", () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Collapse all sections viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded = false; @@ -1217,7 +1479,11 @@ describe("RoomListViewModel", () => { { tag: DefaultTagID.LowPriority, rooms: [] }, ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -1231,7 +1497,11 @@ describe("RoomListViewModel", () => { it("should apply sticky room within the correct section", async () => { stubClient(); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Select favRoom1 (index 0 globally, index 0 in favourites section) jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!fav1:server"); @@ -1263,7 +1533,11 @@ describe("RoomListViewModel", () => { describe("Drag and drop", () => { beforeEach(() => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); // Ensure section header VMs are created before tests that interact with them viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); viewModel.getSectionHeaderViewModel(CHATS_TAG); @@ -1367,7 +1641,11 @@ describe("RoomListViewModel", () => { describe("changeRoomSection", () => { beforeEach(() => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + spaceStore: SpaceStore.instance, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); mocked(tagRoom).mockClear(); }); @@ -1406,7 +1684,11 @@ describe("RoomListViewModel", () => { }); it("should scroll a room into view in a flat list", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); const scrollSpy = jest.fn(); viewModel.setScrollToIndex(scrollSpy); @@ -1432,7 +1714,11 @@ describe("RoomListViewModel", () => { { tag: CHATS_TAG, rooms: [regularRoom1] }, ], }); - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); const scrollSpy = jest.fn(); viewModel.setScrollToIndex(scrollSpy); @@ -1448,7 +1734,11 @@ describe("RoomListViewModel", () => { }); it("should not scroll when the room is not in the current list", async () => { - viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel = new RoomListViewModel({ + client: matrixClient, + roomViewStore: sdkContext.roomViewStore, + spaceStore: sdkContext.spaceStore, + }); const scrollSpy = jest.fn(); viewModel.setScrollToIndex(scrollSpy); diff --git a/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx b/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx index a868200927..75315ea60b 100644 --- a/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx +++ b/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx @@ -70,7 +70,11 @@ describe("DateSeparatorViewModel", () => { const createViewModel = ( props: Partial & { forExport?: boolean } = {}, ): DateSeparatorViewModel => { - return new DateSeparatorViewModel({ ...defaultProps, ...props }); + return new DateSeparatorViewModel({ + ...defaultProps, + ...props, + roomViewStore: SDKContextClass.instance.roomViewStore, + }); }; beforeEach(() => {