diff --git a/package.json b/package.json index b5429f98ef..4e11ae1325 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.24.0", "@matrix-org/emojibase-bindings": "^1.1.2", - "@matrix-org/matrix-wysiwyg": "2.37.4", + "@matrix-org/matrix-wysiwyg": "2.37.8", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.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 ded0c39129..e35ad34de3 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -26,6 +26,7 @@ import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { ComposerFunctions } from "../types"; import { Editor } from "./Editor"; import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; +import { useSettingValue } from "../../../../../hooks/useSettings"; interface PlainTextComposerProps { disabled?: boolean; @@ -52,6 +53,7 @@ export function PlainTextComposer({ rightComponent, eventRelation, }: PlainTextComposerProps): JSX.Element { + const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); const { ref: editorRef, autocompleteRef, @@ -66,14 +68,12 @@ export function PlainTextComposer({ handleCommand, handleMention, handleAtRoomMention, - } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation); - + } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); const composerFunctions = useComposerFunctions(editorRef, setContent); usePlainTextInitialization(initialContent, editorRef); useSetCursorPosition(disabled, editorRef); const { isFocused, onFocus } = useIsFocused(); const computedPlaceholder = (!content && placeholder) || undefined; - return (
{ + const emojiSuggestions = new Map(Array.from(EMOTICON_TO_EMOJI, ([key, value]) => [key, value.unicode])); + return enabled ? emojiSuggestions : new Map(); +} + export const WysiwygComposer = memo(function WysiwygComposer({ disabled = false, onChange, @@ -61,9 +68,14 @@ export const WysiwygComposer = memo(function WysiwygComposer({ const autocompleteRef = useRef(null); const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); + + const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); + const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]); + const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({ initialContent, inputEventProcessor, + emojiSuggestions, }); const { isFocused, onFocus } = useIsFocused(); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 6121f0c877..282ed9d174 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -40,6 +40,8 @@ function isDivElement(target: EventTarget): target is HTMLDivElement { * @param initialContent - the content of the editor when it is first mounted * @param onChange - called whenever there is change in the editor content * @param onSend - called whenever the user sends the message + * @param eventRelation - used to send the event to the correct place eg timeline vs thread + * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis * @returns * - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor * * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component @@ -53,6 +55,7 @@ export function usePlainTextListeners( onChange?: (content: string) => void, onSend?: () => void, eventRelation?: IEventRelation, + isAutoReplaceEmojiEnabled?: boolean, ): { ref: RefObject; autocompleteRef: React.RefObject; @@ -100,7 +103,8 @@ 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 } = useSuggestion(ref, setText); + const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention, handleEmojiReplacement } = + useSuggestion(ref, setText, isAutoReplaceEmojiEnabled); const onInput = useCallback( (event: SyntheticEvent) => { @@ -140,6 +144,10 @@ export function usePlainTextListeners( if (isHandledByAutocomplete) { return; } + // handle accepting of plain text emojicon to emoji replacement + if (event.key == Key.ENTER || event.key == Key.SPACE) { + handleEmojiReplacement(); + } // resume regular flow if (event.key === Key.ENTER) { @@ -161,7 +169,7 @@ export function usePlainTextListeners( } } }, - [autocompleteRef, enterShouldSend, send], + [autocompleteRef, enterShouldSend, send, handleEmojiReplacement], ); return { diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index b7a7236dda..337849aece 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings"; import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; import { SyntheticEvent, useState, SetStateAction } from "react"; import { logger } from "matrix-js-sdk/src/logger"; @@ -41,6 +42,7 @@ type SuggestionState = Suggestion | null; * * @param editorRef - a ref to the div that is the composer textbox * @param setText - setter function to set the content of the composer + * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis * @returns * - `handleMention`: a function that will insert @ or # mentions which are selected from * the autocomplete into the composer, given an href, the text to display, and any additional attributes @@ -53,10 +55,12 @@ type SuggestionState = Suggestion | null; export function useSuggestion( editorRef: React.RefObject, setText: (text?: string) => void, + isAutoReplaceEmojiEnabled?: boolean, ): { handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void; handleAtRoomMention: (attributes: AllowedMentionAttributes) => void; handleCommand: (text: string) => void; + handleEmojiReplacement: () => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { @@ -77,7 +81,7 @@ export function useSuggestion( // We create a `selectionchange` handler here because we need to know when the user has moved the cursor, // we can not depend on input events only - const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData); + const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData, isAutoReplaceEmojiEnabled); const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void => processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText); @@ -88,11 +92,14 @@ export function useSuggestion( const handleCommand = (replacementText: string): void => processCommand(replacementText, suggestionData, setSuggestionData, setText); + const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText); + return { suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, handleAtRoomMention, + handleEmojiReplacement, onSelect, }; } @@ -103,10 +110,12 @@ export function useSuggestion( * * @param editorRef - ref to the composer * @param setSuggestionData - the setter for the suggestion state + * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis */ export function processSelectionChange( editorRef: React.RefObject, setSuggestionData: React.Dispatch>, + isAutoReplaceEmojiEnabled?: boolean, ): void { const selection = document.getSelection(); @@ -132,7 +141,12 @@ export function processSelectionChange( const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); const isFirstTextNode = currentNode === firstTextNode; - const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset, isFirstTextNode); + const foundSuggestion = findSuggestionInText( + currentNode.textContent, + currentOffset, + isFirstTextNode, + isAutoReplaceEmojiEnabled, + ); // if we have not found a suggestion, return, clearing the suggestion state if (foundSuggestion === null) { @@ -241,6 +255,42 @@ export function processCommand( setSuggestionData(null); } +/** + * Replaces the relevant part of the editor text, replacing the plain text emoitcon with the suggested emoji. + * + * @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 processEmojiReplacement( + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, + 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`) { + return; + } + const { node, mappedSuggestion } = suggestionData; + const existingContent = node.textContent; + + if (existingContent == null) { + return; + } + + // replace the emoticon with the suggesed emoji + const newContent = + existingContent.slice(0, suggestionData.startOffset) + + mappedSuggestion.text + + existingContent.slice(suggestionData.endOffset); + + node.textContent = newContent; + + document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length); + setText(newContent); + setSuggestionData(null); +} + /** * Given some text content from a node and the cursor position, find the word that the cursor is currently inside * and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if @@ -250,12 +300,14 @@ export function processCommand( * @param offset - the current cursor offset position within the node * @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine * if a command suggestion is found or not + * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis * @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null */ export function findSuggestionInText( text: string, offset: number, isFirstTextNode: boolean, + isAutoReplaceEmojiEnabled?: boolean, ): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null { // Return null early if the offset is outside the content if (offset < 0 || offset > text.length) { @@ -281,7 +333,7 @@ export function findSuggestionInText( // Get the word at the cursor then check if it contains a suggestion or not const wordAtCursor = text.slice(startSliceIndex, endSliceIndex); - const mappedSuggestion = getMappedSuggestion(wordAtCursor); + const mappedSuggestion = getMappedSuggestion(wordAtCursor, isAutoReplaceEmojiEnabled); /** * If we have a word that could be a command, it is not a valid command if: @@ -339,9 +391,17 @@ function shouldIncrementEndIndex(text: string, index: number): boolean { * Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null. * * @param text - string to check for a suggestion + * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis * @returns a `MappedSuggestion` if a suggestion is present, null otherwise */ -export function getMappedSuggestion(text: string): MappedSuggestion | null { +export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: boolean): MappedSuggestion | null { + if (isAutoReplaceEmojiEnabled) { + const emoji = EMOTICON_TO_EMOJI.get(text.toLocaleLowerCase()); + if (emoji?.unicode) { + return { keyChar: "", text: emoji.unicode, type: "custom" }; + } + } + const firstChar = text.charAt(0); const restOfString = text.slice(1); 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 5e3346b6c1..c83b712bf1 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -423,6 +423,30 @@ describe("WysiwygComposer", () => { }); }); + describe("When emoticons should be replaced by emojis", () => { + const onChange = jest.fn(); + const onSend = jest.fn(); + beforeEach(async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "MessageComposerInput.autoReplaceEmoji") return true; + }); + customRender(onChange, onSend); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); + }); + it("typing a space to trigger an emoji replacement", async () => { + fireEvent.input(screen.getByRole("textbox"), { + data: ":P", + inputType: "insertText", + }); + fireEvent.input(screen.getByRole("textbox"), { + data: " ", + inputType: "insertText", + }); + + await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛"))); + }); + }); + describe("When settings require Ctrl+Enter to send", () => { const onChange = jest.fn(); const onSend = jest.fn(); 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 1ee3ac72f4..bca185a411 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -20,6 +20,7 @@ import { findSuggestionInText, getMappedSuggestion, processCommand, + processEmojiReplacement, processMention, processSelectionChange, } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; @@ -34,6 +35,16 @@ function createMockPlainTextSuggestionPattern(props: Partial = {}): }; } +function createMockCustomSuggestionPattern(props: Partial = {}): Suggestion { + return { + mappedSuggestion: { keyChar: "", type: "custom", text: "🙂", ...props.mappedSuggestion }, + node: document.createTextNode(":)"), + startOffset: 0, + endOffset: 2, + ...props, + }; +} + describe("processCommand", () => { it("does not change parent hook state if suggestion is null", () => { // create a mockSuggestion using the text node above @@ -67,6 +78,39 @@ describe("processCommand", () => { }); }); +describe("processEmojiReplacement", () => { + it("does not change parent hook state if suggestion is null", () => { + // create a mockSuggestion using the text node above + const mockSetSuggestion = jest.fn(); + const mockSetText = jest.fn(); + + // call the function with a null suggestion + processEmojiReplacement(null, mockSetSuggestion, mockSetText); + + // check that the parent state setter has not been called + expect(mockSetText).not.toHaveBeenCalled(); + }); + + it("can change the parent hook state when required", () => { + // create a div and append a text node to it with some initial text + const editorDiv = document.createElement("div"); + const initialText = ":)"; + const textNode = document.createTextNode(initialText); + editorDiv.appendChild(textNode); + + // create a mockSuggestion using the text node above + const mockSuggestion = createMockCustomSuggestionPattern({ node: textNode }); + const mockSetSuggestion = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "🙂"; + + processEmojiReplacement(mockSuggestion, mockSetSuggestion, mockSetText); + + // check that the text has changed and includes a trailing space + expect(mockSetText).toHaveBeenCalledWith(replacementText); + }); +}); + describe("processMention", () => { // TODO refactor and expand tests when mentions become tags it("returns early when suggestion is null", () => { @@ -334,6 +378,17 @@ describe("findSuggestionInText", () => { const mentionWithSpaceAfter = "@ somebody"; expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull(); }); + + it("returns an object for an emoji suggestion", () => { + const emoiticon = ":)"; + const precedingText = "hello "; + const mentionInput = precedingText + emoiticon; + expect(findSuggestionInText(mentionInput, precedingText.length, true, true)).toEqual({ + mappedSuggestion: getMappedSuggestion(emoiticon, true), + startOffset: precedingText.length, + endOffset: precedingText.length + emoiticon.length, + }); + }); }); describe("getMappedSuggestion", () => { @@ -361,4 +416,12 @@ describe("getMappedSuggestion", () => { text: "command", }); }); + + it("returns the expected mapped suggestion when the text is a plain text emoiticon", () => { + expect(getMappedSuggestion(":)", true)).toEqual({ + type: "custom", + keyChar: "", + text: "🙂", + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 83be6aa604..2c08edc197 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1850,10 +1850,12 @@ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-7.0.0.tgz#8d6abdb9ded8656cc9e2a7909913a34bf3fc9b3a" integrity sha512-MOencXiW/gI5MuTtCNsuojjwT5DXCrjMqv9xOslJC9h2tPdLFFFMGr58dY5Lis4DRd9MRWcgrGowUIHOqieWTA== -"@matrix-org/matrix-wysiwyg@2.37.4": - version "2.37.4" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.37.4.tgz#bd9b46051a21c9986477e3a83a1417b1ee926d81" - integrity sha512-4OtBWAHNAOu9P5C6jOIeHlu4ChwV2YusxnbGuN20IceC4bT2h38flZQgm0x9/jgHfF0LwnKUwKXsxtRoq8xW0g== +"@matrix-org/matrix-wysiwyg@2.37.8": + version "2.37.8" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.37.8.tgz#0b61e9023e3c73ca8789e97c80d7282e8c823c6b" + integrity sha512-fx8KGpztmJvwiY1OvE9A7r08kNcUZQIZXPbWcXNtQ61GwV5VyKvMxCmxfRlZ6Ac8oagVxRPu4WySCRX44Y9Ylw== + dependencies: + eslint-plugin-unicorn "^54.0.0" "@matrix-org/react-sdk-module-api@^2.4.0": version "2.4.0"