Merge pull request #3893 from matrix-org/t3chguy/double_slash

Slash Command improvements around sending messages with leading slash
This commit is contained in:
Michael Telatynski 2020-01-22 14:32:09 +00:00 committed by GitHub
commit 97edb824bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 198 additions and 62 deletions

View file

@ -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);
}
}

View file

@ -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: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
const isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator);
@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
});
}
}
this.sendHistoryManager.save(this.model);
// clear composer
this.model.reset([]);

View file

@ -61,18 +61,26 @@ export function textSerialize(model) {
}
export function containsEmote(model) {
return startsWith(model, "/me ");
}
export function startsWith(model, prefix) {
const firstPart = model.parts[0];
// part type will be "plain" while editing,
// and "command" while composing a message.
return firstPart &&
(firstPart.type === "plain" || firstPart.type === "command") &&
firstPart.text.startsWith("/me ");
firstPart.text.startsWith(prefix);
}
export function stripEmoteCommand(model) {
// trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model, prefix) {
model = model.clone();
model.removeText({index: 0, offset: 0}, 4);
model.removeText({index: 0, offset: 0}, prefix.length);
return model;
}

View file

@ -200,7 +200,6 @@
"Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow",
"Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow",
"Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions",
"Unrecognised command:": "Unrecognised command:",
"Reason": "Reason",
"%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
"%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
@ -1078,6 +1077,11 @@
"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 <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> 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",