mirror of
https://github.com/element-hq/element-web
synced 2024-11-26 03:05:51 +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": {
|
"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",
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.
|
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}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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 }])(
|
||||||
|
|
|
@ -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 { 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 };
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue