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

Commit 5d16a38

Browse files
authored
Rich text Editor: Auto-replace plain text emoticons with emoji (#12828)
* 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
1 parent e6835fe commit 5d16a38

File tree

8 files changed

+184
-15
lines changed

8 files changed

+184
-15
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"@babel/runtime": "^7.12.5",
7878
"@matrix-org/analytics-events": "^0.24.0",
7979
"@matrix-org/emojibase-bindings": "^1.1.2",
80-
"@matrix-org/matrix-wysiwyg": "2.37.4",
80+
"@matrix-org/matrix-wysiwyg": "2.37.8",
8181
"@matrix-org/react-sdk-module-api": "^2.4.0",
8282
"@matrix-org/spec": "^1.7.0",
8383
"@sentry/browser": "^8.0.0",

src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
2626
import { ComposerFunctions } from "../types";
2727
import { Editor } from "./Editor";
2828
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
29+
import { useSettingValue } from "../../../../../hooks/useSettings";
2930

3031
interface PlainTextComposerProps {
3132
disabled?: boolean;
@@ -52,6 +53,7 @@ export function PlainTextComposer({
5253
rightComponent,
5354
eventRelation,
5455
}: PlainTextComposerProps): JSX.Element {
56+
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
5557
const {
5658
ref: editorRef,
5759
autocompleteRef,
@@ -66,14 +68,12 @@ export function PlainTextComposer({
6668
handleCommand,
6769
handleMention,
6870
handleAtRoomMention,
69-
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation);
70-
71+
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
7172
const composerFunctions = useComposerFunctions(editorRef, setContent);
7273
usePlainTextInitialization(initialContent, editorRef);
7374
useSetCursorPosition(disabled, editorRef);
7475
const { isFocused, onFocus } = useIsFocused();
7576
const computedPlaceholder = (!content && placeholder) || undefined;
76-
7777
return (
7878
<div
7979
data-testid="PlainTextComposer"

src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

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

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

3537
interface WysiwygComposerProps {
3638
disabled?: boolean;
@@ -45,6 +47,11 @@ interface WysiwygComposerProps {
4547
eventRelation?: IEventRelation;
4648
}
4749

50+
function getEmojiSuggestions(enabled: boolean): Map<string, string> {
51+
const emojiSuggestions = new Map(Array.from(EMOTICON_TO_EMOJI, ([key, value]) => [key, value.unicode]));
52+
return enabled ? emojiSuggestions : new Map();
53+
}
54+
4855
export const WysiwygComposer = memo(function WysiwygComposer({
4956
disabled = false,
5057
onChange,
@@ -61,9 +68,14 @@ export const WysiwygComposer = memo(function WysiwygComposer({
6168
const autocompleteRef = useRef<Autocomplete | null>(null);
6269

6370
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
71+
72+
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
73+
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
74+
6475
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
6576
initialContent,
6677
inputEventProcessor,
78+
emojiSuggestions,
6779
});
6880

6981
const { isFocused, onFocus } = useIsFocused();

src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function isDivElement(target: EventTarget): target is HTMLDivElement {
4040
* @param initialContent - the content of the editor when it is first mounted
4141
* @param onChange - called whenever there is change in the editor content
4242
* @param onSend - called whenever the user sends the message
43+
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
44+
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
4345
* @returns
4446
* - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor
4547
* * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component
@@ -53,6 +55,7 @@ export function usePlainTextListeners(
5355
onChange?: (content: string) => void,
5456
onSend?: () => void,
5557
eventRelation?: IEventRelation,
58+
isAutoReplaceEmojiEnabled?: boolean,
5659
): {
5760
ref: RefObject<HTMLDivElement>;
5861
autocompleteRef: React.RefObject<Autocomplete>;
@@ -100,7 +103,8 @@ export function usePlainTextListeners(
100103
// For separation of concerns, the suggestion handling is kept in a separate hook but is
101104
// nested here because we do need to be able to update the `content` state in this hook
102105
// when a user selects a suggestion from the autocomplete menu
103-
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention } = useSuggestion(ref, setText);
106+
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention, handleEmojiReplacement } =
107+
useSuggestion(ref, setText, isAutoReplaceEmojiEnabled);
104108

105109
const onInput = useCallback(
106110
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
@@ -140,6 +144,10 @@ export function usePlainTextListeners(
140144
if (isHandledByAutocomplete) {
141145
return;
142146
}
147+
// handle accepting of plain text emojicon to emoji replacement
148+
if (event.key == Key.ENTER || event.key == Key.SPACE) {
149+
handleEmojiReplacement();
150+
}
143151

144152
// resume regular flow
145153
if (event.key === Key.ENTER) {
@@ -161,7 +169,7 @@ export function usePlainTextListeners(
161169
}
162170
}
163171
},
164-
[autocompleteRef, enterShouldSend, send],
172+
[autocompleteRef, enterShouldSend, send, handleEmojiReplacement],
165173
);
166174

167175
return {

src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts

+64-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
1718
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
1819
import { SyntheticEvent, useState, SetStateAction } from "react";
1920
import { logger } from "matrix-js-sdk/src/logger";
@@ -41,6 +42,7 @@ type SuggestionState = Suggestion | null;
4142
*
4243
* @param editorRef - a ref to the div that is the composer textbox
4344
* @param setText - setter function to set the content of the composer
45+
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
4446
* @returns
4547
* - `handleMention`: a function that will insert @ or # mentions which are selected from
4648
* the autocomplete into the composer, given an href, the text to display, and any additional attributes
@@ -53,10 +55,12 @@ type SuggestionState = Suggestion | null;
5355
export function useSuggestion(
5456
editorRef: React.RefObject<HTMLDivElement>,
5557
setText: (text?: string) => void,
58+
isAutoReplaceEmojiEnabled?: boolean,
5659
): {
5760
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
5861
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
5962
handleCommand: (text: string) => void;
63+
handleEmojiReplacement: () => void;
6064
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
6165
suggestion: MappedSuggestion | null;
6266
} {
@@ -77,7 +81,7 @@ export function useSuggestion(
7781

7882
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
7983
// we can not depend on input events only
80-
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData);
84+
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData, isAutoReplaceEmojiEnabled);
8185

8286
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
8387
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
@@ -88,11 +92,14 @@ export function useSuggestion(
8892
const handleCommand = (replacementText: string): void =>
8993
processCommand(replacementText, suggestionData, setSuggestionData, setText);
9094

95+
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
96+
9197
return {
9298
suggestion: suggestionData?.mappedSuggestion ?? null,
9399
handleCommand,
94100
handleMention,
95101
handleAtRoomMention,
102+
handleEmojiReplacement,
96103
onSelect,
97104
};
98105
}
@@ -103,10 +110,12 @@ export function useSuggestion(
103110
*
104111
* @param editorRef - ref to the composer
105112
* @param setSuggestionData - the setter for the suggestion state
113+
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
106114
*/
107115
export function processSelectionChange(
108116
editorRef: React.RefObject<HTMLDivElement>,
109117
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
118+
isAutoReplaceEmojiEnabled?: boolean,
110119
): void {
111120
const selection = document.getSelection();
112121

@@ -132,7 +141,12 @@ export function processSelectionChange(
132141

133142
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
134143
const isFirstTextNode = currentNode === firstTextNode;
135-
const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset, isFirstTextNode);
144+
const foundSuggestion = findSuggestionInText(
145+
currentNode.textContent,
146+
currentOffset,
147+
isFirstTextNode,
148+
isAutoReplaceEmojiEnabled,
149+
);
136150

137151
// if we have not found a suggestion, return, clearing the suggestion state
138152
if (foundSuggestion === null) {
@@ -241,6 +255,42 @@ export function processCommand(
241255
setSuggestionData(null);
242256
}
243257

258+
/**
259+
* Replaces the relevant part of the editor text, replacing the plain text emoitcon with the suggested emoji.
260+
*
261+
* @param suggestionData - representation of the part of the DOM that will be replaced
262+
* @param setSuggestionData - setter function to set the suggestion state
263+
* @param setText - setter function to set the content of the composer
264+
*/
265+
export function processEmojiReplacement(
266+
suggestionData: SuggestionState,
267+
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
268+
setText: (text?: string) => void,
269+
): void {
270+
// if we do not have a suggestion of the correct type, return early
271+
if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) {
272+
return;
273+
}
274+
const { node, mappedSuggestion } = suggestionData;
275+
const existingContent = node.textContent;
276+
277+
if (existingContent == null) {
278+
return;
279+
}
280+
281+
// replace the emoticon with the suggesed emoji
282+
const newContent =
283+
existingContent.slice(0, suggestionData.startOffset) +
284+
mappedSuggestion.text +
285+
existingContent.slice(suggestionData.endOffset);
286+
287+
node.textContent = newContent;
288+
289+
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
290+
setText(newContent);
291+
setSuggestionData(null);
292+
}
293+
244294
/**
245295
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
246296
* 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(
250300
* @param offset - the current cursor offset position within the node
251301
* @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine
252302
* if a command suggestion is found or not
303+
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
253304
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
254305
*/
255306
export function findSuggestionInText(
256307
text: string,
257308
offset: number,
258309
isFirstTextNode: boolean,
310+
isAutoReplaceEmojiEnabled?: boolean,
259311
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
260312
// Return null early if the offset is outside the content
261313
if (offset < 0 || offset > text.length) {
@@ -281,7 +333,7 @@ export function findSuggestionInText(
281333

282334
// Get the word at the cursor then check if it contains a suggestion or not
283335
const wordAtCursor = text.slice(startSliceIndex, endSliceIndex);
284-
const mappedSuggestion = getMappedSuggestion(wordAtCursor);
336+
const mappedSuggestion = getMappedSuggestion(wordAtCursor, isAutoReplaceEmojiEnabled);
285337

286338
/**
287339
* 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 {
339391
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
340392
*
341393
* @param text - string to check for a suggestion
394+
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
342395
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
343396
*/
344-
export function getMappedSuggestion(text: string): MappedSuggestion | null {
397+
export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: boolean): MappedSuggestion | null {
398+
if (isAutoReplaceEmojiEnabled) {
399+
const emoji = EMOTICON_TO_EMOJI.get(text.toLocaleLowerCase());
400+
if (emoji?.unicode) {
401+
return { keyChar: "", text: emoji.unicode, type: "custom" };
402+
}
403+
}
404+
345405
const firstChar = text.charAt(0);
346406
const restOfString = text.slice(1);
347407

test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,30 @@ describe("WysiwygComposer", () => {
423423
});
424424
});
425425

426+
describe("When emoticons should be replaced by emojis", () => {
427+
const onChange = jest.fn();
428+
const onSend = jest.fn();
429+
beforeEach(async () => {
430+
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
431+
if (name === "MessageComposerInput.autoReplaceEmoji") return true;
432+
});
433+
customRender(onChange, onSend);
434+
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
435+
});
436+
it("typing a space to trigger an emoji replacement", async () => {
437+
fireEvent.input(screen.getByRole("textbox"), {
438+
data: ":P",
439+
inputType: "insertText",
440+
});
441+
fireEvent.input(screen.getByRole("textbox"), {
442+
data: " ",
443+
inputType: "insertText",
444+
});
445+
446+
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
447+
});
448+
});
449+
426450
describe("When settings require Ctrl+Enter to send", () => {
427451
const onChange = jest.fn();
428452
const onSend = jest.fn();

0 commit comments

Comments
 (0)