Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 1af7108

Browse files
authored
Mentions as links rte (#10422)
* bumps the RTE dependency to introduce user/room mention handling * adds autocomplete behaviour to allow users to insert user and room mentions as links * sets up tests for the autocomplete behaviour
1 parent 8e1b9f4 commit 1af7108

File tree

11 files changed

+585
-23
lines changed

11 files changed

+585
-23
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"dependencies": {
6262
"@babel/runtime": "^7.12.5",
6363
"@matrix-org/analytics-events": "^0.5.0",
64-
"@matrix-org/matrix-wysiwyg": "^1.1.1",
64+
"@matrix-org/matrix-wysiwyg": "^1.4.0",
6565
"@matrix-org/react-sdk-module-api": "^0.0.4",
6666
"@sentry/browser": "^7.0.0",
6767
"@sentry/tracing": "^7.0.0",

res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,10 @@ limitations under the License.
8484
border-color: $quaternary-content;
8585
}
8686
}
87+
88+
.mx_SendWysiwygComposer_AutoCompleteWrapper {
89+
position: relative;
90+
> .mx_Autocomplete {
91+
min-width: 100%;
92+
}
93+
}

res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ limitations under the License.
100100
padding: unset;
101101
}
102102
}
103+
104+
/* this selector represents what will become a pill */
105+
a[data-mention-type] {
106+
cursor: text;
107+
}
103108
}
104109

105110
.mx_WysiwygComposer_Editor_content_placeholder::before {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { ForwardedRef, forwardRef } from "react";
18+
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
19+
import { FormattingFunctions, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
20+
21+
import { useRoomContext } from "../../../../../contexts/RoomContext";
22+
import Autocomplete from "../../Autocomplete";
23+
import { ICompletion } from "../../../../../autocomplete/Autocompleter";
24+
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
25+
26+
interface WysiwygAutocompleteProps {
27+
/**
28+
* The suggestion output from the rust model is used to build the query that is
29+
* passed to the `<Autocomplete />` component
30+
*/
31+
suggestion: MappedSuggestion | null;
32+
33+
/**
34+
* This handler will be called with the href and display text for a mention on clicking
35+
* a mention in the autocomplete list or pressing enter on a selected item
36+
*/
37+
handleMention: FormattingFunctions["mention"];
38+
}
39+
40+
/**
41+
* Builds the query for the `<Autocomplete />` component from the rust suggestion. This
42+
* will change as we implement handling / commands.
43+
*
44+
* @param suggestion - represents if the rust model is tracking a potential mention
45+
* @returns an empty string if we can not generate a query, otherwise a query beginning
46+
* with @ for a user query, # for a room or space query
47+
*/
48+
function buildQuery(suggestion: MappedSuggestion | null): string {
49+
if (!suggestion || !suggestion.keyChar || suggestion.type === "command") {
50+
// if we have an empty key character, we do not build a query
51+
// TODO implement the command functionality
52+
return "";
53+
}
54+
55+
return `${suggestion.keyChar}${suggestion.text}`;
56+
}
57+
58+
/**
59+
* Given a room type mention, determine the text that should be displayed in the mention
60+
* TODO expand this function to more generally handle outputting the display text from a
61+
* given completion
62+
*
63+
* @param completion - the item selected from the autocomplete, currently treated as a room completion
64+
* @param client - the MatrixClient is required for us to look up the correct room mention text
65+
* @returns the text to display in the mention
66+
*/
67+
function getRoomMentionText(completion: ICompletion, client: MatrixClient): string {
68+
const roomId = completion.completionId;
69+
const alias = completion.completion;
70+
71+
let roomForAutocomplete: Room | null | undefined;
72+
73+
// Not quite sure if the logic here makes sense - specifically calling .getRoom with an alias
74+
// that doesn't start with #, but keeping the logic the same as in PartCreator.roomPill for now
75+
if (roomId) {
76+
roomForAutocomplete = client.getRoom(roomId);
77+
} else if (!alias.startsWith("#")) {
78+
roomForAutocomplete = client.getRoom(alias);
79+
} else {
80+
roomForAutocomplete = client.getRooms().find((r) => {
81+
return r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias);
82+
});
83+
}
84+
85+
// if we haven't managed to find the room, use the alias as a fallback
86+
return roomForAutocomplete?.name || alias;
87+
}
88+
89+
/**
90+
* Given the current suggestion from the rust model and a handler function, this component
91+
* will display the legacy `<Autocomplete />` component (as used in the BasicMessageComposer)
92+
* and call the handler function with the required arguments when a mention is selected
93+
*
94+
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
95+
*/
96+
const WysiwygAutocomplete = forwardRef(
97+
({ suggestion, handleMention }: WysiwygAutocompleteProps, ref: ForwardedRef<Autocomplete>): JSX.Element | null => {
98+
const { room } = useRoomContext();
99+
const client = useMatrixClientContext();
100+
101+
function handleConfirm(completion: ICompletion): void {
102+
if (!completion.href) return;
103+
104+
switch (completion.type) {
105+
case "user":
106+
handleMention(completion.href, completion.completion);
107+
break;
108+
case "room": {
109+
handleMention(completion.href, getRoomMentionText(completion, client));
110+
break;
111+
}
112+
// TODO implement the command functionality
113+
// case "command":
114+
// console.log("/command functionality not yet in place");
115+
// break;
116+
default:
117+
break;
118+
}
119+
}
120+
121+
return room ? (
122+
<div className="mx_SendWysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
123+
<Autocomplete
124+
ref={ref}
125+
query={buildQuery(suggestion)}
126+
onConfirm={handleConfirm}
127+
selection={{ start: 0, end: 0 }}
128+
room={room}
129+
/>
130+
</div>
131+
) : null;
132+
},
133+
);
134+
135+
WysiwygAutocomplete.displayName = "WysiwygAutocomplete";
136+
137+
export { WysiwygAutocomplete };

src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { memo, MutableRefObject, ReactNode, useEffect } from "react";
17+
import React, { memo, MutableRefObject, ReactNode, useEffect, useRef } from "react";
1818
import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
1919
import classNames from "classnames";
2020

21+
import Autocomplete from "../../Autocomplete";
22+
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
2123
import { FormattingButtons } from "./FormattingButtons";
2224
import { Editor } from "./Editor";
2325
import { useInputEventProcessor } from "../hooks/useInputEventProcessor";
2426
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
2527
import { useIsFocused } from "../hooks/useIsFocused";
28+
import { useRoomContext } from "../../../../../contexts/RoomContext";
29+
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
30+
import { Action } from "../../../../../dispatcher/actions";
31+
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
2632

2733
interface WysiwygComposerProps {
2834
disabled?: boolean;
@@ -47,21 +53,53 @@ export const WysiwygComposer = memo(function WysiwygComposer({
4753
rightComponent,
4854
children,
4955
}: WysiwygComposerProps) {
50-
const inputEventProcessor = useInputEventProcessor(onSend, initialContent);
56+
const { room } = useRoomContext();
57+
const autocompleteRef = useRef<Autocomplete | null>(null);
5158

52-
const { ref, isWysiwygReady, content, actionStates, wysiwyg } = useWysiwyg({ initialContent, inputEventProcessor });
59+
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent);
60+
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion } = useWysiwyg({
61+
initialContent,
62+
inputEventProcessor,
63+
});
64+
const { isFocused, onFocus } = useIsFocused();
65+
66+
const isReady = isWysiwygReady && !disabled;
67+
const computedPlaceholder = (!content && placeholder) || undefined;
68+
69+
useSetCursorPosition(!isReady, ref);
5370

5471
useEffect(() => {
5572
if (!disabled && content !== null) {
5673
onChange?.(content);
5774
}
5875
}, [onChange, content, disabled]);
5976

60-
const isReady = isWysiwygReady && !disabled;
61-
useSetCursorPosition(!isReady, ref);
77+
useEffect(() => {
78+
function handleClick(e: Event): void {
79+
e.preventDefault();
80+
if (
81+
e.target &&
82+
e.target instanceof HTMLAnchorElement &&
83+
e.target.getAttribute("data-mention-type") === "user"
84+
) {
85+
const parsedLink = parsePermalink(e.target.href);
86+
if (room && parsedLink?.userId)
87+
defaultDispatcher.dispatch({
88+
action: Action.ViewUser,
89+
member: room.getMember(parsedLink.userId),
90+
});
91+
}
92+
}
6293

63-
const { isFocused, onFocus } = useIsFocused();
64-
const computedPlaceholder = (!content && placeholder) || undefined;
94+
const mentions = ref.current?.querySelectorAll("a[data-mention-type]");
95+
if (mentions) {
96+
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
97+
}
98+
99+
return () => {
100+
if (mentions) mentions.forEach((mention) => mention.removeEventListener("click", handleClick));
101+
};
102+
}, [ref, room, content]);
65103

66104
return (
67105
<div
@@ -70,6 +108,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
70108
onFocus={onFocus}
71109
onBlur={onFocus}
72110
>
111+
<WysiwygAutocomplete ref={autocompleteRef} suggestion={suggestion} handleMention={wysiwyg.mention} />
73112
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
74113
<Editor
75114
ref={ref}

src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ import { useMatrixClientContext } from "../../../../../contexts/MatrixClientCont
3232
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
3333
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
3434
import { endEditing } from "../utils/editing";
35+
import Autocomplete from "../../Autocomplete";
3536

3637
export function useInputEventProcessor(
3738
onSend: () => void,
39+
autocompleteRef: React.RefObject<Autocomplete>,
3840
initialContent?: string,
3941
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
4042
const roomContext = useRoomContext();
@@ -51,6 +53,10 @@ export function useInputEventProcessor(
5153
const send = (): void => {
5254
event.stopPropagation?.();
5355
event.preventDefault?.();
56+
// do not send the message if we have the autocomplete open, regardless of settings
57+
if (autocompleteRef?.current && !autocompleteRef.current.state.hide) {
58+
return;
59+
}
5460
onSend();
5561
};
5662

@@ -65,12 +71,13 @@ export function useInputEventProcessor(
6571
roomContext,
6672
composerContext,
6773
mxClient,
74+
autocompleteRef,
6875
);
6976
} else {
7077
return handleInputEvent(event, send, isCtrlEnterToSend);
7178
}
7279
},
73-
[isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient],
80+
[isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient, autocompleteRef],
7481
);
7582
}
7683

@@ -85,12 +92,51 @@ function handleKeyboardEvent(
8592
roomContext: IRoomState,
8693
composerContext: ComposerContextState,
8794
mxClient: MatrixClient,
95+
autocompleteRef: React.RefObject<Autocomplete>,
8896
): KeyboardEvent | null {
8997
const { editorStateTransfer } = composerContext;
9098
const isEditing = Boolean(editorStateTransfer);
9199
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
92100
const action = getKeyBindingsManager().getMessageComposerAction(event);
93101

102+
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
103+
104+
// we need autocomplete to take priority when it is open for using enter to select
105+
if (autocompleteIsOpen) {
106+
let handled = false;
107+
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
108+
const component = autocompleteRef.current;
109+
if (component && component.countCompletions() > 0) {
110+
switch (autocompleteAction) {
111+
case KeyBindingAction.ForceCompleteAutocomplete:
112+
case KeyBindingAction.CompleteAutocomplete:
113+
autocompleteRef.current.onConfirmCompletion();
114+
handled = true;
115+
break;
116+
case KeyBindingAction.PrevSelectionInAutocomplete:
117+
autocompleteRef.current.moveSelection(-1);
118+
handled = true;
119+
break;
120+
case KeyBindingAction.NextSelectionInAutocomplete:
121+
autocompleteRef.current.moveSelection(1);
122+
handled = true;
123+
break;
124+
case KeyBindingAction.CancelAutocomplete:
125+
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
126+
handled = true;
127+
break;
128+
default:
129+
break; // don't return anything, allow event to pass through
130+
}
131+
}
132+
133+
if (handled) {
134+
event.preventDefault();
135+
event.stopPropagation();
136+
return event;
137+
}
138+
}
139+
94140
switch (action) {
95141
case KeyBindingAction.SendMessage:
96142
send();

test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@ describe("SendWysiwygComposer", () => {
9393
customRender(jest.fn(), jest.fn(), false, true);
9494

9595
// Then
96-
await waitFor(() => expect(screen.getByTestId("WysiwygComposer")).toBeTruthy());
96+
expect(await screen.findByTestId("WysiwygComposer")).toBeInTheDocument();
9797
});
9898

99-
it("Should render PlainTextComposer when isRichTextEnabled is at false", () => {
99+
it("Should render PlainTextComposer when isRichTextEnabled is at false", async () => {
100100
// When
101101
customRender(jest.fn(), jest.fn(), false, false);
102102

103103
// Then
104-
expect(screen.getByTestId("PlainTextComposer")).toBeTruthy();
104+
expect(await screen.findByTestId("PlainTextComposer")).toBeInTheDocument();
105105
});
106106

107107
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(

0 commit comments

Comments
 (0)