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

Slash Command improvements around sending messages with leading slash
pull/21833/head
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) { 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); 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} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user. * @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. * processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command. * 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 // trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands // IRC-style commands
input = input.replace(/\s+$/, ''); input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd; let cmd;
let args; let args;
if (bits) { if (bits) {
cmd = bits[1].substring(1).toLowerCase(); cmd = bits[1].substring(1).toLowerCase();
args = bits[3]; args = bits[2];
} else { } else {
cmd = input; cmd = input;
} }
@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) {
cmd = aliases[cmd]; cmd = aliases[cmd];
} }
if (CommandMap[cmd]) { if (CommandMap[cmd]) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` return () => CommandMap[cmd].run(roomId, args);
if (!CommandMap[cmd].runFn) return null;
return CommandMap[cmd].run(roomId, args);
} else {
return reject(_t('Unrecognised command:') + ' ' + input);
} }
} }

View File

@ -24,6 +24,8 @@ import {
containsEmote, containsEmote,
stripEmoteCommand, stripEmoteCommand,
unescapeMessage, unescapeMessage,
startsWith,
stripPrefix,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts'; import {CommandPartCreator} from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
import {processCommandInput} from '../../../SlashCommands'; import {getCommand} from '../../../SlashCommands';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler'; 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); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
} }
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model); model = unescapeMessage(model);
const repliedToEvent = RoomViewStore.getQuotingEvent(); const repliedToEvent = RoomViewStore.getQuotingEvent();
@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component {
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { if (firstPart) {
if (firstPart.type === "command") { if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true; return true;
} }
// be extra resilient when somehow the AutocompleteWrapperModel or // be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send // CommandPartCreator fails to insert a command part, so we don't send
// a command as a message // 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 true;
} }
} }
return false; return false;
} }
async _runSlashCommand() { _getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => { const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command // use mxid to textify user pills in a command
if (part.type === "user-pill") { if (part.type === "user-pill") {
@ -196,9 +203,11 @@ export default class SendMessageComposer extends React.Component {
} }
return text + part.text; return text + part.text;
}, ""); }, "");
const cmd = processCommandInput(this.props.room.roomId, commandText); return [getCommand(this.props.room.roomId, commandText), commandText];
}
if (cmd) { async _runSlashCommand(fn) {
const cmd = fn();
let error = cmd.error; let error = cmd.error;
if (cmd.promise) { if (cmd.promise) {
try { try {
@ -231,15 +240,49 @@ export default class SendMessageComposer extends React.Component {
console.log("Command success."); console.log("Command success.");
} }
} }
}
_sendMessage() { async _sendMessage() {
if (this.model.isEmpty) { if (this.model.isEmpty) {
return; return;
} }
let shouldSend = true;
if (!containsEmote(this.model) && this._isSlashCommand()) { if (!containsEmote(this.model) && this._isSlashCommand()) {
this._runSlashCommand(); const [cmd, commandText] = this._getSlashCommand();
if (cmd) {
shouldSend = false;
this._runSlashCommand(cmd);
} else { } 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 isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room; const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator); const content = createMessageContent(this.model, this.props.permalinkCreator);
@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
}); });
} }
} }
this.sendHistoryManager.save(this.model); this.sendHistoryManager.save(this.model);
// clear composer // clear composer
this.model.reset([]); this.model.reset([]);

View File

@ -61,18 +61,26 @@ export function textSerialize(model) {
} }
export function containsEmote(model) { export function containsEmote(model) {
return startsWith(model, "/me ");
}
export function startsWith(model, prefix) {
const firstPart = model.parts[0]; const firstPart = model.parts[0];
// part type will be "plain" while editing, // part type will be "plain" while editing,
// and "command" while composing a message. // and "command" while composing a message.
return firstPart && return firstPart &&
(firstPart.type === "plain" || firstPart.type === "command") && (firstPart.type === "plain" || firstPart.type === "command") &&
firstPart.text.startsWith("/me "); firstPart.text.startsWith(prefix);
} }
export function stripEmoteCommand(model) { export function stripEmoteCommand(model) {
// trim "/me " // trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model, prefix) {
model = model.clone(); model = model.clone();
model.removeText({index: 0, offset: 0}, 4); model.removeText({index: 0, offset: 0}, prefix.length);
return model; 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 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", "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", "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions",
"Unrecognised command:": "Unrecognised command:",
"Reason": "Reason", "Reason": "Reason",
"%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.", "%(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.", "%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
@ -1078,6 +1077,11 @@
"Server error": "Server error", "Server error": "Server error",
"Command error": "Command error", "Command error": "Command error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", "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", "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", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now", "Add some now": "Add some now",

View File

@ -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('<SendMessageComposer/>', () => {
describe("createMessageContent", () => {
RoomViewStore.getQuotingEvent.mockReturnValue(false);
const permalinkCreator = jest.fn();
it("sends plaintext messages correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("hello world", "insertText", {offset: 11, atNodeEnd: true});
const content = createMessageContent(model, permalinkCreator);
expect(content).toEqual({
body: "hello world",
msgtype: "m.text",
});
});
it("sends markdown messages correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("hello *world*", "insertText", {offset: 13, atNodeEnd: true});
const content = createMessageContent(model, permalinkCreator);
expect(content).toEqual({
body: "hello *world*",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body: "hello <em>world</em>",
});
});
it("strips /me from messages and marks them as m.emote accordingly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("/me blinks __quickly__", "insertText", {offset: 22, atNodeEnd: true});
const content = createMessageContent(model, permalinkCreator);
expect(content).toEqual({
body: "blinks __quickly__",
msgtype: "m.emote",
format: "org.matrix.custom.html",
formatted_body: "blinks <strong>quickly</strong>",
});
});
it("allows sending double-slash escaped slash commands correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("//dev/null is my favourite place", "insertText", {offset: 32, atNodeEnd: true});
const content = createMessageContent(model, permalinkCreator);
expect(content).toEqual({
body: "/dev/null is my favourite place",
msgtype: "m.text",
});
});
});
});

View File

@ -67,3 +67,13 @@ export function createPartCreator(completions = []) {
}; };
return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator); return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator);
} }
export function createRenderer() {
const render = (c) => {
render.caret = c;
render.count += 1;
};
render.count = 0;
render.caret = null;
return render;
}

View File

@ -15,17 +15,7 @@ limitations under the License.
*/ */
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import {createPartCreator} from "./mock"; import {createPartCreator, createRenderer} from "./mock";
function createRenderer() {
const render = (c) => {
render.caret = c;
render.count += 1;
};
render.count = 0;
render.caret = null;
return render;
}
describe('editor/model', function() { describe('editor/model', function() {
describe('plain text manipulation', function() { describe('plain text manipulation', function() {