diff --git a/package.json b/package.json index 79a67aa664..a1d5c3a1f4 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.5.0", - "@matrix-org/matrix-wysiwyg": "^1.1.1", + "@matrix-org/matrix-wysiwyg": "^1.4.1", "@matrix-org/react-sdk-module-api": "^0.0.4", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", diff --git a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index 2eee815c3f..382674f36e 100644 --- a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -84,3 +84,10 @@ limitations under the License. border-color: $quaternary-content; } } + +.mx_SendWysiwygComposer_AutoCompleteWrapper { + position: relative; + > .mx_Autocomplete { + min-width: 100%; + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index a2c66202a4..849fd3c5d3 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -100,6 +100,11 @@ limitations under the License. padding: unset; } } + + /* this selector represents what will become a pill */ + a[data-mention-type] { + cursor: text; + } } .mx_WysiwygComposer_Editor_content_placeholder::before { diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx new file mode 100644 index 0000000000..5ad7b07884 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx @@ -0,0 +1,137 @@ +/* +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, { ForwardedRef, forwardRef } from "react"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { FormattingFunctions, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; + +import { useRoomContext } from "../../../../../contexts/RoomContext"; +import Autocomplete from "../../Autocomplete"; +import { ICompletion } from "../../../../../autocomplete/Autocompleter"; +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; + +interface WysiwygAutocompleteProps { + /** + * The suggestion output from the rust model is used to build the query that is + * passed to the `` component + */ + suggestion: MappedSuggestion | null; + + /** + * This handler will be called with the href and display text for a mention on clicking + * a mention in the autocomplete list or pressing enter on a selected item + */ + handleMention: FormattingFunctions["mention"]; +} + +/** + * Builds the query for the `` component from the rust suggestion. This + * will change as we implement handling / commands. + * + * @param suggestion - represents if the rust model is tracking a potential mention + * @returns an empty string if we can not generate a query, otherwise a query beginning + * with @ for a user query, # for a room or space query + */ +function buildQuery(suggestion: MappedSuggestion | null): string { + if (!suggestion || !suggestion.keyChar || suggestion.type === "command") { + // if we have an empty key character, we do not build a query + // TODO implement the command functionality + return ""; + } + + return `${suggestion.keyChar}${suggestion.text}`; +} + +/** + * Given a room type mention, determine the text that should be displayed in the mention + * TODO expand this function to more generally handle outputting the display text from a + * given completion + * + * @param completion - the item selected from the autocomplete, currently treated as a room completion + * @param client - the MatrixClient is required for us to look up the correct room mention text + * @returns the text to display in the mention + */ +function getRoomMentionText(completion: ICompletion, client: MatrixClient): string { + const roomId = completion.completionId; + const alias = completion.completion; + + let roomForAutocomplete: Room | null | undefined; + + // Not quite sure if the logic here makes sense - specifically calling .getRoom with an alias + // that doesn't start with #, but keeping the logic the same as in PartCreator.roomPill for now + if (roomId) { + roomForAutocomplete = client.getRoom(roomId); + } else if (!alias.startsWith("#")) { + roomForAutocomplete = client.getRoom(alias); + } else { + roomForAutocomplete = client.getRooms().find((r) => { + return r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias); + }); + } + + // if we haven't managed to find the room, use the alias as a fallback + return roomForAutocomplete?.name || alias; +} + +/** + * Given the current suggestion from the rust model and a handler function, this component + * will display the legacy `` component (as used in the BasicMessageComposer) + * and call the handler function with the required arguments when a mention is selected + * + * @param props.ref - the ref will be attached to the rendered `` component + */ +const WysiwygAutocomplete = forwardRef( + ({ suggestion, handleMention }: WysiwygAutocompleteProps, ref: ForwardedRef): JSX.Element | null => { + const { room } = useRoomContext(); + const client = useMatrixClientContext(); + + function handleConfirm(completion: ICompletion): void { + if (!completion.href || !client) return; + + switch (completion.type) { + case "user": + handleMention(completion.href, completion.completion); + break; + case "room": { + handleMention(completion.href, getRoomMentionText(completion, client)); + break; + } + // TODO implement the command functionality + // case "command": + // console.log("/command functionality not yet in place"); + // break; + default: + break; + } + } + + return room ? ( +
+ +
+ ) : null; + }, +); + +WysiwygAutocomplete.displayName = "WysiwygAutocomplete"; + +export { WysiwygAutocomplete }; diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 42a375143b..ca42ba22ec 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -14,15 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { memo, MutableRefObject, ReactNode, useEffect } from "react"; +import React, { memo, MutableRefObject, ReactNode, useEffect, useRef } from "react"; import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import classNames from "classnames"; +import Autocomplete from "../../Autocomplete"; +import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; import { FormattingButtons } from "./FormattingButtons"; import { Editor } from "./Editor"; import { useInputEventProcessor } from "../hooks/useInputEventProcessor"; import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { useIsFocused } from "../hooks/useIsFocused"; +import { useRoomContext } from "../../../../../contexts/RoomContext"; +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { parsePermalink } from "../../../../../utils/permalinks/Permalinks"; interface WysiwygComposerProps { disabled?: boolean; @@ -47,9 +53,20 @@ export const WysiwygComposer = memo(function WysiwygComposer({ rightComponent, children, }: WysiwygComposerProps) { - const inputEventProcessor = useInputEventProcessor(onSend, initialContent); + const { room } = useRoomContext(); + const autocompleteRef = useRef(null); - const { ref, isWysiwygReady, content, actionStates, wysiwyg } = useWysiwyg({ initialContent, inputEventProcessor }); + const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent); + const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion } = useWysiwyg({ + initialContent, + inputEventProcessor, + }); + const { isFocused, onFocus } = useIsFocused(); + + const isReady = isWysiwygReady && !disabled; + const computedPlaceholder = (!content && placeholder) || undefined; + + useSetCursorPosition(!isReady, ref); useEffect(() => { if (!disabled && content !== null) { @@ -57,11 +74,32 @@ export const WysiwygComposer = memo(function WysiwygComposer({ } }, [onChange, content, disabled]); - const isReady = isWysiwygReady && !disabled; - useSetCursorPosition(!isReady, ref); + useEffect(() => { + function handleClick(e: Event): void { + e.preventDefault(); + if ( + e.target && + e.target instanceof HTMLAnchorElement && + e.target.getAttribute("data-mention-type") === "user" + ) { + const parsedLink = parsePermalink(e.target.href); + if (room && parsedLink?.userId) + defaultDispatcher.dispatch({ + action: Action.ViewUser, + member: room.getMember(parsedLink.userId), + }); + } + } - const { isFocused, onFocus } = useIsFocused(); - const computedPlaceholder = (!content && placeholder) || undefined; + const mentions = ref.current?.querySelectorAll("a[data-mention-type]"); + if (mentions) { + mentions.forEach((mention) => mention.addEventListener("click", handleClick)); + } + + return () => { + if (mentions) mentions.forEach((mention) => mention.removeEventListener("click", handleClick)); + }; + }, [ref, room, content]); return (
+ void, + autocompleteRef: React.RefObject, initialContent?: string, ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { const roomContext = useRoomContext(); @@ -51,6 +53,10 @@ export function useInputEventProcessor( const send = (): void => { event.stopPropagation?.(); event.preventDefault?.(); + // do not send the message if we have the autocomplete open, regardless of settings + if (autocompleteRef?.current && !autocompleteRef.current.state.hide) { + return; + } onSend(); }; @@ -65,12 +71,13 @@ export function useInputEventProcessor( roomContext, composerContext, mxClient, + autocompleteRef, ); } else { return handleInputEvent(event, send, isCtrlEnterToSend); } }, - [isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient], + [isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient, autocompleteRef], ); } @@ -85,12 +92,51 @@ function handleKeyboardEvent( roomContext: IRoomState, composerContext: ComposerContextState, mxClient: MatrixClient, + autocompleteRef: React.RefObject, ): KeyboardEvent | null { const { editorStateTransfer } = composerContext; const isEditing = Boolean(editorStateTransfer); const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0; const action = getKeyBindingsManager().getMessageComposerAction(event); + const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide; + + // we need autocomplete to take priority when it is open for using enter to select + if (autocompleteIsOpen) { + 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 event; + } + } + switch (action) { case KeyBindingAction.SendMessage: send(); diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 4dcf8d504e..5c4fd1941a 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -93,15 +93,15 @@ describe("SendWysiwygComposer", () => { customRender(jest.fn(), jest.fn(), false, true); // Then - await waitFor(() => expect(screen.getByTestId("WysiwygComposer")).toBeTruthy()); + expect(await screen.findByTestId("WysiwygComposer")).toBeInTheDocument(); }); - it("Should render PlainTextComposer when isRichTextEnabled is at false", () => { + it("Should render PlainTextComposer when isRichTextEnabled is at false", async () => { // When customRender(jest.fn(), jest.fn(), false, false); // Then - expect(screen.getByTestId("PlainTextComposer")).toBeTruthy(); + expect(await screen.findByTestId("PlainTextComposer")).toBeInTheDocument(); }); describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])( diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx new file mode 100644 index 0000000000..e4de34c269 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx @@ -0,0 +1,120 @@ +/* +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 "@testing-library/jest-dom"; +import React, { createRef } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; + +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import RoomContext from "../../../../../../src/contexts/RoomContext"; +import { WysiwygAutocomplete } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete"; +import { getRoomContext, mkStubRoom, stubClient } from "../../../../../test-utils"; +import Autocomplete from "../../../../../../src/components/views/rooms/Autocomplete"; +import Autocompleter, { ICompletion } from "../../../../../../src/autocomplete/Autocompleter"; +import AutocompleteProvider from "../../../../../../src/autocomplete/AutocompleteProvider"; + +const mockCompletion: ICompletion[] = [ + { + type: "user", + completion: "user_1", + completionId: "@user_1:host.local", + range: { start: 1, end: 1 }, + component:
user_1
, + }, + { + type: "user", + completion: "user_2", + completionId: "@user_2:host.local", + range: { start: 1, end: 1 }, + component:
user_2
, + }, +]; + +const constructMockProvider = (data: ICompletion[]) => + ({ + getCompletions: jest.fn().mockImplementation(async () => data), + getName: jest.fn().mockReturnValue("test provider"), + renderCompletions: jest.fn().mockImplementation((components) => components), + } as unknown as AutocompleteProvider); + +describe("WysiwygAutocomplete", () => { + beforeAll(() => { + // scrollTo not implemented in JSDOM + window.HTMLElement.prototype.scrollTo = function () {}; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + const autocompleteRef = createRef(); + const getCompletionsSpy = jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([ + { + completions: mockCompletion, + provider: constructMockProvider(mockCompletion), + command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing + }, + ]); + const mockHandleMention = jest.fn(); + + const renderComponent = (props = {}) => { + const mockClient = stubClient(); + const mockRoom = mkStubRoom("test_room", "test_room", mockClient); + const mockRoomContext = getRoomContext(mockRoom, {}); + + return render( + + + + + , + ); + }; + + it("does not show the autocomplete when room is undefined", () => { + render(); + expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument(); + }); + + it("does not call for suggestions with a null suggestion prop", async () => { + // render the component, the default props have suggestion = null + renderComponent(); + + // check that getCompletions is not called, and we have no suggestions + expect(getCompletionsSpy).not.toHaveBeenCalled(); + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + it("calls getCompletions when given a valid suggestion prop", async () => { + renderComponent({ suggestion: { keyChar: "@", text: "abc", type: "mention" } }); + + // wait for getCompletions to have been called + await waitFor(() => { + expect(getCompletionsSpy).toHaveBeenCalled(); + }); + + // check that some suggestions are shown + expect(screen.getByRole("presentation")).toBeInTheDocument(); + + // and that they are the mock completions + mockCompletion.forEach(({ completion }) => expect(screen.getByText(completion)).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 3ab7d768d6..3f9694e2a3 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -21,7 +21,7 @@ import userEvent from "@testing-library/user-event"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; -import { createTestClient, flushPromises, mockPlatformPeg } from "../../../../../test-utils"; +import { flushPromises, mockPlatformPeg, stubClient, mkStubRoom } from "../../../../../test-utils"; import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; import * as EventUtils from "../../../../../../src/utils/EventUtils"; import { Action } from "../../../../../../src/dispatcher/actions"; @@ -36,11 +36,25 @@ import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types"; import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent"; +import Autocompleter, { ICompletion } from "../../../../../../src/autocomplete/Autocompleter"; +import AutocompleteProvider from "../../../../../../src/autocomplete/AutocompleteProvider"; +import * as Permalinks from "../../../../../../src/utils/permalinks/Permalinks"; +import { PermalinkParts } from "../../../../../../src/utils/permalinks/PermalinkConstructor"; describe("WysiwygComposer", () => { const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => { + const { mockClient, defaultRoomContext } = createMocks(); return render( - , + + + + + , ); }; @@ -48,12 +62,12 @@ describe("WysiwygComposer", () => { jest.resetAllMocks(); }); - it("Should have contentEditable at false when disabled", () => { + it("Should have contentEditable at false when disabled", async () => { // When customRender(jest.fn(), jest.fn(), true); // Then - expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false")); }); describe("Standard behavior", () => { @@ -144,6 +158,199 @@ describe("WysiwygComposer", () => { }); }); + describe("Mentions", () => { + const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch"); + + const mockCompletions: ICompletion[] = [ + { + type: "user", + href: "www.user1.com", + completion: "user_1", + completionId: "@user_1:host.local", + range: { start: 1, end: 1 }, + component:
user_1
, + }, + { + type: "user", + href: "www.user2.com", + completion: "user_2", + completionId: "@user_2:host.local", + range: { start: 1, end: 1 }, + component:
user_2
, + }, + { + // no href user + type: "user", + completion: "user_without_href", + completionId: "@user_3:host.local", + range: { start: 1, end: 1 }, + component:
user_without_href
, + }, + { + type: "room", + href: "www.room1.com", + completion: "#room_with_completion_id", + completionId: "@room_1:host.local", + range: { start: 1, end: 1 }, + component:
room_with_completion_id
, + }, + { + type: "room", + href: "www.room2.com", + completion: "#room_without_completion_id", + range: { start: 1, end: 1 }, + component:
room_without_completion_id
, + }, + ]; + + const constructMockProvider = (data: ICompletion[]) => + ({ + getCompletions: jest.fn().mockImplementation(async () => data), + getName: jest.fn().mockReturnValue("test provider"), + renderCompletions: jest.fn().mockImplementation((components) => components), + } as unknown as AutocompleteProvider); + + // for each test we will insert input simulating a user mention + const insertMentionInput = async () => { + fireEvent.input(screen.getByRole("textbox"), { + data: "@abc", + inputType: "insertText", + }); + + // the autocomplete suggestions container has the presentation role, wait for it to be present + expect(await screen.findByRole("presentation")).toBeInTheDocument(); + }; + + beforeEach(async () => { + // setup the required spies + jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([ + { + completions: mockCompletions, + provider: constructMockProvider(mockCompletions), + command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing + }, + ]); + jest.spyOn(Permalinks, "parsePermalink").mockReturnValue({ + userId: "mockParsedUserId", + } as unknown as PermalinkParts); + + // then render the component and wait for the composer to be ready + customRender(); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("shows the autocomplete when text has @ prefix and autoselects the first item", async () => { + await insertMentionInput(); + expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true"); + }); + + it("pressing up and down arrows allows us to change the autocomplete selection", async () => { + await insertMentionInput(); + + // press the down arrow - nb using .keyboard allows us to not have to specify a node, which + // means that we know the autocomplete is correctly catching the event + await userEvent.keyboard("{ArrowDown}"); + expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "false"); + expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "true"); + + // reverse the process and check again + await userEvent.keyboard("{ArrowUp}"); + expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "false"); + }); + + it("pressing enter selects the mention and inserts it into the composer as a link", async () => { + await insertMentionInput(); + + // press enter + await userEvent.keyboard("{Enter}"); + + // check that it closes the autocomplete + await waitFor(() => { + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + // check that it inserts the completion text as a link + expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument(); + }); + + it("clicking on a mention in the composer dispatches the correct action", async () => { + await insertMentionInput(); + + // press enter + await userEvent.keyboard("{Enter}"); + + // check that it closes the autocomplete + await waitFor(() => { + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + // click on the user mention link that has been inserted + await userEvent.click(screen.getByRole("link", { name: mockCompletions[0].completion })); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + + // this relies on the output from the mock function in mkStubRoom + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewUser, + member: expect.objectContaining({ + userId: mkStubRoom(undefined, undefined, undefined).getMember("any")?.userId, + }), + }), + ); + }); + + it("selecting a mention without a href closes the autocomplete but does not insert a mention", async () => { + await insertMentionInput(); + + // select the relevant user by clicking + await userEvent.click(screen.getByText("user_without_href")); + + // check that it closes the autocomplete + await waitFor(() => { + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + // check that it has not inserted a link + expect(screen.queryByRole("link", { name: "user_without_href" })).not.toBeInTheDocument(); + }); + + it("selecting a room mention with a completionId uses client.getRoom", async () => { + await insertMentionInput(); + + // select the room suggestion by clicking + await userEvent.click(screen.getByText("room_with_completion_id")); + + // check that it closes the autocomplete + await waitFor(() => { + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + // check that it has inserted a link and looked up the name from the mock client + // which will always return 'My room' + expect(screen.getByRole("link", { name: "My room" })).toBeInTheDocument(); + }); + + it("selecting a room mention without a completionId uses client.getRooms", async () => { + await insertMentionInput(); + + // select the room suggestion + await userEvent.click(screen.getByText("room_without_completion_id")); + + // check that it closes the autocomplete + await waitFor(() => { + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + }); + + // check that it has inserted a link and falls back to the completion text + expect(screen.getByRole("link", { name: "#room_without_completion_id" })).toBeInTheDocument(); + }); + }); + describe("When settings require Ctrl+Enter to send", () => { const onChange = jest.fn(); const onSend = jest.fn(); @@ -241,10 +448,11 @@ describe("WysiwygComposer", () => { const setup = async ( editorState?: EditorStateTransfer, - client = createTestClient(), + client = stubClient(), roomContext = defaultRoomContext, ) => { const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + customRender(client, roomContext, editorState); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); return { textbox: screen.getByRole("textbox"), spyDispatcher }; diff --git a/test/components/views/rooms/wysiwyg_composer/utils.ts b/test/components/views/rooms/wysiwyg_composer/utils.ts index 0eb99b251d..82b2fd537d 100644 --- a/test/components/views/rooms/wysiwyg_composer/utils.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils.ts @@ -16,12 +16,12 @@ limitations under the License. import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; export function createMocks(eventContent = "Replying to this new content") { - const mockClient = createTestClient(); + const mockClient = stubClient(); const mockEvent = mkEvent({ type: "m.room.message", room: "myfakeroom", diff --git a/yarn.lock b/yarn.lock index b568c80fb7..bcfed948ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1704,10 +1704,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz#60ede2c43b9d808ba8cf46085a3b347b290d9658" integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw== -"@matrix-org/matrix-wysiwyg@^1.1.1": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-1.3.0.tgz#647837be552c1a96ca8157e0dc0d7d8f897fcbe2" - integrity sha512-uHcPYP+mriJZcI54lDBpO+wPGDli/+VEL/NjuW+BBgt7PLViSa4xaGdD7K+yUBgntRdbJ/J4fo+lYB06kqF+sA== +"@matrix-org/matrix-wysiwyg@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-1.4.1.tgz#80c392f036dc4d6ef08a91c4964a68682e977079" + integrity sha512-B8sxY3pE2XyRyQ1g7cx0YjGaDZ1A0Uh5XxS/lNdxQ/0ctRJj6IBy7KtiUjxDRdA15ioZnf6aoJBRkBSr02qhaw== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14"