From 796ed35e750a8b8c4cb41dc1ef65d98aa70180a8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 May 2023 16:29:48 +0100 Subject: [PATCH] Refactor SlashCommands to not use MatrixClientPeg (#10905) --- src/SlashCommands.tsx | 267 ++++++++---------- .../views/rooms/EditMessageComposer.tsx | 8 +- .../views/rooms/SendMessageComposer.tsx | 1 + .../rooms/wysiwyg_composer/utils/message.ts | 2 +- src/editor/commands.tsx | 4 +- test/SlashCommands-test.tsx | 76 +++-- .../__snapshots__/SlashCommands-test.tsx.snap | 56 ++++ 7 files changed, 244 insertions(+), 170 deletions(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 3d195716dc..f8a3885ab6 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -26,8 +26,8 @@ import { logger } from "matrix-js-sdk/src/logger"; import { IContent } from "matrix-js-sdk/src/models/event"; import { MRoomTopicEventContent } from "matrix-js-sdk/src/@types/topic"; import { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/types/typescript/SlashCommand"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import { _t, _td, UserFriendlyError } from "./languageHandler"; import Modal from "./Modal"; @@ -75,7 +75,7 @@ interface HTMLInputEvent extends Event { target: HTMLInputElement & EventTarget; } -const singleMxcUpload = async (): Promise => { +const singleMxcUpload = async (cli: MatrixClient): Promise => { return new Promise((resolve) => { const fileSelector = document.createElement("input"); fileSelector.setAttribute("type", "file"); @@ -87,7 +87,7 @@ const singleMxcUpload = async (): Promise => { file, onFinished: async (shouldContinue): Promise => { if (shouldContinue) { - const { content_uri: uri } = await MatrixClientPeg.get().uploadContent(file); + const { content_uri: uri } = await cli.uploadContent(file); resolve(uri); } else { resolve(null); @@ -111,7 +111,7 @@ export const CommandCategories = { export type RunResult = XOR<{ error: Error }, { promise: Promise }>; -type RunFn = (this: Command, roomId: string, args?: string) => RunResult; +type RunFn = (this: Command, matrixClient: MatrixClient, roomId: string, args?: string) => RunResult; interface ICommandOpts { command: string; @@ -122,7 +122,7 @@ interface ICommandOpts { runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; - isEnabled?(): boolean; + isEnabled?(matrixClient?: MatrixClient): boolean; renderingTypes?: TimelineRenderingType[]; } @@ -136,7 +136,7 @@ export class Command { public readonly hideCompletionAfterSpace: boolean; public readonly renderingTypes?: TimelineRenderingType[]; public readonly analyticsName?: SlashCommandEvent["command"]; - private readonly _isEnabled?: () => boolean; + private readonly _isEnabled?: (matrixClient?: MatrixClient) => boolean; public constructor(opts: ICommandOpts) { this.command = opts.command; @@ -159,7 +159,7 @@ export class Command { return this.getCommand() + " " + this.args; } - public run(roomId: string, threadId: string | null, args?: string): RunResult { + public run(matrixClient: MatrixClient, roomId: string, threadId: string | null, args?: string): RunResult { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) { return reject(new UserFriendlyError("Command error: Unable to handle slash command.")); @@ -182,15 +182,15 @@ export class Command { }); } - return this.runFn(roomId, args); + return this.runFn(matrixClient, roomId, args); } public getUsage(): string { return _t("Usage") + ": " + this.getCommandWithArgs(); } - public isEnabled(): boolean { - return this._isEnabled ? this._isEnabled() : true; + public isEnabled(cli?: MatrixClient): boolean { + return this._isEnabled?.(cli) ?? true; } } @@ -206,15 +206,21 @@ function successSync(value: any): RunResult { return success(Promise.resolve(value)); } -const isCurrentLocalRoom = (): boolean => { - const cli = MatrixClientPeg.get(); +const isCurrentLocalRoom = (cli?: MatrixClient): boolean => { const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!roomId) return false; - const room = cli.getRoom(roomId); + const room = cli?.getRoom(roomId); if (!room) return false; return isLocalRoom(room); }; +const canAffectPowerlevels = (cli?: MatrixClient): boolean => { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!cli || !roomId) return false; + const room = cli?.getRoom(roomId); + return !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getSafeUserId()) && !isLocalRoom(room); +}; + /* Disable the "unexpected this" error for these commands - all of the run * functions are called with `this` bound to the Command instance. */ @@ -224,7 +230,7 @@ export const Commands = [ command: "spoiler", args: "", description: _td("Sends the given message as a spoiler"), - runFn: function (roomId, message = "") { + runFn: function (cli, roomId, message = "") { return successSync(ContentHelpers.makeHtmlMessage(message, `${message}`)); }, category: CommandCategories.messages, @@ -233,7 +239,7 @@ export const Commands = [ command: "shrug", args: "", description: _td("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "¯\\_(ツ)_/¯"; if (args) { message = message + " " + args; @@ -246,7 +252,7 @@ export const Commands = [ command: "tableflip", args: "", description: _td("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "(╯°□°)╯︵ ┻━┻"; if (args) { message = message + " " + args; @@ -259,7 +265,7 @@ export const Commands = [ command: "unflip", args: "", description: _td("Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "┬──┬ ノ( ゜-゜ノ)"; if (args) { message = message + " " + args; @@ -272,7 +278,7 @@ export const Commands = [ command: "lenny", args: "", description: _td("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "( ͡° ͜ʖ ͡°)"; if (args) { message = message + " " + args; @@ -285,7 +291,7 @@ export const Commands = [ command: "plain", args: "", description: _td("Sends a message as plain text, without interpreting it as markdown"), - runFn: function (roomId, messages = "") { + runFn: function (cli, roomId, messages = "") { return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, @@ -294,7 +300,7 @@ export const Commands = [ command: "html", args: "", description: _td("Sends a message as html, without interpreting it as markdown"), - runFn: function (roomId, messages = "") { + runFn: function (cli, roomId, messages = "") { return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, @@ -303,10 +309,9 @@ export const Commands = [ command: "upgraderoom", args: "", description: _td("Upgrades a room to a new version"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { return reject( @@ -339,7 +344,7 @@ export const Commands = [ args: "", description: _td("Jump to the given date in the timeline"), isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { return success( (async (): Promise => { @@ -352,7 +357,6 @@ export const Commands = [ ); } - const cli = MatrixClientPeg.get(); const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent( roomId, unixTimestamp, @@ -381,9 +385,9 @@ export const Commands = [ command: "nick", args: "", description: _td("Changes your display nickname"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - return success(MatrixClientPeg.get().setDisplayName(args)); + return success(cli.setDisplayName(args)); } return reject(this.getUsage()); }, @@ -395,16 +399,15 @@ export const Commands = [ aliases: ["roomnick"], args: "", description: _td("Changes your display nickname in the current room only"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getUserId()!); + const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId()); const content = { ...(ev ? ev.getContent() : { membership: "join" }), displayname: args, }; - return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getUserId()!)); + return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getSafeUserId())); } return reject(this.getUsage()); }, @@ -415,17 +418,17 @@ export const Commands = [ command: "roomavatar", args: "[]", description: _td("Changes the avatar of the current room"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().sendStateEvent(roomId, "m.room.avatar", { url }, ""); + return cli.sendStateEvent(roomId, "m.room.avatar", { url }, ""); }), ); }, @@ -436,15 +439,14 @@ export const Commands = [ command: "myroomavatar", args: "[]", description: _td("Changes your avatar in this current room only"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const room = cli.getRoom(roomId); - const userId = cli.getUserId()!; + const userId = cli.getSafeUserId(); let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( @@ -466,16 +468,16 @@ export const Commands = [ command: "myavatar", args: "[]", description: _td("Changes your avatar in all rooms"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().setAvatarUrl(url); + return cli.setAvatarUrl(url); }), ); }, @@ -486,9 +488,8 @@ export const Commands = [ command: "topic", args: "[]", description: _td("Gets or sets the room topic"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false }); return success(cli.setRoomTopic(roomId, args, html)); @@ -525,10 +526,10 @@ export const Commands = [ command: "roomname", args: "", description: _td("Sets the room name"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - return success(MatrixClientPeg.get().setRoomName(roomId, args)); + return success(cli.setRoomName(roomId, args)); } return reject(this.getUsage()); }, @@ -540,8 +541,8 @@ export const Commands = [ args: " []", description: _td("Invites user with given id to current room"), analyticsName: "Invite", - isEnabled: () => !isCurrentLocalRoom() && shouldShowComponent(UIComponent.InviteUsers), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers), + runFn: function (cli, roomId, args) { if (args) { const [address, reason] = args.split(/\s+(.+)/); if (address) { @@ -551,10 +552,7 @@ export const Commands = [ // get a bit more complex here, but we try to show something // meaningful. let prom = Promise.resolve(); - if ( - getAddressType(address) === AddressType.Email && - !MatrixClientPeg.get().getIdentityServerUrl() - ) { + if (getAddressType(address) === AddressType.Email && !cli.getIdentityServerUrl()) { const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); if (defaultIdentityServerUrl) { const { finished } = Modal.createDialog(QuestionDialog, { @@ -576,7 +574,7 @@ export const Commands = [ prom = finished.then(([useDefault]) => { if (useDefault) { - setToDefaultIdentityServer(MatrixClientPeg.get()); + setToDefaultIdentityServer(cli); return; } throw new UserFriendlyError( @@ -589,7 +587,7 @@ export const Commands = [ ); } } - const inviter = new MultiInviter(MatrixClientPeg.get(), roomId); + const inviter = new MultiInviter(cli, roomId); return success( prom .then(() => { @@ -621,7 +619,7 @@ export const Commands = [ aliases: ["j", "goto"], args: "", description: _td("Joins room with given address"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -654,7 +652,7 @@ export const Commands = [ if (params[0][0] === "#") { let roomAlias = params[0]; if (!roomAlias.includes(":")) { - roomAlias += ":" + MatrixClientPeg.get().getDomain(); + roomAlias += ":" + cli.getDomain(); } dis.dispatch({ @@ -733,10 +731,8 @@ export const Commands = [ args: "[]", description: _td("Leave room"), analyticsName: "Part", - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); - + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { let targetRoomId: string | undefined; if (args) { const matches = args.match(/^(\S+)$/); @@ -775,12 +771,12 @@ export const Commands = [ aliases: ["kick"], args: " [reason]", description: _td("Removes user with given id from this room"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { - return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3])); + return success(cli.kick(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); @@ -792,12 +788,12 @@ export const Commands = [ command: "ban", args: " [reason]", description: _td("Bans user with given id"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { - return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3])); + return success(cli.ban(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); @@ -809,13 +805,13 @@ export const Commands = [ command: "unban", args: "", description: _td("Unbans user with given ID"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { // Reset the user membership to "leave" to unban him - return success(MatrixClientPeg.get().unban(roomId, matches[1])); + return success(cli.unban(roomId, matches[1])); } } return reject(this.getUsage()); @@ -827,10 +823,8 @@ export const Commands = [ command: "ignore", args: "", description: _td("Ignores a user, hiding their messages from you"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const matches = args.match(/^(@[^:]+:\S+)$/); if (matches) { const userId = matches[1]; @@ -858,10 +852,8 @@ export const Commands = [ command: "unignore", args: "", description: _td("Stops ignoring a user, showing their messages going forward"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const matches = args.match(/(^@[^:]+:\S+$)/); if (matches) { const userId = matches[1]; @@ -890,17 +882,8 @@ export const Commands = [ command: "op", args: " []", description: _td("Define the power level of a user"), - isEnabled(): boolean { - const cli = MatrixClientPeg.get(); - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return false; - const room = cli.getRoom(roomId); - return ( - !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()!) && - !isLocalRoom(room) - ); - }, - runFn: function (roomId, args) { + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op @@ -910,7 +893,6 @@ export const Commands = [ powerLevel = parseInt(matches[3], 10); } if (!isNaN(powerLevel)) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) { return reject( @@ -941,21 +923,11 @@ export const Commands = [ command: "deop", args: "", description: _td("Deops user with given id"), - isEnabled(): boolean { - const cli = MatrixClientPeg.get(); - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return false; - const room = cli.getRoom(roomId); - return ( - !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()!) && - !isLocalRoom(room) - ); - }, - runFn: function (roomId, args) { + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) { return reject( @@ -981,7 +953,7 @@ export const Commands = [ new Command({ command: "devtools", description: _td("Opens the Developer Tools dialog"), - runFn: function (roomId) { + runFn: function (cli, roomId) { Modal.createDialog(DevtoolsDialog, { roomId }, "mx_DevtoolsDialog_wrapper"); return success(); }, @@ -991,11 +963,11 @@ export const Commands = [ command: "addwidget", args: "", description: _td("Adds a custom widget by URL to the room"), - isEnabled: () => + isEnabled: (cli) => SettingsStore.getValue(UIFeature.Widgets) && shouldShowComponent(UIComponent.AddIntegrations) && - !isCurrentLocalRoom(), - runFn: function (roomId, widgetUrl) { + !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, widgetUrl) { if (!widgetUrl) { return reject(new UserFriendlyError("Please supply a widget URL or embed code")); } @@ -1005,12 +977,12 @@ export const Commands = [ const embed = new DOMParser().parseFromString(widgetUrl, "text/html").body; if (embed?.childNodes?.length === 1) { const iframe = embed.firstElementChild; - if (iframe.tagName.toLowerCase() === "iframe") { + if (iframe?.tagName.toLowerCase() === "iframe") { logger.log("Pulling URL out of iframe (embed code)"); if (!iframe.hasAttribute("src")) { return reject(new UserFriendlyError("iframe has no src attribute")); } - widgetUrl = iframe.getAttribute("src"); + widgetUrl = iframe.getAttribute("src")!; } } } @@ -1018,8 +990,8 @@ export const Commands = [ if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) { return reject(new UserFriendlyError("Please supply a https:// or http:// widget URL")); } - if (WidgetUtils.canUserModifyWidgets(MatrixClientPeg.get(), roomId)) { - const userId = MatrixClientPeg.get().getUserId(); + if (WidgetUtils.canUserModifyWidgets(cli, roomId)) { + const userId = cli.getUserId(); const nowMs = new Date().getTime(); const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`); let type = WidgetType.CUSTOM; @@ -1036,9 +1008,7 @@ export const Commands = [ widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl(); } - return success( - WidgetUtils.setRoomWidget(MatrixClientPeg.get(), roomId, widgetId, type, widgetUrl, name, data), - ); + return success(WidgetUtils.setRoomWidget(cli, roomId, widgetId, type, widgetUrl, name, data)); } else { return reject(new UserFriendlyError("You cannot modify widgets in this room.")); } @@ -1050,12 +1020,10 @@ export const Commands = [ command: "verify", args: " ", description: _td("Verifies a user, session, and pubkey tuple"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); if (matches) { - const cli = MatrixClientPeg.get(); - const userId = matches[1]; const deviceId = matches[2]; const fingerprint = matches[3]; @@ -1130,10 +1098,10 @@ export const Commands = [ new Command({ command: "discardsession", description: _td("Forces the current outbound group session in an encrypted room to be discarded"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId) { try { - MatrixClientPeg.get().forceDiscardSession(roomId); + cli.forceDiscardSession(roomId); } catch (e) { return reject(e.message); } @@ -1145,19 +1113,19 @@ export const Commands = [ new Command({ command: "remakeolm", description: _td("Developer command: Discards the current outbound group session and sets up new Olm sessions"), - isEnabled: () => { - return SettingsStore.getValue("developerMode") && !isCurrentLocalRoom(); + isEnabled: (cli) => { + return SettingsStore.getValue("developerMode") && !isCurrentLocalRoom(cli); }, - runFn: (roomId) => { + runFn: (cli, roomId) => { try { - const room = MatrixClientPeg.get().getRoom(roomId); + const room = cli.getRoom(roomId); - MatrixClientPeg.get().forceDiscardSession(roomId); + cli.forceDiscardSession(roomId); return success( room?.getEncryptionTargetMembers().then((members) => { // noinspection JSIgnoredPromiseFromCall - MatrixClientPeg.get().crypto?.ensureOlmSessionsForUsers( + cli.crypto?.ensureOlmSessionsForUsers( members.map((m) => m.userId), true, ); @@ -1174,7 +1142,7 @@ export const Commands = [ command: "rainbow", description: _td("Sends the given message coloured as a rainbow"), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, @@ -1184,7 +1152,7 @@ export const Commands = [ command: "rainbowme", description: _td("Sends the given emote coloured as a rainbow"), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, @@ -1203,13 +1171,13 @@ export const Commands = [ command: "whois", description: _td("Displays information about a user"), args: "", - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, userId) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, userId) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) { return reject(this.getUsage()); } - const member = MatrixClientPeg.get().getRoom(roomId)?.getMember(userId); + const member = cli.getRoom(roomId)?.getMember(userId); dis.dispatch({ action: Action.ViewUser, // XXX: We should be using a real member object and not assuming what the receiver wants. @@ -1225,7 +1193,7 @@ export const Commands = [ description: _td("Send a bug report with logs"), isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { return success( Modal.createDialog(BugReportDialog, { initialText: args, @@ -1238,10 +1206,10 @@ export const Commands = [ command: "tovirtual", description: _td("Switches to this room's virtual room, if it has one"), category: CommandCategories.advanced, - isEnabled(): boolean { - return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(); + isEnabled(cli): boolean { + return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli); }, - runFn: (roomId) => { + runFn: (cli, roomId) => { return success( (async (): Promise => { const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId); @@ -1260,7 +1228,7 @@ export const Commands = [ command: "query", description: _td("Opens chat with the given user"), args: "", - runFn: function (roomId, userId) { + runFn: function (cli, roomId, userId) { // easter-egg for now: look up phone numbers through the thirdparty API // (very dumb phone number detection...) const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); @@ -1278,7 +1246,7 @@ export const Commands = [ userId = results[0].userid; } - const roomId = await ensureDMExists(MatrixClientPeg.get(), userId); + const roomId = await ensureDMExists(cli, userId); if (!roomId) throw new Error("Failed to ensure DM exists"); dis.dispatch({ @@ -1296,7 +1264,7 @@ export const Commands = [ command: "msg", description: _td("Sends a message to the given user"), args: " []", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { // matches the first whitespace delimited group and then the rest of the string const matches = args.match(/^(\S+?)(?: +(.*))?$/s); @@ -1305,7 +1273,6 @@ export const Commands = [ if (userId && userId.startsWith("@") && userId.includes(":")) { return success( (async (): Promise => { - const cli = MatrixClientPeg.get(); const roomId = await ensureDMExists(cli, userId); if (!roomId) throw new Error("Failed to ensure DM exists"); @@ -1332,8 +1299,8 @@ export const Commands = [ command: "holdcall", description: _td("Places the call in the current room on hold"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1347,8 +1314,8 @@ export const Commands = [ command: "unholdcall", description: _td("Takes the call in the current room off hold"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1362,9 +1329,9 @@ export const Commands = [ command: "converttodm", description: _td("Converts the room to a DM"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const room = MatrixClientPeg.get().getRoom(roomId); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { + const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, true)); }, @@ -1374,9 +1341,9 @@ export const Commands = [ command: "converttoroom", description: _td("Converts the DM to a room"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const room = MatrixClientPeg.get().getRoom(roomId); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { + const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, false)); }, @@ -1398,7 +1365,7 @@ export const Commands = [ command: effect.command, description: effect.description(), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let content: IContent; if (!args) { content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage()); diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 826e37a0a2..1b403e31fb 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -341,7 +341,13 @@ class EditMessageComposer extends React.Component { - const result = cmd.run(roomId, threadId, args); + const result = cmd.run(matrixClient, roomId, threadId, args); let messageContent: IContent | null = null; let error: any = result.error; if (result.promise) { diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index d3d76e350d..fcd6d6e4c8 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -19,11 +19,13 @@ import { mocked } from "jest-mock"; import { Command, Commands, getCommand } from "../src/SlashCommands"; import { createTestClient } from "./test-utils"; -import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../src/models/LocalRoom"; import SettingsStore from "../src/settings/SettingsStore"; import LegacyCallHandler from "../src/LegacyCallHandler"; import { SdkContextClass } from "../src/contexts/SDKContext"; +import Modal from "../src/Modal"; +import WidgetUtils from "../src/utils/WidgetUtils"; +import { WidgetType } from "../src/widgets/WidgetType"; describe("SlashCommands", () => { let client: MatrixClient; @@ -57,7 +59,6 @@ describe("SlashCommands", () => { jest.clearAllMocks(); client = createTestClient(); - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); room = new Room(roomId, client, client.getUserId()!); localRoom = new LocalRoom(localRoomId, client, client.getUserId()!); @@ -70,9 +71,16 @@ describe("SlashCommands", () => { const command = getCommand("/topic pizza"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); - await command.cmd!.run("room-id", null, command.args); + await command.cmd!.run(client, "room-id", null, command.args); expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined); }); + + it("should show topic modal if no args passed", async () => { + const spy = jest.spyOn(Modal, "createDialog"); + const command = getCommand("/topic")!; + await command.cmd!.run(client, roomId, null); + expect(spy).toHaveBeenCalled(); + }); }); describe.each([ @@ -104,12 +112,12 @@ describe("SlashCommands", () => { describe("isEnabled", () => { it("should return true for Room", () => { setCurrentRoom(); - expect(command.isEnabled()).toBe(true); + expect(command.isEnabled(client)).toBe(true); }); it("should return false for LocalRoom", () => { setCurrentLocalRoon(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); }); }); @@ -127,12 +135,12 @@ describe("SlashCommands", () => { it("should return true for Room", () => { setCurrentRoom(); - expect(command.isEnabled()).toBe(true); + expect(command.isEnabled(client)).toBe(true); }); it("should return false for LocalRoom", () => { setCurrentLocalRoon(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); }); @@ -143,12 +151,12 @@ describe("SlashCommands", () => { it("should return false for Room", () => { setCurrentRoom(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); it("should return false for LocalRoom", () => { setCurrentLocalRoon(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); }); }); @@ -169,12 +177,12 @@ describe("SlashCommands", () => { it("should return true for Room", () => { setCurrentRoom(); - expect(command.isEnabled()).toBe(true); + expect(command.isEnabled(client)).toBe(true); }); it("should return false for LocalRoom", () => { setCurrentLocalRoon(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); }); @@ -187,12 +195,12 @@ describe("SlashCommands", () => { it("should return false for Room", () => { setCurrentRoom(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); it("should return false for LocalRoom", () => { setCurrentLocalRoon(); - expect(command.isEnabled()).toBe(false); + expect(command.isEnabled(client)).toBe(false); }); }); }); @@ -209,7 +217,7 @@ describe("SlashCommands", () => { const command = getCommand("/part #foo:bar"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); - await command.cmd!.run("room-id", null, command.args); + await command.cmd!.run(client, "room-id", null, command.args); expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything()); }); @@ -223,7 +231,7 @@ describe("SlashCommands", () => { const command = getCommand("/part #foo:bar"); expect(command.cmd).toBeDefined(); expect(command.args).toBeDefined(); - await command.cmd!.run("room-id", null, command.args!); + await command.cmd!.run(client, "room-id", null, command.args!); expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything()); }); }); @@ -232,11 +240,45 @@ describe("SlashCommands", () => { const command = findCommand(commandName)!; it("should return usage if no args", () => { - expect(command.run(roomId, null, undefined).error).toBe(command.getUsage()); + expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage()); }); it("should make things rainbowy", () => { - return expect(command.run(roomId, null, "this is a test message").promise).resolves.toMatchSnapshot(); + return expect( + command.run(client, roomId, null, "this is a test message").promise, + ).resolves.toMatchSnapshot(); + }); + }); + + describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => { + const command = findCommand(commandName)!; + + it("should match snapshot with no args", () => { + return expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot(); + }); + + it("should match snapshot with args", () => { + return expect( + command.run(client, roomId, null, "this is a test message").promise, + ).resolves.toMatchSnapshot(); + }); + }); + + describe("/addwidget", () => { + it("should parse html iframe snippets", async () => { + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); + const spy = jest.spyOn(WidgetUtils, "setRoomWidget"); + const command = findCommand("addwidget")!; + await command.run(client, roomId, null, ''); + expect(spy).toHaveBeenCalledWith( + client, + roomId, + expect.any(String), + WidgetType.CUSTOM, + "https://element.io", + "Custom", + {}, + ); }); }); }); diff --git a/test/__snapshots__/SlashCommands-test.tsx.snap b/test/__snapshots__/SlashCommands-test.tsx.snap index 925f5e878b..08d3bdcc47 100644 --- a/test/__snapshots__/SlashCommands-test.tsx.snap +++ b/test/__snapshots__/SlashCommands-test.tsx.snap @@ -1,5 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SlashCommands /lenny should match snapshot with args 1`] = ` +{ + "body": "( ͡° ͜ʖ ͡°) this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /lenny should match snapshot with no args 1`] = ` +{ + "body": "( ͡° ͜ʖ ͡°)", + "msgtype": "m.text", +} +`; + exports[`SlashCommands /rainbow should make things rainbowy 1`] = ` { "body": "this is a test message", @@ -17,3 +31,45 @@ exports[`SlashCommands /rainbowme should make things rainbowy 1`] = ` "msgtype": "m.emote", } `; + +exports[`SlashCommands /shrug should match snapshot with args 1`] = ` +{ + "body": "¯\\_(ツ)_/¯ this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /shrug should match snapshot with no args 1`] = ` +{ + "body": "¯\\_(ツ)_/¯", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /tableflip should match snapshot with args 1`] = ` +{ + "body": "(╯°□°)╯︵ ┻━┻ this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /tableflip should match snapshot with no args 1`] = ` +{ + "body": "(╯°□°)╯︵ ┻━┻", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /unflip should match snapshot with args 1`] = ` +{ + "body": "┬──┬ ノ( ゜-゜ノ) this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /unflip should match snapshot with no args 1`] = ` +{ + "body": "┬──┬ ノ( ゜-゜ノ)", + "msgtype": "m.text", +} +`;