diff --git a/package.json b/package.json index e41e0f8b18..e486a2ca52 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index cf54fa7bef..ded0c39129 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -65,6 +65,7 @@ export function PlainTextComposer({ onSelect, handleCommand, handleMention, + handleAtRoomMention, } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation); const composerFunctions = useComposerFunctions(editorRef, setContent); @@ -90,6 +91,7 @@ export function PlainTextComposer({ suggestion={suggestion} handleMention={handleMention} handleCommand={handleCommand} + handleAtRoomMention={handleAtRoomMention} /> , ): JSX.Element | null => { const { room } = useRoomContext(); @@ -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": diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index c4c50b8392..ba0dad529b 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -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; @@ -60,10 +61,11 @@ export const WysiwygComposer = memo(function WysiwygComposer({ const autocompleteRef = useRef(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; @@ -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]); useEffect(() => { function handleClick(e: Event): void { @@ -115,6 +117,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ ref={autocompleteRef} suggestion={suggestion} handleMention={wysiwyg.mention} + handleAtRoomMention={wysiwyg.mentionAtRoom} handleCommand={wysiwyg.command} /> diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 21b43126bb..39d1328811 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -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"; @@ -72,7 +72,8 @@ export function usePlainTextListeners( onPaste(event: SyntheticEvent): void; onKeyDown(event: KeyboardEvent): 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) => void; suggestion: MappedSuggestion | null; @@ -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); } }, [onChange, ref], @@ -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("MessageComposerInput.ctrlEnterToSend"); const onInput = useCallback( @@ -188,5 +190,6 @@ export function usePlainTextListeners( onSelect, handleCommand, handleMention, + handleAtRoomMention, }; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 3e514f9e27..4de5a57579 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -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. @@ -53,7 +53,8 @@ export function useSuggestion( editorRef: React.RefObject, 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) => void; suggestion: MappedSuggestion | null; @@ -64,9 +65,12 @@ 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); @@ -74,6 +78,7 @@ export function useSuggestion( suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, + handleAtRoomMention, onSelect, }; } @@ -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>, setText: (text?: string) => void, @@ -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 diff --git a/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts b/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts index 7f48d8afea..a40f1075aa 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts @@ -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"; @@ -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); @@ -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); @@ -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; } diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx index b666ff1ff9..68815b2e42 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx @@ -70,6 +70,7 @@ describe("WysiwygAutocomplete", () => { ]); const mockHandleMention = jest.fn(); const mockHandleCommand = jest.fn(); + const mockHandleAtRoomMention = jest.fn(); const renderComponent = (props: Partial> = {}) => { const mockClient = stubClient(); @@ -84,6 +85,7 @@ describe("WysiwygAutocomplete", () => { suggestion={null} handleMention={mockHandleMention} handleCommand={mockHandleCommand} + handleAtRoomMention={mockHandleAtRoomMention} {...props} /> @@ -98,6 +100,7 @@ describe("WysiwygAutocomplete", () => { suggestion={null} handleMention={mockHandleMention} handleCommand={mockHandleCommand} + handleAtRoomMention={mockHandleAtRoomMention} />, ); expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument(); diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index b9720652bc..cd008db2ea 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -164,7 +164,7 @@ 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 }, @@ -172,7 +172,7 @@ describe("WysiwygComposer", () => { }, { 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 }, @@ -189,7 +189,7 @@ 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 }, @@ -197,7 +197,7 @@ describe("WysiwygComposer", () => { }, { 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:
room_without_completion_id
, @@ -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(() => { diff --git a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx index 11f545ffda..1ee3ac72f4 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -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(); @@ -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, @@ -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(); diff --git a/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts index 0553f61f14..b997e2fd96 100644 --- a/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts @@ -143,12 +143,12 @@ describe("getMentionDisplayText", () => { }); describe("getMentionAttributes", () => { - it("returns an empty object for completion types other than room, user or at-room", () => { + it("returns an empty map for completion types other than room, user or at-room", () => { const nonHandledCompletionTypes = ["community", "command"] as const; const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type })); nonHandledCompletions.forEach((completion) => { - expect(getMentionAttributes(completion, mockClient, mockRoom)).toEqual({}); + expect(getMentionAttributes(completion, mockClient, mockRoom)).toEqual(new Map()); }); }); @@ -164,14 +164,14 @@ describe("getMentionAttributes", () => { mockAvatar.getInitialLetter.mockReturnValue(testInitialLetter); describe("user mentions", () => { - it("returns an empty object when no member can be found", () => { + it("returns an empty map when no member can be found", () => { const userCompletion = createMockCompletion({ type: "user" }); // mock not being able to find a member mockRoom.getMember.mockImplementationOnce(() => null); const result = getMentionAttributes(userCompletion, mockClient, mockRoom); - expect(result).toEqual({}); + expect(result).toEqual(new Map()); }); it("returns expected attributes when avatar url is not default", () => { @@ -179,10 +179,12 @@ describe("getMentionAttributes", () => { const result = getMentionAttributes(userCompletion, mockClient, mockRoom); - expect(result).toEqual({ - "data-mention-type": "user", - "style": `--avatar-background: url(${testAvatarUrlForMember}); --avatar-letter: '\u200b'`, - }); + expect(result).toEqual( + new Map([ + ["data-mention-type", "user"], + ["style", `--avatar-background: url(${testAvatarUrlForMember}); --avatar-letter: '\u200b'`], + ]), + ); }); it("returns expected style attributes when avatar url matches default", () => { @@ -193,10 +195,15 @@ describe("getMentionAttributes", () => { const result = getMentionAttributes(userCompletion, mockClient, mockRoom); - expect(result).toEqual({ - "data-mention-type": "user", - "style": `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`, - }); + expect(result).toEqual( + new Map([ + ["data-mention-type", "user"], + [ + "style", + `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`, + ], + ]), + ); }); }); @@ -206,10 +213,12 @@ describe("getMentionAttributes", () => { const result = getMentionAttributes(userCompletion, mockClient, mockRoom); - expect(result).toEqual({ - "data-mention-type": "room", - "style": `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`, - }); + expect(result).toEqual( + new Map([ + ["data-mention-type", "room"], + ["style", `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`], + ]), + ); }); it("returns expected style attributes when avatar url for room is falsy", () => { @@ -220,10 +229,15 @@ describe("getMentionAttributes", () => { const result = getMentionAttributes(userCompletion, mockClient, mockRoom); - expect(result).toEqual({ - "data-mention-type": "room", - "style": `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`, - }); + expect(result).toEqual( + new Map([ + ["data-mention-type", "room"], + [ + "style", + `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`, + ], + ]), + ); }); }); @@ -233,7 +247,7 @@ describe("getMentionAttributes", () => { const result = getMentionAttributes(atRoomCompletion, mockClient, mockRoom); - expect(result).toEqual({ "data-mention-type": "at-room" }); + expect(result).toEqual(new Map([["data-mention-type", "at-room"]])); }); }); }); diff --git a/yarn.lock b/yarn.lock index ba97fdec20..3a6c264ceb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1598,10 +1598,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.10.tgz#b6a6395cffd3197ae2e0a88f4eeae8b315571fd2" integrity sha512-8V2NKuzGOFzEZeZVgF2is7gmuopdRbMZ064tzPDE0vN34iX6s3O8A4oxIT7SA3qtymwm3t1yEvTnT+0gfbmh4g== -"@matrix-org/matrix-wysiwyg@^2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.2.2.tgz#911d0a9858a5a4b620f93777085daac8eff6a220" - integrity sha512-FprkgKiqEHoFUfaamKwTGBENqDxbORFgoPjiE1b9yPS3hgRswobVKRl4qrXgVgFj4qQ7gWeTqogiyrHXkm1myw== +"@matrix-org/matrix-wysiwyg@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.3.0.tgz#7a815fb90600342cc74c03a3cc7c9908a1d15dd1" + integrity sha512-VtA+Bti2IdqpnpCNaTFHMjbpKXe4xHR+OWWJl/gjuYgn4NJO9lfeeEIv34ftC6dBh7R280JEiMxQ9mDcH0J54g== "@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"