diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 444814e69a..a3d0e4569e 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -24,10 +24,11 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import { logger } from "matrix-js-sdk/src/logger"; +import { IContent } from 'matrix-js-sdk/src/models/event'; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import { _t, _td, newTranslatableError } from './languageHandler'; +import { _t, _td, newTranslatableError, ITranslatableError } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; @@ -60,6 +61,7 @@ import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpD import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from './contexts/RoomContext'; import RoomViewStore from "./stores/RoomViewStore"; +import { XOR } from "./@types/common"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -94,7 +96,9 @@ export const CommandCategories = { "other": _td("Other"), }; -type RunFn = ((roomId: string, args: string, cmd: string) => {error: any} | {promise: Promise}); +export type RunResult = XOR<{ error: Error | ITranslatableError }, { promise: Promise }>; + +type RunFn = ((roomId: string, args: string, cmd: string) => RunResult); interface ICommandOpts { command: string; @@ -109,15 +113,15 @@ interface ICommandOpts { } export class Command { - command: string; - aliases: string[]; - args: undefined | string; - description: string; - runFn: undefined | RunFn; - category: string; - hideCompletionAfterSpace: boolean; - private _isEnabled?: () => boolean; - public renderingTypes?: TimelineRenderingType[]; + public readonly command: string; + public readonly aliases: string[]; + public readonly args: undefined | string; + public readonly description: string; + public readonly runFn: undefined | RunFn; + public readonly category: string; + public readonly hideCompletionAfterSpace: boolean; + public readonly renderingTypes?: TimelineRenderingType[]; + private readonly _isEnabled?: () => boolean; constructor(opts: ICommandOpts) { this.command = opts.command; @@ -131,15 +135,15 @@ export class Command { this.renderingTypes = opts.renderingTypes; } - getCommand() { + public getCommand() { return `/${this.command}`; } - getCommandWithArgs() { + public getCommandWithArgs() { return this.getCommand() + " " + this.args; } - run(roomId: string, threadId: string, args: string) { + public run(roomId: string, threadId: string, args: string): RunResult { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) { reject( @@ -166,11 +170,11 @@ export class Command { return this.runFn.bind(this)(roomId, args); } - getUsage() { + public getUsage() { return _t('Usage') + ': ' + this.getCommandWithArgs(); } - isEnabled(): boolean { + public isEnabled(): boolean { return this._isEnabled ? this._isEnabled() : true; } } @@ -1289,7 +1293,6 @@ interface ICmd { /** * Process the given text for /commands and return a bound method to perform them. - * @param {string} roomId The room in which the command was performed. * @param {string} input The raw text input by the user. * @return {null|function(): Object} Function returning an object with the property 'error' if there was an error * processing the command, or 'promise' if a request was sent out. diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 36e2e5bf2d..e365d976e4 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -21,25 +21,22 @@ import { MsgType } from 'matrix-js-sdk/src/@types/event'; import { Room } from 'matrix-js-sdk/src/models/room'; import { logger } from "matrix-js-sdk/src/logger"; -import { _t, _td } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; import { getCaretOffsetAndText } from '../../../editor/dom'; import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize'; import { findEditableEvent } from '../../../utils/EventUtils'; import { parseEvent } from '../../../editor/deserialize'; -import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts'; +import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; -import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; +import { CommandCategories } from '../../../SlashCommands'; import { Action } from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SendHistoryManager from '../../../SendHistoryManager'; -import Modal from '../../../Modal'; -import ErrorDialog from "../dialogs/ErrorDialog"; -import QuestionDialog from "../dialogs/QuestionDialog"; import { ActionPayload } from "../../../dispatcher/payloads"; import AccessibleButton from '../elements/AccessibleButton'; import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; @@ -47,6 +44,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext'; import RoomContext from '../../../contexts/RoomContext'; import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -282,22 +280,6 @@ class EditMessageComposer extends React.Component { - // use mxid to textify user pills in a command - if (part.type === Type.UserPill) { - return text + part.resourceId; - } - return text + part.text; - }, ""); - const { cmd, args } = getCommand(commandText); - return [cmd, args, commandText]; - } - - private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise { - const threadId = this.props.editState?.getEvent()?.getThread()?.id || null; - - const result = cmd.run(roomId, threadId, args); - let messageContent; - let error = result.error; - if (result.promise) { - try { - if (cmd.category === CommandCategories.messages) { - messageContent = await result.promise; - } else { - await result.promise; - } - } catch (err) { - error = err; - } - } - if (error) { - logger.error("Command failure: %s", error); - // assume the error is a server error when the command is async - const isServerError = !!result.promise; - const title = isServerError ? _td("Server error") : _td("Command error"); - - let errText; - if (typeof error === 'string') { - errText = error; - } else if (error.message) { - errText = error.message; - } else { - errText = _t("Server unavailable, overloaded, or something else went wrong."); - } - - Modal.createTrackedDialog(title, '', ErrorDialog, { - title: _t(title), - description: errText, - }); - } else { - logger.log("Command success."); - if (messageContent) return messageContent; - } - } - private sendEdit = async (): Promise => { const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); @@ -389,40 +317,22 @@ class EditMessageComposer extends React.Component -

- { _t("Unrecognised command: %(commandText)s", { commandText }) } -

-

- { _t("You can use /help to list available commands. " + - "Did you mean to send this as a message?", {}, { - code: t => { t }, - }) } -

-

- { _t("Hint: Begin your message with // to start it with a slash.", {}, { - code: t => { t }, - }) } -

- , - button: _t('Send as message'), - }); - const [sendAnyway] = await finished; + } else if (!await shouldSendAnyway(commandText)) { // if !sendAnyway bail to let the user edit the composer and try again - if (!sendAnyway) return; + return; } } if (shouldSend) { diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 6f83fe06e2..84bb756858 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -34,13 +34,11 @@ import { unescapeMessage, } from '../../../editor/serialize'; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; -import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; +import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts'; import ReplyChain from "../elements/ReplyChain"; import { findEditableEvent } from '../../../utils/EventUtils'; import SendHistoryManager from "../../../SendHistoryManager"; -import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; -import Modal from '../../../Modal'; -import { _t, _td } from '../../../languageHandler'; +import { CommandCategories } from '../../../SlashCommands'; import ContentMessages from '../../../ContentMessages'; import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; @@ -52,13 +50,12 @@ import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindin import { replaceableComponent } from "../../../utils/replaceableComponent"; import SettingsStore from '../../../settings/SettingsStore'; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import QuestionDialog from "../dialogs/QuestionDialog"; import { ActionPayload } from "../../../dispatcher/payloads"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import DocumentPosition from "../../../editor/position"; import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands"; function addReplyToMessageContent( content: IContent, @@ -284,24 +281,6 @@ export class SendMessageComposer extends React.Component { - // use mxid to textify user pills in a command - if (part.type === "user-pill") { - return text + part.resourceId; - } - return text + part.text; - }, ""); - const { cmd, args } = getCommand(commandText); - return [cmd, args, commandText]; - } - - private async runSlashCommand(cmd: Command, args: string): Promise { - const threadId = this.props.relation?.rel_type === RelationType.Thread - ? this.props.relation?.event_id - : null; - - const result = cmd.run(this.props.room.roomId, threadId, args); - let messageContent; - let error = result.error; - if (result.promise) { - try { - if (cmd.category === CommandCategories.messages) { - // The command returns a modified message that we need to pass on - messageContent = await result.promise; - } else { - await result.promise; - } - } catch (err) { - error = err; - } - } - if (error) { - logger.error("Command failure: %s", error); - // assume the error is a server error when the command is async - const isServerError = !!result.promise; - const title = isServerError ? _td("Server error") : _td("Command error"); - - let errText; - if (typeof error === 'string') { - errText = error; - } else if (error.translatedMessage) { - // Check for translatable errors (newTranslatableError) - errText = error.translatedMessage; - } else if (error.message) { - errText = error.message; - } else { - errText = _t("Server unavailable, overloaded, or something else went wrong."); - } - - Modal.createTrackedDialog(title, '', ErrorDialog, { - title: _t(title), - description: errText, - }); - } else { - logger.log("Command success."); - if (messageContent) return messageContent; - } - } - public async sendMessage(): Promise { const model = this.model; @@ -416,50 +335,32 @@ export class SendMessageComposer extends React.Component -

- { _t("Unrecognised command: %(commandText)s", { commandText }) } -

-

- { _t("You can use /help to list available commands. " + - "Did you mean to send this as a message?", {}, { - code: t => { t }, - }) } -

-

- { _t("Hint: Begin your message with // to start it with a slash.", {}, { - code: t => { t }, - }) } -

- , - button: _t('Send as message'), - }); - const [sendAnyway] = await finished; + } else if (!await shouldSendAnyway(commandText)) { // if !sendAnyway bail to let the user edit the composer and try again - if (!sendAnyway) return; + return; } } diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx new file mode 100644 index 0000000000..e434eadd14 --- /dev/null +++ b/src/editor/commands.tsx @@ -0,0 +1,129 @@ +/* +Copyright 2019 - 2022 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 React from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { IContent } from "matrix-js-sdk/src/models/event"; + +import EditorModel from "./model"; +import { Type } from "./parts"; +import { Command, CommandCategories, getCommand } from "../SlashCommands"; +import { ITranslatableError, _t, _td } from "../languageHandler"; +import Modal from "../Modal"; +import ErrorDialog from "../components/views/dialogs/ErrorDialog"; +import QuestionDialog from "../components/views/dialogs/QuestionDialog"; + +export function isSlashCommand(model: EditorModel): boolean { + const parts = model.parts; + const firstPart = parts[0]; + if (firstPart) { + if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) { + return true; + } + + if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") + && (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) { + return true; + } + } + return false; +} + +export function getSlashCommand(model: EditorModel): [Command, string, string] { + const commandText = model.parts.reduce((text, part) => { + // use mxid to textify user pills in a command and room alias/id for room pills + if (part.type === Type.UserPill || part.type === Type.RoomPill) { + return text + part.resourceId; + } + return text + part.text; + }, ""); + const { cmd, args } = getCommand(commandText); + return [cmd, args, commandText]; +} + +export async function runSlashCommand( + cmd: Command, + args: string, + roomId: string, + threadId: string | null, +): Promise { + const result = cmd.run(roomId, threadId, args); + let messageContent: IContent | null = null; + let error = result.error; + if (result.promise) { + try { + if (cmd.category === CommandCategories.messages) { + messageContent = await result.promise; + } else { + await result.promise; + } + } catch (err) { + error = err; + } + } + if (error) { + logger.error("Command failure: %s", error); + // assume the error is a server error when the command is async + const isServerError = !!result.promise; + const title = isServerError ? _td("Server error") : _td("Command error"); + + let errText; + if (typeof error === 'string') { + errText = error; + } else if ((error as ITranslatableError).translatedMessage) { + // Check for translatable errors (newTranslatableError) + errText = (error as ITranslatableError).translatedMessage; + } else if (error.message) { + errText = error.message; + } else { + errText = _t("Server unavailable, overloaded, or something else went wrong."); + } + + Modal.createTrackedDialog(title, '', ErrorDialog, { + title: _t(title), + description: errText, + }); + } else { + logger.log("Command success."); + if (messageContent) return messageContent; + } +} + +export async function shouldSendAnyway(commandText: string): Promise { + // ask the user if their unknown command should be sent as a message + const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { + title: _t("Unknown Command"), + description:
+

+ { _t("Unrecognised command: %(commandText)s", { commandText }) } +

+

+ { _t("You can use /help to list available commands. " + + "Did you mean to send this as a message?", {}, { + code: t => { t }, + }) } +

+

+ { _t("Hint: Begin your message with // to start it with a slash.", {}, { + code: t => { t }, + }) } +

+
, + button: _t('Send as message'), + }); + const [sendAnyway] = await finished; + return sendAnyway; +} diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index ed77c733bd..8dc4ed58df 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -179,7 +179,7 @@ export function textSerialize(model: EditorModel): string { } export function containsEmote(model: EditorModel): boolean { - return startsWith(model, "/me ", false); + return startsWith(model, "/me ", false) && model.parts[0]?.text?.length > 4; } export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 68abb912d7..e22f07e1db 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -976,6 +976,14 @@ "sends snowfall": "sends snowfall", "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", "sends space invaders": "sends space invaders", + "Server error": "Server error", + "Command error": "Command error", + "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", + "Unknown Command": "Unknown Command", + "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", + "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", "unknown person": "unknown person", "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", "You held the call Switch": "You held the call Switch", @@ -1632,14 +1640,6 @@ "Someone is using an unknown session": "Someone is using an unknown session", "This room is end-to-end encrypted": "This room is end-to-end encrypted", "Everyone in this room is verified": "Everyone in this room is verified", - "Server error": "Server error", - "Command error": "Command error", - "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", - "Unknown Command": "Unknown Command", - "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", - "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", - "Send as message": "Send as message", "Edit message": "Edit message", "Mod": "Mod", "%(count)s reply|other": "%(count)s replies", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 8439b18652..e9991dcad0 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -43,7 +43,7 @@ counterpart.setSeparator('|'); const FALLBACK_LOCALE = 'en'; counterpart.setFallbackLocale(FALLBACK_LOCALE); -interface ITranslatableError extends Error { +export interface ITranslatableError extends Error { translatedMessage: string; } @@ -51,6 +51,7 @@ interface ITranslatableError extends Error { * Helper function to create an error which has an English message * with a translatedMessage property for use by the consumer. * @param {string} message Message to translate. + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } * @returns {Error} The constructed error. */ export function newTranslatableError(message: string, variables?: IVariables): ITranslatableError {