mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 03:36:07 +03:00
Allow using room pills in slash commands (#7513)
This commit is contained in:
parent
31247a50ca
commit
b835588331
7 changed files with 193 additions and 249 deletions
|
@ -24,10 +24,11 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
|
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
|
||||||
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
|
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { IContent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
|
||||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||||
import dis from './dispatcher/dispatcher';
|
import dis from './dispatcher/dispatcher';
|
||||||
import { _t, _td, newTranslatableError } from './languageHandler';
|
import { _t, _td, newTranslatableError, ITranslatableError } from './languageHandler';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import MultiInviter from './utils/MultiInviter';
|
import MultiInviter from './utils/MultiInviter';
|
||||||
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
||||||
|
@ -60,6 +61,7 @@ import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpD
|
||||||
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
|
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
|
||||||
import { TimelineRenderingType } from './contexts/RoomContext';
|
import { TimelineRenderingType } from './contexts/RoomContext';
|
||||||
import RoomViewStore from "./stores/RoomViewStore";
|
import RoomViewStore from "./stores/RoomViewStore";
|
||||||
|
import { XOR } from "./@types/common";
|
||||||
|
|
||||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||||
interface HTMLInputEvent extends Event {
|
interface HTMLInputEvent extends Event {
|
||||||
|
@ -94,7 +96,9 @@ export const CommandCategories = {
|
||||||
"other": _td("Other"),
|
"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 {
|
interface ICommandOpts {
|
||||||
command: string;
|
command: string;
|
||||||
|
@ -109,15 +113,15 @@ interface ICommandOpts {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Command {
|
export class Command {
|
||||||
command: string;
|
public readonly command: string;
|
||||||
aliases: string[];
|
public readonly aliases: string[];
|
||||||
args: undefined | string;
|
public readonly args: undefined | string;
|
||||||
description: string;
|
public readonly description: string;
|
||||||
runFn: undefined | RunFn;
|
public readonly runFn: undefined | RunFn;
|
||||||
category: string;
|
public readonly category: string;
|
||||||
hideCompletionAfterSpace: boolean;
|
public readonly hideCompletionAfterSpace: boolean;
|
||||||
private _isEnabled?: () => boolean;
|
public readonly renderingTypes?: TimelineRenderingType[];
|
||||||
public renderingTypes?: TimelineRenderingType[];
|
private readonly _isEnabled?: () => boolean;
|
||||||
|
|
||||||
constructor(opts: ICommandOpts) {
|
constructor(opts: ICommandOpts) {
|
||||||
this.command = opts.command;
|
this.command = opts.command;
|
||||||
|
@ -131,15 +135,15 @@ export class Command {
|
||||||
this.renderingTypes = opts.renderingTypes;
|
this.renderingTypes = opts.renderingTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommand() {
|
public getCommand() {
|
||||||
return `/${this.command}`;
|
return `/${this.command}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommandWithArgs() {
|
public getCommandWithArgs() {
|
||||||
return this.getCommand() + " " + this.args;
|
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 it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
|
||||||
if (!this.runFn) {
|
if (!this.runFn) {
|
||||||
reject(
|
reject(
|
||||||
|
@ -166,11 +170,11 @@ export class Command {
|
||||||
return this.runFn.bind(this)(roomId, args);
|
return this.runFn.bind(this)(roomId, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsage() {
|
public getUsage() {
|
||||||
return _t('Usage') + ': ' + this.getCommandWithArgs();
|
return _t('Usage') + ': ' + this.getCommandWithArgs();
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(): boolean {
|
public isEnabled(): boolean {
|
||||||
return this._isEnabled ? this._isEnabled() : true;
|
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.
|
* 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.
|
* @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
|
* @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.
|
||||||
|
|
|
@ -21,25 +21,22 @@ import { MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { _t, _td } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import { getCaretOffsetAndText } from '../../../editor/dom';
|
import { getCaretOffsetAndText } from '../../../editor/dom';
|
||||||
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import { parseEvent } from '../../../editor/deserialize';
|
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 EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||||
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
||||||
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
|
import { CommandCategories } from '../../../SlashCommands';
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||||
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import SendHistoryManager from '../../../SendHistoryManager';
|
import SendHistoryManager from '../../../SendHistoryManager';
|
||||||
import Modal from '../../../Modal';
|
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
|
||||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
|
||||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
|
import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
|
||||||
|
@ -47,6 +44,7 @@ import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
|
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
|
||||||
import RoomContext from '../../../contexts/RoomContext';
|
import RoomContext from '../../../contexts/RoomContext';
|
||||||
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
|
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
||||||
|
|
||||||
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
|
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
|
||||||
const html = mxEvent.getContent().formatted_body;
|
const html = mxEvent.getContent().formatted_body;
|
||||||
|
@ -282,22 +280,6 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
||||||
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
|
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 {
|
private isContentModified(newContent: IContent): boolean {
|
||||||
// if nothing has changed then bail
|
// if nothing has changed then bail
|
||||||
const oldContent = this.props.editState.getEvent().getContent();
|
const oldContent = this.props.editState.getEvent().getContent();
|
||||||
|
@ -309,60 +291,6 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
||||||
return true;
|
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> => {
|
private sendEdit = async (): Promise<void> => {
|
||||||
const startTime = CountlyAnalytics.getTimestamp();
|
const startTime = CountlyAnalytics.getTimestamp();
|
||||||
const editedEvent = this.props.editState.getEvent();
|
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 content is modified then send an updated event into the room
|
||||||
if (this.isContentModified(newContent)) {
|
if (this.isContentModified(newContent)) {
|
||||||
const roomId = editedEvent.getRoomId();
|
const roomId = editedEvent.getRoomId();
|
||||||
if (!containsEmote(this.model) && this.isSlashCommand()) {
|
if (!containsEmote(this.model) && isSlashCommand(this.model)) {
|
||||||
const [cmd, args, commandText] = this.getSlashCommand();
|
const [cmd, args, commandText] = getSlashCommand(this.model);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
|
const threadId = this.props.editState?.getEvent()?.getThread()?.id || null;
|
||||||
if (cmd.category === CommandCategories.messages) {
|
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);
|
||||||
} else {
|
if (!editContent["m.new_content"]) {
|
||||||
this.runSlashCommand(cmd, args, roomId);
|
return; // errored
|
||||||
shouldSend = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ask the user if their unknown command should be sent as a message
|
runSlashCommand(cmd, args, roomId, threadId);
|
||||||
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
shouldSend = false;
|
||||||
title: _t("Unknown Command"),
|
}
|
||||||
description: <div>
|
} else if (!await shouldSendAnyway(commandText)) {
|
||||||
<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 bail to let the user edit the composer and try again
|
||||||
if (!sendAnyway) return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
|
|
|
@ -34,13 +34,11 @@ import {
|
||||||
unescapeMessage,
|
unescapeMessage,
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
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 ReplyChain from "../elements/ReplyChain";
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import SendHistoryManager from "../../../SendHistoryManager";
|
import SendHistoryManager from "../../../SendHistoryManager";
|
||||||
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
|
import { CommandCategories } from '../../../SlashCommands';
|
||||||
import Modal from '../../../Modal';
|
|
||||||
import { _t, _td } from '../../../languageHandler';
|
|
||||||
import ContentMessages from '../../../ContentMessages';
|
import ContentMessages from '../../../ContentMessages';
|
||||||
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
@ -52,13 +50,12 @@ import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindin
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import SettingsStore from '../../../settings/SettingsStore';
|
import SettingsStore from '../../../settings/SettingsStore';
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
|
||||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
|
||||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
|
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
|
||||||
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
|
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
|
||||||
import DocumentPosition from "../../../editor/position";
|
import DocumentPosition from "../../../editor/position";
|
||||||
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
|
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
||||||
|
|
||||||
function addReplyToMessageContent(
|
function addReplyToMessageContent(
|
||||||
content: IContent,
|
content: IContent,
|
||||||
|
@ -284,24 +281,6 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||||
return true;
|
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 {
|
private sendQuickReaction(): void {
|
||||||
const timeline = this.context.liveTimeline;
|
const timeline = this.context.liveTimeline;
|
||||||
const events = timeline.getEvents();
|
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> {
|
public async sendMessage(): Promise<void> {
|
||||||
const model = this.model;
|
const model = this.model;
|
||||||
|
|
||||||
|
@ -416,50 +335,32 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||||
|
|
||||||
const replyToEvent = this.props.replyToEvent;
|
const replyToEvent = this.props.replyToEvent;
|
||||||
let shouldSend = true;
|
let shouldSend = true;
|
||||||
let content;
|
let content: IContent;
|
||||||
|
|
||||||
if (!containsEmote(model) && this.isSlashCommand()) {
|
if (!containsEmote(model) && isSlashCommand(this.model)) {
|
||||||
const [cmd, args, commandText] = this.getSlashCommand();
|
const [cmd, args, commandText] = getSlashCommand(this.model);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
|
const threadId = this.props.relation?.rel_type === RelationType.Thread
|
||||||
|
? this.props.relation?.event_id
|
||||||
|
: null;
|
||||||
|
|
||||||
if (cmd.category === CommandCategories.messages) {
|
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) {
|
if (replyToEvent) {
|
||||||
addReplyToMessageContent(
|
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
|
||||||
content,
|
|
||||||
replyToEvent,
|
|
||||||
this.props.permalinkCreator,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
attachRelation(content, this.props.relation);
|
attachRelation(content, this.props.relation);
|
||||||
} else {
|
} else {
|
||||||
this.runSlashCommand(cmd, args);
|
runSlashCommand(cmd, args, this.props.room.roomId, threadId);
|
||||||
shouldSend = false;
|
shouldSend = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!await shouldSendAnyway(commandText)) {
|
||||||
// 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;
|
|
||||||
// if !sendAnyway bail to let the user edit the composer and try again
|
// if !sendAnyway bail to let the user edit the composer and try again
|
||||||
if (!sendAnyway) return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
129
src/editor/commands.tsx
Normal file
129
src/editor/commands.tsx
Normal 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;
|
||||||
|
}
|
|
@ -179,7 +179,7 @@ export function textSerialize(model: EditorModel): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsEmote(model: EditorModel): boolean {
|
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 {
|
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
|
||||||
|
|
|
@ -976,6 +976,14 @@
|
||||||
"sends snowfall": "sends snowfall",
|
"sends snowfall": "sends snowfall",
|
||||||
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
||||||
"sends space invaders": "sends space invaders",
|
"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",
|
"unknown person": "unknown person",
|
||||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
"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>",
|
"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",
|
"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",
|
"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",
|
"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",
|
"Edit message": "Edit message",
|
||||||
"Mod": "Mod",
|
"Mod": "Mod",
|
||||||
"%(count)s reply|other": "%(count)s replies",
|
"%(count)s reply|other": "%(count)s replies",
|
||||||
|
|
|
@ -43,7 +43,7 @@ counterpart.setSeparator('|');
|
||||||
const FALLBACK_LOCALE = 'en';
|
const FALLBACK_LOCALE = 'en';
|
||||||
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
||||||
|
|
||||||
interface ITranslatableError extends Error {
|
export interface ITranslatableError extends Error {
|
||||||
translatedMessage: string;
|
translatedMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,6 +51,7 @@ interface ITranslatableError extends Error {
|
||||||
* Helper function to create an error which has an English message
|
* Helper function to create an error which has an English message
|
||||||
* with a translatedMessage property for use by the consumer.
|
* with a translatedMessage property for use by the consumer.
|
||||||
* @param {string} message Message to translate.
|
* @param {string} message Message to translate.
|
||||||
|
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||||
* @returns {Error} The constructed error.
|
* @returns {Error} The constructed error.
|
||||||
*/
|
*/
|
||||||
export function newTranslatableError(message: string, variables?: IVariables): ITranslatableError {
|
export function newTranslatableError(message: string, variables?: IVariables): ITranslatableError {
|
||||||
|
|
Loading…
Reference in a new issue