From a8f632ae19c417e3aa9a1fe149273e32b2fa2969 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Jul 2023 13:53:33 +0100 Subject: [PATCH] Warn when demoting self via /op and /deop slash commands (#11214) * Warn when demoting self via /op and /deop slash commands * Iterate and DRY * i18n * Improve coverage * Improve coverage * Improve coverage * Iterate --- src/SlashCommands.tsx | 248 +----------------- src/components/views/right_panel/UserInfo.tsx | 2 +- src/i18n/strings/en_EN.json | 24 +- src/slash-commands/command.ts | 116 ++++++++ src/slash-commands/interface.ts | 34 +++ src/slash-commands/op.ts | 101 +++++++ src/slash-commands/utils.ts | 83 ++++++ test/SlashCommands-test.tsx | 90 ++++++- 8 files changed, 431 insertions(+), 267 deletions(-) create mode 100644 src/slash-commands/command.ts create mode 100644 src/slash-commands/interface.ts create mode 100644 src/slash-commands/op.ts create mode 100644 src/slash-commands/utils.ts diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index de2ec6adbc..929d6e6e6f 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -20,13 +20,10 @@ limitations under the License. import * as React from "react"; import { User } from "matrix-js-sdk/src/models/user"; import { Direction } from "matrix-js-sdk/src/models/event-timeline"; -import { EventType } from "matrix-js-sdk/src/@types/event"; import * as ContentHelpers from "matrix-js-sdk/src/content-helpers"; 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 dis from "./dispatcher/dispatcher"; import { _t, _td, UserFriendlyError } from "./languageHandler"; @@ -46,7 +43,6 @@ import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { Action } from "./dispatcher/actions"; -import { EffectiveMembership, getEffectiveMembership } from "./utils/membership"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; import { UIComponent, UIFeature } from "./settings/UIFeature"; @@ -54,184 +50,24 @@ import { CHAT_EFFECTS } from "./effects"; import LegacyCallHandler from "./LegacyCallHandler"; import { guessAndSetDMRoom } from "./Rooms"; import { upgradeRoom } from "./utils/RoomUpgrade"; -import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import DevtoolsDialog from "./components/views/dialogs/DevtoolsDialog"; import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; import InfoDialog from "./components/views/dialogs/InfoDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from "./contexts/RoomContext"; -import { XOR } from "./@types/common"; -import { PosthogAnalytics } from "./PosthogAnalytics"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import VoipUserMapper from "./VoipUserMapper"; import { htmlSerializeFromMdIfNeeded } from "./editor/serialize"; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; -import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; -import { SdkContextClass } from "./contexts/SDKContext"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { getDeviceCryptoInfo } from "./utils/crypto/deviceInfo"; +import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./slash-commands/utils"; +import { deop, op } from "./slash-commands/op"; +import { CommandCategories } from "./slash-commands/interface"; +import { Command } from "./slash-commands/command"; -// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 -interface HTMLInputEvent extends Event { - target: HTMLInputElement & EventTarget; -} - -const singleMxcUpload = async (cli: MatrixClient): Promise => { - return new Promise((resolve) => { - const fileSelector = document.createElement("input"); - fileSelector.setAttribute("type", "file"); - fileSelector.onchange = (ev: Event) => { - const file = (ev as HTMLInputEvent).target.files?.[0]; - if (!file) return; - - Modal.createDialog(UploadConfirmDialog, { - file, - onFinished: async (shouldContinue): Promise => { - if (shouldContinue) { - const { content_uri: uri } = await cli.uploadContent(file); - resolve(uri); - } else { - resolve(null); - } - }, - }); - }; - - fileSelector.click(); - }); -}; - -export const CommandCategories = { - messages: _td("Messages"), - actions: _td("Actions"), - admin: _td("Admin"), - advanced: _td("Advanced"), - effects: _td("Effects"), - other: _td("Other"), -}; - -export type RunResult = XOR<{ error: Error }, { promise: Promise }>; - -type RunFn = ( - this: Command, - matrixClient: MatrixClient, - roomId: string, - threadId: string | null, - args?: string, -) => RunResult; - -interface ICommandOpts { - command: string; - aliases?: string[]; - args?: string; - description: string; - analyticsName?: SlashCommandEvent["command"]; - runFn?: RunFn; - category: string; - hideCompletionAfterSpace?: boolean; - isEnabled?(matrixClient: MatrixClient | null): boolean; - renderingTypes?: TimelineRenderingType[]; -} - -export class Command { - public readonly command: string; - public readonly aliases: string[]; - public readonly args?: string; - public readonly description: string; - public readonly runFn?: RunFn; - public readonly category: string; - public readonly hideCompletionAfterSpace: boolean; - public readonly renderingTypes?: TimelineRenderingType[]; - public readonly analyticsName?: SlashCommandEvent["command"]; - private readonly _isEnabled?: (matrixClient: MatrixClient | null) => boolean; - - public constructor(opts: ICommandOpts) { - this.command = opts.command; - this.aliases = opts.aliases || []; - this.args = opts.args || ""; - this.description = opts.description; - this.runFn = opts.runFn?.bind(this); - this.category = opts.category || CommandCategories.other; - this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; - this._isEnabled = opts.isEnabled; - this.renderingTypes = opts.renderingTypes; - this.analyticsName = opts.analyticsName; - } - - public getCommand(): string { - return `/${this.command}`; - } - - public getCommandWithArgs(): string { - return this.getCommand() + " " + this.args; - } - - 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.")); - } - - const renderingType = threadId ? TimelineRenderingType.Thread : TimelineRenderingType.Room; - if (this.renderingTypes && !this.renderingTypes?.includes(renderingType)) { - return reject( - new UserFriendlyError("Command error: Unable to find rendering type (%(renderingType)s)", { - renderingType, - cause: undefined, - }), - ); - } - - if (this.analyticsName) { - PosthogAnalytics.instance.trackEvent({ - eventName: "SlashCommand", - command: this.analyticsName, - }); - } - - return this.runFn(matrixClient, roomId, threadId, args); - } - - public getUsage(): string { - return _t("Usage") + ": " + this.getCommandWithArgs(); - } - - public isEnabled(cli: MatrixClient | null): boolean { - return this._isEnabled?.(cli) ?? true; - } -} - -function reject(error?: any): RunResult { - return { error }; -} - -function success(promise: Promise = Promise.resolve()): RunResult { - return { promise }; -} - -function successSync(value: any): RunResult { - return success(Promise.resolve(value)); -} - -const isCurrentLocalRoom = (cli: MatrixClient | null): boolean => { - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return false; - const room = cli?.getRoom(roomId); - if (!room) return false; - return isLocalRoom(room); -}; - -const canAffectPowerlevels = (cli: MatrixClient | null): 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. - */ +export { CommandCategories, Command }; export const Commands = [ new Command({ @@ -886,78 +722,8 @@ export const Commands = [ }, category: CommandCategories.actions, }), - new Command({ - command: "op", - args: " []", - description: _td("Define the power level of a user"), - isEnabled: canAffectPowerlevels, - runFn: function (cli, roomId, threadId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(-?\d+))?$/); - let powerLevel = 50; // default power level for op - if (matches) { - const userId = matches[1]; - if (matches.length === 4 && undefined !== matches[3]) { - powerLevel = parseInt(matches[3], 10); - } - if (!isNaN(powerLevel)) { - const room = cli.getRoom(roomId); - if (!room) { - return reject( - new UserFriendlyError("Command failed: Unable to find room (%(roomId)s", { - roomId, - cause: undefined, - }), - ); - } - const member = room.getMember(userId); - if ( - !member?.membership || - getEffectiveMembership(member.membership) === EffectiveMembership.Leave - ) { - return reject(new UserFriendlyError("Could not find user in room")); - } - const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); - return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent)); - } - } - } - return reject(this.getUsage()); - }, - category: CommandCategories.admin, - renderingTypes: [TimelineRenderingType.Room], - }), - new Command({ - command: "deop", - args: "", - description: _td("Deops user with given id"), - isEnabled: canAffectPowerlevels, - runFn: function (cli, roomId, threadId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const room = cli.getRoom(roomId); - if (!room) { - return reject( - new UserFriendlyError("Command failed: Unable to find room (%(roomId)s", { - roomId, - cause: undefined, - }), - ); - } - - const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); - if (!powerLevelEvent?.getContent().users[args]) { - return reject(new UserFriendlyError("Could not find user in room")); - } - return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent)); - } - } - return reject(this.getUsage()); - }, - category: CommandCategories.admin, - renderingTypes: [TimelineRenderingType.Room], - }), + op, + deop, new Command({ command: "devtools", description: _td("Opens the Developer Tools dialog"), diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index aa06ef3cc7..72768064ca 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -513,7 +513,7 @@ export const UserOptionsSection: React.FC<{ ); }; -const warnSelfDemote = async (isSpace: boolean): Promise => { +export const warnSelfDemote = async (isSpace: boolean): Promise => { const { finished } = Modal.createDialog(QuestionDialog, { title: _t("Demote yourself?"), description: ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 45b3d453ff..4b3d180041 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -409,14 +409,6 @@ "Go Back": "Go Back", "Cancel": "Cancel", "Setting up keys": "Setting up keys", - "Messages": "Messages", - "Actions": "Actions", - "Advanced": "Advanced", - "Effects": "Effects", - "Other": "Other", - "Command error: Unable to handle slash command.": "Command error: Unable to handle slash command.", - "Command error: Unable to find rendering type (%(renderingType)s)": "Command error: Unable to find rendering type (%(renderingType)s)", - "Usage": "Usage", "Sends the given message as a spoiler": "Sends the given message as a spoiler", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message", @@ -455,10 +447,6 @@ "Stops ignoring a user, showing their messages going forward": "Stops ignoring a user, showing their messages going forward", "Unignored user": "Unignored user", "You are no longer ignoring %(userId)s": "You are no longer ignoring %(userId)s", - "Define the power level of a user": "Define the power level of a user", - "Command failed: Unable to find room (%(roomId)s": "Command failed: Unable to find room (%(roomId)s", - "Could not find user in room": "Could not find user in room", - "Deops user with given id": "Deops user with given id", "Opens the Developer Tools dialog": "Opens the Developer Tools dialog", "Adds a custom widget by URL to the room": "Adds a custom widget by URL to the room", "Please supply a widget URL or embed code": "Please supply a widget URL or embed code", @@ -938,6 +926,18 @@ "Unsent": "Unsent", "unknown": "unknown", "Change notification settings": "Change notification settings", + "Command error: Unable to handle slash command.": "Command error: Unable to handle slash command.", + "Command error: Unable to find rendering type (%(renderingType)s)": "Command error: Unable to find rendering type (%(renderingType)s)", + "Usage": "Usage", + "Messages": "Messages", + "Actions": "Actions", + "Advanced": "Advanced", + "Effects": "Effects", + "Other": "Other", + "Command failed: Unable to find room (%(roomId)s": "Command failed: Unable to find room (%(roomId)s", + "Could not find user in room": "Could not find user in room", + "Define the power level of a user": "Define the power level of a user", + "Deops user with given id": "Deops user with given id", "Messaging": "Messaging", "Profile": "Profile", "Spaces": "Spaces", diff --git a/src/slash-commands/command.ts b/src/slash-commands/command.ts new file mode 100644 index 0000000000..30fa7732f9 --- /dev/null +++ b/src/slash-commands/command.ts @@ -0,0 +1,116 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/types/typescript/SlashCommand"; + +import { TimelineRenderingType } from "../contexts/RoomContext"; +import { reject } from "./utils"; +import { _t, UserFriendlyError } from "../languageHandler"; +import { PosthogAnalytics } from "../PosthogAnalytics"; +import { CommandCategories, RunResult } from "./interface"; + +type RunFn = ( + this: Command, + matrixClient: MatrixClient, + roomId: string, + threadId: string | null, + args?: string, +) => RunResult; + +interface ICommandOpts { + command: string; + aliases?: string[]; + args?: string; + description: string; + analyticsName?: SlashCommandEvent["command"]; + runFn?: RunFn; + category: string; + hideCompletionAfterSpace?: boolean; + isEnabled?(matrixClient: MatrixClient | null): boolean; + renderingTypes?: TimelineRenderingType[]; +} + +export class Command { + public readonly command: string; + public readonly aliases: string[]; + public readonly args?: string; + public readonly description: string; + public readonly runFn?: RunFn; + public readonly category: string; + public readonly hideCompletionAfterSpace: boolean; + public readonly renderingTypes?: TimelineRenderingType[]; + public readonly analyticsName?: SlashCommandEvent["command"]; + private readonly _isEnabled?: (matrixClient: MatrixClient | null) => boolean; + + public constructor(opts: ICommandOpts) { + this.command = opts.command; + this.aliases = opts.aliases || []; + this.args = opts.args || ""; + this.description = opts.description; + this.runFn = opts.runFn?.bind(this); + this.category = opts.category || CommandCategories.other; + this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; + this._isEnabled = opts.isEnabled; + this.renderingTypes = opts.renderingTypes; + this.analyticsName = opts.analyticsName; + } + + public getCommand(): string { + return `/${this.command}`; + } + + public getCommandWithArgs(): string { + return this.getCommand() + " " + this.args; + } + + 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.")); + } + + const renderingType = threadId ? TimelineRenderingType.Thread : TimelineRenderingType.Room; + if (this.renderingTypes && !this.renderingTypes?.includes(renderingType)) { + return reject( + new UserFriendlyError("Command error: Unable to find rendering type (%(renderingType)s)", { + renderingType, + cause: undefined, + }), + ); + } + + if (this.analyticsName) { + PosthogAnalytics.instance.trackEvent({ + eventName: "SlashCommand", + command: this.analyticsName, + }); + } + + return this.runFn(matrixClient, roomId, threadId, args); + } + + public getUsage(): string { + return _t("Usage") + ": " + this.getCommandWithArgs(); + } + + public isEnabled(cli: MatrixClient | null): boolean { + return this._isEnabled?.(cli) ?? true; + } +} diff --git a/src/slash-commands/interface.ts b/src/slash-commands/interface.ts new file mode 100644 index 0000000000..9320727181 --- /dev/null +++ b/src/slash-commands/interface.ts @@ -0,0 +1,34 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IContent } from "matrix-js-sdk/src/matrix"; + +import { _td } from "../languageHandler"; +import { XOR } from "../@types/common"; + +export const CommandCategories = { + messages: _td("Messages"), + actions: _td("Actions"), + admin: _td("Admin"), + advanced: _td("Advanced"), + effects: _td("Effects"), + other: _td("Other"), +}; + +export type RunResult = XOR<{ error: Error }, { promise: Promise }>; diff --git a/src/slash-commands/op.ts b/src/slash-commands/op.ts new file mode 100644 index 0000000000..8af22edba4 --- /dev/null +++ b/src/slash-commands/op.ts @@ -0,0 +1,101 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { _td, UserFriendlyError } from "../languageHandler"; +import { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; +import { warnSelfDemote } from "../components/views/right_panel/UserInfo"; +import { TimelineRenderingType } from "../contexts/RoomContext"; +import { canAffectPowerlevels, success, reject } from "./utils"; +import { CommandCategories, RunResult } from "./interface"; +import { Command } from "./command"; + +const updatePowerLevel = async (room: Room, member: RoomMember, powerLevel: number | undefined): Promise => { + // Only warn if the target is ourselves and the power level is decreasing or being unset + if (member.userId === room.client.getUserId() && (powerLevel === undefined || member.powerLevel > powerLevel)) { + const ok = await warnSelfDemote(room.isSpaceRoom()); + if (!ok) return; // Nothing to do + } + return room.client.setPowerLevel(room.roomId, member.userId, powerLevel); +}; + +const updatePowerLevelHelper = ( + client: MatrixClient, + roomId: string, + userId: string, + powerLevel: number | undefined, +): RunResult => { + const room = client.getRoom(roomId); + if (!room) { + return reject( + new UserFriendlyError("Command failed: Unable to find room (%(roomId)s", { + roomId, + cause: undefined, + }), + ); + } + const member = room.getMember(userId); + if (!member?.membership || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) { + return reject(new UserFriendlyError("Could not find user in room")); + } + + return success(updatePowerLevel(room, member, powerLevel)); +}; + +export const op = new Command({ + command: "op", + args: " []", + description: _td("Define the power level of a user"), + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, threadId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); + let powerLevel = 50; // default power level for op + if (matches) { + const userId = matches[1]; + if (matches.length === 4 && undefined !== matches[3]) { + powerLevel = parseInt(matches[3], 10); + } + return updatePowerLevelHelper(cli, roomId, userId, powerLevel); + } + } + return reject(this.getUsage()); + }, + category: CommandCategories.admin, + renderingTypes: [TimelineRenderingType.Room], +}); + +export const deop = new Command({ + command: "deop", + args: "", + description: _td("Deops user with given id"), + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, threadId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + return updatePowerLevelHelper(cli, roomId, args, undefined); + } + } + return reject(this.getUsage()); + }, + category: CommandCategories.admin, + renderingTypes: [TimelineRenderingType.Room], +}); diff --git a/src/slash-commands/utils.ts b/src/slash-commands/utils.ts new file mode 100644 index 0000000000..122a90db1b --- /dev/null +++ b/src/slash-commands/utils.ts @@ -0,0 +1,83 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { SdkContextClass } from "../contexts/SDKContext"; +import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; +import Modal from "../Modal"; +import UploadConfirmDialog from "../components/views/dialogs/UploadConfirmDialog"; +import { RunResult } from "./interface"; + +export function reject(error?: any): RunResult { + return { error }; +} + +export function success(promise: Promise = Promise.resolve()): RunResult { + return { promise }; +} + +export function successSync(value: any): RunResult { + return success(Promise.resolve(value)); +} + +export const canAffectPowerlevels = (cli: MatrixClient | null): 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); +}; + +// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 +interface HTMLInputEvent extends Event { + target: HTMLInputElement & EventTarget; +} + +export const singleMxcUpload = async (cli: MatrixClient): Promise => { + return new Promise((resolve) => { + const fileSelector = document.createElement("input"); + fileSelector.setAttribute("type", "file"); + fileSelector.onchange = (ev: Event) => { + const file = (ev as HTMLInputEvent).target.files?.[0]; + if (!file) return; + + Modal.createDialog(UploadConfirmDialog, { + file, + onFinished: async (shouldContinue): Promise => { + if (shouldContinue) { + const { content_uri: uri } = await cli.uploadContent(file); + resolve(uri); + } else { + resolve(null); + } + }, + }); + }; + + fileSelector.click(); + }); +}; + +export const isCurrentLocalRoom = (cli: MatrixClient | null): boolean => { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!roomId) return false; + const room = cli?.getRoom(roomId); + if (!room) return false; + return isLocalRoom(room); +}; diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index fcd6d6e4c8..26820386e2 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import { Command, Commands, getCommand } from "../src/SlashCommands"; @@ -26,6 +26,9 @@ import { SdkContextClass } from "../src/contexts/SDKContext"; import Modal from "../src/Modal"; import WidgetUtils from "../src/utils/WidgetUtils"; import { WidgetType } from "../src/widgets/WidgetType"; +import { warnSelfDemote } from "../src/components/views/right_panel/UserInfo"; + +jest.mock("../src/components/views/right_panel/UserInfo"); describe("SlashCommands", () => { let client: MatrixClient; @@ -47,7 +50,7 @@ describe("SlashCommands", () => { }); }; - const setCurrentLocalRoon = (): void => { + const setCurrentLocalRoom = (): void => { mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId); mocked(client.getRoom).mockImplementation((rId: string): Room | null => { if (rId === localRoomId) return localRoom; @@ -60,8 +63,8 @@ describe("SlashCommands", () => { client = createTestClient(); - room = new Room(roomId, client, client.getUserId()!); - localRoom = new LocalRoom(localRoomId, client, client.getUserId()!); + room = new Room(roomId, client, client.getSafeUserId()); + localRoom = new LocalRoom(localRoomId, client, client.getSafeUserId()); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId"); }); @@ -116,12 +119,73 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); }); + describe("/op", () => { + beforeEach(() => { + command = findCommand("op")!; + }); + + it("should return usage if no args", () => { + expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage()); + }); + + it("should reject with usage if given an invalid power level value", () => { + expect(command.run(client, roomId, null, "@bob:server Admin").error).toBe(command.getUsage()); + }); + + it("should reject with usage for invalid input", () => { + expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage()); + }); + + it("should warn about self demotion", async () => { + setCurrentRoom(); + const member = new RoomMember(roomId, client.getSafeUserId()); + member.membership = "join"; + member.powerLevel = 100; + room.getMember = () => member; + command.run(client, roomId, null, `${client.getUserId()} 0`); + expect(warnSelfDemote).toHaveBeenCalled(); + }); + + it("should default to 50 if no powerlevel specified", async () => { + setCurrentRoom(); + const member = new RoomMember(roomId, "@user:server"); + member.membership = "join"; + room.getMember = () => member; + command.run(client, roomId, null, member.userId); + expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50); + }); + }); + + describe("/deop", () => { + beforeEach(() => { + command = findCommand("deop")!; + }); + + it("should return usage if no args", () => { + expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage()); + }); + + it("should warn about self demotion", async () => { + setCurrentRoom(); + const member = new RoomMember(roomId, client.getSafeUserId()); + member.membership = "join"; + member.powerLevel = 100; + room.getMember = () => member; + command.run(client, roomId, null, client.getSafeUserId()); + expect(warnSelfDemote).toHaveBeenCalled(); + }); + + it("should reject with usage for invalid input", () => { + expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage()); + }); + }); + describe("/tovirtual", () => { beforeEach(() => { command = findCommand("tovirtual")!; @@ -139,7 +203,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -155,7 +219,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -181,7 +245,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -199,7 +263,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -208,9 +272,9 @@ describe("SlashCommands", () => { describe("/part", () => { it("should part room matching alias if found", async () => { - const room1 = new Room("room-id", client, client.getUserId()!); + const room1 = new Room("room-id", client, client.getSafeUserId()); room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar"); - const room2 = new Room("other-room", client, client.getUserId()!); + const room2 = new Room("other-room", client, client.getSafeUserId()); room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar"); mocked(client.getRooms).mockReturnValue([room1, room2]); @@ -222,9 +286,9 @@ describe("SlashCommands", () => { }); it("should part room matching alt alias if found", async () => { - const room1 = new Room("room-id", client, client.getUserId()!); + const room1 = new Room("room-id", client, client.getSafeUserId()); room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]); - const room2 = new Room("other-room", client, client.getUserId()!); + const room2 = new Room("other-room", client, client.getSafeUserId()); room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]); mocked(client.getRooms).mockReturnValue([room1, room2]);