mirror of
https://github.com/element-hq/element-web
synced 2024-11-22 01:05:42 +03:00
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:
parent
212977c4ac
commit
6f791d28c8
11 changed files with 585 additions and 23 deletions
|
@ -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",
|
||||
|
|
|
@ -84,3 +84,10 @@ limitations under the License.
|
|||
border-color: $quaternary-content;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SendWysiwygComposer_AutoCompleteWrapper {
|
||||
position: relative;
|
||||
> .mx_Autocomplete {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 };
|
|
@ -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<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(() => {
|
||||
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 (
|
||||
<div
|
||||
|
@ -70,6 +108,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
|||
onFocus={onFocus}
|
||||
onBlur={onFocus}
|
||||
>
|
||||
<WysiwygAutocomplete ref={autocompleteRef} suggestion={suggestion} handleMention={wysiwyg.mention} />
|
||||
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
|
||||
<Editor
|
||||
ref={ref}
|
||||
|
|
|
@ -32,9 +32,11 @@ import { useMatrixClientContext } from "../../../../../contexts/MatrixClientCont
|
|||
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
|
||||
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
|
||||
import { endEditing } from "../utils/editing";
|
||||
import Autocomplete from "../../Autocomplete";
|
||||
|
||||
export function useInputEventProcessor(
|
||||
onSend: () => void,
|
||||
autocompleteRef: React.RefObject<Autocomplete>,
|
||||
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<Autocomplete>,
|
||||
): 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();
|
||||
|
|
|
@ -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 }])(
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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(
|
||||
<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();
|
||||
});
|
||||
|
||||
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: <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", () => {
|
||||
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 };
|
||||
|
|
|
@ -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 <strong>to</strong> this new content") {
|
||||
const mockClient = createTestClient();
|
||||
const mockClient = stubClient();
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue