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

@ -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);
editContent["m.new_content"] = await runSlashCommand(cmd, args, roomId, threadId);
if (!editContent["m.new_content"]) {
return; // errored
}
} else {
this.runSlashCommand(cmd, args, roomId);
runSlashCommand(cmd, args, 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;
}
}
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;
}
}