Allow using room pills in slash commands (#7513)

This commit is contained in:
Michael Telatynski 2022-01-12 09:40:18 +00:00 committed by GitHub
parent 31247a50ca
commit b835588331
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 193 additions and 249 deletions

View file

@ -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<any>});
export type RunResult = XOR<{ error: Error | ITranslatableError }, { promise: Promise<IContent | undefined> }>;
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.

View file

@ -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<IEditMessageComposerProps, ISt
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
};
private isSlashCommand(): boolean {
const parts = this.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;
}
private isContentModified(newContent: IContent): boolean {
// if nothing has changed then bail
const oldContent = this.props.editState.getEvent().getContent();
@ -309,60 +291,6 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
return true;
}
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// 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<void> {
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<void> => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent();
@ -389,40 +317,22 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
// If content is modified then send an updated event into the room
if (this.isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
if (!containsEmote(this.model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (!containsEmote(this.model) && isSlashCommand(this.model)) {
const [cmd, args, commandText] = getSlashCommand(this.model);
if (cmd) {
const threadId = this.props.editState?.getEvent()?.getThread()?.id || null;
if (cmd.category === CommandCategories.messages) {
editContent["m.new_content"] = await this.runSlashCommand(cmd, args, roomId);
} else {
this.runSlashCommand(cmd, args, roomId);
shouldSend = false;
editContent["m.new_content"] = await runSlashCommand(cmd, args, roomId, threadId);
if (!editContent["m.new_content"]) {
return; // errored
}
} else {
// 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: <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;
runSlashCommand(cmd, args, roomId, threadId);
shouldSend = false;
}
} else if (!await shouldSendAnyway(commandText)) {
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
return;
}
}
if (shouldSend) {

View file

@ -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<ISendMessageComposerPro
return true;
}
private isSlashCommand(): boolean {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === 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.text.startsWith("//")
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
return false;
}
private sendQuickReaction(): void {
const timeline = this.context.liveTimeline;
const events = timeline.getEvents();
@ -337,66 +316,6 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
}
}
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// 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<void> {
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<void> {
const model = this.model;
@ -416,50 +335,32 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
const replyToEvent = this.props.replyToEvent;
let shouldSend = true;
let content;
let content: IContent;
if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (!containsEmote(model) && isSlashCommand(this.model)) {
const [cmd, args, commandText] = getSlashCommand(this.model);
if (cmd) {
const threadId = this.props.relation?.rel_type === RelationType.Thread
? this.props.relation?.event_id
: null;
if (cmd.category === CommandCategories.messages) {
content = await this.runSlashCommand(cmd, args);
content = await runSlashCommand(cmd, args, this.props.room.roomId, threadId);
if (!content) {
return; // errored
}
if (replyToEvent) {
addReplyToMessageContent(
content,
replyToEvent,
this.props.permalinkCreator,
);
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
}
attachRelation(content, this.props.relation);
} else {
this.runSlashCommand(cmd, args);
runSlashCommand(cmd, args, this.props.room.roomId, threadId);
shouldSend = false;
}
} else {
// 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: <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;
} else if (!await shouldSendAnyway(commandText)) {
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
return;
}
}

129
src/editor/commands.tsx Normal file
View file

@ -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<IContent | null> {
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<boolean> {
// 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: <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;
return sendAnyway;
}

View file

@ -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 {

View file

@ -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 <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",
"unknown person": "unknown person",
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
@ -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 <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",
"Edit message": "Edit message",
"Mod": "Mod",
"%(count)s reply|other": "%(count)s replies",

View file

@ -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 {