Skip to content

Commit

Permalink
Add mentions as links to rte (#10463)
Browse files Browse the repository at this point in the history
* adds autocomplete behaviour for mentions in rich text editor
* allows adding mentions as links
* adds tests
  • Loading branch information
alunturner committed Mar 31, 2023
1 parent 212977c commit 6f791d2
Show file tree
Hide file tree
Showing 11 changed files with 585 additions and 23 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@ limitations under the License.
border-color: $quaternary-content;
}
}

.mx_SendWysiwygComposer_AutoCompleteWrapper {
position: relative;
> .mx_Autocomplete {
min-width: 100%;
}
}
5 changes: 5 additions & 0 deletions res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,21 +53,53 @@ 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) {
onChange?.(content);
}
}, [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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
};

Expand All @@ -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],
);
}

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }])(
Expand Down
Loading

0 comments on commit 6f791d2

Please sign in to comment.