Add mentions as links to rte (#10463)

* adds autocomplete behaviour for mentions in rich text editor
* allows adding mentions as links
* adds tests
This commit is contained in:
alunturner 2023-03-31 14:14:55 +01:00 committed by GitHub
parent 212977c4ac
commit 6f791d28c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 585 additions and 23 deletions

View file

@ -61,7 +61,7 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.5.0", "@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", "@matrix-org/react-sdk-module-api": "^0.0.4",
"@sentry/browser": "^7.0.0", "@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0", "@sentry/tracing": "^7.0.0",

View file

@ -84,3 +84,10 @@ limitations under the License.
border-color: $quaternary-content; border-color: $quaternary-content;
} }
} }
.mx_SendWysiwygComposer_AutoCompleteWrapper {
position: relative;
> .mx_Autocomplete {
min-width: 100%;
}
}

View file

@ -100,6 +100,11 @@ limitations under the License.
padding: unset; padding: unset;
} }
} }
/* this selector represents what will become a pill */
a[data-mention-type] {
cursor: text;
}
} }
.mx_WysiwygComposer_Editor_content_placeholder::before { .mx_WysiwygComposer_Editor_content_placeholder::before {

View file

@ -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 `<Autocomplete />` 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 `<Autocomplete />` 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 `<Autocomplete />` 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 `<Autocomplete />` component
*/
const WysiwygAutocomplete = forwardRef(
({ suggestion, handleMention }: WysiwygAutocompleteProps, ref: ForwardedRef<Autocomplete>): 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 ? (
<div className="mx_SendWysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
<Autocomplete
ref={ref}
query={buildQuery(suggestion)}
onConfirm={handleConfirm}
selection={{ start: 0, end: 0 }}
room={room}
/>
</div>
) : null;
},
);
WysiwygAutocomplete.displayName = "WysiwygAutocomplete";
export { WysiwygAutocomplete };

View file

@ -14,15 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import classNames from "classnames"; import classNames from "classnames";
import Autocomplete from "../../Autocomplete";
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
import { FormattingButtons } from "./FormattingButtons"; import { FormattingButtons } from "./FormattingButtons";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
import { useInputEventProcessor } from "../hooks/useInputEventProcessor"; import { useInputEventProcessor } from "../hooks/useInputEventProcessor";
import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
import { useIsFocused } from "../hooks/useIsFocused"; 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 { interface WysiwygComposerProps {
disabled?: boolean; disabled?: boolean;
@ -47,9 +53,20 @@ export const WysiwygComposer = memo(function WysiwygComposer({
rightComponent, rightComponent,
children, children,
}: WysiwygComposerProps) { }: WysiwygComposerProps) {
const inputEventProcessor = useInputEventProcessor(onSend, initialContent); const { room } = useRoomContext();
const autocompleteRef = useRef<Autocomplete | null>(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(() => { useEffect(() => {
if (!disabled && content !== null) { if (!disabled && content !== null) {
@ -57,11 +74,32 @@ export const WysiwygComposer = memo(function WysiwygComposer({
} }
}, [onChange, content, disabled]); }, [onChange, content, disabled]);
const isReady = isWysiwygReady && !disabled; useEffect(() => {
useSetCursorPosition(!isReady, ref); 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 mentions = ref.current?.querySelectorAll("a[data-mention-type]");
const computedPlaceholder = (!content && placeholder) || undefined; if (mentions) {
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
}
return () => {
if (mentions) mentions.forEach((mention) => mention.removeEventListener("click", handleClick));
};
}, [ref, room, content]);
return ( return (
<div <div
@ -70,6 +108,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
onFocus={onFocus} onFocus={onFocus}
onBlur={onFocus} onBlur={onFocus}
> >
<WysiwygAutocomplete ref={autocompleteRef} suggestion={suggestion} handleMention={wysiwyg.mention} />
<FormattingButtons composer={wysiwyg} actionStates={actionStates} /> <FormattingButtons composer={wysiwyg} actionStates={actionStates} />
<Editor <Editor
ref={ref} ref={ref}

View file

@ -32,9 +32,11 @@ import { useMatrixClientContext } from "../../../../../contexts/MatrixClientCont
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event"; import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
import { endEditing } from "../utils/editing"; import { endEditing } from "../utils/editing";
import Autocomplete from "../../Autocomplete";
export function useInputEventProcessor( export function useInputEventProcessor(
onSend: () => void, onSend: () => void,
autocompleteRef: React.RefObject<Autocomplete>,
initialContent?: string, initialContent?: string,
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
const roomContext = useRoomContext(); const roomContext = useRoomContext();
@ -51,6 +53,10 @@ export function useInputEventProcessor(
const send = (): void => { const send = (): void => {
event.stopPropagation?.(); event.stopPropagation?.();
event.preventDefault?.(); 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(); onSend();
}; };
@ -65,12 +71,13 @@ export function useInputEventProcessor(
roomContext, roomContext,
composerContext, composerContext,
mxClient, mxClient,
autocompleteRef,
); );
} else { } else {
return handleInputEvent(event, send, isCtrlEnterToSend); 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, roomContext: IRoomState,
composerContext: ComposerContextState, composerContext: ComposerContextState,
mxClient: MatrixClient, mxClient: MatrixClient,
autocompleteRef: React.RefObject<Autocomplete>,
): KeyboardEvent | null { ): KeyboardEvent | null {
const { editorStateTransfer } = composerContext; const { editorStateTransfer } = composerContext;
const isEditing = Boolean(editorStateTransfer); const isEditing = Boolean(editorStateTransfer);
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0; const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
const action = getKeyBindingsManager().getMessageComposerAction(event); 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) { switch (action) {
case KeyBindingAction.SendMessage: case KeyBindingAction.SendMessage:
send(); send();

View file

@ -93,15 +93,15 @@ describe("SendWysiwygComposer", () => {
customRender(jest.fn(), jest.fn(), false, true); customRender(jest.fn(), jest.fn(), false, true);
// Then // 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 // When
customRender(jest.fn(), jest.fn(), false, false); customRender(jest.fn(), jest.fn(), false, false);
// Then // Then
expect(screen.getByTestId("PlainTextComposer")).toBeTruthy(); expect(await screen.findByTestId("PlainTextComposer")).toBeInTheDocument();
}); });
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])( describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(

View file

@ -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: <div>user_1</div>,
},
{
type: "user",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
component: <div>user_2</div>,
},
];
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<Autocomplete>();
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(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={mockRoomContext}>
<WysiwygAutocomplete
ref={autocompleteRef}
suggestion={null}
handleMention={mockHandleMention}
{...props}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
it("does not show the autocomplete when room is undefined", () => {
render(<WysiwygAutocomplete ref={autocompleteRef} suggestion={null} handleMention={mockHandleMention} />);
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());
});
});

View file

@ -21,7 +21,7 @@ import userEvent from "@testing-library/user-event";
import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import SettingsStore from "../../../../../../src/settings/SettingsStore"; 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 defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import * as EventUtils from "../../../../../../src/utils/EventUtils"; import * as EventUtils from "../../../../../../src/utils/EventUtils";
import { Action } from "../../../../../../src/dispatcher/actions"; 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 { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types";
import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent"; 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", () => { describe("WysiwygComposer", () => {
const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => { const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => {
const { mockClient, defaultRoomContext } = createMocks();
return render( return render(
<WysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} initialContent={initialContent} />, <MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={defaultRoomContext}>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>,
); );
}; };
@ -48,12 +62,12 @@ describe("WysiwygComposer", () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
it("Should have contentEditable at false when disabled", () => { it("Should have contentEditable at false when disabled", async () => {
// When // When
customRender(jest.fn(), jest.fn(), true); customRender(jest.fn(), jest.fn(), true);
// Then // Then
expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"));
}); });
describe("Standard behavior", () => { 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: <div>user_1</div>,
},
{
type: "user",
href: "www.user2.com",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
component: <div>user_2</div>,
},
{
// no href user
type: "user",
completion: "user_without_href",
completionId: "@user_3:host.local",
range: { start: 1, end: 1 },
component: <div>user_without_href</div>,
},
{
type: "room",
href: "www.room1.com",
completion: "#room_with_completion_id",
completionId: "@room_1:host.local",
range: { start: 1, end: 1 },
component: <div>room_with_completion_id</div>,
},
{
type: "room",
href: "www.room2.com",
completion: "#room_without_completion_id",
range: { start: 1, end: 1 },
component: <div>room_without_completion_id</div>,
},
];
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", () => { describe("When settings require Ctrl+Enter to send", () => {
const onChange = jest.fn(); const onChange = jest.fn();
const onSend = jest.fn(); const onSend = jest.fn();
@ -241,10 +448,11 @@ describe("WysiwygComposer", () => {
const setup = async ( const setup = async (
editorState?: EditorStateTransfer, editorState?: EditorStateTransfer,
client = createTestClient(), client = stubClient(),
roomContext = defaultRoomContext, roomContext = defaultRoomContext,
) => { ) => {
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender(client, roomContext, editorState); customRender(client, roomContext, editorState);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
return { textbox: screen.getByRole("textbox"), spyDispatcher }; return { textbox: screen.getByRole("textbox"), spyDispatcher };

View file

@ -16,12 +16,12 @@ limitations under the License.
import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix"; 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 { IRoomState } from "../../../../../src/components/structures/RoomView";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
export function createMocks(eventContent = "Replying <strong>to</strong> this new content") { export function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
const mockClient = createTestClient(); const mockClient = stubClient();
const mockEvent = mkEvent({ const mockEvent = mkEvent({
type: "m.room.message", type: "m.room.message",
room: "myfakeroom", room: "myfakeroom",

View file

@ -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" 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== integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw==
"@matrix-org/matrix-wysiwyg@^1.1.1": "@matrix-org/matrix-wysiwyg@^1.4.1":
version "1.3.0" version "1.4.1"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-1.3.0.tgz#647837be552c1a96ca8157e0dc0d7d8f897fcbe2" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-1.4.1.tgz#80c392f036dc4d6ef08a91c4964a68682e977079"
integrity sha512-uHcPYP+mriJZcI54lDBpO+wPGDli/+VEL/NjuW+BBgt7PLViSa4xaGdD7K+yUBgntRdbJ/J4fo+lYB06kqF+sA== 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": "@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" version "3.2.14"