diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index cf87bc79d0..e41a719cb0 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -61,6 +61,7 @@ import { CommandCategories } from "./slash-commands/interface"; import { Command } from "./slash-commands/command"; import { goto, join } from "./slash-commands/join"; import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import { parseUpgradeRoomArgs } from "./slash-commands/upgraderoom/parseUpgradeRoomArgs"; export { CommandCategories, Command }; @@ -146,11 +147,15 @@ export const Commands = [ }), new Command({ command: "upgraderoom", - args: "", + args: " [ ...]", description: _td("slash_command|upgraderoom"), isEnabled: (cli) => !isCurrentLocalRoom(cli), runFn: function (cli, roomId, threadId, args) { - if (args) { + if (!args) { + return reject(this.getUsage()); + } + const parsedArgs = parseUpgradeRoomArgs(args); + if (parsedArgs) { const room = cli.getRoom(roomId); if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { return reject(new UserFriendlyError("slash_command|upgraderoom_permission_error")); @@ -158,7 +163,7 @@ export const Commands = [ const { finished } = Modal.createDialog( RoomUpgradeWarningDialog, - { roomId: roomId, targetVersion: args }, + { roomId: roomId, targetVersion: parsedArgs.targetVersion }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, @@ -167,7 +172,17 @@ export const Commands = [ return success( finished.then(async ([resp]): Promise => { if (!resp?.continue) return; - await upgradeRoom(room, args, resp.invite); + await upgradeRoom( + room, + parsedArgs.targetVersion, + resp.invite, + true, + true, + false, + undefined, + false, + parsedArgs.additionalCreators, + ); }), ); } diff --git a/src/slash-commands/upgraderoom/parseUpgradeRoomArgs.ts b/src/slash-commands/upgraderoom/parseUpgradeRoomArgs.ts new file mode 100644 index 0000000000..129b8ca13e --- /dev/null +++ b/src/slash-commands/upgraderoom/parseUpgradeRoomArgs.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Element Creations 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. + */ + +export type UpgradeRoomParsedArgs = { + targetVersion: string; + additionalCreators?: string[]; +}; + +/** + * Parse the supplied arguments for a room upgrade, or return null if the + * arguments are not valid. The arguments must be a room version followed by + * zero or more valid user IDs. + */ +export function parseUpgradeRoomArgs(args: string): UpgradeRoomParsedArgs | null { + const parts = args.split(/\s+/); + if (parts.length === 0 || parts[0] === "") { + return null; + } else { + const targetVersion = parts[0]; + let additionalCreators: string[] | undefined; + for (let i = 1; i < parts.length; ++i) { + if (additionalCreators === undefined) { + additionalCreators = []; + } + additionalCreators.push(parts[i]); + } + return { targetVersion, additionalCreators }; + } +} diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts index 0c476a2ddd..6fe6a2f4d1 100644 --- a/src/utils/RoomUpgrade.ts +++ b/src/utils/RoomUpgrade.ts @@ -53,6 +53,7 @@ export async function upgradeRoom( awaitRoom = false, progressCallback?: (progress: RoomUpgradeProgress) => void, inhibitInviteProgressDialog = false, + additionalCreators?: string[], ): Promise { const cli = room.client; let spinnerModal: IHandle | undefined; @@ -91,7 +92,7 @@ export async function upgradeRoom( let newRoomId: string; try { - ({ replacement_room: newRoomId } = await cli.upgradeRoom(room.roomId, targetVersion)); + ({ replacement_room: newRoomId } = await cli.upgradeRoom(room.roomId, targetVersion, additionalCreators)); } catch (e) { if (!handleError) throw e; logger.error(e); diff --git a/test/unit-tests/SlashCommands-test.tsx b/test/unit-tests/SlashCommands-test.tsx index 1f583138c4..dbbc69a72e 100644 --- a/test/unit-tests/SlashCommands-test.tsx +++ b/test/unit-tests/SlashCommands-test.tsx @@ -22,6 +22,10 @@ import { warnSelfDemote } from "../../src/components/views/right_panel/UserInfo" import dispatcher from "../../src/dispatcher/dispatcher"; import QuestionDialog from "../../src/components/views/dialogs/QuestionDialog"; import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog"; +import RoomUpgradeWarningDialog, { + type IFinishedOpts, +} from "../../src/components/views/dialogs/RoomUpgradeWarningDialog"; +import { parseUpgradeRoomArgs } from "../../src/slash-commands/upgraderoom/parseUpgradeRoomArgs"; jest.mock("../../src/components/views/right_panel/UserInfo"); @@ -128,6 +132,56 @@ describe("SlashCommands", () => { it("should be enabled by default", () => { expect(command.isEnabled(client, roomId)).toBe(true); }); + + it("should return usage if given no args", () => { + expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage()); + expect(command.run(client, roomId, null, "").error).toBe(command.getUsage()); + }); + + it("should accept arguments of a room version with no additional creators", () => { + expect(parseUpgradeRoomArgs("12")).toEqual({ targetVersion: "12" }); + }); + + it("should accept arguments of a room version and additional creators", () => { + expect(parseUpgradeRoomArgs("13 @u:s.co")).toEqual({ + targetVersion: "13", + additionalCreators: ["@u:s.co"], + }); + + expect(parseUpgradeRoomArgs("14 @u:s.co @v:s.co @w:z.uk")).toEqual({ + targetVersion: "14", + additionalCreators: ["@u:s.co", "@v:s.co", "@w:z.uk"], + }); + }); + + it("should upgrade the room when given valid arguments", async () => { + // Given we mock out creating dialogs and upgrading rooms + const createDialog = jest.spyOn(Modal, "createDialog"); + const upgradeRoom = jest.fn().mockResolvedValue({ replacement_room: "!newroom" }); + const resp: IFinishedOpts = { continue: true, invite: false }; + createDialog.mockReturnValue({ + finished: Promise.resolve([resp]), + close: jest.fn(), + }); + client.upgradeRoom = upgradeRoom; + + // When we run a room upgrade + const result = command.run(client, roomId, null, "12 @foo:bar.com @baz:qux.uk"); + expect(result.promise).toBeDefined(); + await result.promise; + + // Then we warned the user + expect(createDialog).toHaveBeenCalledWith( + RoomUpgradeWarningDialog, + { roomId: "!room:example.com", targetVersion: "12" }, + undefined, + false, + true, + ); + + // And when they said yes, we called into upgradeRoom + expect(upgradeRoom).toHaveBeenCalledWith("!room:example.com", "12", ["@foo:bar.com", "@baz:qux.uk"]); + }); }); describe("/op", () => { diff --git a/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx b/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx index 57e5dd39b3..adedbb8737 100644 --- a/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx +++ b/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx @@ -197,7 +197,7 @@ describe("", () => { fireEvent.click(within(dialog).getByText("Upgrade")); - expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion); + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion, undefined); expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument(); @@ -245,7 +245,7 @@ describe("", () => { fireEvent.click(within(dialog).getByText("Upgrade")); - expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion); + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion, undefined); expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument();