From 38f6e147ccf20ca5b1cb81a163f59a551f54cac5 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Oct 2025 16:04:45 +0100 Subject: [PATCH 01/29] Pass roomViewStore to the RoomView and add to the RoomContext. --- src/components/structures/LoggedInView.tsx | 3 +- src/components/structures/RoomView.tsx | 43 +++++++++++-------- src/contexts/RoomContext.ts | 1 + test/test-utils/room.ts | 2 + .../components/structures/RoomView-test.tsx | 2 + .../views/rooms/SendMessageComposer-test.tsx | 2 + 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 871085c24f4..f25995864cc 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -66,7 +66,7 @@ import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushR import { type ConfigOptions } from "../../SdkConfig"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; -import { SDKContext } from "../../contexts/SDKContext.ts"; +import { SDKContext, SdkContextClass } from "../../contexts/SDKContext.ts"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -685,6 +685,7 @@ class LoggedInView extends React.Component { key={this.props.currentRoomId || "roomview"} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} + roomViewStore={SdkContextClass.instance.roomViewStore} /> ); break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 10647ee86b5..1d9f8068fb6 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -134,6 +134,7 @@ import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts"; import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; +import { RoomViewStore } from "../../stores/RoomViewStore.tsx"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -157,11 +158,14 @@ interface IRoomProps { // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; + roomViewStore: RoomViewStore; } export { MainSplitContentType }; export interface IRoomState { + // The room view store for the room we are displaying + roomViewStore: RoomViewStore; room?: Room; roomId?: string; roomAlias?: string; @@ -394,6 +398,7 @@ export class RoomView extends React.Component { const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { + roomViewStore: props.roomViewStore, roomId: undefined, roomLoading: true, peekLoading: false, @@ -525,7 +530,7 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room): MainSplitContentType => { - if (this.context.roomViewStore.isViewingCall() || isVideoRoom(room)) { + if (this.state.roomViewStore.isViewingCall() || isVideoRoom(room)) { return MainSplitContentType.Call; } if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { @@ -539,8 +544,8 @@ export class RoomView extends React.Component { return; } - const roomLoadError = this.context.roomViewStore.getRoomLoadError() ?? undefined; - if (!initial && !roomLoadError && this.state.roomId !== this.context.roomViewStore.getRoomId()) { + const roomLoadError = this.state.roomViewStore.getRoomLoadError() ?? undefined; + if (!initial && !roomLoadError && this.state.roomId !== this.state.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -555,29 +560,29 @@ export class RoomView extends React.Component { return; } - const roomId = this.context.roomViewStore.getRoomId() ?? null; + const roomId = this.state.roomViewStore.getRoomId() ?? null; const room = this.context.client?.getRoom(roomId ?? undefined) ?? undefined; const newState: Partial = { roomId: roomId ?? undefined, - roomAlias: this.context.roomViewStore.getRoomAlias() ?? undefined, - roomLoading: this.context.roomViewStore.isRoomLoading(), + roomAlias: this.state.roomViewStore.getRoomAlias() ?? undefined, + roomLoading: this.state.roomViewStore.isRoomLoading(), roomLoadError, - joining: this.context.roomViewStore.isJoining(), - replyToEvent: this.context.roomViewStore.getQuotingEvent() ?? undefined, + joining: this.state.roomViewStore.isJoining(), + replyToEvent: this.state.roomViewStore.getQuotingEvent() ?? undefined, // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && this.state.roomViewStore.shouldPeek(), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: this.context.roomViewStore.getWasContextSwitch(), + wasContextSwitch: this.state.roomViewStore.getWasContextSwitch(), mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: roomId ? this.context.rightPanelStore.isOpenForRoom(roomId) : false, - promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), - viewRoomOpts: this.context.roomViewStore.getViewRoomOpts(), + promptAskToJoin: this.state.roomViewStore.promptAskToJoin(), + viewRoomOpts: this.state.roomViewStore.getViewRoomOpts(), }; if ( @@ -593,7 +598,7 @@ export class RoomView extends React.Component { newState.showRightPanel = false; } - const initialEventId = this.context.roomViewStore.getInitialEventId() ?? this.state.initialEventId; + const initialEventId = this.state.roomViewStore.getInitialEventId() ?? this.state.initialEventId; if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -619,13 +624,13 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: this.context.roomViewStore.isInitialEventHighlighted(), - scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), + highlighted: this.state.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.state.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.state.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.state.roomViewStore.initialEventScrollIntoView(); } } @@ -885,7 +890,7 @@ export class RoomView extends React.Component { this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); } // Start listening for RoomViewStore updates - this.context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.state.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); @@ -1002,7 +1007,7 @@ export class RoomView extends React.Component { window.removeEventListener("beforeunload", this.onPageUnload); - this.context.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.state.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 95e21fb0b8e..39728b1a0cf 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -75,6 +75,7 @@ const RoomContext = createContext< promptAskToJoin: false, viewRoomOpts: { buttons: [] }, isRoomEncrypted: null, + roomViewStore: undefined! }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index d4c6466ef28..09c5bd98234 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -14,6 +14,7 @@ import { type IRoomState, MainSplitContentType } from "../../src/components/stru import { TimelineRenderingType } from "../../src/contexts/RoomContext"; import { Layout } from "../../src/settings/enums/Layout"; import { mkEvent } from "./test-utils"; +import { SdkContextClass } from "../../src/contexts/SDKContext"; export const makeMembershipEvent = (roomId: string, userId: string, membership = KnownMembership.Join) => mkEvent({ @@ -44,6 +45,7 @@ export const makeRoomWithStateEvents = ( export function getRoomContext(room: Room, override: Partial): IRoomState { return { + roomViewStore: SdkContextClass.instance.roomViewStore, room, roomLoading: true, peekLoading: false, diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index b0efc27ddad..e7a99733f97 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -158,6 +158,7 @@ describe("RoomView", () => { threepidInvite={undefined as any} forceTimeline={false} ref={ref} + roomViewStore={stores.roomViewStore} /> , @@ -196,6 +197,7 @@ describe("RoomView", () => { threepidInvite={undefined} forceTimeline={false} onRegistered={jest.fn()} + roomViewStore={stores.roomViewStore} /> , diff --git a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index 412065353d1..f4cde171e9f 100644 --- a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -31,6 +31,7 @@ import { mockPlatformPeg } from "../../../../test-utils/platform"; import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { addTextToComposer } from "../../../../test-utils/composer"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; +import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -38,6 +39,7 @@ jest.mock("../../../../../src/utils/local-room", () => ({ describe("", () => { const defaultRoomContext: IRoomState = { + roomViewStore: SdkContextClass.instance.roomViewStore, roomLoading: true, peekLoading: false, shouldPeek: true, From a6adb535e574f3b99ba6cce6d01643547932e756 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Oct 2025 16:09:33 +0100 Subject: [PATCH 02/29] lint --- src/components/structures/RoomView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1d9f8068fb6..59907ce3890 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -134,7 +134,7 @@ import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts"; import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; -import { RoomViewStore } from "../../stores/RoomViewStore.tsx"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; From 6838f5320b124a3c4cc049c5532ab79439ff834c Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Oct 2025 16:17:18 +0100 Subject: [PATCH 03/29] lint --- src/contexts/RoomContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 39728b1a0cf..a6bf2b8461c 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -75,7 +75,7 @@ const RoomContext = createContext< promptAskToJoin: false, viewRoomOpts: { buttons: [] }, isRoomEncrypted: null, - roomViewStore: undefined! + roomViewStore: undefined!, }); RoomContext.displayName = "RoomContext"; export default RoomContext; From 1f3d1778a1b4aa31ce75c9f3fa50d5918ac12e30 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Oct 2025 17:02:04 +0100 Subject: [PATCH 04/29] Make constants more DRY --- src/components/structures/RoomView.tsx | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 59907ce3890..79f0735bbf5 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -559,30 +559,38 @@ export class RoomView extends React.Component { // it was, it means we're about to be unmounted. return; } - - const roomId = this.state.roomViewStore.getRoomId() ?? null; + const roomViewStore = this.state.roomViewStore; + const roomId = roomViewStore.getRoomId() ?? null; + const roomAlias = roomViewStore.getRoomAlias() ?? undefined; + const roomLoading = roomViewStore.isRoomLoading(); + const joining = roomViewStore.isJoining(); + const replyToEvent = roomViewStore.getQuotingEvent() ?? undefined; + const shouldPeek = roomViewStore.shouldPeek(); + const wasContextSwitch = roomViewStore.getWasContextSwitch(); + const promptAskToJoin = roomViewStore.promptAskToJoin(); + const viewRoomOpts = roomViewStore.getViewRoomOpts(); const room = this.context.client?.getRoom(roomId ?? undefined) ?? undefined; const newState: Partial = { roomId: roomId ?? undefined, - roomAlias: this.state.roomViewStore.getRoomAlias() ?? undefined, - roomLoading: this.state.roomViewStore.isRoomLoading(), + roomAlias: roomAlias, + roomLoading: roomLoading, roomLoadError, - joining: this.state.roomViewStore.isJoining(), - replyToEvent: this.state.roomViewStore.getQuotingEvent() ?? undefined, + joining: joining, + replyToEvent: replyToEvent, // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && this.state.roomViewStore.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && shouldPeek, showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: this.state.roomViewStore.getWasContextSwitch(), + wasContextSwitch: wasContextSwitch, mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: roomId ? this.context.rightPanelStore.isOpenForRoom(roomId) : false, - promptAskToJoin: this.state.roomViewStore.promptAskToJoin(), - viewRoomOpts: this.state.roomViewStore.getViewRoomOpts(), + promptAskToJoin: promptAskToJoin, + viewRoomOpts: viewRoomOpts, }; if ( From bdb3caabc134d57348bc14a03b4e0062c07befdf Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Oct 2025 17:22:12 +0100 Subject: [PATCH 05/29] Make constants more DRY --- src/components/structures/RoomView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 79f0735bbf5..2b1fee8322a 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -565,7 +565,7 @@ export class RoomView extends React.Component { const roomLoading = roomViewStore.isRoomLoading(); const joining = roomViewStore.isJoining(); const replyToEvent = roomViewStore.getQuotingEvent() ?? undefined; - const shouldPeek = roomViewStore.shouldPeek(); + const shouldPeek = this.state.matrixClientIsReady && roomViewStore.shouldPeek(); const wasContextSwitch = roomViewStore.getWasContextSwitch(); const promptAskToJoin = roomViewStore.promptAskToJoin(); const viewRoomOpts = roomViewStore.getViewRoomOpts(); @@ -579,7 +579,7 @@ export class RoomView extends React.Component { joining: joining, replyToEvent: replyToEvent, // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && shouldPeek, + shouldPeek: shouldPeek, showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), From f9d780c719f8d7c3a86e0adde890287d2af377bf Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 09:43:21 +0100 Subject: [PATCH 06/29] Commend non-null assertion on roomViewStore property of the RoomContext --- src/contexts/RoomContext.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index a6bf2b8461c..566e1e5d19b 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -75,6 +75,8 @@ const RoomContext = createContext< promptAskToJoin: false, viewRoomOpts: { buttons: [] }, isRoomEncrypted: null, + // roomViewStore should always be present as it is passed to RoomView constructor. + // In time when we migrate the RoomView to MVVM it will cease to exist(become a ViewModel). roomViewStore: undefined!, }); RoomContext.displayName = "RoomContext"; From 6fe4d02eb4a5b8047171ed46af012d9919330476 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 10:15:56 +0100 Subject: [PATCH 07/29] update ContentMessages.ts --- src/ContentMessages.ts | 2 +- src/components/structures/RoomView.tsx | 2 ++ src/components/structures/ThreadView.tsx | 1 + src/components/views/rooms/MessageComposerButtons.tsx | 3 ++- src/components/views/rooms/SendMessageComposer.tsx | 1 + src/components/views/rooms/wysiwyg_composer/hooks/utils.ts | 2 +- 6 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index bf39d9ea1fd..f08ab0a34d3 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -433,6 +433,7 @@ export default class ContentMessages { roomId: string, relation: IEventRelation | undefined, matrixClient: MatrixClient, + replyToEvent: MatrixEvent | undefined, context = TimelineRenderingType.Room, ): Promise { if (matrixClient.isGuest()) { @@ -440,7 +441,6 @@ export default class ContentMessages { return; } - const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent(); if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2b1fee8322a..540162caa53 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1124,6 +1124,7 @@ export class RoomView extends React.Component { roomId, undefined, this.context.client, + this.state.replyToEvent, ); } @@ -2043,6 +2044,7 @@ export class RoomView extends React.Component { roomId, undefined, this.context.client, + this.state.replyToEvent, TimelineRenderingType.Room, ); }; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 514fd9875ce..756a2975020 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -335,6 +335,7 @@ export default class ThreadView extends React.Component { roomId, this.threadRelation, MatrixClientPeg.safeGet(), + this.context.replyToEvent, TimelineRenderingType.Thread, ); } else { diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index af9b61dd0dc..a2514e541ef 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -170,7 +170,7 @@ interface IUploadButtonProps { // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); - const roomContext = useScopedRoomContext("timelineRenderingType"); + const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent"); const uploadInput = useRef(null); const onUploadClick = (): void => { @@ -196,6 +196,7 @@ const UploadButtonContextProvider: React.FC = ({ roomId, rel roomId, relation, cli, + roomContext.replyToEvent, roomContext.timelineRenderingType, ); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 51b14168df1..c481626be6a 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -566,6 +566,7 @@ export class SendMessageComposer extends React.Component Date: Wed, 22 Oct 2025 10:32:57 +0100 Subject: [PATCH 08/29] Add docs, change param order, remove unneeded import. --- src/ContentMessages.ts | 13 +++++++++++-- src/components/structures/RoomView.tsx | 4 ++-- src/components/structures/ThreadView.tsx | 2 +- .../views/rooms/MessageComposerButtons.tsx | 2 +- src/components/views/rooms/SendMessageComposer.tsx | 2 +- .../views/rooms/wysiwyg_composer/hooks/utils.ts | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index f08ab0a34d3..ebfc23d43a1 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -55,7 +55,6 @@ import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog" import { createThumbnail } from "./utils/image-media"; import { attachMentions, attachRelation } from "./utils/messages.ts"; import { doMaybeLocalRoomAction } from "./utils/local-room"; -import { SdkContextClass } from "./contexts/SDKContext"; import { blobIsAnimated } from "./utils/Image.ts"; // scraped out of a macOS hidpi (5660ppm) screenshot png @@ -428,12 +427,22 @@ export default class ContentMessages { return this.mediaConfig?.["m.upload.size"] ?? null; } + /** + * Sends a list of files to a room. + * @param files - The files to send. + * @param roomId - The ID of the room to send the files to. + * @param relation - The relation to the event being replied to. + * @param replyToEvent - The event being replied to, if any. + * @param matrixClient - The Matrix client to use for sending the files. + * @param context - The context in which the files are being sent. + * @returns A promise that resolves when the files have been sent. + */ public async sendContentListToRoom( files: File[], roomId: string, relation: IEventRelation | undefined, - matrixClient: MatrixClient, replyToEvent: MatrixEvent | undefined, + matrixClient: MatrixClient, context = TimelineRenderingType.Room, ): Promise { if (matrixClient.isGuest()) { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 540162caa53..5485214346a 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1123,8 +1123,8 @@ export class RoomView extends React.Component { [payload.file], roomId, undefined, - this.context.client, this.state.replyToEvent, + this.context.client, ); } @@ -2043,8 +2043,8 @@ export class RoomView extends React.Component { Array.from(dataTransfer.files), roomId, undefined, - this.context.client, this.state.replyToEvent, + this.context.client, TimelineRenderingType.Room, ); }; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 756a2975020..0d3c36c1105 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -334,8 +334,8 @@ export default class ThreadView extends React.Component { Array.from(dataTransfer.files), roomId, this.threadRelation, - MatrixClientPeg.safeGet(), this.context.replyToEvent, + MatrixClientPeg.safeGet(), TimelineRenderingType.Thread, ); } else { diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index a2514e541ef..d0c9e00ffc2 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -195,8 +195,8 @@ const UploadButtonContextProvider: React.FC = ({ roomId, rel Array.from(ev.target.files!), roomId, relation, - cli, roomContext.replyToEvent, + cli, roomContext.timelineRenderingType, ); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index c481626be6a..d352827c268 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -565,8 +565,8 @@ export class SendMessageComposer extends React.Component Date: Wed, 22 Oct 2025 11:00:38 +0100 Subject: [PATCH 09/29] update PlaybackQueue.ts --- src/audio/PlaybackQueue.ts | 24 +++++++++++++++---- .../views/messages/MVoiceMessageBody.tsx | 9 ++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index f5721df6cf4..025c9d25874 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -15,7 +15,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; import { PlaybackManager } from "./PlaybackManager"; import { isVoiceMessage } from "../utils/EventUtils"; -import { SdkContextClass } from "../contexts/SDKContext"; +import { type RoomViewStore } from "../stores/RoomViewStore"; /** * Audio playback queue management for a given room. This keeps track of where the user @@ -38,10 +38,18 @@ export class PlaybackQueue { private currentPlaybackId: string | null = null; // event ID, broken out from above for ease of use private recentFullPlays = new Set(); // event IDs - public constructor(private room: Room) { + /** + * Create a PlaybackQueue for a given room. + * @param room The room + * @param roomViewStore The RoomViewStore instance + */ + public constructor( + private room: Room, + private roomViewStore: RoomViewStore, + ) { this.loadClocks(); - SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { + this.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { if (!isActive) return; // Reset the state of the playbacks before they start mounting and enqueuing updates. @@ -53,14 +61,20 @@ export class PlaybackQueue { }); } - public static forRoom(roomId: string): PlaybackQueue { + /** + * Get the PlaybackQueue for a given room, creating it if necessary. + * @param roomId The ID of the room + * @param roomViewStore The RoomViewStore instance + * @returns The PlaybackQueue for the room + */ + public static forRoom(roomId: string, roomViewStore: RoomViewStore): PlaybackQueue { const cli = MatrixClientPeg.safeGet(); const room = cli.getRoom(roomId); if (!room) throw new Error("Unknown room"); if (PlaybackQueue.queues.has(room.roomId)) { return PlaybackQueue.queues.get(room.roomId)!; } - const queue = new PlaybackQueue(room); + const queue = new PlaybackQueue(room, roomViewStore); PlaybackQueue.queues.set(room.roomId, queue); return queue; } diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index 0f07c115764..89610091659 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -17,11 +17,18 @@ import MediaProcessingError from "./shared/MediaProcessingError"; import { isVoiceMessage } from "../../../utils/EventUtils"; import { PlaybackQueue } from "../../../audio/PlaybackQueue"; import { type Playback } from "../../../audio/Playback"; +import RoomContext from "../../../contexts/RoomContext"; export default class MVoiceMessageBody extends MAudioBody { + public static contextType = RoomContext; + declare public context: React.ContextType; + protected onMount(playback: Playback): void { if (isVoiceMessage(this.props.mxEvent)) { - PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback); + PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!, this.context.roomViewStore).unsortedEnqueue( + this.props.mxEvent, + playback, + ); } } From 97ffb7e19eae09e1726276e6256349f98cccaf44 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 11:06:12 +0100 Subject: [PATCH 10/29] Update SpaceHierarchy.tsx --- src/components/structures/SpaceHierarchy.tsx | 27 +++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index f460784dc67..9a6fd85bdbe 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -67,10 +67,11 @@ import { type JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomRea import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getTopic } from "../../hooks/room/useTopic"; -import { SdkContextClass } from "../../contexts/SDKContext"; import { getDisplayAliasForAliasSet } from "../../Rooms"; import SettingsStore from "../../settings/SettingsStore"; import { filterBoolean } from "../../utils/arrays.ts"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; +import RoomContext from "../../contexts/RoomContext.ts"; interface IProps { space: Room; @@ -404,7 +405,20 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st }); }; -export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise => { +/** + * Join a room. + * @param cli The Matrix client + * @param roomViewStore The RoomViewStore instance + * @param hierarchy The RoomHierarchy instance + * @param roomId The ID of the room to join + * @returns A promise that resolves when the room has been joined + */ +export const joinRoom = async ( + cli: MatrixClient, + roomViewStore: RoomViewStore, + hierarchy: RoomHierarchy, + roomId: string, +): Promise => { // Don't let the user view a room they won't be able to either peek or join: // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { @@ -418,10 +432,10 @@ export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, room }); } catch (err: unknown) { if (err instanceof MatrixError) { - SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); + roomViewStore.showJoinRoomError(err, roomId); } else { logger.warn("Got a non-MatrixError while joining room", err); - SdkContextClass.instance.roomViewStore.showJoinRoomError( + roomViewStore.showJoinRoomError( new MatrixError({ error: _t("error|unknown"), }), @@ -761,6 +775,7 @@ const ManageButtons: React.FC = ({ hierarchy, selected, set const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, additionalButtons }) => { const cli = useContext(MatrixClientContext); + const roomContext = useContext(RoomContext); const [query, setQuery] = useState(initialText); const [selected, setSelected] = useState(new Map>()); // Map> @@ -855,10 +870,10 @@ const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, a onJoinRoomClick={async (roomId, parents) => { for (const parent of parents) { if (cli.getRoom(parent)?.getMyMembership() !== KnownMembership.Join) { - await joinRoom(cli, hierarchy, parent); + await joinRoom(cli, roomContext.roomViewStore, hierarchy, parent); } } - await joinRoom(cli, hierarchy, roomId); + await joinRoom(cli, roomContext.roomViewStore, hierarchy, roomId); }} /> From 39604e9313dc77116103224c33c6cac085b24045 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 11:07:29 +0100 Subject: [PATCH 11/29] Update ThreadView.tsx --- src/components/structures/ThreadView.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 0d3c36c1105..8ddbbf6367e 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -49,7 +49,6 @@ import { type ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from "../views/typography/Heading"; -import { SdkContextClass } from "../../contexts/SDKContext"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; @@ -124,7 +123,7 @@ export default class ThreadView extends React.Component { const roomId = this.props.mxEvent.getRoomId(); SettingsStore.unwatchSetting(this.layoutWatcherRef); - const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId; + const hasRoomChanged = this.context.roomViewStore.getRoomId() !== roomId; if (this.props.initialEvent && !hasRoomChanged) { dis.dispatch({ action: Action.ViewRoom, From 0bbbba8b11c4060ed2eb954ca3f7bbeed87665e1 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 11:09:16 +0100 Subject: [PATCH 12/29] Update useStickyRoomList.tsx --- src/components/viewmodels/roomlist/useStickyRoomList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx index ad8a72b8b06..537dfb5e396 100644 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ b/src/components/viewmodels/roomlist/useStickyRoomList.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { SdkContextClass } from "../../../contexts/SDKContext"; import { useDispatcher } from "../../../hooks/useDispatcher"; import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -15,6 +14,7 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import type { Optional } from "matrix-events-sdk"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; function getIndexByRoomId(rooms: Room[], roomId: Optional): number | undefined { const index = rooms.findIndex((room) => room.roomId === roomId); @@ -87,8 +87,9 @@ export interface StickyRoomListResult { * @see {@link StickyRoomListResult} details what this hook returns.. */ export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult { + const { roomId } = useScopedRoomContext("roomId"); const [listState, setListState] = useState({ - activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()), + activeIndex: getIndexByRoomId(roomsResult.rooms, roomId), roomsResult: roomsResult, }); @@ -97,7 +98,7 @@ export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResul const updateRoomsAndIndex = useCallback( (newRoomId: string | null, isRoomChange: boolean = false) => { setListState((current) => { - const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); + const activeRoomId = newRoomId ?? roomId; const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId); const oldIndex = current.activeIndex; const { newIndex, newRooms } = getRoomsWithStickyRoom( From a43d8f7d94781ba06af1435c5916e22d95b68aa9 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 11:10:43 +0100 Subject: [PATCH 13/29] Update RoomCallBanner.tsx --- src/components/views/beacon/RoomCallBanner.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 7c2131d4b48..25109a0a5a6 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -20,7 +20,7 @@ import { useCall } from "../../../hooks/useCall"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; import { SessionDuration } from "../voip/CallDuration"; -import { SdkContextClass } from "../../../contexts/SDKContext"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; interface RoomCallBannerProps { roomId: Room["roomId"]; @@ -83,7 +83,7 @@ interface Props { const RoomCallBanner: React.FC = ({ roomId }) => { const call = useCall(roomId); - + const { roomViewStore } = useScopedRoomContext("roomViewStore"); // this section is to check if we have a live location share. If so, we dont show the call banner const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -100,7 +100,7 @@ const RoomCallBanner: React.FC = ({ roomId }) => { } // Check if the call is already showing. No banner is needed in this case. - if (SdkContextClass.instance.roomViewStore.isViewingCall()) { + if (roomViewStore.isViewingCall()) { return null; } From a7a1eb654b4886d353c8c9c37ca38146b75f45fe Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 13:54:05 +0100 Subject: [PATCH 14/29] Update DateSeparator.tsx --- src/components/views/messages/DateSeparator.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 9edb5fb94ef..3b9a1bc393b 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -31,8 +31,8 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import JumpToDatePicker from "./JumpToDatePicker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { SdkContextClass } from "../../../contexts/SDKContext"; import TimelineSeparator from "./TimelineSeparator"; +import RoomContext from "../../../contexts/RoomContext"; interface IProps { roomId: string; @@ -51,6 +51,8 @@ interface IState { * Has additional jump to date functionality when labs flag is enabled */ export default class DateSeparator extends React.Component { + public static contextType = RoomContext; + declare public context: React.ContextType; private settingWatcherRef?: string; public constructor(props: IProps) { @@ -143,7 +145,7 @@ export default class DateSeparator extends React.Component { // 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.context.roomViewStore.getRoomId(); if (currentRoomId === roomIdForJumpRequest) { dispatcher.dispatch({ action: Action.ViewRoom, @@ -169,7 +171,7 @@ export default class DateSeparator extends React.Component { // 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.context.roomViewStore.getRoomId(); if (currentRoomId === roomIdForJumpRequest) { let friendlyErrorMessage = "An error occured while trying to find and jump to the given date."; let submitDebugLogsContent: JSX.Element = <>; From 9ee4272423767910109e0655bad66f0c798fcd1c Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 13:55:41 +0100 Subject: [PATCH 15/29] Update TimelineCard.tsx --- src/components/views/right_panel/TimelineCard.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index 52366f099e7..7a56e7c15ef 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -37,7 +37,6 @@ import JumpToBottomButton from "../rooms/JumpToBottomButton"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from "../elements/Measured"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; -import { SdkContextClass } from "../../../contexts/SDKContext"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { @@ -88,7 +87,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[, , , value]) => this.setState({ showReadReceipts: value as boolean }), @@ -99,7 +98,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.context.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); SettingsStore.unwatchSetting(this.layoutWatcherRef); @@ -109,9 +108,9 @@ export default class TimelineCard extends React.Component { private onRoomViewStoreUpdate = async (_initial?: boolean): Promise => { const newState: Pick = { - initialEventId: SdkContextClass.instance.roomViewStore.getInitialEventId(), - isInitialEventHighlighted: SdkContextClass.instance.roomViewStore.isInitialEventHighlighted(), - replyToEvent: SdkContextClass.instance.roomViewStore.getQuotingEvent(), + initialEventId: this.context.roomViewStore.getInitialEventId(), + isInitialEventHighlighted: this.context.roomViewStore.isInitialEventHighlighted(), + replyToEvent: this.context.roomViewStore.getQuotingEvent(), }; this.setState(newState); From 555c9ae8af784d122648aeb82d4e9ad9558ee900 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 14:14:56 +0100 Subject: [PATCH 16/29] Update UserInfoBasicOptions --- .../user_info/UserInfoBasicOptionsViewModel.tsx | 14 +++++--------- .../user_info/UserInfoBasicOptionsView.tsx | 2 +- .../UserInfoBasicOptionsViewModel-test.tsx | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx index 6af49cbe6ad..ab45e194f44 100644 --- a/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx @@ -17,7 +17,6 @@ import PosthogTrackers from "../../../../PosthogTrackers"; import { ShareDialog } from "../../../views/dialogs/ShareDialog"; import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../../dispatcher/actions"; -import { SdkContextClass } from "../../../../contexts/SDKContext"; import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import MultiInviter from "../../../../utils/MultiInviter"; import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; @@ -41,7 +40,7 @@ export interface UserInfoBasicOptionsState { // Method called when a share user button is clicked, will display modal with profile to share onShareUserClick: () => void; // Method called when a invite button is clicked, will display modal to invite user - onInviteUserButton: (evt: Event) => Promise; + onInviteUserButton: (roomId: string, evt: Event) => Promise; // Method called when the DM button is clicked, will open a DM with the selected member onOpenDmForUser: (member: Member) => Promise; } @@ -91,15 +90,12 @@ export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | Room }); }; - const onInviteUserButton = async (ev: Event): Promise => { + const onInviteUserButton = async (roomId: string, ev: Event): Promise => { try { - const roomId = - member instanceof RoomMember && member.roomId - ? member.roomId - : SdkContextClass.instance.roomViewStore.getRoomId(); + const memberOrRoomRoomId = member instanceof RoomMember && member.roomId ? member.roomId : roomId; // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. - const inviter = new MultiInviter(cli, roomId || ""); + const inviter = new MultiInviter(cli, memberOrRoomRoomId || ""); await inviter.invite([member.userId]).then(() => { if (inviter.getCompletionState(member.userId) !== "invited") { const errorStringFromInviterUtility = inviter.getErrorText(member.userId); @@ -108,7 +104,7 @@ export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | Room } else { throw new UserFriendlyError("slash_command|invite_failed", { user: member.userId, - roomId, + memberOrRoomRoomId, cause: undefined, }); } diff --git a/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx index b14bd872787..cd80d77d857 100644 --- a/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx +++ b/src/components/views/right_panel/user_info/UserInfoBasicOptionsView.tsx @@ -88,7 +88,7 @@ export const UserInfoBasicOptionsView: React.FC<{ role="button" onSelect={async (ev) => { ev.preventDefault(); - vm.onInviteUserButton(ev); + vm.onInviteUserButton(room.roomId, ev); }} label={_t("action|invite")} Icon={InviteIcon} diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx index fba632199e1..d3f731e20c6 100644 --- a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel-test.tsx @@ -204,7 +204,7 @@ describe("", () => { jest.spyOn(Modal, "createDialog"); const { result } = renderUserInfoBasicOptionsViewModelHook(); - result.current.onInviteUserButton(new Event("click")); + result.current.onInviteUserButton("roomId", new Event("click")); // check that we have called .invite expect(spy).toHaveBeenCalledWith([defaultMember.userId]); From 2c47f7d5f91c5b5053a465177fe72ff98da79d9f Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 14:18:01 +0100 Subject: [PATCH 17/29] Update useRoomCall.tsx --- src/hooks/room/useRoomCall.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 92f68d02f83..9f724304925 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -26,7 +26,6 @@ import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget, isManagedHybridWidgetEnabled } from "../../widgets/ManagedHybrid"; import { type IApp } from "../../stores/WidgetStore"; -import { SdkContextClass } from "../../contexts/SDKContext"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; @@ -37,6 +36,7 @@ import { UIFeature } from "../../settings/UIFeature"; import { type InteractionName } from "../../PosthogTrackers"; import { ElementCallMemberEventType } from "../../call-types"; import { LocalRoom, LocalRoomState } from "../../models/LocalRoom"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext"; export enum PlatformCallType { ElementCall, @@ -98,6 +98,7 @@ export const useRoomCall = ( showVideoCallButton: boolean; showVoiceCallButton: boolean; } => { + const roomViewStore = useScopedRoomContext("roomViewStore").roomViewStore; // settings const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const widgetsFeatureEnabled = useSettingValue(UIFeature.Widgets); @@ -124,9 +125,9 @@ export const useRoomCall = ( const hasGroupCall = groupCall !== null; const hasActiveCallSession = useParticipantCount(groupCall) > 0; const isViewingCall = useEventEmitterState( - SdkContextClass.instance.roomViewStore, + roomViewStore, UPDATE_EVENT, - () => SdkContextClass.instance.roomViewStore.isViewingCall() || isVideoRoom(room), + () => roomViewStore.isViewingCall() || isVideoRoom(room), ); // room From 51c6b205d5b08c438f7cbc41673fef82881d5535 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 22 Oct 2025 15:22:13 +0100 Subject: [PATCH 18/29] Update slask-commands/utils.ts --- src/autocomplete/CommandProvider.tsx | 7 ++++--- src/slash-commands/command.ts | 8 ++++---- src/slash-commands/utils.ts | 3 +-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index 76d53bb8655..41781c0a681 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -25,7 +25,7 @@ const COMMAND_RE = /(^\/\w*)(?: .*)?/g; export default class CommandProvider extends AutocompleteProvider { public matcher: QueryMatcher; - + private room: Room; public constructor(room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: COMMAND_RE, renderingType }); this.matcher = new QueryMatcher(Commands, { @@ -33,6 +33,7 @@ export default class CommandProvider extends AutocompleteProvider { funcs: [({ aliases }) => aliases.join(" ")], // aliases context: renderingType, }); + this.room = room; } public async getCompletions( @@ -51,7 +52,7 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].slice(1); // strip leading `/` - if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled(cli)) { + if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled(cli, this.room.roomId)) { // some commands, namely `me` don't suit having the usage shown whilst typing their arguments if (CommandMap.get(name)!.hideCompletionAfterSpace) return []; matches = [CommandMap.get(name)!]; @@ -70,7 +71,7 @@ export default class CommandProvider extends AutocompleteProvider { return matches .filter((cmd) => { const display = !cmd.renderingTypes || cmd.renderingTypes.includes(this.renderingType); - return cmd.isEnabled(cli) && display; + return cmd.isEnabled(cli, this.room.roomId) && display; }) .map((result) => { let completion = result.getCommand() + " "; diff --git a/src/slash-commands/command.ts b/src/slash-commands/command.ts index 029d42db45b..ed18ae58e7b 100644 --- a/src/slash-commands/command.ts +++ b/src/slash-commands/command.ts @@ -35,7 +35,7 @@ interface ICommandOpts { runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; - isEnabled?(matrixClient: MatrixClient | null): boolean; + isEnabled?(matrixClient: MatrixClient | null, roomId: string | null): boolean; renderingTypes?: TimelineRenderingType[]; } @@ -49,7 +49,7 @@ export class Command { public readonly hideCompletionAfterSpace: boolean; public readonly renderingTypes?: TimelineRenderingType[]; public readonly analyticsName?: SlashCommandEvent["command"]; - private readonly _isEnabled?: (matrixClient: MatrixClient | null) => boolean; + private readonly _isEnabled?: (matrixClient: MatrixClient | null, roomId: string | null) => boolean; public constructor(opts: ICommandOpts) { this.command = opts.command; @@ -102,7 +102,7 @@ export class Command { return _t("slash_command|usage") + ": " + this.getCommandWithArgs(); } - public isEnabled(cli: MatrixClient | null): boolean { - return this._isEnabled?.(cli) ?? true; + public isEnabled(cli: MatrixClient | null, roomId: string | null): boolean { + return this._isEnabled?.(cli, roomId) ?? true; } } diff --git a/src/slash-commands/utils.ts b/src/slash-commands/utils.ts index d9cf15d16a2..0f019bac60c 100644 --- a/src/slash-commands/utils.ts +++ b/src/slash-commands/utils.ts @@ -29,8 +29,7 @@ export function successSync(value: any): RunResult { return success(Promise.resolve(value)); } -export const canAffectPowerlevels = (cli: MatrixClient | null): boolean => { - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); +export const canAffectPowerlevels = (cli: MatrixClient | null, roomId: string | null): boolean => { if (!cli || !roomId) return false; const room = cli?.getRoom(roomId); return !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getSafeUserId()) && !isLocalRoom(room); From 7354e764ed71ea8b4808c0dcf31787ff538f258b Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 12:43:24 +0100 Subject: [PATCH 19/29] add roomId callback dependencies --- src/components/viewmodels/roomlist/useStickyRoomList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx index 537dfb5e396..967a95e13c7 100644 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ b/src/components/viewmodels/roomlist/useStickyRoomList.tsx @@ -110,7 +110,7 @@ export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResul return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } }; }); }, - [roomsResult], + [roomsResult, roomId], ); // Re-calculate the index when the active room has changed. From 90de9637d41f1932ca4434663fc4f7835c246d67 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 15:37:52 +0100 Subject: [PATCH 20/29] SlashCommands iterate --- src/SlashCommands.tsx | 9 ++++---- .../views/dialogs/SlashCommandHelpDialog.tsx | 5 +++-- .../views/rooms/BasicMessageComposer.tsx | 5 ++++- .../views/rooms/EditMessageComposer.tsx | 2 +- .../views/rooms/SendMessageComposer.tsx | 2 +- .../rooms/wysiwyg_composer/utils/message.ts | 2 +- src/editor/commands.tsx | 4 ++-- test/unit-tests/SlashCommands-test.tsx | 22 +++++++++---------- 8 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index f0d9085507b..cf87bc79d0e 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -732,8 +732,8 @@ export const Commands = [ new Command({ command: "help", description: _td("slash_command|help"), - runFn: function () { - Modal.createDialog(SlashCommandHelpDialog); + runFn: function (cli, roomId, threadId, args) { + Modal.createDialog(SlashCommandHelpDialog, { roomId }); return success(); }, category: CommandCategories.advanced, @@ -967,14 +967,15 @@ interface ICmd { /** * Process the given text for /commands and returns a parsed command that can be used for running the operation. + * @param {string} roomId The room ID where the command was issued. * @param {string} input The raw text input by the user. * @return {ICmd} The parsed command object. * Returns an empty object if the input didn't match a command. */ -export function getCommand(input: string): ICmd { +export function getCommand(roomId: string, input: string): ICmd { const { cmd, args } = parseCommandString(input); - if (cmd && CommandMap.has(cmd) && CommandMap.get(cmd)!.isEnabled(MatrixClientPeg.get())) { + if (cmd && CommandMap.has(cmd) && CommandMap.get(cmd)!.isEnabled(MatrixClientPeg.get(), roomId)) { return { cmd: CommandMap.get(cmd), args, diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.tsx b/src/components/views/dialogs/SlashCommandHelpDialog.tsx index 819d28513ef..f3059d278e8 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.tsx +++ b/src/components/views/dialogs/SlashCommandHelpDialog.tsx @@ -14,13 +14,14 @@ import InfoDialog from "./InfoDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps { + roomId: string; onFinished(): void; } -const SlashCommandHelpDialog: React.FC = ({ onFinished }) => { +const SlashCommandHelpDialog: React.FC = ({ roomId, onFinished }) => { const categories: Record = {}; Commands.forEach((cmd) => { - if (!cmd.isEnabled(MatrixClientPeg.get())) return; + if (!cmd.isEnabled(MatrixClientPeg.get(), roomId)) return; if (!categories[cmd.category]) { categories[cmd.category] = []; } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 23111939c2e..9aee26072e5 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -244,7 +244,10 @@ export default class BasicMessageEditor extends React.Component if (isTyping && this.props.model.parts[0].type === "command") { const { cmd } = parseCommandString(this.props.model.parts[0].text); const command = CommandMap.get(cmd!); - if (!command?.isEnabled(MatrixClientPeg.get()) || command.category !== CommandCategories.messages) { + if ( + !command?.isEnabled(MatrixClientPeg.get(), this.props.room.roomId) || + command.category !== CommandCategories.messages + ) { isTyping = false; } } diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index f6677286b8e..51cd5093bf5 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -312,7 +312,7 @@ class EditMessageComposer extends React.Component { // use mxid to textify user pills in a command and room alias/id for room pills if (part.type === Type.UserPill || part.type === Type.RoomPill) { @@ -46,7 +46,7 @@ export function getSlashCommand(model: EditorModel): [Command | undefined, strin } return text + part.text; }, ""); - const { cmd, args } = getCommand(commandText); + const { cmd, args } = getCommand(roomId, commandText); return [cmd, args, commandText]; } diff --git a/test/unit-tests/SlashCommands-test.tsx b/test/unit-tests/SlashCommands-test.tsx index bf8d30c3dd7..1f583138c40 100644 --- a/test/unit-tests/SlashCommands-test.tsx +++ b/test/unit-tests/SlashCommands-test.tsx @@ -66,7 +66,7 @@ describe("SlashCommands", () => { describe("/topic", () => { it("sets topic", async () => { - const command = getCommand("/topic pizza"); + const command = getCommand(roomId, "/topic pizza"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); await command.cmd!.run(client, "room-id", null, command.args); @@ -75,7 +75,7 @@ describe("SlashCommands", () => { it("should show topic modal if no args passed", async () => { const spy = jest.spyOn(Modal, "createDialog"); - const command = getCommand("/topic")!; + const command = getCommand(roomId, "/topic")!; await command.cmd!.run(client, roomId, null); expect(spy).toHaveBeenCalled(); }); @@ -109,12 +109,12 @@ describe("SlashCommands", () => { describe("isEnabled", () => { it("should return true for Room", () => { setCurrentRoom(); - expect(command.isEnabled(client)).toBe(true); + expect(command.isEnabled(client, roomId)).toBe(true); }); it("should return false for LocalRoom", () => { setCurrentLocalRoom(); - expect(command.isEnabled(client)).toBe(false); + expect(command.isEnabled(client, roomId)).toBe(false); }); }); }); @@ -126,7 +126,7 @@ describe("SlashCommands", () => { }); it("should be enabled by default", () => { - expect(command.isEnabled(client)).toBe(true); + expect(command.isEnabled(client, roomId)).toBe(true); }); }); @@ -199,11 +199,11 @@ describe("SlashCommands", () => { room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar"); mocked(client.getRooms).mockReturnValue([room1, room2]); - const command = getCommand("/part #foo:bar"); + const command = getCommand(room1.roomId, "/part #foo:bar"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); - await command.cmd!.run(client, "room-id", null, command.args); - expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything()); + await command.cmd!.run(client, room1.roomId, null, command.args); + expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything()); }); it("should part room matching alt alias if found", async () => { @@ -213,11 +213,11 @@ describe("SlashCommands", () => { room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]); mocked(client.getRooms).mockReturnValue([room1, room2]); - const command = getCommand("/part #foo:bar"); + const command = getCommand(room1.roomId, "/part #foo:bar"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); - await command.cmd!.run(client, "room-id", null, command.args!); - expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything()); + await command.cmd!.run(client, room1.roomId, null, command.args!); + expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything()); }); }); From 0d9aea4b31e7d9cf997dd41f10a74d578e398d33 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 15:38:05 +0100 Subject: [PATCH 21/29] lint --- .../views/rooms/wysiwyg_composer/hooks/utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 606a1108896..0a34cf0940b 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -148,7 +148,14 @@ export function handleClipboardEvent( // it puts the filename in as text/plain which we want to ignore. if (data.files.length && !data.types.includes("text/rtf")) { ContentMessages.sharedInstance() - .sendContentListToRoom(Array.from(data.files), room.roomId, eventRelation, roomContext.replyToEvent, mxClient, timelineRenderingType) + .sendContentListToRoom( + Array.from(data.files), + room.roomId, + eventRelation, + roomContext.replyToEvent, + mxClient, + timelineRenderingType, + ) .catch(handleError); return true; } From fc4705a510d5ba507403e7aba28c60164571a2b2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 15:51:41 +0100 Subject: [PATCH 22/29] Update PlaybackQueue, MVoiceMessageBody and UserInfoBasicOptionsView tests. --- test/unit-tests/audio/PlaybackQueue-test.ts | 5 +++-- .../components/views/messages/MVoiceMessageBody-test.tsx | 3 ++- .../right_panel/user_info/UserInfoBasicOptionsView-test.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/unit-tests/audio/PlaybackQueue-test.ts b/test/unit-tests/audio/PlaybackQueue-test.ts index 59e4cd9ab4b..15bad793453 100644 --- a/test/unit-tests/audio/PlaybackQueue-test.ts +++ b/test/unit-tests/audio/PlaybackQueue-test.ts @@ -12,6 +12,7 @@ import { PlaybackQueue } from "../../../src/audio/PlaybackQueue"; import { type Playback, PlaybackState } from "../../../src/audio/Playback"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { MockedPlayback } from "./MockedPlayback"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; describe("PlaybackQueue", () => { let playbackQueue: PlaybackQueue; @@ -21,7 +22,7 @@ describe("PlaybackQueue", () => { mockRoom = { getMember: jest.fn(), } as unknown as Mocked; - playbackQueue = new PlaybackQueue(mockRoom); + playbackQueue = new PlaybackQueue(mockRoom, SdkContextClass.instance.roomViewStore); }); it.each([ @@ -75,7 +76,7 @@ describe("PlaybackQueue", () => { `mx_voice_message_clocks_${mockRoom.roomId}`, JSON.stringify(Array.from(clockStates.entries())), ); - playbackQueue = new PlaybackQueue(mockRoom); + playbackQueue = new PlaybackQueue(mockRoom, SdkContextClass.instance.roomViewStore); // @ts-ignore expect(playbackQueue.clockStates.has("a")).toBe(true); diff --git a/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx b/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx index 4a20266af4e..d31c6f7390e 100644 --- a/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx +++ b/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx @@ -16,6 +16,7 @@ import type { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper import MVoiceMessageBody from "../../../../../src/components/views/messages/MVoiceMessageBody"; import { PlaybackQueue } from "../../../../../src/audio/PlaybackQueue"; import { createTestClient } from "../../../../test-utils"; +import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; describe("", () => { let event: MatrixEvent; @@ -25,7 +26,7 @@ describe("", () => { const matrixClient = createTestClient(); const room = new Room("!TESTROOM", matrixClient, "@alice:example.org"); - const playbackQueue = new PlaybackQueue(room); + const playbackQueue = new PlaybackQueue(room, SdkContextClass.instance.roomViewStore); jest.spyOn(PlaybackQueue, "forRoom").mockReturnValue(playbackQueue); jest.spyOn(playbackQueue, "unsortedEnqueue").mockReturnValue(undefined); diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx index 3047541e651..302f87bd052 100644 --- a/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoBasicOptionsView-test.tsx @@ -40,7 +40,7 @@ describe("", () => { onInsertPillButton: () => jest.fn(), onReadReceiptButton: () => jest.fn(), onShareUserClick: () => jest.fn(), - onInviteUserButton: (evt: Event) => Promise.resolve(), + onInviteUserButton: (roomId: string, evt: Event) => Promise.resolve(), onOpenDmForUser: (member: Member) => Promise.resolve(), }; From 15a846ab2e6e45284a4827111c311792457574ce Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 16:41:24 +0100 Subject: [PATCH 23/29] Fix DateSeparator test --- .../views/messages/DateSeparator-test.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 190872feeab..4bca216f2de 100644 --- a/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ b/test/unit-tests/components/views/messages/DateSeparator-test.tsx @@ -14,7 +14,6 @@ import { type TimestampToEventResponse, ConnectionError, HTTPError, MatrixError import dispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { type ViewRoomPayload } from "../../../../../src/dispatcher/payloads/ViewRoomPayload"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; import { formatFullDateNoTime } from "../../../../../src/DateUtils"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { UIFeature } from "../../../../../src/settings/UIFeature"; @@ -26,6 +25,9 @@ import { waitEnoughCyclesForModal, } from "../../../../test-utils"; import DateSeparator from "../../../../../src/components/views/messages/DateSeparator"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; +import { IRoomState } from "../../../../../src/components/structures/RoomView"; +import RoomContext from "../../../../../src/contexts/RoomContext"; jest.mock("../../../../../src/settings/SettingsStore"); @@ -40,13 +42,25 @@ describe("DateSeparator", () => { roomId, }; + const mockRoomViewStore = { + getRoomId: jest.fn().mockReturnValue(roomId), + }; + + const defaultRoomContext = { + ...RoomContext, + roomId, + roomViewStore: mockRoomViewStore, + } as unknown as IRoomState; + const mockClient = getMockClientWithEventEmitter({ timestampToEvent: jest.fn(), }); const getComponent = (props = {}) => render( - + + + , ); @@ -74,7 +88,7 @@ describe("DateSeparator", () => { return true; } }); - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(roomId); + mockRoomViewStore.getRoomId.mockReturnValue(roomId); }); afterAll(() => { @@ -200,7 +214,7 @@ describe("DateSeparator", () => { // network request is taking a while, so we got bored, switched rooms; we // shouldn't jump back to the previous room after the network request // happens to finish later. - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!some-other-room"); + mockRoomViewStore.getRoomId.mockReturnValue("!some-other-room"); // Jump to "last week" mockClient.timestampToEvent.mockResolvedValue({ @@ -230,7 +244,7 @@ describe("DateSeparator", () => { // network request is taking a while, so we got bored, switched rooms; we // shouldn't jump back to the previous room after the network request // happens to finish later. - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!some-other-room"); + mockRoomViewStore.getRoomId.mockReturnValue("!some-other-room"); // Try to jump to "last week" but we want an error to occur and ensure that // we don't show an error dialog for it since we already switched away to From 4cde0bd1769f1b90d2a184277989f6840c29742e Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 16:59:32 +0100 Subject: [PATCH 24/29] Update RoomHeader-test.tsx --- .../rooms/RoomHeader/RoomHeader-test.tsx | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index b0ffb4f1fee..86c4a9f352b 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -40,6 +40,9 @@ import { filterConsole, stubClient } from "../../../../../test-utils"; import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader"; import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext"; +import { type IRoomState } from "../../../../../../src/components/structures/RoomView"; +import RoomContext from "../../../../../../src/contexts/RoomContext"; import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases"; import LegacyCallHandler from "../../../../../../src/LegacyCallHandler"; @@ -52,7 +55,6 @@ import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils"; import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore"; import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; import { _t } from "../../../../../../src/languageHandler"; -import { SdkContextClass } from "../../../../../../src/contexts/SDKContext"; import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore"; import { UIFeature } from "../../../../../../src/settings/UIFeature"; import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; @@ -65,14 +67,6 @@ jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({ }, })); -function getWrapper(): RenderOptions { - return { - wrapper: ({ children }) => ( - {children} - ), - }; -} - describe("RoomHeader", () => { filterConsole( "[getType] Room !1:example.org does not have an m.room.create event", @@ -84,6 +78,25 @@ describe("RoomHeader", () => { let setCardSpy: jest.SpyInstance | undefined; + const mockRoomViewStore = { + isViewingCall: jest.fn().mockReturnValue(false), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }; + + let roomContext: IRoomState; + + function getWrapper(): RenderOptions { + return { + wrapper: ({ children }) => ( + + {children} + + ), + }; + } + beforeEach(async () => { stubClient(); room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@alice:example.org", { @@ -99,6 +112,16 @@ describe("RoomHeader", () => { // Mock CallStore.instance.getCall to return null by default // Individual tests can override this when they need a specific Call object jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); + + // Reset the mock RoomViewStore + mockRoomViewStore.isViewingCall.mockReturnValue(false); + + // Create a stable room context for this test + roomContext = { + ...RoomContext, + roomId: ROOM_ID, + roomViewStore: mockRoomViewStore, + } as unknown as IRoomState; }); afterEach(() => { @@ -581,7 +604,7 @@ describe("RoomHeader", () => { it("close lobby button is shown", async () => { mockRoomMembers(room, 3); - jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true); + mockRoomViewStore.isViewingCall.mockReturnValue(true); render(, getWrapper()); getByLabelText(document.body, "Close lobby"); }); @@ -590,21 +613,21 @@ describe("RoomHeader", () => { mockRoomMembers(room, 3); // Mock CallStore to return a call with 3 participants jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); - jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true); + mockRoomViewStore.isViewingCall.mockReturnValue(true); render(, getWrapper()); getByLabelText(document.body, "Close lobby"); }); it("don't show external conference button if the call is not shown", () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(false); + mockRoomViewStore.isViewingCall.mockReturnValue(false); jest.spyOn(SdkConfig, "get").mockImplementation((key) => { return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" }; }); render(, getWrapper()); expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument(); - jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true); + mockRoomViewStore.isViewingCall.mockReturnValue(true); render(, getWrapper()); From 0621fc33f5c56dd12f88391691494b668c05e68e Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 17:03:01 +0100 Subject: [PATCH 25/29] Update RoomCallBanner-test.tsx --- .../views/beacon/RoomCallBanner-test.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 38cb77eb03f..908f8bdf6fe 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -30,7 +30,9 @@ import { CallStore } from "../../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; +import { type IRoomState } from "../../../../../src/components/structures/RoomView"; +import RoomContext from "../../../../../src/contexts/RoomContext"; describe("", () => { let client: Mocked; @@ -42,6 +44,15 @@ describe("", () => { roomId: "!1:example.org", }; + const mockRoomViewStore = { + isViewingCall: jest.fn().mockReturnValue(false), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }; + + let roomContext: IRoomState; + beforeEach(() => { stubClient(); @@ -59,6 +70,16 @@ describe("", () => { setupAsyncStoreWithClient(CallStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); + + // Reset the mock RoomViewStore + mockRoomViewStore.isViewingCall.mockReturnValue(false); + + // Create a stable room context for this test + roomContext = { + ...RoomContext, + roomId: room.roomId, + roomViewStore: mockRoomViewStore, + } as unknown as IRoomState; }); afterEach(async () => { @@ -66,7 +87,11 @@ describe("", () => { }); const renderBanner = async (props = {}): Promise => { - render(); + render( + + + , + ); await act(() => Promise.resolve()); // Let effects settle }; @@ -117,8 +142,7 @@ describe("", () => { }); it("doesn't show banner if the call is shown", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall"); - mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true); + mockRoomViewStore.isViewingCall.mockReturnValue(true); await renderBanner(); const banner = await screen.queryByText("Video call"); expect(banner).toBeFalsy(); From 0a41186632d7f8443ab4b65f7bb62b298788df1f Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 17:08:33 +0100 Subject: [PATCH 26/29] lint --- .../unit-tests/components/views/messages/DateSeparator-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 4bca216f2de..30e48305bea 100644 --- a/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ b/test/unit-tests/components/views/messages/DateSeparator-test.tsx @@ -26,7 +26,7 @@ import { } from "../../../../test-utils"; import DateSeparator from "../../../../../src/components/views/messages/DateSeparator"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; +import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import RoomContext from "../../../../../src/contexts/RoomContext"; jest.mock("../../../../../src/settings/SettingsStore"); From 484f87d672303b5aeb85f6a4fe24e3475715d702 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 17:24:43 +0100 Subject: [PATCH 27/29] Add ts docs --- .../views/dialogs/SlashCommandHelpDialog.tsx | 5 +++++ src/editor/commands.tsx | 6 ++++++ src/slash-commands/command.ts | 21 +++++++++++++++++++ src/slash-commands/utils.ts | 6 ++++++ 4 files changed, 38 insertions(+) diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.tsx b/src/components/views/dialogs/SlashCommandHelpDialog.tsx index f3059d278e8..0ac1a0de0d7 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.tsx +++ b/src/components/views/dialogs/SlashCommandHelpDialog.tsx @@ -13,6 +13,11 @@ import { type Command, CommandCategories, Commands } from "../../../SlashCommand import InfoDialog from "./InfoDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +/** + * Props for {@link SlashCommandHelpDialog} + * @param roomId - The room ID to check whether commands are enabled + * @param onFinished - Callback called when the dialog is closed + */ interface IProps { roomId: string; onFinished(): void; diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx index ecd12b6e0b0..63467857558 100644 --- a/src/editor/commands.tsx +++ b/src/editor/commands.tsx @@ -38,6 +38,12 @@ export function isSlashCommand(model: EditorModel): boolean { return false; } +/** + * Get the slash command and its arguments from the editor model + * @param roomId - The room ID to check whether the command is enabled + * @param model - The editor model + * @returns A tuple of the command (or undefined if not found), the arguments (or undefined), and the full command text + */ export function getSlashCommand(roomId: string, model: EditorModel): [Command | undefined, string | undefined, string] { const commandText = model.parts.reduce((text, part) => { // use mxid to textify user pills in a command and room alias/id for room pills diff --git a/src/slash-commands/command.ts b/src/slash-commands/command.ts index ed18ae58e7b..9619c514dd8 100644 --- a/src/slash-commands/command.ts +++ b/src/slash-commands/command.ts @@ -18,6 +18,14 @@ import { _t, type TranslationKey, UserFriendlyError } from "../languageHandler"; import { PosthogAnalytics } from "../PosthogAnalytics"; import { CommandCategories, type RunResult } from "./interface"; +/** + * The function signature for the run function of a {@link Command} + * @param matrixClient - The Matrix client + * @param roomId - The room ID where the command is run + * @param threadId - The thread ID where the command is run, or null for room timeline + * @param args - The arguments passed to the command + * @returns The result of running the command + */ type RunFn = ( this: Command, matrixClient: MatrixClient, @@ -26,6 +34,19 @@ type RunFn = ( args?: string, ) => RunResult; +/** + * Options for {@link Command} + * @param command - The command name, e.g. "me" for the /me command + * @param aliases - Alternative names for the command + * @param args - The arguments for the command, e.g. "" for the /me command + * @param description - A translation key describing the command + * @param analyticsName - The name to use for analytics tracking + * @param runFn - The function to execute when the command is run + * @param category - The category of the command, e.g. CommandCategories.emoji + * @param hideCompletionAfterSpace - Whether to hide autocomplete after a space is typed + * @param isEnabled - A function to determine if the command is enabled in a given context + * @param renderingTypes - The rendering types (room/thread) where this command is valid + */ interface ICommandOpts { command: string; aliases?: string[]; diff --git a/src/slash-commands/utils.ts b/src/slash-commands/utils.ts index 0f019bac60c..a96a324d5d6 100644 --- a/src/slash-commands/utils.ts +++ b/src/slash-commands/utils.ts @@ -29,6 +29,12 @@ export function successSync(value: any): RunResult { return success(Promise.resolve(value)); } +/** + * Check whether the user can affect power levels in the given room + * @param cli - The Matrix client + * @param roomId - The room ID + * @returns True if the user can affect power levels, false otherwise + */ export const canAffectPowerlevels = (cli: MatrixClient | null, roomId: string | null): boolean => { if (!cli || !roomId) return false; const room = cli?.getRoom(roomId); From 691b0afb0a45399adb7b4ba8f99c63ccd91f8995 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 17:40:04 +0100 Subject: [PATCH 28/29] Update utils-test.tsx --- .../views/rooms/wysiwyg_composer/hooks/utils-test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx index f47c8075fd9..8ee33028241 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx @@ -85,12 +85,13 @@ describe("handleClipboardEvent", () => { clipboardData: { files: ["something here"], types: [] }, }); const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - + const mockReplyToEvent = {} as unknown as MatrixEvent; expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); expect(sendContentListToRoomSpy).toHaveBeenCalledWith( originalEvent.clipboardData?.files, mockRoom.roomId, undefined, // this is the event relation, an optional arg + mockReplyToEvent, mockClient, mockRoomState.timelineRenderingType, ); @@ -103,6 +104,7 @@ describe("handleClipboardEvent", () => { clipboardData: { files: ["something here"], types: [] }, }); const mockEventRelation = {} as unknown as IEventRelation; + const mockReplyToEvent = {} as unknown as MatrixEvent; const output = handleClipboardEvent( originalEvent, originalEvent.clipboardData, @@ -116,6 +118,7 @@ describe("handleClipboardEvent", () => { originalEvent.clipboardData?.files, mockRoom.roomId, mockEventRelation, // this is the event relation, an optional arg + mockReplyToEvent, mockClient, mockRoomState.timelineRenderingType, ); From 0a53aedada857ebea594837a52a8799597548ba4 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 23 Oct 2025 17:50:06 +0100 Subject: [PATCH 29/29] Update RoomListViewModel-test.tsx --- .../roomlist/RoomListViewModel-test.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx index b6c18a25455..5ab4f965042 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { range } from "lodash"; import { act, renderHook, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; +import React from "react"; import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3"; import { mkStubRoom } from "../../../../test-utils"; @@ -16,9 +17,9 @@ import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filt import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils"; import dispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../../../../src/stores/spaces"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({ hasCreateRoomRights: jest.fn().mockReturnValue(false), @@ -179,6 +180,15 @@ describe("RoomListViewModel", () => { expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId); } + function renderViewModel(roomId: string | undefined) { + const roomContext = { roomId } as any; + return renderHook(() => useRoomListViewModel(), { + wrapper: ({ children }) => ( + {children} + ), + }); + } + it("active index is calculated with the last opened room in a space", () => { // Let's say there's two spaces: !space1:matrix.org and !space2:matrix.org // Let's also say that the current active space is !space1:matrix.org @@ -196,9 +206,8 @@ describe("RoomListViewModel", () => { // Let's say that the room at index 4 is currently active const roomId = rooms[4].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expect(vm.current.activeIndex).toEqual(4); // Let's say that space is changed to "!space2:matrix.org" @@ -221,9 +230,8 @@ describe("RoomListViewModel", () => { // Let's say that the room at index 5 is active const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expect(vm.current.activeIndex).toEqual(5); // Let's say that room at index 9 moves to index 5 @@ -248,9 +256,8 @@ describe("RoomListViewModel", () => { it("active room and active index are updated when another room is opened", () => { const { rooms } = mockAndCreateRooms(); const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expectActiveRoom(vm.current, 5, roomId); // Let's say that room at index 9 becomes active @@ -274,9 +281,8 @@ describe("RoomListViewModel", () => { const { rooms } = mockAndCreateRooms(); // Let's say that the room at index 5 is active const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expectActiveRoom(vm.current, 5, roomId); // Let's say that we remove rooms from the start of the array @@ -298,9 +304,8 @@ describe("RoomListViewModel", () => { const { rooms } = mockAndCreateRooms(); // Let's say that the room at index 5 is active const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expectActiveRoom(vm.current, 5, roomId); // Let's say that we remove rooms from the start of the array @@ -316,9 +321,8 @@ describe("RoomListViewModel", () => { const { rooms } = mockAndCreateRooms(); // Let's say that the room at index 5 is active let roomId: string | undefined = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(roomId); expectActiveRoom(vm.current, 5, roomId); // Let's remove the active room (i.e room at index 5) @@ -332,9 +336,7 @@ describe("RoomListViewModel", () => { mockAndCreateRooms(); // Let's say that there's no active room currently - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => undefined); - - const { result: vm } = renderHook(() => useRoomListViewModel()); + const { result: vm } = renderViewModel(undefined); expect(vm.current.activeIndex).toEqual(undefined); }); });