mirror of
https://github.com/element-hq/element-web
synced 2024-11-26 19:26:04 +03:00
Handle command completions in RTE (#10521)
* pass handleCommand prop down and use it in WysiwygAutocomplete * allow a command to generate a query from buildQuery * port command functionality into the sendMessage util * tidy up comments * remove use of shouldSend and update comments * remove console log * make logic more explicit and amend comment * uncomment replyToEvent block * update util test * remove commented out test * use local text over import from current composer * expand tests * expand tests * handle the FocusAComposer action for the wysiwyg composer * remove TODO comment * remove TODO * test for action dispatch * fix failing tests * tidy up tests * fix TS error and improve typing * fix TS error * amend return types for sendMessage, editMessage * fix null content TS error * fix another null content TS error * use as to correct final TS error * remove undefined argument * try to fix TS errors for editMessage function usage * tidy up * add TODO * improve comments * update comment
This commit is contained in:
parent
7ef7ccb55f
commit
3fa6f8cbf0
10 changed files with 268 additions and 31 deletions
|
@ -28,7 +28,7 @@ export const dynamicImportSendMessage = async (
|
||||||
message: string,
|
message: string,
|
||||||
isHTML: boolean,
|
isHTML: boolean,
|
||||||
params: SendMessageParams,
|
params: SendMessageParams,
|
||||||
): Promise<ISendEventResponse> => {
|
): Promise<ISendEventResponse | undefined> => {
|
||||||
const { sendMessage } = await import("./utils/message");
|
const { sendMessage } = await import("./utils/message");
|
||||||
|
|
||||||
return sendMessage(message, isHTML, params);
|
return sendMessage(message, isHTML, params);
|
||||||
|
|
|
@ -35,6 +35,12 @@ interface WysiwygAutocompleteProps {
|
||||||
* a mention in the autocomplete list or pressing enter on a selected item
|
* a mention in the autocomplete list or pressing enter on a selected item
|
||||||
*/
|
*/
|
||||||
handleMention: FormattingFunctions["mention"];
|
handleMention: FormattingFunctions["mention"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This handler will be called with the display text for a command on clicking
|
||||||
|
* a command in the autocomplete list or pressing enter on a selected item
|
||||||
|
*/
|
||||||
|
handleCommand: FormattingFunctions["command"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,13 +51,23 @@ interface WysiwygAutocompleteProps {
|
||||||
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
|
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
|
||||||
*/
|
*/
|
||||||
const WysiwygAutocomplete = forwardRef(
|
const WysiwygAutocomplete = forwardRef(
|
||||||
({ suggestion, handleMention }: WysiwygAutocompleteProps, ref: ForwardedRef<Autocomplete>): JSX.Element | null => {
|
(
|
||||||
|
{ suggestion, handleMention, handleCommand }: WysiwygAutocompleteProps,
|
||||||
|
ref: ForwardedRef<Autocomplete>,
|
||||||
|
): JSX.Element | null => {
|
||||||
const { room } = useRoomContext();
|
const { room } = useRoomContext();
|
||||||
const client = useMatrixClientContext();
|
const client = useMatrixClientContext();
|
||||||
|
|
||||||
function handleConfirm(completion: ICompletion): void {
|
function handleConfirm(completion: ICompletion): void {
|
||||||
// TODO handle all of the completion types
|
// TODO handle all of the completion types
|
||||||
// Using this to pick out the ones we can handle during implementation
|
// Using this to pick out the ones we can handle during implementation
|
||||||
|
if (completion.type === "command") {
|
||||||
|
// TODO determine if utils in SlashCommands.tsx are required
|
||||||
|
|
||||||
|
// trim the completion as some include trailing spaces, but we always insert a
|
||||||
|
// trailing space in the rust model anyway
|
||||||
|
handleCommand(completion.completion.trim());
|
||||||
|
}
|
||||||
if (client && room && completion.href && (completion.type === "room" || completion.type === "user")) {
|
if (client && room && completion.href && (completion.type === "room" || completion.type === "user")) {
|
||||||
handleMention(
|
handleMention(
|
||||||
completion.href,
|
completion.href,
|
||||||
|
@ -61,6 +77,8 @@ const WysiwygAutocomplete = forwardRef(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO - determine if we show all of the /command suggestions, there are some options in the
|
||||||
|
// list which don't seem to make sense in this context, specifically /html and /plain
|
||||||
return room ? (
|
return room ? (
|
||||||
<div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
|
<div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
|
|
|
@ -91,7 +91,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentions = ref.current?.querySelectorAll("a[data-mention-type]");
|
const mentions: NodeList | undefined = ref.current?.querySelectorAll("a[data-mention-type]");
|
||||||
if (mentions) {
|
if (mentions) {
|
||||||
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
|
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
|
||||||
}
|
}
|
||||||
|
@ -108,7 +108,12 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onFocus}
|
onBlur={onFocus}
|
||||||
>
|
>
|
||||||
<WysiwygAutocomplete ref={autocompleteRef} suggestion={suggestion} handleMention={wysiwyg.mention} />
|
<WysiwygAutocomplete
|
||||||
|
ref={autocompleteRef}
|
||||||
|
suggestion={suggestion}
|
||||||
|
handleMention={wysiwyg.mention}
|
||||||
|
handleCommand={wysiwyg.command}
|
||||||
|
/>
|
||||||
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
|
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
|
||||||
<Editor
|
<Editor
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -29,7 +29,7 @@ export function useEditing(
|
||||||
): {
|
): {
|
||||||
isSaveDisabled: boolean;
|
isSaveDisabled: boolean;
|
||||||
onChange(content: string): void;
|
onChange(content: string): void;
|
||||||
editMessage(): Promise<ISendEventResponse>;
|
editMessage(): Promise<ISendEventResponse | undefined>;
|
||||||
endEditing(): void;
|
endEditing(): void;
|
||||||
} {
|
} {
|
||||||
const roomContext = useRoomContext();
|
const roomContext = useRoomContext();
|
||||||
|
@ -45,11 +45,12 @@ export function useEditing(
|
||||||
[initialContent],
|
[initialContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editMessageMemoized = useCallback(
|
const editMessageMemoized = useCallback(async () => {
|
||||||
() =>
|
if (mxClient === undefined || content === undefined) {
|
||||||
!!mxClient && content !== undefined && editMessage(content, { roomContext, mxClient, editorStateTransfer }),
|
return;
|
||||||
[content, roomContext, mxClient, editorStateTransfer],
|
}
|
||||||
);
|
return editMessage(content, { roomContext, mxClient, editorStateTransfer });
|
||||||
|
}, [content, roomContext, mxClient, editorStateTransfer]);
|
||||||
|
|
||||||
const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]);
|
const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]);
|
||||||
return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled };
|
return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled };
|
||||||
|
|
|
@ -46,6 +46,7 @@ export function useWysiwygSendActionHandler(
|
||||||
|
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case "reply_to_event":
|
case "reply_to_event":
|
||||||
|
case Action.FocusAComposer:
|
||||||
case Action.FocusSendMessageComposer:
|
case Action.FocusSendMessageComposer:
|
||||||
focusComposer(composerElement, context, roomContext, timeoutId);
|
focusComposer(composerElement, context, roomContext, timeoutId);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -29,9 +29,8 @@ import * as Avatar from "../../../../../Avatar";
|
||||||
* with @ for a user query, # for a room or space query
|
* with @ for a user query, # for a room or space query
|
||||||
*/
|
*/
|
||||||
export function buildQuery(suggestion: MappedSuggestion | null): string {
|
export function buildQuery(suggestion: MappedSuggestion | null): string {
|
||||||
if (!suggestion || !suggestion.keyChar || suggestion.type === "command") {
|
if (!suggestion || !suggestion.keyChar) {
|
||||||
// if we have an empty key character, we do not build a query
|
// if we have an empty key character, we do not build a query
|
||||||
// TODO implement the command functionality
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
||||||
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||||
|
|
||||||
|
@ -33,6 +33,11 @@ import { endEditing, cancelPreviousPendingEdit } from "./editing";
|
||||||
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
||||||
import { createMessageContent } from "./createMessageContent";
|
import { createMessageContent } from "./createMessageContent";
|
||||||
import { isContentModified } from "./isContentModified";
|
import { isContentModified } from "./isContentModified";
|
||||||
|
import { CommandCategories, getCommand } from "../../../../../SlashCommands";
|
||||||
|
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
|
||||||
|
import { Action } from "../../../../../dispatcher/actions";
|
||||||
|
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||||
|
import { attachRelation } from "../../SendMessageComposer";
|
||||||
|
|
||||||
export interface SendMessageParams {
|
export interface SendMessageParams {
|
||||||
mxClient: MatrixClient;
|
mxClient: MatrixClient;
|
||||||
|
@ -47,8 +52,8 @@ export async function sendMessage(
|
||||||
message: string,
|
message: string,
|
||||||
isHTML: boolean,
|
isHTML: boolean,
|
||||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||||
): Promise<ISendEventResponse> {
|
): Promise<ISendEventResponse | undefined> {
|
||||||
const { relation, replyToEvent } = params;
|
const { relation, replyToEvent, permalinkCreator } = params;
|
||||||
const { room } = roomContext;
|
const { room } = roomContext;
|
||||||
const roomId = room?.roomId;
|
const roomId = room?.roomId;
|
||||||
|
|
||||||
|
@ -71,9 +76,51 @@ export async function sendMessage(
|
||||||
}*/
|
}*/
|
||||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
||||||
|
|
||||||
const content = await createMessageContent(message, isHTML, params);
|
let content: IContent | null = null;
|
||||||
|
|
||||||
// TODO slash comment
|
// Functionality here approximates what can be found in SendMessageComposer.sendMessage()
|
||||||
|
if (message.startsWith("/") && !message.startsWith("//")) {
|
||||||
|
const { cmd, args } = getCommand(message);
|
||||||
|
if (cmd) {
|
||||||
|
// TODO handle /me special case separately, see end of SlashCommands.Commands
|
||||||
|
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation?.event_id : null;
|
||||||
|
let commandSuccessful: boolean;
|
||||||
|
[content, commandSuccessful] = await runSlashCommand(cmd, args, roomId, threadId ?? null);
|
||||||
|
|
||||||
|
if (!commandSuccessful) {
|
||||||
|
return; // errored
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
content &&
|
||||||
|
(cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects)
|
||||||
|
) {
|
||||||
|
attachRelation(content, relation);
|
||||||
|
if (replyToEvent) {
|
||||||
|
addReplyToMessageContent(content, replyToEvent, {
|
||||||
|
permalinkCreator,
|
||||||
|
// Exclude the legacy fallback for custom event types such as those used by /fireworks
|
||||||
|
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// instead of setting shouldSend to false as in SendMessageComposer, just return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sendAnyway = await shouldSendAnyway(message);
|
||||||
|
// re-focus the composer after QuestionDialog is closed
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.FocusAComposer,
|
||||||
|
context: roomContext.timelineRenderingType,
|
||||||
|
});
|
||||||
|
// if !sendAnyway bail to let the user edit the composer and try again
|
||||||
|
if (!sendAnyway) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if content is null, we haven't done any slash command processing, so generate some content
|
||||||
|
content ??= await createMessageContent(message, isHTML, params);
|
||||||
|
|
||||||
// TODO replace emotion end of message ?
|
// TODO replace emotion end of message ?
|
||||||
|
|
||||||
|
@ -92,7 +139,7 @@ export async function sendMessage(
|
||||||
|
|
||||||
const prom = doMaybeLocalRoomAction(
|
const prom = doMaybeLocalRoomAction(
|
||||||
roomId,
|
roomId,
|
||||||
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content),
|
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content as IContent),
|
||||||
mxClient,
|
mxClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -108,7 +155,7 @@ export async function sendMessage(
|
||||||
|
|
||||||
dis.dispatch({ action: "message_sent" });
|
dis.dispatch({ action: "message_sent" });
|
||||||
CHAT_EFFECTS.forEach((effect) => {
|
CHAT_EFFECTS.forEach((effect) => {
|
||||||
if (containsEmoji(content, effect.emojis)) {
|
if (content && containsEmoji(content, effect.emojis)) {
|
||||||
// For initial threads launch, chat effects are disabled
|
// For initial threads launch, chat effects are disabled
|
||||||
// see #19731
|
// see #19731
|
||||||
const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name;
|
const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name;
|
||||||
|
@ -146,7 +193,7 @@ interface EditMessageParams {
|
||||||
export async function editMessage(
|
export async function editMessage(
|
||||||
html: string,
|
html: string,
|
||||||
{ roomContext, mxClient, editorStateTransfer }: EditMessageParams,
|
{ roomContext, mxClient, editorStateTransfer }: EditMessageParams,
|
||||||
): Promise<ISendEventResponse> {
|
): Promise<ISendEventResponse | undefined> {
|
||||||
const editedEvent = editorStateTransfer.getEvent();
|
const editedEvent = editorStateTransfer.getEvent();
|
||||||
|
|
||||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
|
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
|
||||||
|
|
|
@ -69,8 +69,9 @@ describe("WysiwygAutocomplete", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const mockHandleMention = jest.fn();
|
const mockHandleMention = jest.fn();
|
||||||
|
const mockHandleCommand = jest.fn();
|
||||||
|
|
||||||
const renderComponent = (props = {}) => {
|
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
|
||||||
const mockClient = stubClient();
|
const mockClient = stubClient();
|
||||||
const mockRoom = mkStubRoom("test_room", "test_room", mockClient);
|
const mockRoom = mkStubRoom("test_room", "test_room", mockClient);
|
||||||
const mockRoomContext = getRoomContext(mockRoom, {});
|
const mockRoomContext = getRoomContext(mockRoom, {});
|
||||||
|
@ -82,6 +83,7 @@ describe("WysiwygAutocomplete", () => {
|
||||||
ref={autocompleteRef}
|
ref={autocompleteRef}
|
||||||
suggestion={null}
|
suggestion={null}
|
||||||
handleMention={mockHandleMention}
|
handleMention={mockHandleMention}
|
||||||
|
handleCommand={mockHandleCommand}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</RoomContext.Provider>
|
</RoomContext.Provider>
|
||||||
|
@ -90,7 +92,14 @@ describe("WysiwygAutocomplete", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it("does not show the autocomplete when room is undefined", () => {
|
it("does not show the autocomplete when room is undefined", () => {
|
||||||
render(<WysiwygAutocomplete ref={autocompleteRef} suggestion={null} handleMention={mockHandleMention} />);
|
render(
|
||||||
|
<WysiwygAutocomplete
|
||||||
|
ref={autocompleteRef}
|
||||||
|
suggestion={null}
|
||||||
|
handleMention={mockHandleMention}
|
||||||
|
handleCommand={mockHandleCommand}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -53,15 +53,12 @@ describe("buildQuery", () => {
|
||||||
expect(buildQuery(noKeyCharSuggestion)).toBe("");
|
expect(buildQuery(noKeyCharSuggestion)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty string when suggestion is a command", () => {
|
|
||||||
// TODO alter this test when commands are implemented
|
|
||||||
const commandSuggestion = { keyChar: "/" as const, text: "slash", type: "command" as const };
|
|
||||||
expect(buildQuery(commandSuggestion)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("combines the keyChar and text of the suggestion in the query", () => {
|
it("combines the keyChar and text of the suggestion in the query", () => {
|
||||||
const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
|
const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
|
||||||
expect(buildQuery(handledSuggestion)).toBe("@alice");
|
expect(buildQuery(handledSuggestion)).toBe("@alice");
|
||||||
|
|
||||||
|
const handledCommand = { keyChar: "/" as const, text: "spoiler", type: "mention" as const };
|
||||||
|
expect(buildQuery(handledCommand)).toBe("/spoiler");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventStatus } from "matrix-js-sdk/src/matrix";
|
import { EventStatus, IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
||||||
import { editMessage, sendMessage } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
|
import { editMessage, sendMessage } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
|
||||||
|
@ -25,6 +25,11 @@ import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
|
||||||
import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
|
||||||
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
||||||
import * as ConfirmRedactDialog from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
import * as ConfirmRedactDialog from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
||||||
|
import * as SlashCommands from "../../../../../../src/SlashCommands";
|
||||||
|
import * as Commands from "../../../../../../src/editor/commands";
|
||||||
|
import * as Reply from "../../../../../../src/utils/Reply";
|
||||||
|
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||||
|
import { Action } from "../../../../../../src/dispatcher/actions";
|
||||||
|
|
||||||
describe("message", () => {
|
describe("message", () => {
|
||||||
const permalinkCreator = {
|
const permalinkCreator = {
|
||||||
|
@ -47,6 +52,9 @@ describe("message", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockClient = createTestClient();
|
const mockClient = createTestClient();
|
||||||
|
mockClient.setDisplayName = jest.fn().mockResolvedValue({});
|
||||||
|
mockClient.setRoomName = jest.fn().mockResolvedValue({});
|
||||||
|
|
||||||
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
|
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
|
||||||
mockRoom.findEventById = jest.fn((eventId) => {
|
mockRoom.findEventById = jest.fn((eventId) => {
|
||||||
return eventId === mockEvent.getId() ? mockEvent : null;
|
return eventId === mockEvent.getId() ? mockEvent : null;
|
||||||
|
@ -56,8 +64,13 @@ describe("message", () => {
|
||||||
|
|
||||||
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||||
|
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage", () => {
|
describe("sendMessage", () => {
|
||||||
|
@ -226,6 +239,153 @@ describe("message", () => {
|
||||||
// Then
|
// Then
|
||||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
|
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("slash commands", () => {
|
||||||
|
const getCommandSpy = jest.spyOn(SlashCommands, "getCommand");
|
||||||
|
|
||||||
|
it("calls getCommand for a message starting with a valid command", async () => {
|
||||||
|
// When
|
||||||
|
const validCommand = "/spoiler";
|
||||||
|
await sendMessage(validCommand, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(getCommandSpy).toHaveBeenCalledWith(validCommand);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call getCommand for valid command with invalid prefix", async () => {
|
||||||
|
// When
|
||||||
|
const invalidPrefixCommand = "//spoiler";
|
||||||
|
await sendMessage(invalidPrefixCommand, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(getCommandSpy).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the command is not successful", async () => {
|
||||||
|
// When
|
||||||
|
const validCommand = "/spoiler";
|
||||||
|
jest.spyOn(Commands, "runSlashCommand").mockResolvedValueOnce([{ content: "mock content" }, false]);
|
||||||
|
|
||||||
|
const result = await sendMessage(validCommand, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// /spoiler is a .messages category command, /fireworks is an .effect category command
|
||||||
|
const messagesAndEffectCategoryTestCases = ["/spoiler text", "/fireworks"];
|
||||||
|
it.each(messagesAndEffectCategoryTestCases)(
|
||||||
|
"does not add relations for a .messages or .effects category command if there is no relation to add",
|
||||||
|
async (inputText) => {
|
||||||
|
await sendMessage(inputText, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"myfakeroom",
|
||||||
|
null,
|
||||||
|
expect.not.objectContaining({ "m.relates_to": expect.any }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(messagesAndEffectCategoryTestCases)(
|
||||||
|
"adds relations for a .messages or .effects category command if there is a relation",
|
||||||
|
async (inputText) => {
|
||||||
|
const mockRelation: IEventRelation = {
|
||||||
|
rel_type: "mock relation type",
|
||||||
|
};
|
||||||
|
await sendMessage(inputText, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
relation: mockRelation,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"myfakeroom",
|
||||||
|
null,
|
||||||
|
expect.objectContaining({ "m.relates_to": expect.objectContaining(mockRelation) }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("calls addReplyToMessageContent when there is an event to reply to", async () => {
|
||||||
|
const addReplySpy = jest.spyOn(Reply, "addReplyToMessageContent");
|
||||||
|
await sendMessage("input", true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
replyToEvent: mockEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addReplySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// these test cases are .action and .admin categories
|
||||||
|
const otherCategoryTestCases = ["/nick new_nickname", "/roomname new_room_name"];
|
||||||
|
it.each(otherCategoryTestCases)(
|
||||||
|
"returns undefined when the command category is not .messages or .effects",
|
||||||
|
async (input) => {
|
||||||
|
const result = await sendMessage(input, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
replyToEvent: mockEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("if user enters invalid command and then sends it anyway", async () => {
|
||||||
|
// mock out returning a true value for `shouldSendAnyway` to avoid rendering the modal
|
||||||
|
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(true);
|
||||||
|
const invalidCommandInput = "/badCommand";
|
||||||
|
|
||||||
|
await sendMessage(invalidCommandInput, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
// we expect the message to have been sent
|
||||||
|
// and a composer focus action to have been dispatched
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"myfakeroom",
|
||||||
|
null,
|
||||||
|
expect.objectContaining({ body: invalidCommandInput }),
|
||||||
|
);
|
||||||
|
expect(spyDispatcher).toHaveBeenCalledWith(expect.objectContaining({ action: Action.FocusAComposer }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("if user enters invalid command and then does not send, return undefined", async () => {
|
||||||
|
// mock out returning a false value for `shouldSendAnyway` to avoid rendering the modal
|
||||||
|
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(false);
|
||||||
|
const invalidCommandInput = "/badCommand";
|
||||||
|
|
||||||
|
const result = await sendMessage(invalidCommandInput, true, {
|
||||||
|
roomContext: defaultRoomContext,
|
||||||
|
mxClient: mockClient,
|
||||||
|
permalinkCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("editMessage", () => {
|
describe("editMessage", () => {
|
||||||
|
|
Loading…
Reference in a new issue