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

Commit fa31ed5

Browse files
authored
Update rich text editor dependency and associated changes (#11098)
* fix logic error * update types * extract message content to variable * use the new messageContent property * sort out mention types to make them a map * update getMentionAttributes to use AllowedMentionAttributes * add plain text handling * change type and handling for attributes when creating a mention in plain text * tidy, add comment * revert TS config change * fix broken types in test * update tests * bump rte * fix import and ts errors * fix broken tests
1 parent 9776561 commit fa31ed5

File tree

12 files changed

+108
-77
lines changed

12 files changed

+108
-77
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": "^2.2.2",
64+
"@matrix-org/matrix-wysiwyg": "^2.3.0",
6565
"@matrix-org/react-sdk-module-api": "^0.0.5",
6666
"@sentry/browser": "^7.0.0",
6767
"@sentry/tracing": "^7.0.0",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function PlainTextComposer({
6565
onSelect,
6666
handleCommand,
6767
handleMention,
68+
handleAtRoomMention,
6869
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation);
6970

7071
const composerFunctions = useComposerFunctions(editorRef, setContent);
@@ -90,6 +91,7 @@ export function PlainTextComposer({
9091
suggestion={suggestion}
9192
handleMention={handleMention}
9293
handleCommand={handleCommand}
94+
handleAtRoomMention={handleAtRoomMention}
9395
/>
9496
<Editor
9597
ref={editorRef}

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ interface WysiwygAutocompleteProps {
4141
* a command in the autocomplete list or pressing enter on a selected item
4242
*/
4343
handleCommand: FormattingFunctions["command"];
44+
45+
/**
46+
* Handler purely for the at-room mentions special case
47+
*/
48+
handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
4449
}
4550

4651
/**
@@ -52,7 +57,7 @@ interface WysiwygAutocompleteProps {
5257
*/
5358
const WysiwygAutocomplete = forwardRef(
5459
(
55-
{ suggestion, handleMention, handleCommand }: WysiwygAutocompleteProps,
60+
{ suggestion, handleMention, handleCommand, handleAtRoomMention }: WysiwygAutocompleteProps,
5661
ref: ForwardedRef<Autocomplete>,
5762
): JSX.Element | null => {
5863
const { room } = useRoomContext();
@@ -72,15 +77,7 @@ const WysiwygAutocomplete = forwardRef(
7277
return;
7378
}
7479
case "at-room": {
75-
// TODO improve handling of at-room to either become a span or use a placeholder href
76-
// We have an issue in that we can't use a placeholder because the rust model is always
77-
// applying a prefix to the href, so an href of "#" becomes https://# and also we can not
78-
// represent a plain span in rust
79-
handleMention(
80-
window.location.href,
81-
getMentionDisplayText(completion, client),
82-
getMentionAttributes(completion, client, room),
83-
);
80+
handleAtRoomMention(getMentionAttributes(completion, client, room));
8481
return;
8582
}
8683
case "room":

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ import { useRoomContext } from "../../../../../contexts/RoomContext";
3030
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
3131
import { Action } from "../../../../../dispatcher/actions";
3232
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
33+
import { isNotNull } from "../../../../../Typeguards";
3334

3435
interface WysiwygComposerProps {
3536
disabled?: boolean;
36-
onChange?: (content: string) => void;
37+
onChange: (content: string) => void;
3738
onSend: () => void;
3839
placeholder?: string;
3940
initialContent?: string;
@@ -60,10 +61,11 @@ export const WysiwygComposer = memo(function WysiwygComposer({
6061
const autocompleteRef = useRef<Autocomplete | null>(null);
6162

6263
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
63-
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion } = useWysiwyg({
64+
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
6465
initialContent,
6566
inputEventProcessor,
6667
});
68+
6769
const { isFocused, onFocus } = useIsFocused();
6870

6971
const isReady = isWysiwygReady && !disabled;
@@ -72,10 +74,10 @@ export const WysiwygComposer = memo(function WysiwygComposer({
7274
useSetCursorPosition(!isReady, ref);
7375

7476
useEffect(() => {
75-
if (!disabled && content !== null) {
76-
onChange?.(content);
77+
if (!disabled && isNotNull(messageContent)) {
78+
onChange(messageContent);
7779
}
78-
}, [onChange, content, disabled]);
80+
}, [onChange, messageContent, disabled]);
7981

8082
useEffect(() => {
8183
function handleClick(e: Event): void {
@@ -115,6 +117,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
115117
ref={autocompleteRef}
116118
suggestion={suggestion}
117119
handleMention={wysiwyg.mention}
120+
handleAtRoomMention={wysiwyg.mentionAtRoom}
118121
handleCommand={wysiwyg.command}
119122
/>
120123
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
18-
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
18+
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
1919
import { IEventRelation } from "matrix-js-sdk/src/matrix";
2020

2121
import { useSettingValue } from "../../../../../hooks/useSettings";
@@ -72,7 +72,8 @@ export function usePlainTextListeners(
7272
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
7373
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
7474
setContent(text?: string): void;
75-
handleMention: (link: string, text: string, attributes: Attributes) => void;
75+
handleMention: (link: string, text: string, attributes: AllowedMentionAttributes) => void;
76+
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
7677
handleCommand: (text: string) => void;
7778
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
7879
suggestion: MappedSuggestion | null;
@@ -97,10 +98,11 @@ export function usePlainTextListeners(
9798
setContent(text);
9899
onChange?.(text);
99100
} else if (isNotNull(ref) && isNotNull(ref.current)) {
100-
// if called with no argument, read the current innerHTML from the ref
101+
// if called with no argument, read the current innerHTML from the ref and amend it as per `onInput`
101102
const currentRefContent = ref.current.innerHTML;
102-
setContent(currentRefContent);
103-
onChange?.(currentRefContent);
103+
const amendedContent = amendInnerHtml(currentRefContent);
104+
setContent(amendedContent);
105+
onChange?.(amendedContent);
104106
}
105107
},
106108
[onChange, ref],
@@ -109,7 +111,7 @@ export function usePlainTextListeners(
109111
// For separation of concerns, the suggestion handling is kept in a separate hook but is
110112
// nested here because we do need to be able to update the `content` state in this hook
111113
// when a user selects a suggestion from the autocomplete menu
112-
const { suggestion, onSelect, handleCommand, handleMention } = useSuggestion(ref, setText);
114+
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention } = useSuggestion(ref, setText);
113115

114116
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
115117
const onInput = useCallback(
@@ -188,5 +190,6 @@ export function usePlainTextListeners(
188190
onSelect,
189191
handleCommand,
190192
handleMention,
193+
handleAtRoomMention,
191194
};
192195
}

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

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

17-
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
17+
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
1818
import { SyntheticEvent, useState } from "react";
1919

20-
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
20+
import { isNotNull } from "../../../../../Typeguards";
2121

2222
/**
2323
* Information about the current state of the `useSuggestion` hook.
@@ -53,7 +53,8 @@ export function useSuggestion(
5353
editorRef: React.RefObject<HTMLDivElement>,
5454
setText: (text?: string) => void,
5555
): {
56-
handleMention: (href: string, displayName: string, attributes: Attributes) => void;
56+
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
57+
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
5758
handleCommand: (text: string) => void;
5859
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
5960
suggestion: MappedSuggestion | null;
@@ -64,16 +65,20 @@ export function useSuggestion(
6465
// we can not depend on input events only
6566
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData);
6667

67-
const handleMention = (href: string, displayName: string, attributes: Attributes): void =>
68+
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
6869
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
6970

71+
const handleAtRoomMention = (attributes: AllowedMentionAttributes): void =>
72+
processMention("#", "@room", attributes, suggestionData, setSuggestionData, setText);
73+
7074
const handleCommand = (replacementText: string): void =>
7175
processCommand(replacementText, suggestionData, setSuggestionData, setText);
7276

7377
return {
7478
suggestion: suggestionData?.mappedSuggestion ?? null,
7579
handleCommand,
7680
handleMention,
81+
handleAtRoomMention,
7782
onSelect,
7883
};
7984
}
@@ -143,7 +148,7 @@ export function processSelectionChange(
143148
export function processMention(
144149
href: string,
145150
displayName: string,
146-
attributes: Attributes, // these will be used when formatting the link as a pill
151+
attributes: AllowedMentionAttributes, // these will be used when formatting the link as a pill
147152
suggestionData: SuggestionState,
148153
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
149154
setText: (text?: string) => void,
@@ -160,9 +165,11 @@ export function processMention(
160165
const linkTextNode = document.createTextNode(displayName);
161166
linkElement.setAttribute("href", href);
162167
linkElement.setAttribute("contenteditable", "false");
163-
Object.entries(attributes).forEach(
164-
([attr, value]) => isNotUndefined(value) && linkElement.setAttribute(attr, value),
165-
);
168+
169+
for (const [attr, value] of attributes.entries()) {
170+
linkElement.setAttribute(attr, value);
171+
}
172+
166173
linkElement.appendChild(linkTextNode);
167174

168175
// create text nodes to go before and after the link

src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts

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

17-
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
17+
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
1818
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
1919

2020
import { ICompletion } from "../../../../../autocomplete/Autocompleter";
@@ -91,18 +91,22 @@ export function getMentionDisplayText(completion: ICompletion, client: MatrixCli
9191
* @param client - the MatrixClient is required for us to look up the correct room mention text
9292
* @returns an object of attributes containing HTMLAnchor attributes or data-* attributes
9393
*/
94-
export function getMentionAttributes(completion: ICompletion, client: MatrixClient, room: Room): Attributes {
94+
export function getMentionAttributes(
95+
completion: ICompletion,
96+
client: MatrixClient,
97+
room: Room,
98+
): AllowedMentionAttributes {
9599
// To ensure that we always have something set in the --avatar-letter CSS variable
96100
// as otherwise alignment varies depending on whether the content is empty or not.
97-
98101
// Use a zero width space so that it counts as content, but does not display anything.
99102
const defaultLetterContent = "\u200b";
103+
const attributes: AllowedMentionAttributes = new Map();
100104

101105
if (completion.type === "user") {
102106
// logic as used in UserPillPart.setAvatar in parts.ts
103107
const mentionedMember = room.getMember(completion.completionId || "");
104108

105-
if (!mentionedMember) return {};
109+
if (!mentionedMember) return attributes;
106110

107111
const name = mentionedMember.name || mentionedMember.userId;
108112
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(mentionedMember.userId);
@@ -112,10 +116,8 @@ export function getMentionAttributes(completion: ICompletion, client: MatrixClie
112116
initialLetter = Avatar.getInitialLetter(name) ?? defaultLetterContent;
113117
}
114118

115-
return {
116-
"data-mention-type": completion.type,
117-
"style": `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`,
118-
};
119+
attributes.set("data-mention-type", completion.type);
120+
attributes.set("style", `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`);
119121
} else if (completion.type === "room") {
120122
// logic as used in RoomPillPart.setAvatar in parts.ts
121123
const mentionedRoom = getRoomFromCompletion(completion, client);
@@ -128,12 +130,12 @@ export function getMentionAttributes(completion: ICompletion, client: MatrixClie
128130
avatarUrl = Avatar.defaultAvatarUrlForString(mentionedRoom?.roomId ?? aliasFromCompletion);
129131
}
130132

131-
return {
132-
"data-mention-type": completion.type,
133-
"style": `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`,
134-
};
133+
attributes.set("data-mention-type", completion.type);
134+
attributes.set("style", `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`);
135135
} else if (completion.type === "at-room") {
136-
return { "data-mention-type": completion.type };
136+
// TODO add avatar logic for at-room
137+
attributes.set("data-mention-type", completion.type);
137138
}
138-
return {};
139+
140+
return attributes;
139141
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe("WysiwygAutocomplete", () => {
7070
]);
7171
const mockHandleMention = jest.fn();
7272
const mockHandleCommand = jest.fn();
73+
const mockHandleAtRoomMention = jest.fn();
7374

7475
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
7576
const mockClient = stubClient();
@@ -84,6 +85,7 @@ describe("WysiwygAutocomplete", () => {
8485
suggestion={null}
8586
handleMention={mockHandleMention}
8687
handleCommand={mockHandleCommand}
88+
handleAtRoomMention={mockHandleAtRoomMention}
8789
{...props}
8890
/>
8991
</RoomContext.Provider>
@@ -98,6 +100,7 @@ describe("WysiwygAutocomplete", () => {
98100
suggestion={null}
99101
handleMention={mockHandleMention}
100102
handleCommand={mockHandleCommand}
103+
handleAtRoomMention={mockHandleAtRoomMention}
101104
/>,
102105
);
103106
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,15 @@ describe("WysiwygComposer", () => {
164164
const mockCompletions: ICompletion[] = [
165165
{
166166
type: "user",
167-
href: "www.user1.com",
167+
href: "https://matrix.to/#/@user_1:element.io",
168168
completion: "user_1",
169169
completionId: "@user_1:host.local",
170170
range: { start: 1, end: 1 },
171171
component: <div>user_1</div>,
172172
},
173173
{
174174
type: "user",
175-
href: "www.user2.com",
175+
href: "https://matrix.to/#/@user_2:element.io",
176176
completion: "user_2",
177177
completionId: "@user_2:host.local",
178178
range: { start: 1, end: 1 },
@@ -189,15 +189,15 @@ describe("WysiwygComposer", () => {
189189
},
190190
{
191191
type: "room",
192-
href: "www.room1.com",
192+
href: "https://matrix.to/#/#room_1:element.io",
193193
completion: "#room_with_completion_id",
194194
completionId: "@room_1:host.local",
195195
range: { start: 1, end: 1 },
196196
component: <div>room_with_completion_id</div>,
197197
},
198198
{
199199
type: "room",
200-
href: "www.room2.com",
200+
href: "https://matrix.to/#/#room_2:element.io",
201201
completion: "#room_without_completion_id",
202202
range: { start: 1, end: 1 },
203203
component: <div>room_without_completion_id</div>,
@@ -285,9 +285,9 @@ describe("WysiwygComposer", () => {
285285

286286
it("pressing enter selects the mention and inserts it into the composer as a link", async () => {
287287
await insertMentionInput();
288-
289288
// press enter
290289
await userEvent.keyboard("{Enter}");
290+
screen.debug();
291291

292292
// check that it closes the autocomplete
293293
await waitFor(() => {

test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe("processMention", () => {
7272
it("returns early when suggestion is null", () => {
7373
const mockSetSuggestion = jest.fn();
7474
const mockSetText = jest.fn();
75-
processMention("href", "displayName", {}, null, mockSetSuggestion, mockSetText);
75+
processMention("href", "displayName", new Map(), null, mockSetSuggestion, mockSetText);
7676

7777
expect(mockSetSuggestion).not.toHaveBeenCalled();
7878
expect(mockSetText).not.toHaveBeenCalled();
@@ -95,7 +95,7 @@ describe("processMention", () => {
9595
processMention(
9696
href,
9797
displayName,
98-
{ "data-test-attribute": "test" },
98+
new Map([["style", "test"]]),
9999
{ node: textNode, startOffset: 0, endOffset: 2 } as unknown as Suggestion,
100100
mockSetSuggestionData,
101101
mockSetText,
@@ -109,7 +109,7 @@ describe("processMention", () => {
109109
expect(linkElement).toBeInstanceOf(HTMLAnchorElement);
110110
expect(linkElement).toHaveAttribute(href, href);
111111
expect(linkElement).toHaveAttribute("contenteditable", "false");
112-
expect(linkElement).toHaveAttribute("data-test-attribute", "test");
112+
expect(linkElement).toHaveAttribute("style", "test");
113113
expect(linkElement.textContent).toBe(displayName);
114114

115115
expect(mockSetText).toHaveBeenCalledWith();

0 commit comments

Comments
 (0)