Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Handle command completions in RTE (#10521)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
artcodespace authored Apr 10, 2023
1 parent 7ef7ccb commit 3fa6f8c
Show file tree
Hide file tree
Showing 10 changed files with 268 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const dynamicImportSendMessage = async (
message: string,
isHTML: boolean,
params: SendMessageParams,
): Promise<ISendEventResponse> => {
): Promise<ISendEventResponse | undefined> => {
const { sendMessage } = await import("./utils/message");

return sendMessage(message, isHTML, params);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ interface WysiwygAutocompleteProps {
* a mention in the autocomplete list or pressing enter on a selected item
*/
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"];
}

/**
Expand All @@ -45,13 +51,23 @@ interface WysiwygAutocompleteProps {
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
*/
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 client = useMatrixClientContext();

function handleConfirm(completion: ICompletion): void {
// TODO handle all of the completion types
// 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")) {
handleMention(
completion.href,
Expand All @@ -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 ? (
<div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
<Autocomplete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
}
Expand All @@ -108,7 +108,12 @@ export const WysiwygComposer = memo(function WysiwygComposer({
onFocus={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} />
<Editor
ref={ref}
Expand Down
13 changes: 7 additions & 6 deletions src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function useEditing(
): {
isSaveDisabled: boolean;
onChange(content: string): void;
editMessage(): Promise<ISendEventResponse>;
editMessage(): Promise<ISendEventResponse | undefined>;
endEditing(): void;
} {
const roomContext = useRoomContext();
Expand All @@ -45,11 +45,12 @@ export function useEditing(
[initialContent],
);

const editMessageMemoized = useCallback(
() =>
!!mxClient && content !== undefined && editMessage(content, { roomContext, mxClient, editorStateTransfer }),
[content, roomContext, mxClient, editorStateTransfer],
);
const editMessageMemoized = useCallback(async () => {
if (mxClient === undefined || content === undefined) {
return;
}
return editMessage(content, { roomContext, mxClient, editorStateTransfer });
}, [content, roomContext, mxClient, editorStateTransfer]);

const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]);
return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function useWysiwygSendActionHandler(

switch (payload.action) {
case "reply_to_event":
case Action.FocusAComposer:
case Action.FocusSendMessageComposer:
focusComposer(composerElement, context, roomContext, timeoutId);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ import * as Avatar from "../../../../../Avatar";
* with @ for a user query, # for a room or space query
*/
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
// TODO implement the command functionality
return "";
}

Expand Down
63 changes: 55 additions & 8 deletions src/components/views/rooms/wysiwyg_composer/utils/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

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 { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";

Expand All @@ -33,6 +33,11 @@ import { endEditing, cancelPreviousPendingEdit } from "./editing";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent } from "./createMessageContent";
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 {
mxClient: MatrixClient;
Expand All @@ -47,8 +52,8 @@ export async function sendMessage(
message: string,
isHTML: boolean,
{ roomContext, mxClient, ...params }: SendMessageParams,
): Promise<ISendEventResponse> {
const { relation, replyToEvent } = params;
): Promise<ISendEventResponse | undefined> {
const { relation, replyToEvent, permalinkCreator } = params;
const { room } = roomContext;
const roomId = room?.roomId;

Expand All @@ -71,9 +76,51 @@ export async function sendMessage(
}*/
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 ?

Expand All @@ -92,7 +139,7 @@ export async function sendMessage(

const prom = doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content),
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content as IContent),
mxClient,
);

Expand All @@ -108,7 +155,7 @@ export async function sendMessage(

dis.dispatch({ action: "message_sent" });
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content, effect.emojis)) {
if (content && containsEmoji(content, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name;
Expand Down Expand Up @@ -146,7 +193,7 @@ interface EditMessageParams {
export async function editMessage(
html: string,
{ roomContext, mxClient, editorStateTransfer }: EditMessageParams,
): Promise<ISendEventResponse> {
): Promise<ISendEventResponse | undefined> {
const editedEvent = editorStateTransfer.getEvent();

PosthogAnalytics.instance.trackEvent<ComposerEvent>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ describe("WysiwygAutocomplete", () => {
},
]);
const mockHandleMention = jest.fn();
const mockHandleCommand = jest.fn();

const renderComponent = (props = {}) => {
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
const mockClient = stubClient();
const mockRoom = mkStubRoom("test_room", "test_room", mockClient);
const mockRoomContext = getRoomContext(mockRoom, {});
Expand All @@ -82,6 +83,7 @@ describe("WysiwygAutocomplete", () => {
ref={autocompleteRef}
suggestion={null}
handleMention={mockHandleMention}
handleCommand={mockHandleCommand}
{...props}
/>
</RoomContext.Provider>
Expand All @@ -90,7 +92,14 @@ describe("WysiwygAutocomplete", () => {
};

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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,12 @@ describe("buildQuery", () => {
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", () => {
const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
expect(buildQuery(handledSuggestion)).toBe("@alice");

const handledCommand = { keyChar: "/" as const, text: "spoiler", type: "mention" as const };
expect(buildQuery(handledCommand)).toBe("/spoiler");
});
});

Expand Down
Loading

0 comments on commit 3fa6f8c

Please sign in to comment.