Merge pull request #3893 from matrix-org/t3chguy/double_slash
Slash Command improvements around sending messages with leading slashpull/21833/head
commit
97edb824bc
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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([]);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
Loading…
Reference in New Issue