Skip to content

Commit

Permalink
Rich text Editor: Auto-replace plain text emoticons with emoji (#12828)
Browse files Browse the repository at this point in the history
* Detect autoReplaceEmoji setting

* Add plain text emoticon to emoji replacement for plain and rich text modes of the RTE.

* Use latest wysiwyg

* lint

* fix existing jest tests and docs

* Add unit tests

* Update wysiwyg to fix flakes.

* fix wording of tests and comments

* use useSettingValue
  • Loading branch information
langleyd authored Aug 7, 2024
1 parent e6835fe commit 5d16a38
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 15 deletions.
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
68 changes: 64 additions & 4 deletions src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts
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

0 comments on commit 5d16a38

Please sign in to comment.