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

Update rich text editor dependency and associated changes #11098

Merged
merged 17 commits into from
Jun 19, 2023
Merged
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": "^2.2.2",
"@matrix-org/matrix-wysiwyg": "^2.3.0",
"@matrix-org/react-sdk-module-api": "^0.0.5",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function PlainTextComposer({
onSelect,
handleCommand,
handleMention,
handleAtRoomMention,
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation);

const composerFunctions = useComposerFunctions(editorRef, setContent);
Expand All @@ -90,6 +91,7 @@ export function PlainTextComposer({
suggestion={suggestion}
handleMention={handleMention}
handleCommand={handleCommand}
handleAtRoomMention={handleAtRoomMention}
/>
<Editor
ref={editorRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ interface WysiwygAutocompleteProps {
* a command in the autocomplete list or pressing enter on a selected item
*/
handleCommand: FormattingFunctions["command"];

/**
* Handler purely for the at-room mentions special case
*/
handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
}

/**
Expand All @@ -52,7 +57,7 @@ interface WysiwygAutocompleteProps {
*/
const WysiwygAutocomplete = forwardRef(
(
{ suggestion, handleMention, handleCommand }: WysiwygAutocompleteProps,
{ suggestion, handleMention, handleCommand, handleAtRoomMention }: WysiwygAutocompleteProps,
ref: ForwardedRef<Autocomplete>,
): JSX.Element | null => {
const { room } = useRoomContext();
Expand All @@ -72,15 +77,7 @@ const WysiwygAutocomplete = forwardRef(
return;
}
case "at-room": {
// TODO improve handling of at-room to either become a span or use a placeholder href
// We have an issue in that we can't use a placeholder because the rust model is always
// applying a prefix to the href, so an href of "#" becomes https://# and also we can not
// represent a plain span in rust
handleMention(
window.location.href,
getMentionDisplayText(completion, client),
getMentionAttributes(completion, client, room),
);
handleAtRoomMention(getMentionAttributes(completion, client, room));
return;
}
case "room":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import { useRoomContext } from "../../../../../contexts/RoomContext";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
import { isNotNull } from "../../../../../Typeguards";

interface WysiwygComposerProps {
disabled?: boolean;
onChange?: (content: string) => void;
onChange: (content: string) => void;
onSend: () => void;
placeholder?: string;
initialContent?: string;
Expand All @@ -60,10 +61,11 @@ export const WysiwygComposer = memo(function WysiwygComposer({
const autocompleteRef = useRef<Autocomplete | null>(null);

const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion } = useWysiwyg({
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
initialContent,
inputEventProcessor,
});

const { isFocused, onFocus } = useIsFocused();

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

useEffect(() => {
if (!disabled && content !== null) {
onChange?.(content);
if (!disabled && isNotNull(messageContent)) {
onChange(messageContent);
}
}, [onChange, content, disabled]);
}, [onChange, messageContent, disabled]);
Comment on lines +77 to +80
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the change that ensures our message content, when sent, is correctly formatted according to the matrix spec as opposed to being exactly the markup used in the html inside the composer.


useEffect(() => {
function handleClick(e: Event): void {
Expand Down Expand Up @@ -115,6 +117,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
ref={autocompleteRef}
suggestion={suggestion}
handleMention={wysiwyg.mention}
handleAtRoomMention={wysiwyg.mentionAtRoom}
handleCommand={wysiwyg.command}
/>
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

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

import { useSettingValue } from "../../../../../hooks/useSettings";
Expand Down Expand Up @@ -72,7 +72,8 @@ export function usePlainTextListeners(
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
setContent(text?: string): void;
handleMention: (link: string, text: string, attributes: Attributes) => void;
handleMention: (link: string, text: string, attributes: AllowedMentionAttributes) => void;
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
Expand All @@ -97,10 +98,11 @@ export function usePlainTextListeners(
setContent(text);
onChange?.(text);
} else if (isNotNull(ref) && isNotNull(ref.current)) {
// if called with no argument, read the current innerHTML from the ref
// if called with no argument, read the current innerHTML from the ref and amend it as per `onInput`
const currentRefContent = ref.current.innerHTML;
setContent(currentRefContent);
onChange?.(currentRefContent);
const amendedContent = amendInnerHtml(currentRefContent);
setContent(amendedContent);
onChange?.(amendedContent);
Comment on lines +103 to +105
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an error I picked up working in the area, the content of the composer needs to be run through amendInnerHtml before being stored in the content state.

}
},
[onChange, ref],
Expand All @@ -109,7 +111,7 @@ export function usePlainTextListeners(
// For separation of concerns, the suggestion handling is kept in a separate hook but is
// nested here because we do need to be able to update the `content` state in this hook
// when a user selects a suggestion from the autocomplete menu
const { suggestion, onSelect, handleCommand, handleMention } = useSuggestion(ref, setText);
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention } = useSuggestion(ref, setText);

const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onInput = useCallback(
Expand Down Expand Up @@ -188,5 +190,6 @@ export function usePlainTextListeners(
onSelect,
handleCommand,
handleMention,
handleAtRoomMention,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

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

import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
import { isNotNull } from "../../../../../Typeguards";

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

const handleMention = (href: string, displayName: string, attributes: Attributes): void =>
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);

const handleAtRoomMention = (attributes: AllowedMentionAttributes): void =>
processMention("#", "@room", attributes, suggestionData, setSuggestionData, setText);

const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestionData, setSuggestionData, setText);

return {
suggestion: suggestionData?.mappedSuggestion ?? null,
handleCommand,
handleMention,
handleAtRoomMention,
onSelect,
};
}
Expand Down Expand Up @@ -143,7 +148,7 @@ export function processSelectionChange(
export function processMention(
href: string,
displayName: string,
attributes: Attributes, // these will be used when formatting the link as a pill
attributes: AllowedMentionAttributes, // these will be used when formatting the link as a pill
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
Expand All @@ -160,9 +165,11 @@ export function processMention(
const linkTextNode = document.createTextNode(displayName);
linkElement.setAttribute("href", href);
linkElement.setAttribute("contenteditable", "false");
Object.entries(attributes).forEach(
([attr, value]) => isNotUndefined(value) && linkElement.setAttribute(attr, value),
);

for (const [attr, value] of attributes.entries()) {
linkElement.setAttribute(attr, value);
}

linkElement.appendChild(linkTextNode);

// create text nodes to go before and after the link
Expand Down
30 changes: 16 additions & 14 deletions src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

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

import { ICompletion } from "../../../../../autocomplete/Autocompleter";
Expand Down Expand Up @@ -91,18 +91,22 @@ export function getMentionDisplayText(completion: ICompletion, client: MatrixCli
* @param client - the MatrixClient is required for us to look up the correct room mention text
* @returns an object of attributes containing HTMLAnchor attributes or data-* attributes
*/
export function getMentionAttributes(completion: ICompletion, client: MatrixClient, room: Room): Attributes {
export function getMentionAttributes(
completion: ICompletion,
client: MatrixClient,
room: Room,
): AllowedMentionAttributes {
// To ensure that we always have something set in the --avatar-letter CSS variable
// as otherwise alignment varies depending on whether the content is empty or not.

// Use a zero width space so that it counts as content, but does not display anything.
const defaultLetterContent = "\u200b";
const attributes: AllowedMentionAttributes = new Map();

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

if (!mentionedMember) return {};
if (!mentionedMember) return attributes;

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

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

return {
"data-mention-type": completion.type,
"style": `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`,
};
attributes.set("data-mention-type", completion.type);
attributes.set("style", `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`);
} else if (completion.type === "at-room") {
return { "data-mention-type": completion.type };
// TODO add avatar logic for at-room
attributes.set("data-mention-type", completion.type);
}
return {};

return attributes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe("WysiwygAutocomplete", () => {
]);
const mockHandleMention = jest.fn();
const mockHandleCommand = jest.fn();
const mockHandleAtRoomMention = jest.fn();

const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
const mockClient = stubClient();
Expand All @@ -84,6 +85,7 @@ describe("WysiwygAutocomplete", () => {
suggestion={null}
handleMention={mockHandleMention}
handleCommand={mockHandleCommand}
handleAtRoomMention={mockHandleAtRoomMention}
{...props}
/>
</RoomContext.Provider>
Expand All @@ -98,6 +100,7 @@ describe("WysiwygAutocomplete", () => {
suggestion={null}
handleMention={mockHandleMention}
handleCommand={mockHandleCommand}
handleAtRoomMention={mockHandleAtRoomMention}
/>,
);
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ describe("WysiwygComposer", () => {
const mockCompletions: ICompletion[] = [
{
type: "user",
href: "www.user1.com",
href: "https://matrix.to/#/@user_1:element.io",
completion: "user_1",
completionId: "@user_1:host.local",
range: { start: 1, end: 1 },
component: <div>user_1</div>,
},
{
type: "user",
href: "www.user2.com",
href: "https://matrix.to/#/@user_2:element.io",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
Expand All @@ -189,15 +189,15 @@ describe("WysiwygComposer", () => {
},
{
type: "room",
href: "www.room1.com",
href: "https://matrix.to/#/#room_1:element.io",
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",
href: "https://matrix.to/#/#room_2:element.io",
completion: "#room_without_completion_id",
range: { start: 1, end: 1 },
component: <div>room_without_completion_id</div>,
Expand Down Expand Up @@ -285,9 +285,9 @@ describe("WysiwygComposer", () => {

it("pressing enter selects the mention and inserts it into the composer as a link", async () => {
await insertMentionInput();

// press enter
await userEvent.keyboard("{Enter}");
screen.debug();

// check that it closes the autocomplete
await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("processMention", () => {
it("returns early when suggestion is null", () => {
const mockSetSuggestion = jest.fn();
const mockSetText = jest.fn();
processMention("href", "displayName", {}, null, mockSetSuggestion, mockSetText);
processMention("href", "displayName", new Map(), null, mockSetSuggestion, mockSetText);

expect(mockSetSuggestion).not.toHaveBeenCalled();
expect(mockSetText).not.toHaveBeenCalled();
Expand All @@ -95,7 +95,7 @@ describe("processMention", () => {
processMention(
href,
displayName,
{ "data-test-attribute": "test" },
new Map([["style", "test"]]),
{ node: textNode, startOffset: 0, endOffset: 2 } as unknown as Suggestion,
mockSetSuggestionData,
mockSetText,
Expand All @@ -109,7 +109,7 @@ describe("processMention", () => {
expect(linkElement).toBeInstanceOf(HTMLAnchorElement);
expect(linkElement).toHaveAttribute(href, href);
expect(linkElement).toHaveAttribute("contenteditable", "false");
expect(linkElement).toHaveAttribute("data-test-attribute", "test");
expect(linkElement).toHaveAttribute("style", "test");
expect(linkElement.textContent).toBe(displayName);

expect(mockSetText).toHaveBeenCalledWith();
Expand Down
Loading