();
+ 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 3ab7d768d64..3f9694e2a3c 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 0eb99b251db..82b2fd537dd 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 b568c80fb76..bcfed948ad2 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"