Use context provided RoomViewStore within the RoomView component hierarchy (#31077)

* Update ContentMessages.ts

Update ContentMessages.ts

* update PlaybackQueue.ts

* Update SpaceHierarchy.tsx

* Update ThreadView.tsx

* Update RoomCallBanner.tsx

* Update useRoomCall.tsx

* Update DateSeparator.tsx

* Update TimelineCard.tsx

* Update UserInfoBasicOptions

* Update slask-commands/utils.ts

* lint

* Update PlaybackQueue, MVoiceMessageBody and UserInfoBasicOptionsView tests.

* Update RoomHeader-test.tsx

* lint

* Add ts docs

* Update utils-test.tsx

* Update message-test.ts

* coverage

* lint

* Improve naming

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
David Langley
2025-10-29 09:40:21 +00:00
committed by GitHub
parent 209dfece21
commit ae2acdf311
38 changed files with 520 additions and 104 deletions

View File

@@ -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("<RoomCallBanner />", () => {
let client: Mocked<MatrixClient>;
@@ -42,6 +44,15 @@ describe("<RoomCallBanner />", () => {
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("<RoomCallBanner />", () => {
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("<RoomCallBanner />", () => {
});
const renderBanner = async (props = {}): Promise<void> => {
render(<RoomCallBanner {...defaultProps} {...props} />);
render(
<ScopedRoomContextProvider {...roomContext}>
<RoomCallBanner {...defaultProps} {...props} />
</ScopedRoomContextProvider>,
);
await act(() => Promise.resolve()); // Let effects settle
};
@@ -117,8 +142,7 @@ describe("<RoomCallBanner />", () => {
});
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();

View File

@@ -0,0 +1,90 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import SlashCommandHelpDialog from "../../../../../src/components/views/dialogs/SlashCommandHelpDialog";
import { stubClient } from "../../../../test-utils";
import { Command } from "../../../../../src/slash-commands/command";
import { CommandCategories } from "../../../../../src/slash-commands/interface";
import { _t, _td } from "../../../../../src/languageHandler";
import * as SlashCommands from "../../../../../src/SlashCommands";
describe("SlashCommandHelpDialog", () => {
const roomId = "!room:server";
beforeEach(() => {
stubClient();
});
it("should filter out disabled commands", () => {
// Create commands with some enabled and some disabled
const enabledCommand = new Command({
command: "enabled",
args: "<arg>",
description: _td("slash_command|spoiler"),
runFn: jest.fn(),
category: CommandCategories.messages,
isEnabled: () => true,
});
const disabledCommand = new Command({
command: "disabled",
args: "<arg>",
description: _td("slash_command|shrug"),
runFn: jest.fn(),
category: CommandCategories.messages,
isEnabled: () => false,
});
// Mock the Commands array by replacing the property
Object.defineProperty(SlashCommands, "Commands", {
value: [enabledCommand, disabledCommand],
configurable: true,
});
const onFinished = jest.fn();
render(<SlashCommandHelpDialog roomId={roomId} onFinished={onFinished} />);
// The enabled command should be visible
expect(screen.getByText("/enabled")).toBeInTheDocument();
// The disabled command should not be visible
expect(screen.queryByText("/disabled")).not.toBeInTheDocument();
});
it("should group commands by category", () => {
const messageCommand = new Command({
command: "msg",
args: "",
description: _td("slash_command|plain"),
runFn: jest.fn(),
category: CommandCategories.messages,
});
const adminCommand = new Command({
command: "admin",
args: "",
description: _td("slash_command|upgraderoom"),
runFn: jest.fn(),
category: CommandCategories.admin,
});
Object.defineProperty(SlashCommands, "Commands", {
value: [messageCommand, adminCommand],
configurable: true,
});
const onFinished = jest.fn();
render(<SlashCommandHelpDialog roomId={roomId} onFinished={onFinished} />);
// Both category headers should be present
expect(screen.getByText(_t(CommandCategories.messages))).toBeInTheDocument();
expect(screen.getByText(_t(CommandCategories.admin))).toBeInTheDocument();
});
});

View File

@@ -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 { type 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(
<MatrixClientContext.Provider value={mockClient}>
<DateSeparator {...defaultProps} {...props} />
<ScopedRoomContextProvider {...defaultRoomContext}>
<DateSeparator {...defaultProps} {...props} />
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -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

View File

@@ -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("<MVvoiceMessageBody />", () => {
let event: MatrixEvent;
@@ -25,7 +26,7 @@ describe("<MVvoiceMessageBody />", () => {
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);

View File

@@ -40,7 +40,7 @@ describe("<UserOptionsSection />", () => {
onInsertPillButton: () => jest.fn(),
onReadReceiptButton: () => jest.fn(),
onShareUserClick: () => jest.fn(),
onInviteUserButton: (evt: Event) => Promise.resolve(),
onInviteUserButton: (fallbackRoomId: string, evt: Event) => Promise.resolve(),
onOpenDmForUser: (member: Member) => Promise.resolve(),
};

View File

@@ -16,6 +16,9 @@ import * as TestUtils from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import EditorModel from "../../../../../src/editor/model";
import { createPartCreator, createRenderer } from "../../../editor/mock";
import { CommandPartCreator } from "../../../../../src/editor/parts";
import DocumentOffset from "../../../../../src/editor/offset";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
import SettingsStore from "../../../../../src/settings/SettingsStore";
describe("BasicMessageComposer", () => {
@@ -103,6 +106,25 @@ describe("BasicMessageComposer", () => {
const placeholder = input[0].style.getPropertyValue("--placeholder");
expect(placeholder).toMatch("'w\\\\e'");
});
it("should not consider typing for unknown or disabled slash commands", async () => {
// create a command part which represents a slash command the client doesn't recognise
const commandPc = new CommandPartCreator(room as unknown as Room, client as unknown as MatrixClient, null);
const commandPart = commandPc.command("/unknown do stuff");
const model = new EditorModel([commandPart], commandPc, renderer);
// spy on typingStore.setSelfTyping
const spy = jest.spyOn(SdkContextClass.instance.typingStore, "setSelfTyping");
render(<BasicMessageComposer model={model} room={room} />);
// 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));
// Since the command is not in CommandMap, it should not be considered typing
expect(spy).toHaveBeenCalledWith(room.roomId, null, false);
spy.mockRestore();
});
});
function generateMockDataTransferForString(string: string): DataTransfer {

View File

@@ -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 }) => (
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>{children}</MatrixClientContext.Provider>
),
};
}
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 }) => (
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
),
};
}
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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument();
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
mockRoomViewStore.isViewingCall.mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());

View File

@@ -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,
);

View File

@@ -230,7 +230,7 @@ describe("message", () => {
});
// Then
expect(getCommandSpy).toHaveBeenCalledWith(validCommand);
expect(getCommandSpy).toHaveBeenCalledWith(mockRoom.roomId, validCommand);
});
it("does not call getCommand for valid command with invalid prefix", async () => {