Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using room pills in slash commands #7513

Merged
merged 8 commits into from
Jan 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions src/SlashCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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.
Expand Down
118 changes: 14 additions & 104 deletions src/components/views/rooms/EditMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,30 @@ 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';
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;
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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) {
Expand Down
Loading