From 2c19ea6253d2b8d854d5723e4cbced9e4a48eec0 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 8 Oct 2025 16:17:00 +0100 Subject: [PATCH 1/6] feat: Move room name, avatar, and topic to IOpts. --- src/components/structures/SpaceRoomView.tsx | 8 +++---- .../views/dialogs/CreateRoomDialog.tsx | 4 ++-- src/createRoom.ts | 22 ++++++++++++++++++- .../views/dialogs/CreateRoomDialog-test.tsx | 11 +++++----- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index c2aa7f2f100..f9210ec96b8 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -329,8 +329,8 @@ const SpaceSetupFirstRooms: React.FC<{ return createRoom(space.client, { createOpts: { preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, - name, }, + name, spinner: false, encryption: false, andView: false, @@ -423,7 +423,7 @@ const SpaceSetupPublicShare: React.FC = ({

{_t("create_space|share_heading", { - name: justCreatedOpts?.createOpts?.name || space.name, + name: justCreatedOpts?.name || space.name, })}

{_t("create_space|share_description")}
@@ -449,7 +449,7 @@ const SpaceSetupPrivateScope: React.FC<{

{_t("create_space|private_personal_heading")}

{_t("create_space|private_personal_description", { - name: justCreatedOpts?.createOpts?.name || space.name, + name: justCreatedOpts?.name || space.name, })}
@@ -686,7 +686,7 @@ export default class SpaceRoomView extends React.PureComponent { diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index d3771d102df..821182aff05 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -126,7 +126,7 @@ export default class CreateRoomDialog extends React.Component { const opts: IOpts = {}; const createOpts: IOpts["createOpts"] = (opts.createOpts = {}); opts.roomType = this.props.type; - createOpts.name = this.state.name; + opts.name = this.state.name; if (this.state.joinRule === JoinRule.Public) { createOpts.visibility = Visibility.Public; @@ -139,7 +139,7 @@ export default class CreateRoomDialog extends React.Component { } if (this.state.topic) { - createOpts.topic = this.state.topic; + opts.topic = this.state.topic; } if (this.state.noFederate) { createOpts.creation_content = { "m.federate": false }; diff --git a/src/createRoom.ts b/src/createRoom.ts index c3bc1e6b1a1..8e392609211 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -49,7 +49,19 @@ import { ElementCallEventType, ElementCallMemberEventType } from "./call-types"; export interface IOpts { dmUserId?: string; - createOpts?: ICreateRoomOpts; + /** + * The name of the room to be created. + */ + name?: string; + /** + * The topic for the room. + */ + topic?: string; + /** + * Additional options to pass to the room creation API. + * Note: "name", "topic", and "avatar" should be set via their respective properties in IOpts. + */ + createOpts?: Omit; spinner?: boolean; guestAccess?: boolean; encryption?: boolean; @@ -251,6 +263,14 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro }); } + if (opts.name) { + createOpts.name = opts.name; + } + + if (opts.topic) { + createOpts.topic = opts.topic; + } + if (opts.avatar) { let url = opts.avatar; if (opts.avatar instanceof File) { diff --git a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx index 5fa02511293..2a705d32e28 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -198,9 +198,8 @@ describe("", () => { await flushPromises(); expect(onFinished).toHaveBeenCalledWith(true, { - createOpts: { - name: roomName, - }, + createOpts: {}, + name: roomName, encryption: true, parentSpace: undefined, roomType: undefined, @@ -259,9 +258,9 @@ describe("", () => { await flushPromises(); expect(onFinished).toHaveBeenCalledWith(true, { createOpts: { - name: roomName, visibility: Visibility.Private, }, + name: roomName, encryption: true, joinRule: JoinRule.Knock, parentSpace: undefined, @@ -277,9 +276,9 @@ describe("", () => { await flushPromises(); expect(onFinished).toHaveBeenCalledWith(true, { createOpts: { - name: roomName, visibility: Visibility.Public, }, + name: roomName, encryption: true, joinRule: JoinRule.Knock, parentSpace: undefined, @@ -349,11 +348,11 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith(true, { createOpts: { - name: roomName, preset: Preset.PublicChat, room_alias_name: roomAlias, visibility: Visibility.Public, }, + name: roomName, guestAccess: false, parentSpace: undefined, roomType: undefined, From 6a455ac6f0ce1aa36febd65e204ceca0ef239b33 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Thu, 9 Oct 2025 17:31:29 +0100 Subject: [PATCH 2/6] tests: Add a plethora of tests for `CreateRoomDialog`. --- .../views/dialogs/CreateRoomDialog-test.tsx | 155 +++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx index 2a705d32e28..f4cb63e09c5 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { fireEvent, render, screen, within } from "jest-matrix-react"; -import { JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import { type Room, JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix"; import CreateRoomDialog from "../../../../../src/components/views/dialogs/CreateRoomDialog"; -import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; +import { flushPromises, getMockClientWithEventEmitter, mkSpace, mockClientMethodsUser } from "../../../../test-utils"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { UIFeature } from "../../../../../src/settings/UIFeature"; @@ -58,6 +58,57 @@ describe("", () => { expect(screen.getByLabelText("Name")).toHaveDisplayValue(defaultName); }); + it("should include topic in room creation options", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + const topic = "This is a test topic"; + + // Set room name and topic. + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Room with topic" } }); + fireEvent.change(screen.getByLabelText("Topic (optional)"), { target: { value: topic } }); + + // Create the room. + fireEvent.click(screen.getByText("Create room")); + await flushPromises(); + + expect(onFinished).toHaveBeenCalledWith( + true, + expect.objectContaining({ + createOpts: expect.objectContaining({ + name: "Room with topic", + topic, + }), + }), + ); + }); + + it("should include no federate option in room creation options when enabled", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + // Set room name, and disable federation. + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "NoFederate Room" } }); + fireEvent.click(screen.getByLabelText("Block anyone not part of server.org from ever joining this room.")); + + fireEvent.click(screen.getByText("Create room")); + await flushPromises(); + + expect(onFinished).toHaveBeenCalledWith( + true, + expect.objectContaining({ + createOpts: expect.objectContaining({ + name: "NoFederate Room", + creation_content: expect.objectContaining({ + "m.federate": false, + }), + }), + }), + ); + }); + describe("for a private room", () => { // default behaviour is a private room @@ -359,4 +410,104 @@ describe("", () => { }); }); }); + + describe("for a room in a space", () => { + let parentSpace: Room; + beforeEach(() => { + parentSpace = mkSpace(mockClient, "!space:server") as unknown as Room; + }); + + it("should create a room with restricted join rule when selected", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + // Set room name and visibility. + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Restricted Room" } }); + fireEvent.click(screen.getByLabelText("Room visibility")); + fireEvent.click(screen.getByRole("option", { name: "Visible to space members" })); + + fireEvent.click(screen.getByText("Create room")); + await flushPromises(); + + expect(onFinished).toHaveBeenCalledWith( + true, + expect.objectContaining({ + createOpts: expect.objectContaining({ + name: "Restricted Room", + }), + joinRule: JoinRule.Restricted, + }), + ); + }); + + it("should create a room with public join rule when selected", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + // Set room name and visibility. Rooms in spaces also need an address. + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Public Room" } }); + fireEvent.click(screen.getByLabelText("Room visibility")); + fireEvent.click(screen.getByRole("option", { name: "Public room" })); + fireEvent.change(screen.getByLabelText("Room address"), { target: { value: "testroom" } }); + + // Create the room. + fireEvent.click(screen.getByText("Create room")); + await flushPromises(); + + expect(onFinished).toHaveBeenCalledWith( + true, + expect.objectContaining({ + createOpts: expect.objectContaining({ + name: "Public Room", + room_alias_name: "testroom", + visibility: Visibility.Public, + preset: Preset.PublicChat, + }), + guestAccess: false, + roomType: undefined, + }), + ); + }); + }); + + describe("keyboard shortcuts", () => { + it("should submit the form when Enter is pressed", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + // Simulate pressing the Enter key. + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Keyboard Room" } }); + fireEvent.keyDown(screen.getByLabelText("Name"), { key: "Enter", code: "Enter", charCode: 13 }); + + await flushPromises(); + + expect(onFinished).toHaveBeenCalledWith( + true, + expect.objectContaining({ + createOpts: expect.objectContaining({ + name: "Keyboard Room", + }), + }), + ); + }); + + it("should cancel the dialog when Escape is pressed", async () => { + const onFinished = jest.fn(); + render(); + await flushPromises(); + + // Simulate pressing the Escape key. + fireEvent.keyDown(screen.getByLabelText("Name"), { key: "Escape", code: "Escape", charCode: 27 }); + + await flushPromises(); + + // BaseDialog passes no arguments, but DialogButtons pass false - might not be desirable? + expect(onFinished).toHaveBeenCalled(); + const callArgs = onFinished.mock.calls[0]; + expect(callArgs.length === 0 || callArgs[0] === false).toBe(true); + }); + }); }); From 499ad9edc87b6ac651a2b0affde0e30256c0ca57 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Thu, 9 Oct 2025 17:44:34 +0100 Subject: [PATCH 3/6] tests: Correct to use new IOpts format. --- .../views/dialogs/CreateRoomDialog-test.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx index f4cb63e09c5..6ac423570f8 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -76,10 +76,8 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith( true, expect.objectContaining({ - createOpts: expect.objectContaining({ - name: "Room with topic", - topic, - }), + name: "Room with topic", + topic, }), ); }); @@ -99,8 +97,8 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith( true, expect.objectContaining({ + name: "NoFederate Room", createOpts: expect.objectContaining({ - name: "NoFederate Room", creation_content: expect.objectContaining({ "m.federate": false, }), @@ -433,9 +431,7 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith( true, expect.objectContaining({ - createOpts: expect.objectContaining({ - name: "Restricted Room", - }), + name: "Restricted Room", joinRule: JoinRule.Restricted, }), ); @@ -459,8 +455,8 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith( true, expect.objectContaining({ + name: "Public Room", createOpts: expect.objectContaining({ - name: "Public Room", room_alias_name: "testroom", visibility: Visibility.Public, preset: Preset.PublicChat, @@ -487,9 +483,7 @@ describe("", () => { expect(onFinished).toHaveBeenCalledWith( true, expect.objectContaining({ - createOpts: expect.objectContaining({ - name: "Keyboard Room", - }), + name: "Keyboard Room", }), ); }); From 1cc0224223549b3c03680bc2294b5f59ef2be19d Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Thu, 9 Oct 2025 17:57:24 +0100 Subject: [PATCH 4/6] chore: Update `CreateRoomDialog` test snapshots. --- .../CreateRoomDialog-test.tsx.snap | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap index d4789c9b93f..6f3b7d26698 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[` for a private room should create a private room 1`] = ` @@ -32,14 +32,14 @@ exports[` for a private room should create a private room 1` class="mx_Field mx_Field_input mx_CreateRoomDialog_name" > @@ -48,14 +48,14 @@ exports[` for a private room should create a private room 1` class="mx_Field mx_Field_input mx_CreateRoomDialog_topic" > @@ -98,7 +98,7 @@ exports[` for a private room should create a private room 1` class="mx_SettingsFlag_label" >
Enable end-to-end encryption
@@ -106,7 +106,7 @@ exports[` for a private room should create a private room 1`
for a private room should create a private room 1` class="mx_SettingsFlag_label" >
Block anyone not part of server.org from ever joining this room.
@@ -142,7 +142,7 @@ exports[` for a private room should create a private room 1`
for a private room should render not the advanced class="mx_Field mx_Field_input mx_CreateRoomDialog_name" > @@ -242,14 +242,14 @@ exports[` for a private room should render not the advanced class="mx_Field mx_Field_input mx_CreateRoomDialog_topic" > @@ -292,7 +292,7 @@ exports[` for a private room should render not the advanced class="mx_SettingsFlag_label" >
Enable end-to-end encryption
@@ -300,7 +300,7 @@ exports[` for a private room should render not the advanced
Date: Fri, 10 Oct 2025 15:16:16 +0100 Subject: [PATCH 5/6] tests: Add tests for `SpaceSetupFirstRooms`. --- .../structures/SpaceRoomView-test.tsx | 106 +++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/test/unit-tests/components/structures/SpaceRoomView-test.tsx b/test/unit-tests/components/structures/SpaceRoomView-test.tsx index f181b90576b..cffce2d610d 100644 --- a/test/unit-tests/components/structures/SpaceRoomView-test.tsx +++ b/test/unit-tests/components/structures/SpaceRoomView-test.tsx @@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { mocked, type MockedObject } from "jest-mock"; -import { type MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { render, cleanup, screen, fireEvent } from "jest-matrix-react"; +import { type MatrixClient, MatrixEvent, Preset, Room } from "matrix-js-sdk/src/matrix"; +import { render, cleanup, screen, fireEvent, waitFor } from "jest-matrix-react"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, withClientContextRenderOptions } from "../../../test-utils"; import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; @@ -17,6 +17,7 @@ import ResizeNotifier from "../../../../src/utils/ResizeNotifier.ts"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks.ts"; import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore.ts"; import DMRoomMap from "../../../../src/utils/DMRoomMap.ts"; +import { IOpts } from "../../../../src/createRoom.ts"; describe("SpaceRoomView", () => { let cli: MockedObject; @@ -86,7 +87,7 @@ describe("SpaceRoomView", () => { cleanup(); }); - const renderSpaceRoomView = async (): Promise> => { + const renderSpaceRoomView = async (justCreatedOpts?: IOpts): Promise> => { const resizeNotifier = new ResizeNotifier(); const permalinkCreator = new RoomPermalinkCreator(space); @@ -97,6 +98,7 @@ describe("SpaceRoomView", () => { permalinkCreator={permalinkCreator} onJoinButtonClicked={jest.fn()} onRejectButtonClicked={jest.fn()} + justCreatedOpts={justCreatedOpts} />, withClientContextRenderOptions(cli), ); @@ -114,4 +116,102 @@ describe("SpaceRoomView", () => { expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); }); }); + + describe("SpaceSetupFirstRooms", () => { + beforeEach(async () => { + await renderSpaceRoomView({ + createOpts: { preset: Preset.PublicChat }, + }); + }); + + it("renders SpaceSetupFirstRooms with correct title and description", () => { + expect( + screen.getByText("What are some things you want to discuss in !space:example.org?"), + ).toBeInTheDocument(); + // using regex here since there's a stray
+ expect(screen.getByText(/let's create a room for each of them/i)).toBeInTheDocument(); + expect( + screen.getByText(/you can add more later too, including already existing ones/i), + ).toBeInTheDocument(); + }); + + it("renders three input fields with correct placeholders", () => { + expect(screen.getAllByPlaceholderText(/general/i)).toHaveLength(1); + expect(screen.getAllByPlaceholderText(/random/i)).toHaveLength(1); + expect(screen.getAllByPlaceholderText(/support/i)).toHaveLength(1); + }); + + it("updates input value when typed", () => { + const input = screen.getAllByRole("textbox")[0]; + fireEvent.change(input, { target: { value: "My Room" } }); + expect(input).toHaveValue("My Room"); + }); + + it("shows 'Skip for now' when all fields are empty, 'Continue' when any field is filled", () => { + // Clear all fields first + screen.getAllByRole("textbox").forEach((input) => fireEvent.change(input, { target: { value: "" } })); + + // Should say 'Skip for now' + const button = screen.getByRole("button"); + expect(button).toHaveValue("Skip for now"); + + // Fill a field + fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: "Room" } }); + expect(button).toHaveValue("Continue"); + }); + + it("calls onFinished with no argument when skipping", () => { + const button = screen.getByRole("button"); + fireEvent.click(button); + // Since onFinished is handled internally, check that SpaceSetupFirstRooms is no longer rendered + expect(screen.queryByText(/setup_rooms_community_heading/i)).not.toBeInTheDocument(); + }); + + it("calls createRoom for each non-empty field and onFinished with first room id", async () => { + cli.createRoom.mockResolvedValueOnce({ room_id: "room1" }).mockResolvedValueOnce({ room_id: "room2" }); + + fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: "Room A" } }); + fireEvent.change(screen.getAllByRole("textbox")[1], { target: { value: "Room B" } }); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(cli.createRoom).toHaveBeenCalledTimes(2); + }); + // After finishing, SpaceSetupFirstRooms should not be rendered + expect(screen.queryByText(/setup_rooms_community_heading/i)).not.toBeInTheDocument(); + }); + + it("shows error message if room creation fails", async () => { + // Force failure. + cli.createRoom.mockRejectedValue(new Error("fail")); + + // Create a room. + fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: "Room A" } }); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect( + screen.getByText((content) => + content.toLowerCase().includes("failed to create initial space rooms"), + ), + ).toBeInTheDocument(); + }); + }); + + it("disables button and shows 'Creating rooms' while busy", async () => { + cli.createRoom.mockImplementation( + () => + new Promise((resolve) => { + /* intentionally unresolved to mock work by the server */ + }), + ); + + fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: "Room A" } }); + fireEvent.click(screen.getByRole("button")); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(button).toHaveValue("Creating rooms…"); // Note the ellipsis + }); + }); }); From 5c3a6b3f322e8830a3be83727c6adb222ecf769a Mon Sep 17 00:00:00 2001 From: kaylendog Date: Mon, 13 Oct 2025 12:47:25 +0100 Subject: [PATCH 6/6] tests: Add test for `SpaceLaningAddButton` context menu. --- .../components/structures/SpaceRoomView-test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/components/structures/SpaceRoomView-test.tsx b/test/unit-tests/components/structures/SpaceRoomView-test.tsx index cffce2d610d..13304478eba 100644 --- a/test/unit-tests/components/structures/SpaceRoomView-test.tsx +++ b/test/unit-tests/components/structures/SpaceRoomView-test.tsx @@ -17,7 +17,7 @@ import ResizeNotifier from "../../../../src/utils/ResizeNotifier.ts"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks.ts"; import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore.ts"; import DMRoomMap from "../../../../src/utils/DMRoomMap.ts"; -import { IOpts } from "../../../../src/createRoom.ts"; +import { type IOpts } from "../../../../src/createRoom.ts"; describe("SpaceRoomView", () => { let cli: MockedObject; @@ -115,6 +115,17 @@ describe("SpaceRoomView", () => { expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); }); + + it("shows SpaceLandingAddButton context menu when Add button is clicked", async () => { + await renderSpaceRoomView(); + await expect(screen.findByText("Welcome to")).resolves.toBeVisible(); + + const addButton = screen.getByRole("button", { name: /add/i }); + fireEvent.click(addButton); + + expect(await screen.findByText(/new room/i)).toBeInTheDocument(); + expect(screen.getByText(/add existing room/i)).toBeInTheDocument(); + }); }); describe("SpaceSetupFirstRooms", () => {