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

Rich text Editor: Auto-replace plain text emoticons with emoji #12828

Merged
merged 16 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -52,6 +53,7 @@ export function PlainTextComposer({
rightComponent,
eventRelation,
}: PlainTextComposerProps): JSX.Element {
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
const {
ref: editorRef,
autocompleteRef,
Expand All @@ -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 (
<div
data-testid="PlainTextComposer"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { memo, MutableRefObject, ReactNode, useEffect, useRef } from "react";
import React, { memo, MutableRefObject, ReactNode, useEffect, useMemo, useRef } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import classNames from "classnames";

Expand All @@ -31,6 +32,7 @@ import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
import { isNotNull } from "../../../../../Typeguards";
import { useSettingValue } from "../../../../../hooks/useSettings";

interface WysiwygComposerProps {
disabled?: boolean;
Expand All @@ -45,6 +47,11 @@ interface WysiwygComposerProps {
eventRelation?: IEventRelation;
}

function getEmojiSuggestions(enabled: boolean): Map<string, string> {
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,
Expand All @@ -61,9 +68,14 @@ export const WysiwygComposer = memo(function WysiwygComposer({
const autocompleteRef = useRef<Autocomplete | null>(null);

const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);

const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);

const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
initialContent,
inputEventProcessor,
emojiSuggestions,
});

const { isFocused, onFocus } = useIsFocused();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +55,7 @@ export function usePlainTextListeners(
onChange?: (content: string) => void,
onSend?: () => void,
eventRelation?: IEventRelation,
isAutoReplaceEmojiEnabled?: boolean,
): {
ref: RefObject<HTMLDivElement>;
autocompleteRef: React.RefObject<Autocomplete>;
Expand Down Expand Up @@ -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<HTMLDivElement, InputEvent | ClipboardEvent>) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -161,7 +169,7 @@ export function usePlainTextListeners(
}
}
},
[autocompleteRef, enterShouldSend, send],
[autocompleteRef, enterShouldSend, send, handleEmojiReplacement],
);

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -53,10 +55,12 @@ type SuggestionState = Suggestion | null;
export function useSuggestion(
editorRef: React.RefObject<HTMLDivElement>,
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<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
Expand All @@ -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);
Expand All @@ -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,
};
}
Expand All @@ -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<HTMLDivElement>,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
isAutoReplaceEmojiEnabled?: boolean,
): void {
const selection = document.getSelection();

Expand All @@ -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) {
Expand Down Expand Up @@ -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<React.SetStateAction<SuggestionState>>,
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
Expand All @@ -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) {
Expand All @@ -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:
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading