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
This commit is contained in:
parent
e6835fe9d2
commit
5d16a38b17
8 changed files with 184 additions and 15 deletions
|
@ -77,7 +77,7 @@
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/analytics-events": "^0.24.0",
|
"@matrix-org/analytics-events": "^0.24.0",
|
||||||
"@matrix-org/emojibase-bindings": "^1.1.2",
|
"@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/react-sdk-module-api": "^2.4.0",
|
||||||
"@matrix-org/spec": "^1.7.0",
|
"@matrix-org/spec": "^1.7.0",
|
||||||
"@sentry/browser": "^8.0.0",
|
"@sentry/browser": "^8.0.0",
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
|
||||||
import { ComposerFunctions } from "../types";
|
import { ComposerFunctions } from "../types";
|
||||||
import { Editor } from "./Editor";
|
import { Editor } from "./Editor";
|
||||||
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
||||||
|
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
|
|
||||||
interface PlainTextComposerProps {
|
interface PlainTextComposerProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -52,6 +53,7 @@ export function PlainTextComposer({
|
||||||
rightComponent,
|
rightComponent,
|
||||||
eventRelation,
|
eventRelation,
|
||||||
}: PlainTextComposerProps): JSX.Element {
|
}: PlainTextComposerProps): JSX.Element {
|
||||||
|
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
|
||||||
const {
|
const {
|
||||||
ref: editorRef,
|
ref: editorRef,
|
||||||
autocompleteRef,
|
autocompleteRef,
|
||||||
|
@ -66,14 +68,12 @@ export function PlainTextComposer({
|
||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation);
|
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
||||||
|
|
||||||
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
||||||
usePlainTextInitialization(initialContent, editorRef);
|
usePlainTextInitialization(initialContent, editorRef);
|
||||||
useSetCursorPosition(disabled, editorRef);
|
useSetCursorPosition(disabled, editorRef);
|
||||||
const { isFocused, onFocus } = useIsFocused();
|
const { isFocused, onFocus } = useIsFocused();
|
||||||
const computedPlaceholder = (!content && placeholder) || undefined;
|
const computedPlaceholder = (!content && placeholder) || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="PlainTextComposer"
|
data-testid="PlainTextComposer"
|
||||||
|
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { 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 { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
@ -31,6 +32,7 @@ import defaultDispatcher from "../../../../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../../../../dispatcher/actions";
|
import { Action } from "../../../../../dispatcher/actions";
|
||||||
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
|
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
|
||||||
import { isNotNull } from "../../../../../Typeguards";
|
import { isNotNull } from "../../../../../Typeguards";
|
||||||
|
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
|
|
||||||
interface WysiwygComposerProps {
|
interface WysiwygComposerProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -45,6 +47,11 @@ interface WysiwygComposerProps {
|
||||||
eventRelation?: IEventRelation;
|
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({
|
export const WysiwygComposer = memo(function WysiwygComposer({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -61,9 +68,14 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
||||||
const autocompleteRef = useRef<Autocomplete | null>(null);
|
const autocompleteRef = useRef<Autocomplete | null>(null);
|
||||||
|
|
||||||
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
|
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({
|
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
|
||||||
initialContent,
|
initialContent,
|
||||||
inputEventProcessor,
|
inputEventProcessor,
|
||||||
|
emojiSuggestions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isFocused, onFocus } = useIsFocused();
|
const { isFocused, onFocus } = useIsFocused();
|
||||||
|
|
|
@ -40,6 +40,8 @@ function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||||
* @param initialContent - the content of the editor when it is first mounted
|
* @param initialContent - the content of the editor when it is first mounted
|
||||||
* @param onChange - called whenever there is change in the editor content
|
* @param onChange - called whenever there is change in the editor content
|
||||||
* @param onSend - called whenever the user sends the message
|
* @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
|
* @returns
|
||||||
* - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor
|
* - `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
|
* * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component
|
||||||
|
@ -53,6 +55,7 @@ export function usePlainTextListeners(
|
||||||
onChange?: (content: string) => void,
|
onChange?: (content: string) => void,
|
||||||
onSend?: () => void,
|
onSend?: () => void,
|
||||||
eventRelation?: IEventRelation,
|
eventRelation?: IEventRelation,
|
||||||
|
isAutoReplaceEmojiEnabled?: boolean,
|
||||||
): {
|
): {
|
||||||
ref: RefObject<HTMLDivElement>;
|
ref: RefObject<HTMLDivElement>;
|
||||||
autocompleteRef: React.RefObject<Autocomplete>;
|
autocompleteRef: React.RefObject<Autocomplete>;
|
||||||
|
@ -100,7 +103,8 @@ export function usePlainTextListeners(
|
||||||
// For separation of concerns, the suggestion handling is kept in a separate hook but is
|
// 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
|
// 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
|
// 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(
|
const onInput = useCallback(
|
||||||
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||||
|
@ -140,6 +144,10 @@ export function usePlainTextListeners(
|
||||||
if (isHandledByAutocomplete) {
|
if (isHandledByAutocomplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// handle accepting of plain text emojicon to emoji replacement
|
||||||
|
if (event.key == Key.ENTER || event.key == Key.SPACE) {
|
||||||
|
handleEmojiReplacement();
|
||||||
|
}
|
||||||
|
|
||||||
// resume regular flow
|
// resume regular flow
|
||||||
if (event.key === Key.ENTER) {
|
if (event.key === Key.ENTER) {
|
||||||
|
@ -161,7 +169,7 @@ export function usePlainTextListeners(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[autocompleteRef, enterShouldSend, send],
|
[autocompleteRef, enterShouldSend, send, handleEmojiReplacement],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
|
||||||
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
|
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
|
||||||
import { SyntheticEvent, useState, SetStateAction } from "react";
|
import { SyntheticEvent, useState, SetStateAction } from "react";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 editorRef - a ref to the div that is the composer textbox
|
||||||
* @param setText - setter function to set the content of the composer
|
* @param setText - setter function to set the content of the composer
|
||||||
|
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
|
||||||
* @returns
|
* @returns
|
||||||
* - `handleMention`: a function that will insert @ or # mentions which are selected from
|
* - `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
|
* 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(
|
export function useSuggestion(
|
||||||
editorRef: React.RefObject<HTMLDivElement>,
|
editorRef: React.RefObject<HTMLDivElement>,
|
||||||
setText: (text?: string) => void,
|
setText: (text?: string) => void,
|
||||||
|
isAutoReplaceEmojiEnabled?: boolean,
|
||||||
): {
|
): {
|
||||||
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
|
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
|
||||||
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
||||||
handleCommand: (text: string) => void;
|
handleCommand: (text: string) => void;
|
||||||
|
handleEmojiReplacement: () => void;
|
||||||
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
||||||
suggestion: MappedSuggestion | null;
|
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 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
|
// 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 =>
|
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
|
||||||
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
|
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
|
||||||
|
@ -88,11 +92,14 @@ export function useSuggestion(
|
||||||
const handleCommand = (replacementText: string): void =>
|
const handleCommand = (replacementText: string): void =>
|
||||||
processCommand(replacementText, suggestionData, setSuggestionData, setText);
|
processCommand(replacementText, suggestionData, setSuggestionData, setText);
|
||||||
|
|
||||||
|
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestion: suggestionData?.mappedSuggestion ?? null,
|
suggestion: suggestionData?.mappedSuggestion ?? null,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
|
handleEmojiReplacement,
|
||||||
onSelect,
|
onSelect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -103,10 +110,12 @@ export function useSuggestion(
|
||||||
*
|
*
|
||||||
* @param editorRef - ref to the composer
|
* @param editorRef - ref to the composer
|
||||||
* @param setSuggestionData - the setter for the suggestion state
|
* @param setSuggestionData - the setter for the suggestion state
|
||||||
|
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
|
||||||
*/
|
*/
|
||||||
export function processSelectionChange(
|
export function processSelectionChange(
|
||||||
editorRef: React.RefObject<HTMLDivElement>,
|
editorRef: React.RefObject<HTMLDivElement>,
|
||||||
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
||||||
|
isAutoReplaceEmojiEnabled?: boolean,
|
||||||
): void {
|
): void {
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
|
|
||||||
|
@ -132,7 +141,12 @@ export function processSelectionChange(
|
||||||
|
|
||||||
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
|
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
|
||||||
const isFirstTextNode = currentNode === firstTextNode;
|
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 we have not found a suggestion, return, clearing the suggestion state
|
||||||
if (foundSuggestion === null) {
|
if (foundSuggestion === null) {
|
||||||
|
@ -241,6 +255,42 @@ export function processCommand(
|
||||||
setSuggestionData(null);
|
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
|
* 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
|
* 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 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
|
* @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
|
* 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
|
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
|
||||||
*/
|
*/
|
||||||
export function findSuggestionInText(
|
export function findSuggestionInText(
|
||||||
text: string,
|
text: string,
|
||||||
offset: number,
|
offset: number,
|
||||||
isFirstTextNode: boolean,
|
isFirstTextNode: boolean,
|
||||||
|
isAutoReplaceEmojiEnabled?: boolean,
|
||||||
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
|
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
|
||||||
// Return null early if the offset is outside the content
|
// Return null early if the offset is outside the content
|
||||||
if (offset < 0 || offset > text.length) {
|
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
|
// Get the word at the cursor then check if it contains a suggestion or not
|
||||||
const wordAtCursor = text.slice(startSliceIndex, endSliceIndex);
|
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:
|
* 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.
|
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
|
||||||
*
|
*
|
||||||
* @param text - string to check for a suggestion
|
* @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
|
* @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 firstChar = text.charAt(0);
|
||||||
const restOfString = text.slice(1);
|
const restOfString = text.slice(1);
|
||||||
|
|
||||||
|
|
|
@ -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", () => {
|
describe("When settings require Ctrl+Enter to send", () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
const onSend = jest.fn();
|
const onSend = jest.fn();
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {
|
||||||
findSuggestionInText,
|
findSuggestionInText,
|
||||||
getMappedSuggestion,
|
getMappedSuggestion,
|
||||||
processCommand,
|
processCommand,
|
||||||
|
processEmojiReplacement,
|
||||||
processMention,
|
processMention,
|
||||||
processSelectionChange,
|
processSelectionChange,
|
||||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||||
|
@ -34,6 +35,16 @@ function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}):
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMockCustomSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||||
|
return {
|
||||||
|
mappedSuggestion: { keyChar: "", type: "custom", text: "🙂", ...props.mappedSuggestion },
|
||||||
|
node: document.createTextNode(":)"),
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 2,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("processCommand", () => {
|
describe("processCommand", () => {
|
||||||
it("does not change parent hook state if suggestion is null", () => {
|
it("does not change parent hook state if suggestion is null", () => {
|
||||||
// create a mockSuggestion using the text node above
|
// 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", () => {
|
describe("processMention", () => {
|
||||||
// TODO refactor and expand tests when mentions become <a> tags
|
// TODO refactor and expand tests when mentions become <a> tags
|
||||||
it("returns early when suggestion is null", () => {
|
it("returns early when suggestion is null", () => {
|
||||||
|
@ -334,6 +378,17 @@ describe("findSuggestionInText", () => {
|
||||||
const mentionWithSpaceAfter = "@ somebody";
|
const mentionWithSpaceAfter = "@ somebody";
|
||||||
expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
|
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", () => {
|
describe("getMappedSuggestion", () => {
|
||||||
|
@ -361,4 +416,12 @@ describe("getMappedSuggestion", () => {
|
||||||
text: "command",
|
text: "command",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the expected mapped suggestion when the text is a plain text emoiticon", () => {
|
||||||
|
expect(getMappedSuggestion(":)", true)).toEqual({
|
||||||
|
type: "custom",
|
||||||
|
keyChar: "",
|
||||||
|
text: "🙂",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
10
yarn.lock
10
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"
|
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-7.0.0.tgz#8d6abdb9ded8656cc9e2a7909913a34bf3fc9b3a"
|
||||||
integrity sha512-MOencXiW/gI5MuTtCNsuojjwT5DXCrjMqv9xOslJC9h2tPdLFFFMGr58dY5Lis4DRd9MRWcgrGowUIHOqieWTA==
|
integrity sha512-MOencXiW/gI5MuTtCNsuojjwT5DXCrjMqv9xOslJC9h2tPdLFFFMGr58dY5Lis4DRd9MRWcgrGowUIHOqieWTA==
|
||||||
|
|
||||||
"@matrix-org/matrix-wysiwyg@2.37.4":
|
"@matrix-org/matrix-wysiwyg@2.37.8":
|
||||||
version "2.37.4"
|
version "2.37.8"
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.37.4.tgz#bd9b46051a21c9986477e3a83a1417b1ee926d81"
|
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.37.8.tgz#0b61e9023e3c73ca8789e97c80d7282e8c823c6b"
|
||||||
integrity sha512-4OtBWAHNAOu9P5C6jOIeHlu4ChwV2YusxnbGuN20IceC4bT2h38flZQgm0x9/jgHfF0LwnKUwKXsxtRoq8xW0g==
|
integrity sha512-fx8KGpztmJvwiY1OvE9A7r08kNcUZQIZXPbWcXNtQ61GwV5VyKvMxCmxfRlZ6Ac8oagVxRPu4WySCRX44Y9Ylw==
|
||||||
|
dependencies:
|
||||||
|
eslint-plugin-unicorn "^54.0.0"
|
||||||
|
|
||||||
"@matrix-org/react-sdk-module-api@^2.4.0":
|
"@matrix-org/react-sdk-module-api@^2.4.0":
|
||||||
version "2.4.0"
|
version "2.4.0"
|
||||||
|
|
Loading…
Reference in a new issue