diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 20b8ba76da..2eb34576ac 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -81,6 +81,8 @@ class Command { } run(roomId, args) { + // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` + if (!this.runFn) return; return this.runFn.bind(this)(roomId, args); } @@ -905,25 +907,25 @@ const aliases = { /** - * Process the given text for /commands and perform them. + * 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 {Object|null} An object with the property 'error' if there was an error + * @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. * Returns null if the input didn't match a command. */ -export function processCommandInput(roomId, input) { +export function getCommand(roomId, input) { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return null; // not a command - const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/); let cmd; let args; if (bits) { cmd = bits[1].substring(1).toLowerCase(); - args = bits[3]; + args = bits[2]; } else { cmd = input; } @@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) { cmd = aliases[cmd]; } if (CommandMap[cmd]) { - // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` - if (!CommandMap[cmd].runFn) return null; - - return CommandMap[cmd].run(roomId, args); - } else { - return reject(_t('Unrecognised command:') + ' ' + input); + return () => CommandMap[cmd].run(roomId, args); } } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index e3b794b1d0..63e58bf738 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -24,6 +24,8 @@ import { containsEmote, stripEmoteCommand, unescapeMessage, + startsWith, + stripPrefix, } from '../../../editor/serialize'; import {CommandPartCreator} from '../../../editor/parts'; import BasicMessageComposer from "./BasicMessageComposer"; @@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread"; import {parseEvent} from '../../../editor/deserialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import SendHistoryManager from "../../../SendHistoryManager"; -import {processCommandInput} from '../../../SlashCommands'; +import {getCommand} from '../../../SlashCommands'; import * as sdk from '../../../index'; import Modal from '../../../Modal'; import {_t, _td} from '../../../languageHandler'; @@ -56,11 +58,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { } } -function createMessageContent(model, permalinkCreator) { +// exported for tests +export function createMessageContent(model, permalinkCreator) { const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); } + if (startsWith(model, "//")) { + model = stripPrefix(model, "/"); + } model = unescapeMessage(model); const repliedToEvent = RoomViewStore.getQuotingEvent(); @@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component { const parts = this.model.parts; const firstPart = parts[0]; if (firstPart) { - if (firstPart.type === "command") { + if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) { return true; } // be extra resilient when somehow the AutocompleteWrapperModel or // CommandPartCreator fails to insert a command part, so we don't send // a command as a message - if (firstPart.text.startsWith("/") && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { + if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") + && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { return true; } } return false; } - async _runSlashCommand() { + _getSlashCommand() { const commandText = this.model.parts.reduce((text, part) => { // use mxid to textify user pills in a command if (part.type === "user-pill") { @@ -196,50 +203,86 @@ export default class SendMessageComposer extends React.Component { } return text + part.text; }, ""); - const cmd = processCommandInput(this.props.room.roomId, commandText); + return [getCommand(this.props.room.roomId, commandText), commandText]; + } - if (cmd) { - let error = cmd.error; - if (cmd.promise) { - try { - await cmd.promise; - } catch (err) { - error = err; - } + async _runSlashCommand(fn) { + const cmd = fn(); + let error = cmd.error; + if (cmd.promise) { + try { + await cmd.promise; + } catch (err) { + error = err; } - if (error) { - console.error("Command failure: %s", error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // assume the error is a server error when the command is async - const isServerError = !!cmd.promise; - const title = isServerError ? _td("Server error") : _td("Command error"); + } + if (error) { + console.error("Command failure: %s", error); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // assume the error is a server error when the command is async + const isServerError = !!cmd.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, - }); + let errText; + if (typeof error === 'string') { + errText = error; + } else if (error.message) { + errText = error.message; } else { - console.log("Command success."); + errText = _t("Server unavailable, overloaded, or something else went wrong."); } + + Modal.createTrackedDialog(title, '', ErrorDialog, { + title: _t(title), + description: errText, + }); + } else { + console.log("Command success."); } } - _sendMessage() { + async _sendMessage() { if (this.model.isEmpty) { return; } + + let shouldSend = true; + if (!containsEmote(this.model) && this._isSlashCommand()) { - this._runSlashCommand(); - } else { + const [cmd, commandText] = this._getSlashCommand(); + if (cmd) { + shouldSend = false; + this._runSlashCommand(cmd); + } else { + // ask the user if their unknown command should be sent as a message + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + 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 }
,
+ }) }
+
/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",
"Failed to connect to integration manager": "Failed to connect to integration manager",
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now",
diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js
new file mode 100644
index 0000000000..d5a143a1fb
--- /dev/null
+++ b/test/components/views/rooms/SendMessageComposer-test.js
@@ -0,0 +1,83 @@
+/*
+Copyright 2020 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 RoomViewStore from "../../../../src/stores/RoomViewStore";
+import {createMessageContent} from "../../../../src/components/views/rooms/SendMessageComposer";
+import EditorModel from "../../../../src/editor/model";
+import {createPartCreator, createRenderer} from "../../../editor/mock";
+
+jest.mock("../../../../src/stores/RoomViewStore");
+
+describe('