From 7f39bb61ec3ab5031edd7c463de72f482d10b2bf Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 25 Sep 2025 15:28:04 +0100 Subject: [PATCH] Rich Text Editor: Add emoji suggestion support (#30873) * Add support for emoji suggestions To both the rich text/plain text modes of the RTE. * Add emoji completion test to WysiwygComposer * Fix code as per test case, do no-op for community case * bump wysiwyg to the version with suggestions supported. * Add more unit tests for processTextReplacement --- package.json | 2 +- .../components/PlainTextComposer.tsx | 2 + .../components/WysiwygAutocomplete.tsx | 14 +++ .../components/WysiwygComposer.tsx | 1 + .../hooks/usePlainTextListeners.ts | 13 ++- .../wysiwyg_composer/hooks/useSuggestion.ts | 34 ++++++- .../components/WysiwygAutocomplete-test.tsx | 3 + .../components/WysiwygComposer-test.tsx | 15 ++++ .../hooks/useSuggestion-test.tsx | 90 +++++++++++++++++++ yarn.lock | 10 +-- 10 files changed, 173 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 2cfb80d3b4..c36ae5d4eb 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@types/png-chunks-extract": "^1.0.2", "@vector-im/compound-design-tokens": "^6.0.0", "@vector-im/compound-web": "^8.1.2", - "@vector-im/matrix-wysiwyg": "2.39.0", + "@vector-im/matrix-wysiwyg": "2.40.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index 33d8e88e03..aadb3c0e78 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -60,6 +60,7 @@ export function PlainTextComposer({ handleCommand, handleMention, handleAtRoomMention, + handleEmoji, } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); const composerFunctions = useComposerFunctions(editorRef, setContent); usePlainTextInitialization(initialContent, editorRef); @@ -84,6 +85,7 @@ export function PlainTextComposer({ handleMention={handleMention} handleCommand={handleCommand} handleAtRoomMention={handleAtRoomMention} + handleEmoji={handleEmoji} /> ; } @@ -55,6 +61,7 @@ const WysiwygAutocomplete = ({ handleMention, handleCommand, handleAtRoomMention, + handleEmoji, ref, }: WysiwygAutocompleteProps): JSX.Element | null => { const { room } = useScopedRoomContext("room"); @@ -89,7 +96,14 @@ const WysiwygAutocomplete = ({ return; } // TODO - handle "community" type + case "community": { + return; // no-op until we decide how to handle community in the wysiwyg composer + } default: + { + // similar to the cider editor we handle emoji and other plain text replacement in the default case + handleEmoji(completion.completion); + } return; } } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 10b13a3523..b0696143aa 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -127,6 +127,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ handleMention={wysiwyg.mention} handleAtRoomMention={wysiwyg.mentionAtRoom} handleCommand={wysiwyg.command} + handleEmoji={wysiwyg.emoji} /> void; handleAtRoomMention: (attributes: AllowedMentionAttributes) => void; handleCommand: (text: string) => void; + handleEmoji: (emoji: string) => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { @@ -95,8 +96,15 @@ 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, handleAtRoomMention, handleEmojiReplacement } = - useSuggestion(ref, setText, isAutoReplaceEmojiEnabled); + const { + suggestion, + onSelect, + handleCommand, + handleMention, + handleAtRoomMention, + handleEmojiSuggestion, + handleEmojiReplacement, + } = useSuggestion(ref, setText, isAutoReplaceEmojiEnabled); const onInput = useCallback( (event: SyntheticEvent) => { @@ -178,5 +186,6 @@ export function usePlainTextListeners( handleCommand, handleMention, handleAtRoomMention, + handleEmoji: handleEmojiSuggestion, }; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 89d189257a..2aae412eb7 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -52,6 +52,7 @@ export function useSuggestion( handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void; handleAtRoomMention: (attributes: AllowedMentionAttributes) => void; handleCommand: (text: string) => void; + handleEmojiSuggestion: (text: string) => void; handleEmojiReplacement: () => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; @@ -86,11 +87,15 @@ export function useSuggestion( const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText); + const handleEmojiSuggestion = (emoji: string): void => + processTextReplacement(emoji, suggestionData, setSuggestionData, setText); + return { suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, handleAtRoomMention, + handleEmojiSuggestion, handleEmojiReplacement, onSelect, }; @@ -260,10 +265,31 @@ export function processEmojiReplacement( setText: (text?: string) => void, ): void { // if we do not have a suggestion of the correct type, return early - if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) { + if (suggestionData?.mappedSuggestion?.type !== `custom`) { return; } - const { node, mappedSuggestion } = suggestionData; + + processTextReplacement(suggestionData.mappedSuggestion.text, suggestionData, setSuggestionData, setText); +} + +/** + * Replaces the relevant part of the editor text, replacing the suggestionData selection with the replacement text. + * @param replacementText - the text that we will insert into the DOM + * @param suggestionData - representation of the part of the DOM that will be replaced + * @param setSuggestionData - setter function to set the suggestion state + * @param setText - setter function to set the content of the composer + */ +export function processTextReplacement( + replacementText: string, + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, + setText: (text?: string) => void, +): void { + // if we do not have suggestion data return early + if (suggestionData === null) { + return; + } + const { node } = suggestionData; const existingContent = node.textContent; if (existingContent == null) { @@ -273,7 +299,7 @@ export function processEmojiReplacement( // replace the emoticon with the suggesed emoji const newContent = existingContent.slice(0, suggestionData.startOffset) + - mappedSuggestion.text + + replacementText + existingContent.slice(suggestionData.endOffset); node.textContent = newContent; @@ -405,6 +431,8 @@ export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: bo case "#": case "@": return { keyChar: firstChar, text: restOfString, type: "mention" }; + case ":": + return { keyChar: firstChar, text: restOfString, type: "emoji" }; default: return null; } diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx index 8a3d0af791..70809716a4 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx @@ -66,6 +66,7 @@ describe("WysiwygAutocomplete", () => { const mockHandleMention = jest.fn(); const mockHandleCommand = jest.fn(); const mockHandleAtRoomMention = jest.fn(); + const mockHandleEmoji = jest.fn(); const renderComponent = (props: Partial> = {}) => { const mockClient = stubClient(); @@ -81,6 +82,7 @@ describe("WysiwygAutocomplete", () => { handleMention={mockHandleMention} handleCommand={mockHandleCommand} handleAtRoomMention={mockHandleAtRoomMention} + handleEmoji={mockHandleEmoji} {...props} /> @@ -96,6 +98,7 @@ describe("WysiwygAutocomplete", () => { handleMention={mockHandleMention} handleCommand={mockHandleCommand} handleAtRoomMention={mockHandleAtRoomMention} + handleEmoji={mockHandleEmoji} />, ); expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument(); diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 610003b8ff..ce4eb8634f 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -234,6 +234,11 @@ describe("WysiwygComposer", () => { range: { start: 1, end: 1 }, component:
community
, }, + { + completion: "😄", + range: { start: 1, end: 1 }, + component:
😄
, + }, ]; const constructMockProvider = (data: ICompletion[]) => @@ -435,6 +440,16 @@ describe("WysiwygComposer", () => { // check that it we still have the initial text expect(screen.getByText(initialInput)).toBeInTheDocument(); }); + + it("selecting an emoji suggestion inserts the emoji", async () => { + await insertMentionInput(); + + // select the room suggestion + await userEvent.click(screen.getByText("😄")); + + // check that it has inserted the plain text + expect(screen.getByText("😄")).toBeInTheDocument(); + }); }); describe("When emoticons should be replaced by emojis", () => { diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx index 203f82cc66..8adc38a638 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -14,6 +14,7 @@ import { processEmojiReplacement, processMention, processSelectionChange, + processTextReplacement, } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; function createMockPlainTextSuggestionPattern(props: Partial = {}): Suggestion { @@ -382,6 +383,95 @@ describe("findSuggestionInText", () => { }); }); +describe("processTextReplacement", () => { + it("does not change parent hook state if suggestionData is null", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "replacement"; + + // call the function with null suggestionData + processTextReplacement(replacementText, null, mockSetSuggestionData, mockSetText); + + // check that the parent state setters have not been called + expect(mockSetText).not.toHaveBeenCalled(); + expect(mockSetSuggestionData).not.toHaveBeenCalled(); + }); + + it("does not change parent hook state if existingContent is null", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "replacement"; + + // create a mock node with null textContent + const mockNode = { + textContent: null, + } as unknown as Text; + + const mockSuggestion: Suggestion = { + mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" }, + node: mockNode, + startOffset: 0, + endOffset: 2, + }; + + // call the function with a node that has null textContent + processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText); + + // check that the parent state setters have not been called + expect(mockSetText).not.toHaveBeenCalled(); + expect(mockSetSuggestionData).not.toHaveBeenCalled(); + }); + + it("can replace text content when both suggestionData and existingContent are valid", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "🙂"; + const initialText = "Hello :) world"; + + // create a div and append a text node to it + const editorDiv = document.createElement("div"); + const textNode = document.createTextNode(initialText); + editorDiv.appendChild(textNode); + document.body.appendChild(editorDiv); + + const mockSuggestion: Suggestion = { + mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" }, + node: textNode, + startOffset: 6, // position of ":)" + endOffset: 8, // end of ":)" + }; + + // mock document.getSelection + const mockSelection = { + setBaseAndExtent: jest.fn(), + }; + jest.spyOn(document, "getSelection").mockReturnValue(mockSelection as any); + + // call the function + processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText); + + // check that the text content was updated correctly + expect(textNode.textContent).toBe("Hello 🙂 world"); + + // check that setText was called with the new content + expect(mockSetText).toHaveBeenCalledWith("Hello 🙂 world"); + + // check that suggestionData was cleared + expect(mockSetSuggestionData).toHaveBeenCalledWith(null); + + // check that the cursor was positioned at the end + expect(mockSelection.setBaseAndExtent).toHaveBeenCalledWith( + textNode, + "Hello 🙂 world".length, + textNode, + "Hello 🙂 world".length, + ); + + // clean up + document.body.removeChild(editorDiv); + }); +}); + describe("getMappedSuggestion", () => { it("returns null when the first character is not / # @", () => { expect(getMappedSuggestion("Zzz")).toBe(null); diff --git a/yarn.lock b/yarn.lock index 39d178a0dd..59460e89c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4752,12 +4752,12 @@ version "0.0.0" uid "" -"@vector-im/matrix-wysiwyg@2.39.0": - version "2.39.0" - resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163" - integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q== +"@vector-im/matrix-wysiwyg@2.40.0": + version "2.40.0" + resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" + integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@vitest/expect@3.2.4": version "3.2.4"