diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 58ae5e783..1edbf6b6c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5727,6 +5727,14 @@ "messageformat": "To change this setting, open the Signal app on your mobile device and navigate to Settings > Chats", "description": "Description for the generate link previews setting" }, + "icu:Preferences__auto-convert-emoji--title": { + "messageformat": "Convert typed emoticons to emoji", + "description": "Title for the auto convert emoji setting" + }, + "icu:Preferences__auto-convert-emoji--description": { + "messageformat": "For example, :-) will be converted to 🙂", + "description": "Description for the auto convert emoji setting" + }, "icu:Preferences--advanced": { "messageformat": "Advanced", "description": "Title for advanced settings" diff --git a/ts/background.ts b/ts/background.ts index 0e6699745..c4e357e61 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -972,6 +972,14 @@ export async function startApp(): Promise { } } + if ( + window.storage.get('autoConvertEmoji') === undefined && + newVersion && + !lastVersion + ) { + await window.storage.put('autoConvertEmoji', true); + } + setAppLoadingScreenMessage( window.i18n('icu:optimizingApplication'), window.i18n diff --git a/ts/components/Checkbox.tsx b/ts/components/Checkbox.tsx index f279dbf28..db52563dc 100644 --- a/ts/components/Checkbox.tsx +++ b/ts/components/Checkbox.tsx @@ -5,6 +5,7 @@ import React, { forwardRef, useMemo } from 'react'; import { v4 as uuid } from 'uuid'; import { getClassNamesFor } from '../util/getClassNamesFor'; +import { Emojify } from './conversation/Emojify'; export type PropsType = { checked?: boolean; @@ -61,7 +62,9 @@ export const Checkbox = forwardRef(function CheckboxInner(
); diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 37627debd..13f549f84 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -39,7 +39,9 @@ import { getDeltaToRemoveStaleMentions, getTextAndRangesFromOps, isMentionBlot, + isEmojiBlot, getDeltaToRestartMention, + getDeltaToRestartEmoji, insertEmojiOps, insertFormattingAndMentionsOps, } from '../quill/util'; @@ -284,7 +286,7 @@ export function CompositionInput(props: Props): React.ReactElement { const delta = new Delta() .retain(insertionRange.index) .delete(insertionRange.length) - .insert({ emoji }); + .insert({ emoji: { value: emoji } }); quill.updateContents(delta, 'user'); quill.setSelection(insertionRange.index + 1, 0, 'user'); @@ -512,17 +514,24 @@ export function CompositionInput(props: Props): React.ReactElement { } const [blotToDelete] = quill.getLeaf(selection.index); - if (!isMentionBlot(blotToDelete)) { - return true; + if (isMentionBlot(blotToDelete)) { + const contents = quill.getContents(0, selection.index - 1); + const restartDelta = getDeltaToRestartMention(contents.ops); + + quill.updateContents(restartDelta); + quill.setSelection(selection.index, 0); + return false; } - const contents = quill.getContents(0, selection.index - 1); - const restartDelta = getDeltaToRestartMention(contents.ops); + if (isEmojiBlot(blotToDelete)) { + const contents = quill.getContents(0, selection.index); + const restartDelta = getDeltaToRestartEmoji(contents.ops); - quill.updateContents(restartDelta); - quill.setSelection(selection.index, 0); + quill.updateContents(restartDelta); + return false; + } - return false; + return true; }; const onChange = (): void => { @@ -731,7 +740,9 @@ export function CompositionInput(props: Props): React.ReactElement { callbacksRef.current.onPickEmoji(emoji), skinTone, }, - autoSubstituteAsciiEmojis: true, + autoSubstituteAsciiEmojis: { + skinTone, + }, formattingMenu: { i18n, isMenuEnabled: isFormattingEnabled, diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 307642e33..404e728ca 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -76,6 +76,7 @@ export default { defaultConversationColor: DEFAULT_CONVERSATION_COLOR, deviceName: 'Work Windows ME', hasAudioNotifications: true, + hasAutoConvertEmoji: true, hasAutoDownloadUpdate: true, hasAutoLaunch: true, hasCallNotifications: true, @@ -133,6 +134,7 @@ export default { executeMenuRole: action('executeMenuRole'), makeSyncRequest: action('makeSyncRequest'), onAudioNotificationsChange: action('onAudioNotificationsChange'), + onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'), onAutoDownloadUpdateChange: action('onAutoDownloadUpdateChange'), onAutoLaunchChange: action('onAutoLaunchChange'), onCallNotificationsChange: action('onCallNotificationsChange'), diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 8ce2fdbb0..e1e860238 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -75,6 +75,7 @@ export type PropsDataType = { defaultConversationColor: DefaultConversationColorType; deviceName?: string; hasAudioNotifications?: boolean; + hasAutoConvertEmoji: boolean; hasAutoDownloadUpdate: boolean; hasAutoLaunch: boolean; hasCallNotifications: boolean; @@ -159,6 +160,7 @@ type PropsFunctionType = { // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; + onAutoConvertEmojiChange: CheckboxChangeHandlerType; onAutoDownloadUpdateChange: CheckboxChangeHandlerType; onAutoLaunchChange: CheckboxChangeHandlerType; onCallNotificationsChange: CheckboxChangeHandlerType; @@ -257,6 +259,7 @@ export function Preferences({ executeMenuRole, getConversationsWithCustomColor, hasAudioNotifications, + hasAutoConvertEmoji, hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, @@ -293,6 +296,7 @@ export function Preferences({ makeSyncRequest, notificationContent, onAudioNotificationsChange, + onAutoConvertEmojiChange, onAutoDownloadUpdateChange, onAutoLaunchChange, onCallNotificationsChange, @@ -856,6 +860,16 @@ export function Preferences({ name="linkPreviews" onChange={noop} /> + = { - ':)': 'slightly_smiling_face', ':-)': 'slightly_smiling_face', - ':(': 'slightly_frowning_face', ':-(': 'slightly_frowning_face', - ':D': 'smiley', - ':-D': 'smiley', - ':*': 'kissing', - ':-*': 'kissing', - ':P': 'stuck_out_tongue', + ':-D': 'grinning', + ':-*': 'kissing_heart', ':-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', + ':-p': 'stuck_out_tongue', ":'(": 'cry', - ":'-(": 'cry', - ':/': 'confused', - ':-/': 'confused', - ';)': 'wink', + ':-\\': 'confused', ';-)': 'wink', '(Y)': '+1', '(N)': '-1', + '(y)': '+1', + '(n)': '-1', + '<3': 'heart', + '^_^': 'grin', + '>_<': 'laughing', }; +function buildRegexp(obj: Record): RegExp { + const sanitizedKeys = Object.keys(obj).map(x => + x.replace(/([^a-zA-Z0-9])/g, '\\$1') + ); + + return new RegExp(`(${sanitizedKeys.join('|')})$`); +} + +const EMOJI_REGEXP = buildRegexp(emojiMap); + export class AutoSubstituteAsciiEmojis { options: AutoSubstituteAsciiEmojisOptions; @@ -50,13 +51,24 @@ export class AutoSubstituteAsciiEmojis { this.options = options; this.quill = quill; - this.quill.on( - 'text-change', - _.debounce(() => this.onTextChange(), 100) - ); + this.quill.on('text-change', (_now, _before, source) => { + if (source !== 'user') { + return; + } + + // When pasting - Quill first updates contents with "user" source and only + // then updates the selection with "silent" source. This means that unless + // we wrap `onTextChange` with setTimeout - we are not going to see the + // updated cursor position. + setTimeout(() => this.onTextChange(), 0); + }); } onTextChange(): void { + if (!window.storage.get('autoConvertEmoji', false)) { + return; + } + const range = this.quill.getSelection(); if (!range) { @@ -65,32 +77,44 @@ export class AutoSubstituteAsciiEmojis { 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; - }); + if (blot?.text == null) { + return; + } + + const textBeforeCursor = blot.text.slice(0, index); + const match = textBeforeCursor.match(EMOJI_REGEXP); + if (match == null) { + return; + } + + const [, textEmoji] = match; + const emojiName = emojiMap[textEmoji]; + + const emojiData = convertShortNameToData(emojiName, this.options.skinTone); + if (emojiData) { + this.insertEmoji( + emojiData, + range.index - textEmoji.length, + textEmoji.length, + textEmoji + ); } } - insertEmoji(emojiData: EmojiData, index: number, range: number): void { + insertEmoji( + emojiData: EmojiData, + index: number, + range: number, + source: string + ): 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'); + const delta = new Delta() + .retain(index) + .delete(range) + .insert({ + emoji: { value: emoji, source }, + }); + this.quill.updateContents(delta, 'api'); this.quill.setSelection(index + 1, 0); } } diff --git a/ts/quill/emoji/blot.tsx b/ts/quill/emoji/blot.tsx index 797f5bc1d..cb5f24d0b 100644 --- a/ts/quill/emoji/blot.tsx +++ b/ts/quill/emoji/blot.tsx @@ -12,6 +12,11 @@ const Embed: typeof Parchment.Embed = Quill.import('blots/embed'); // ts/components/conversation/Emojify.tsx // ts/components/emoji/Emoji.tsx +export type EmojiBlotValue = Readonly<{ + value: string; + source?: string; +}>; + export class EmojiBlot extends Embed { static override blotName = 'emoji'; @@ -19,21 +24,30 @@ export class EmojiBlot extends Embed { static override className = 'emoji-blot'; - static override create(emoji: string): Node { + static override create({ value: emoji, source }: EmojiBlotValue): Node { const node = super.create(undefined) as HTMLElement; node.dataset.emoji = emoji; + node.dataset.source = source; const image = emojiToImage(emoji); node.setAttribute('src', image || ''); node.setAttribute('data-emoji', emoji); + node.setAttribute('data-source', source || ''); node.setAttribute('title', emoji); node.setAttribute('aria-label', emoji); return node; } - static override value(node: HTMLElement): string | undefined { - return node.dataset.emoji; + static override value(node: HTMLElement): EmojiBlotValue | undefined { + const { emoji, source } = node.dataset; + if (emoji === undefined) { + throw new Error( + `Failed to make EmojiBlot with emoji: ${emoji}, source: ${source}` + ); + } + + return { value: emoji, source }; } } diff --git a/ts/quill/emoji/completion.tsx b/ts/quill/emoji/completion.tsx index 127e39973..fc885789e 100644 --- a/ts/quill/emoji/completion.tsx +++ b/ts/quill/emoji/completion.tsx @@ -247,7 +247,12 @@ export class EmojiCompletion { ): void { const emoji = convertShortName(emojiData.short_name, this.options.skinTone); - const delta = new Delta().retain(index).delete(range).insert({ emoji }); + const delta = new Delta() + .retain(index) + .delete(range) + .insert({ + emoji: { value: emoji }, + }); if (withTrailingSpace) { // The extra space we add won't be formatted unless we manually provide attributes diff --git a/ts/quill/emoji/matchers.ts b/ts/quill/emoji/matchers.ts index 60b454f51..2a29c335b 100644 --- a/ts/quill/emoji/matchers.ts +++ b/ts/quill/emoji/matchers.ts @@ -15,8 +15,8 @@ export const matchEmojiImage: Matcher = ( node.classList.contains('emoji') || node.classList.contains('module-emoji__image--16px') ) { - const emoji = node.getAttribute('aria-label'); - return new Delta().insert({ emoji }, attributes); + const value = node.getAttribute('aria-label'); + return new Delta().insert({ emoji: { value } }, attributes); } return delta; }; @@ -27,8 +27,8 @@ export const matchEmojiBlot: Matcher = ( attributes: AttributeMap ): Delta => { if (node.classList.contains('emoji-blot')) { - const { emoji } = node.dataset; - return new Delta().insert({ emoji }, attributes); + const { emoji: value, source } = node.dataset; + return new Delta().insert({ emoji: { value, source } }, attributes); } return delta; }; diff --git a/ts/quill/util.ts b/ts/quill/util.ts index 73a26f93b..96e8c6b00 100644 --- a/ts/quill/util.ts +++ b/ts/quill/util.ts @@ -13,6 +13,7 @@ import type { } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; import type { MentionBlot } from './mentions/blot'; +import type { EmojiBlot } from './emoji/blot'; import { isNewlineOnlyOp, QuillFormattingStyle } from './formatting/menu'; import { isNotNil } from '../util/isNotNil'; import type { AciString } from '../types/ServiceId'; @@ -27,6 +28,9 @@ export type FormattingBlotValue = { style: BodyRange.Style; }; +export const isEmojiBlot = (blot: LeafBlot): blot is EmojiBlot => + blot.value() && blot.value().emoji; + export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot => blot.value() && blot.value().mention; @@ -37,7 +41,10 @@ export type RetainOp = Op & { retain: number }; export type InsertOp = Op & { insert: { [V in K]: T } }; export type InsertMentionOp = InsertOp<'mention', MentionBlotValue>; -export type InsertEmojiOp = InsertOp<'emoji', string>; +export type InsertEmojiOp = InsertOp< + 'emoji', + { value: string; source?: string } +>; export const isRetainOp = (op?: Op): op is RetainOp => op !== undefined && op.retain !== undefined; @@ -64,7 +71,7 @@ export const getTextFromOps = (ops: Array): string => } if (isInsertEmojiOp(op)) { - return acc + op.insert.emoji; + return acc + op.insert.emoji.value; } if (isInsertMentionOp(op)) { @@ -187,7 +194,7 @@ export const getTextAndRangesFromOps = ( } if (isInsertEmojiOp(op)) { - return acc + op.insert.emoji; + return acc + op.insert.emoji.value; } if (isInsertMentionOp(op)) { @@ -304,6 +311,27 @@ export const getDeltaToRestartMention = (ops: Array): Delta => { return new Delta(changes); }; +export const getDeltaToRestartEmoji = (ops: Array): Delta => { + const changes = new Array(); + for (const op of ops.slice(0, -1)) { + if (op.insert && typeof op.insert === 'string') { + changes.push({ retain: op.insert.length }); + } else { + changes.push({ retain: 1 }); + } + } + const last = ops.at(-1); + if (!last || !last.insert) { + throw new Error('No emoji to delete'); + } + + changes.push({ delete: 1 }); + if ((last as InsertEmojiOp).insert.emoji?.source) { + changes.push({ insert: (last as InsertEmojiOp).insert.emoji?.source }); + } + return new Delta(changes); +}; + export const getDeltaToRemoveStaleMentions = ( ops: Array, memberAcis: Array @@ -422,7 +450,7 @@ export const insertEmojiOps = ( if (emojiData) { ops.push({ insert: text.slice(index, match.index), attributes }); ops.push({ - insert: { emoji }, + insert: { emoji: { value: emoji } }, attributes: { ...existingAttributes, ...attributes }, }); index = match.index + emoji.length; diff --git a/ts/test-node/quill/util_test.ts b/ts/test-node/quill/util_test.ts index a49655431..d4cec93f3 100644 --- a/ts/test-node/quill/util_test.ts +++ b/ts/test-node/quill/util_test.ts @@ -59,12 +59,12 @@ describe('getDeltaToRemoveStaleMentions', () => { const originalOps = [ { insert: { - emoji: '😂', + emoji: { value: '😂' }, }, }, { insert: { - emoji: '🍋', + emoji: { value: '🍋' }, }, }, ]; @@ -312,7 +312,7 @@ describe('getTextAndRangesFromOps', () => { const ops = [ { insert: { - emoji: '😂', + emoji: { value: '😂' }, }, }, { @@ -579,7 +579,7 @@ describe('getDeltaToRestartMention', () => { const originalOps = [ { insert: { - emoji: '😂', + emoji: { value: '😂' }, }, }, { diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 3885ed326..966a191ad 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -49,6 +49,7 @@ export type StorageAccessType = { 'always-relay-calls': boolean; 'audio-notification': boolean; 'auto-download-update': boolean; + autoConvertEmoji: boolean; 'badge-count-muted-conversations': boolean; 'blocked-groups': ReadonlyArray; 'blocked-uuids': ReadonlyArray; diff --git a/ts/types/StorageUIKeys.ts b/ts/types/StorageUIKeys.ts index 8ac38fcef..fec72f38d 100644 --- a/ts/types/StorageUIKeys.ts +++ b/ts/types/StorageUIKeys.ts @@ -13,6 +13,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'audio-notification', 'audioMessage', 'auto-download-update', + 'autoConvertEmoji', 'badge-count-muted-conversations', 'call-ringtone-notification', 'call-system-notification', diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 13a9b7a8e..ad1178cad 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -50,6 +50,7 @@ export type IPCEventsValuesType = { alwaysRelayCalls: boolean | undefined; audioNotification: boolean | undefined; audioMessage: boolean; + autoConvertEmoji: boolean; autoDownloadUpdate: boolean; autoLaunch: boolean; callRingtoneNotification: boolean; @@ -344,6 +345,8 @@ export function createIPCEvents( window.storage.get('auto-download-update', true), setAutoDownloadUpdate: value => window.storage.put('auto-download-update', value), + getAutoConvertEmoji: () => window.storage.get('autoConvertEmoji', false), + setAutoConvertEmoji: value => window.storage.put('autoConvertEmoji', value), getSentMediaQualitySetting: () => window.storage.get('sent-media-quality', 'standard'), setSentMediaQualitySetting: value => diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 6c2064e4e..72d85058e 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -38,6 +38,7 @@ installCallback('syncRequest'); installSetting('alwaysRelayCalls'); installSetting('audioMessage'); installSetting('audioNotification'); +installSetting('autoConvertEmoji'); installSetting('autoDownloadUpdate'); installSetting('autoLaunch'); installSetting('callRingtoneNotification'); diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx index 4360f6538..abe5f22b3 100644 --- a/ts/windows/settings/app.tsx +++ b/ts/windows/settings/app.tsx @@ -34,6 +34,7 @@ SettingsWindowProps.onRender( executeMenuRole, getConversationsWithCustomColor, hasAudioNotifications, + hasAutoConvertEmoji, hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, @@ -69,6 +70,7 @@ SettingsWindowProps.onRender( makeSyncRequest, notificationContent, onAudioNotificationsChange, + onAutoConvertEmojiChange, onAutoDownloadUpdateChange, onAutoLaunchChange, onCallNotificationsChange, @@ -135,6 +137,7 @@ SettingsWindowProps.onRender( executeMenuRole={executeMenuRole} getConversationsWithCustomColor={getConversationsWithCustomColor} hasAudioNotifications={hasAudioNotifications} + hasAutoConvertEmoji={hasAutoConvertEmoji} hasAutoDownloadUpdate={hasAutoDownloadUpdate} hasAutoLaunch={hasAutoLaunch} hasCallNotifications={hasCallNotifications} @@ -174,6 +177,7 @@ SettingsWindowProps.onRender( makeSyncRequest={makeSyncRequest} notificationContent={notificationContent} onAudioNotificationsChange={onAudioNotificationsChange} + onAutoConvertEmojiChange={onAutoConvertEmojiChange} onAutoDownloadUpdateChange={onAutoDownloadUpdateChange} onAutoLaunchChange={onAutoLaunchChange} onCallNotificationsChange={onCallNotificationsChange} diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index 7f387d061..cd301aaef 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -22,6 +22,7 @@ function doneRendering() { const settingMessageAudio = createSetting('audioMessage'); const settingAudioNotification = createSetting('audioNotification'); +const settingAutoConvertEmoji = createSetting('autoConvertEmoji'); const settingAutoDownloadUpdate = createSetting('autoDownloadUpdate'); const settingAutoLaunch = createSetting('autoLaunch'); const settingCallRingtoneNotification = createSetting( @@ -140,6 +141,7 @@ async function renderPreferences() { blockedCount, deviceName, hasAudioNotifications, + hasAutoConvertEmoji, hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, @@ -181,6 +183,7 @@ async function renderPreferences() { blockedCount: settingBlockedCount.getValue(), deviceName: settingDeviceName.getValue(), hasAudioNotifications: settingAudioNotification.getValue(), + hasAutoConvertEmoji: settingAutoConvertEmoji.getValue(), hasAutoDownloadUpdate: settingAutoDownloadUpdate.getValue(), hasAutoLaunch: settingAutoLaunch.getValue(), hasCallNotifications: settingCallSystemNotification.getValue(), @@ -247,6 +250,7 @@ async function renderPreferences() { defaultConversationColor, deviceName, hasAudioNotifications, + hasAutoConvertEmoji, hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, @@ -320,6 +324,9 @@ async function renderPreferences() { onAudioNotificationsChange: attachRenderCallback( settingAudioNotification.setValue ), + onAutoConvertEmojiChange: attachRenderCallback( + settingAutoConvertEmoji.setValue + ), onAutoDownloadUpdateChange: attachRenderCallback( settingAutoDownloadUpdate.setValue ),