From 04271e7d74a242f36cfc0a6d89f3354538d75935 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Jul 2023 14:28:27 +0100 Subject: [PATCH 1/7] Warn when demoting self via /op and /deop slash commands --- src/SlashCommands.tsx | 40 +++++++++++++++++-- src/components/views/right_panel/UserInfo.tsx | 2 +- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index de2ec6adbc6..886d77f62a4 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -71,6 +71,7 @@ import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; import { SdkContextClass } from "./contexts/SDKContext"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { getDeviceCryptoInfo } from "./utils/crypto/deviceInfo"; +import { warnSelfDemote } from "./components/views/right_panel/UserInfo"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -917,8 +918,24 @@ export const Commands = [ ) { 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)); + + const updatePowerlevel = (): Promise => { + const powerLevelEvent = room!.currentState.getStateEvents("m.room.power_levels", ""); + return cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent); + }; + + let prom: Promise = Promise.resolve(); + if (member.userId === cli.getUserId() && member.powerLevel > powerLevel) { + const warningPromise = warnSelfDemote(room.isSpaceRoom()); + prom = warningPromise.then((ok) => { + if (ok) { + prom = updatePowerlevel(); + } + }); + } else { + prom = updatePowerlevel(); + } + return success(prom); } } } @@ -950,7 +967,24 @@ export const Commands = [ if (!powerLevelEvent?.getContent().users[args]) { return reject(new UserFriendlyError("Could not find user in room")); } - return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent)); + + const updatePowerlevel = (): Promise => { + const powerLevelEvent = room!.currentState.getStateEvents("m.room.power_levels", ""); + return cli.setPowerLevel(roomId, args, undefined, powerLevelEvent); + }; + + let prom: Promise = Promise.resolve(); + if (args === cli.getUserId()) { + const warningPromise = warnSelfDemote(room.isSpaceRoom()); + prom = warningPromise.then((ok) => { + if (ok) { + prom = updatePowerlevel(); + } + }); + } else { + prom = updatePowerlevel(); + } + return success(prom); } } return reject(this.getUsage()); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index aa06ef3cc70..72768064ca0 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: ( From 6015543517b0ca425f02f60d82a704a2ec49c93b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Jul 2023 17:18:35 +0100 Subject: [PATCH 2/7] Iterate and DRY --- src/SlashCommands.tsx | 282 +------------------------------- src/slash-commands/command.ts | 116 +++++++++++++ src/slash-commands/interface.ts | 34 ++++ src/slash-commands/op.ts | 103 ++++++++++++ src/slash-commands/utils.ts | 83 ++++++++++ 5 files changed, 343 insertions(+), 275 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 886d77f62a4..929d6e6e6f9 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,185 +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 { warnSelfDemote } from "./components/views/right_panel/UserInfo"; +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({ @@ -887,111 +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 updatePowerlevel = (): Promise => { - const powerLevelEvent = room!.currentState.getStateEvents("m.room.power_levels", ""); - return cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent); - }; - - let prom: Promise = Promise.resolve(); - if (member.userId === cli.getUserId() && member.powerLevel > powerLevel) { - const warningPromise = warnSelfDemote(room.isSpaceRoom()); - prom = warningPromise.then((ok) => { - if (ok) { - prom = updatePowerlevel(); - } - }); - } else { - prom = updatePowerlevel(); - } - return success(prom); - } - } - } - 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")); - } - - const updatePowerlevel = (): Promise => { - const powerLevelEvent = room!.currentState.getStateEvents("m.room.power_levels", ""); - return cli.setPowerLevel(roomId, args, undefined, powerLevelEvent); - }; - - let prom: Promise = Promise.resolve(); - if (args === cli.getUserId()) { - const warningPromise = warnSelfDemote(room.isSpaceRoom()); - prom = warningPromise.then((ok) => { - if (ok) { - prom = updatePowerlevel(); - } - }); - } else { - prom = updatePowerlevel(); - } - return success(prom); - } - } - 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/slash-commands/command.ts b/src/slash-commands/command.ts new file mode 100644 index 00000000000..30fa7732f9b --- /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 00000000000..93207271813 --- /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 00000000000..865685d33e7 --- /dev/null +++ b/src/slash-commands/op.ts @@ -0,0 +1,103 @@ +/* +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); + } + if (!isNaN(powerLevel)) { + 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 00000000000..122a90db1b7 --- /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); +}; From 68b44615eebd540b09aed0de779083a56316a10a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Jul 2023 17:28:59 +0100 Subject: [PATCH 3/7] i18n --- src/i18n/strings/en_EN.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b545df98923..d3a0cc43588 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -408,14 +408,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", @@ -454,10 +446,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", @@ -937,6 +925,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", From 17f0fc829f1453913d01d428e32d105f63a1a75c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Jul 2023 11:37:28 +0100 Subject: [PATCH 4/7] Improve coverage --- test/SlashCommands-test.tsx | 49 +++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index fcd6d6e4c88..f6db5814409 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,32 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); }); + describe("/op", () => { + beforeEach(() => { + command = findCommand("op")!; + }); + + 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 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(); + }); + }); + describe("/tovirtual", () => { beforeEach(() => { command = findCommand("tovirtual")!; @@ -139,7 +162,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -155,7 +178,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -181,7 +204,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -199,7 +222,7 @@ describe("SlashCommands", () => { }); it("should return false for LocalRoom", () => { - setCurrentLocalRoon(); + setCurrentLocalRoom(); expect(command.isEnabled(client)).toBe(false); }); }); @@ -208,9 +231,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 +245,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]); From 9600c5734b0d20b3fde5f9fb1a8accf501487d75 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Jul 2023 12:25:49 +0100 Subject: [PATCH 5/7] Improve coverage --- test/SlashCommands-test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index f6db5814409..2c4b333b139 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -145,6 +145,22 @@ describe("SlashCommands", () => { }); }); + describe("/deop", () => { + beforeEach(() => { + command = findCommand("deop")!; + }); + + 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(); + }); + }); + describe("/tovirtual", () => { beforeEach(() => { command = findCommand("tovirtual")!; From 3a1010ac52a663ae3c9e40f2fb25ac6289119e2e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Jul 2023 12:53:23 +0100 Subject: [PATCH 6/7] Improve coverage --- test/SlashCommands-test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 2c4b333b139..8ecb4eca314 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -130,6 +130,10 @@ describe("SlashCommands", () => { 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()); }); @@ -143,6 +147,15 @@ describe("SlashCommands", () => { 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", () => { @@ -150,6 +163,10 @@ describe("SlashCommands", () => { 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()); From 76a9c170abd27c68c9f8c9434ec869ea39948b7e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Jul 2023 13:30:03 +0100 Subject: [PATCH 7/7] Iterate --- src/slash-commands/op.ts | 4 +--- test/SlashCommands-test.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/slash-commands/op.ts b/src/slash-commands/op.ts index 865685d33e7..8af22edba44 100644 --- a/src/slash-commands/op.ts +++ b/src/slash-commands/op.ts @@ -73,9 +73,7 @@ export const op = new Command({ if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3], 10); } - if (!isNaN(powerLevel)) { - return updatePowerLevelHelper(cli, roomId, userId, powerLevel); - } + return updatePowerLevelHelper(cli, roomId, userId, powerLevel); } } return reject(this.getUsage()); diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 8ecb4eca314..26820386e25 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -138,6 +138,10 @@ describe("SlashCommands", () => { 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()); @@ -176,6 +180,10 @@ describe("SlashCommands", () => { 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", () => {