@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
1414limitations under the License.
1515*/
1616
17+ import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings" ;
1718import { AllowedMentionAttributes , MappedSuggestion } from "@matrix-org/matrix-wysiwyg" ;
1819import { SyntheticEvent , useState , SetStateAction } from "react" ;
1920import { 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;
5355export 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 */
107115export 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 */
255306export 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
0 commit comments