diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index c07ebf89cc42..fd334fa42a65 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -61,12 +61,14 @@ import { matchStrikethrough, } from '../quill/formatting/matchers'; import { missingCaseError } from '../util/missingCaseError'; +import { AutoSubstituteAsciiEmojis } from '../quill/auto-substitute-ascii-emojis'; Quill.register('formats/emoji', EmojiBlot); Quill.register('formats/mention', MentionBlot); Quill.register('formats/block', DirectionalBlot); Quill.register('formats/monospace', MonospaceBlot); Quill.register('formats/spoiler', SpoilerBlot); +Quill.register('modules/autoSubstituteAsciiEmojis', AutoSubstituteAsciiEmojis); Quill.register('modules/emojiCompletion', EmojiCompletion); Quill.register('modules/mentionCompletion', MentionCompletion); Quill.register('modules/formattingMenu', FormattingMenu); @@ -767,6 +769,7 @@ export function CompositionInput(props: Props): React.ReactElement { callbacksRef.current.onPickEmoji(emoji), skinTone, }, + autoSubstituteAsciiEmojis: true, formattingMenu: { i18n, isMenuEnabled: isFormattingEnabled, diff --git a/ts/quill/auto-substitute-ascii-emojis/index.tsx b/ts/quill/auto-substitute-ascii-emojis/index.tsx new file mode 100644 index 000000000000..4d788c7a7a1c --- /dev/null +++ b/ts/quill/auto-substitute-ascii-emojis/index.tsx @@ -0,0 +1,96 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Quill from 'quill'; +import Delta from 'quill-delta'; +import _ from 'lodash'; +import type { EmojiData } from '../../components/emoji/lib'; +import { + convertShortName, + convertShortNameToData, +} from '../../components/emoji/lib'; + +type AutoSubstituteAsciiEmojisOptions = { + skinTone: number; +}; + +const emojiMap: Record = { + ':)': 'slightly_smiling_face', + ':-)': 'slightly_smiling_face', + ':(': 'slightly_frowning_face', + ':-(': 'slightly_frowning_face', + ':D': 'smiley', + ':-D': 'smiley', + ':*': 'kissing', + ':-*': 'kissing', + ':P': 'stuck_out_tongue', + ':-P': 'stuck_out_tongue', + ';P': 'stuck_out_tongue_winking_eye', + ';-P': 'stuck_out_tongue_winking_eye', + 'D:': 'anguished', + "D-':": 'anguished', + ':O': 'open_mouth', + ':-O': 'open_mouth', + ":'(": 'cry', + ":'-(": 'cry', + ':/': 'confused', + ':-/': 'confused', + ';)': 'wink', + ';-)': 'wink', + '(Y)': '+1', + '(N)': '-1', +}; + +export class AutoSubstituteAsciiEmojis { + options: AutoSubstituteAsciiEmojisOptions; + + quill: Quill; + + constructor(quill: Quill, options: AutoSubstituteAsciiEmojisOptions) { + this.options = options; + this.quill = quill; + + this.quill.on( + 'text-change', + _.debounce(() => this.onTextChange(), 100) + ); + } + + onTextChange(): void { + const range = this.quill.getSelection(); + + if (!range) { + return; + } + + const [blot, index] = this.quill.getLeaf(range.index); + + if (blot !== undefined && blot.text !== undefined) { + const blotText: string = blot.text; + Object.entries(emojiMap).some(([textEmoji, emojiName]) => { + if (blotText.substring(0, index).endsWith(textEmoji)) { + const emojiData = convertShortNameToData( + emojiName, + this.options.skinTone + ); + if (emojiData) { + this.insertEmoji( + emojiData, + range.index - textEmoji.length, + textEmoji.length + ); + return true; + } + } + return false; + }); + } + } + + insertEmoji(emojiData: EmojiData, index: number, range: number): void { + const emoji = convertShortName(emojiData.short_name, this.options.skinTone); + const delta = new Delta().retain(index).delete(range).insert({ emoji }); + this.quill.updateContents(delta, 'user'); + this.quill.setSelection(index + 1, 0); + } +}