+ const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
+
+ // if we're not in the first text node or we have no text content, return
+ if (currentNode !== firstTextNode || currentNode.textContent === null) {
+ return;
+ }
+
+ // it's a command if:
+ // it is the first textnode AND
+ // it starts with /, not // AND
+ // then has letters all the way up to the end of the textcontent
+ const commandRegex = /^\/(\w*)$/;
+ const commandMatches = currentNode.textContent.match(commandRegex);
+
+ // if we don't have any matches, return, clearing the suggeston state if it is non-null
+ if (commandMatches === null) {
+ if (suggestion !== null) {
+ setSuggestion(null);
+ }
+ return;
+ } else {
+ setSuggestion({
+ keyChar: "/",
+ type: "command",
+ text: commandMatches[1],
+ node: selection.anchorNode,
+ startOffset: 0,
+ endOffset: currentNode.textContent.length,
+ });
+ }
+};
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
index 83a18fb55b..636b5d2bf2 100644
--- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
@@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { MutableRefObject } from "react";
+import { MutableRefObject, RefObject } from "react";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
+import Autocomplete from "../../Autocomplete";
+import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
+import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
export function focusComposer(
composerElement: MutableRefObject
,
@@ -51,3 +54,59 @@ export function setCursorPositionAtTheEnd(element: HTMLElement): void {
element.focus();
}
+
+/**
+ * When the autocomplete modal is open we need to be able to properly
+ * handle events that are dispatched. This allows the user to move the selection
+ * in the autocomplete and select using enter.
+ *
+ * @param autocompleteRef - a ref to the autocomplete of interest
+ * @param event - the keyboard event that has been dispatched
+ * @returns boolean - whether or not the autocomplete has handled the event
+ */
+export function handleEventWithAutocomplete(
+ autocompleteRef: RefObject,
+ // we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer
+ event: KeyboardEvent | React.KeyboardEvent,
+): boolean {
+ const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
+
+ if (!autocompleteIsOpen) {
+ return false;
+ }
+
+ let handled = false;
+ const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
+ const component = autocompleteRef.current;
+
+ if (component && component.countCompletions() > 0) {
+ switch (autocompleteAction) {
+ case KeyBindingAction.ForceCompleteAutocomplete:
+ case KeyBindingAction.CompleteAutocomplete:
+ autocompleteRef.current.onConfirmCompletion();
+ handled = true;
+ break;
+ case KeyBindingAction.PrevSelectionInAutocomplete:
+ autocompleteRef.current.moveSelection(-1);
+ handled = true;
+ break;
+ case KeyBindingAction.NextSelectionInAutocomplete:
+ autocompleteRef.current.moveSelection(1);
+ handled = true;
+ break;
+ case KeyBindingAction.CancelAutocomplete:
+ autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
+ handled = true;
+ break;
+ default:
+ break; // don't return anything, allow event to pass through
+ }
+ }
+
+ if (handled) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ return handled;
+}
diff --git a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx
index e3b1b2fce1..9277ecb16c 100644
--- a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx
+++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx
@@ -21,6 +21,8 @@ import userEvent from "@testing-library/user-event";
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
import * as mockKeyboard from "../../../../../../src/Keyboard";
+import { createMocks } from "../utils";
+import RoomContext from "../../../../../../src/contexts/RoomContext";
describe("PlainTextComposer", () => {
const customRender = (
@@ -271,4 +273,21 @@ describe("PlainTextComposer", () => {
jest.useRealTimers();
(global.ResizeObserver as jest.Mock).mockRestore();
});
+
+ it("Should not render if not wrapped in room context", () => {
+ customRender();
+ expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
+ });
+
+ it("Should render if wrapped in room context", () => {
+ const { defaultRoomContext } = createMocks();
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument();
+ });
});
diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
index 25f3c8815e..b9720652bc 100644
--- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
+++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
@@ -298,6 +298,28 @@ describe("WysiwygComposer", () => {
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
});
+ it("pressing escape closes the autocomplete", async () => {
+ await insertMentionInput();
+
+ // press escape
+ await userEvent.keyboard("{Escape}");
+
+ // check that it closes the autocomplete
+ await waitFor(() => {
+ expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
+ });
+ });
+
+ it("typing with the autocomplete open still works as expected", async () => {
+ await insertMentionInput();
+
+ // add some more text, then check the autocomplete is open AND the text is in the composer
+ await userEvent.keyboard("extra");
+
+ expect(screen.queryByRole("presentation")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toHaveTextContent("@abcextra");
+ });
+
it("clicking on a mention in the composer dispatches the correct action", async () => {
await insertMentionInput();
diff --git a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx
new file mode 100644
index 0000000000..c25a869a09
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx
@@ -0,0 +1,179 @@
+/*
+Copyright 2023 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 {
+ Suggestion,
+ mapSuggestion,
+ processCommand,
+ processSelectionChange,
+} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
+
+function createMockPlainTextSuggestionPattern(props: Partial = {}): Suggestion {
+ return {
+ keyChar: "/",
+ type: "command",
+ text: "some text",
+ node: document.createTextNode(""),
+ startOffset: 0,
+ endOffset: 0,
+ ...props,
+ };
+}
+
+describe("mapSuggestion", () => {
+ it("returns null if called with a null argument", () => {
+ expect(mapSuggestion(null)).toBeNull();
+ });
+
+ it("returns a mapped suggestion when passed a suggestion", () => {
+ const inputFields = {
+ keyChar: "/" as const,
+ type: "command" as const,
+ text: "some text",
+ };
+ const input = createMockPlainTextSuggestionPattern(inputFields);
+ const output = mapSuggestion(input);
+
+ expect(output).toEqual(inputFields);
+ });
+});
+
+describe("processCommand", () => {
+ it("does not change parent hook state if suggestion is null", () => {
+ // create a mockSuggestion using the text node above
+ const mockSetSuggestion = jest.fn();
+ const mockSetText = jest.fn();
+
+ // call the function with a null suggestion
+ processCommand("should not be seen", null, mockSetSuggestion, mockSetText);
+
+ // check that the parent state setter has not been called
+ expect(mockSetText).not.toHaveBeenCalled();
+ });
+
+ it("can change the parent hook state when required", () => {
+ // create a div and append a text node to it with some initial text
+ const editorDiv = document.createElement("div");
+ const initialText = "text";
+ const textNode = document.createTextNode(initialText);
+ editorDiv.appendChild(textNode);
+
+ // create a mockSuggestion using the text node above
+ const mockSuggestion = createMockPlainTextSuggestionPattern({ node: textNode });
+ const mockSetSuggestion = jest.fn();
+ const mockSetText = jest.fn();
+ const replacementText = "/replacement text";
+
+ processCommand(replacementText, mockSuggestion, mockSetSuggestion, mockSetText);
+
+ // check that the text has changed and includes a trailing space
+ expect(mockSetText).toHaveBeenCalledWith(`${replacementText} `);
+ });
+});
+
+describe("processSelectionChange", () => {
+ function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject {
+ return { current: element } as React.RefObject;
+ }
+
+ function appendEditorWithTextNodeContaining(initialText = ""): [HTMLDivElement, Node] {
+ // create the elements/nodes
+ const mockEditor = document.createElement("div");
+ const textNode = document.createTextNode(initialText);
+
+ // append text node to the editor, editor to the document body
+ mockEditor.appendChild(textNode);
+ document.body.appendChild(mockEditor);
+
+ return [mockEditor, textNode];
+ }
+
+ const mockSetSuggestion = jest.fn();
+ beforeEach(() => {
+ mockSetSuggestion.mockClear();
+ });
+
+ it("returns early if current editorRef is null", () => {
+ const mockEditorRef = createMockEditorRef(null);
+ // we monitor for the call to document.createNodeIterator to indicate an early return
+ const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
+
+ processSelectionChange(mockEditorRef, null, jest.fn());
+ expect(nodeIteratorSpy).not.toHaveBeenCalled();
+
+ // tidy up to avoid potential impacts on other tests
+ nodeIteratorSpy.mockRestore();
+ });
+
+ it("does not call setSuggestion if selection is not a cursor", () => {
+ const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
+ const mockEditorRef = createMockEditorRef(mockEditor);
+
+ // create a selection in the text node that has different start and end locations ie it
+ // is not a cursor
+ document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
+
+ // process the selection and check that we do not attempt to set the suggestion
+ processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
+ expect(mockSetSuggestion).not.toHaveBeenCalled();
+ });
+
+ it("does not call setSuggestion if selection cursor is not inside a text node", () => {
+ const [mockEditor] = appendEditorWithTextNodeContaining("content");
+ const mockEditorRef = createMockEditorRef(mockEditor);
+
+ // create a selection that points at the editor element, not the text node it contains
+ document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
+
+ // process the selection and check that we do not attempt to set the suggestion
+ processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
+ expect(mockSetSuggestion).not.toHaveBeenCalled();
+ });
+
+ it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
+ const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
+ const mockEditorRef = createMockEditorRef(mockEditor);
+
+ // create a selection in the text node that has identical start and end locations, ie it is a cursor
+ document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
+
+ // the call to process the selection will have an existing suggestion in state due to the second
+ // argument being non-null, expect that we clear this suggestion now that the text is not a command
+ processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
+ expect(mockSetSuggestion).toHaveBeenCalledWith(null);
+ });
+
+ it("calls setSuggestion with the expected arguments when text node is valid command", () => {
+ const commandText = "/potentialCommand";
+ const [mockEditor, textNode] = appendEditorWithTextNodeContaining(commandText);
+ const mockEditorRef = createMockEditorRef(mockEditor);
+
+ // create a selection in the text node that has identical start and end locations, ie it is a cursor
+ document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
+
+ // process the change and check the suggestion that is set looks as we expect it to
+ processSelectionChange(mockEditorRef, null, mockSetSuggestion);
+ expect(mockSetSuggestion).toHaveBeenCalledWith({
+ keyChar: "/",
+ type: "command",
+ text: "potentialCommand",
+ node: textNode,
+ startOffset: 0,
+ endOffset: commandText.length,
+ });
+ });
+});