Some improvements

This commit is contained in:
Fedor Indutnyy 2023-12-18 15:22:46 -08:00
parent 14a2714c1e
commit c53eefaf6d
19 changed files with 205 additions and 70 deletions

View file

@ -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"

View file

@ -972,6 +972,14 @@ export async function startApp(): Promise<void> {
}
}
if (
window.storage.get('autoConvertEmoji') === undefined &&
newVersion &&
!lastVersion
) {
await window.storage.put('autoConvertEmoji', true);
}
setAppLoadingScreenMessage(
window.i18n('icu:optimizingApplication'),
window.i18n

View file

@ -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(
<div>
<label htmlFor={id}>
<div>{label}</div>
<div className={getClassName('__description')}>{description}</div>
<div className={getClassName('__description')}>
<Emojify text={description ?? ''} />
</div>
</label>
</div>
);

View file

@ -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,

View file

@ -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'),

View file

@ -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}
/>
<Checkbox
checked={hasAutoConvertEmoji}
description={i18n(
'icu:Preferences__auto-convert-emoji--description'
)}
label={i18n('icu:Preferences__auto-convert-emoji--title')}
moduleClassName="Preferences__checkbox"
name="autoConvertEmoji"
onChange={onAutoConvertEmojiChange}
/>
<Control
left={i18n('icu:Preferences__sent-media-quality')}
right={

View file

@ -93,6 +93,7 @@ export class SettingsChannel extends EventEmitter {
});
this.installSetting('textFormatting');
this.installSetting('autoConvertEmoji');
this.installSetting('autoDownloadUpdate');
this.installSetting('autoLaunch');

View file

@ -3,7 +3,6 @@
import type Quill from 'quill';
import Delta from 'quill-delta';
import _ from 'lodash';
import type { EmojiData } from '../../components/emoji/lib';
import {
convertShortName,
@ -15,32 +14,34 @@ type AutoSubstituteAsciiEmojisOptions = {
};
const emojiMap: Record<string, string> = {
':)': '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<string, string>): 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);
}
}

View file

@ -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 };
}
}

View file

@ -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

View file

@ -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;
};

View file

@ -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<K extends string, T> = 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<DeltaOperation>): 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<Op>): Delta => {
return new Delta(changes);
};
export const getDeltaToRestartEmoji = (ops: Array<Op>): Delta => {
const changes = new Array<Op>();
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<Op>,
memberAcis: Array<AciString>
@ -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;

View file

@ -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: '😂' },
},
},
{

View file

@ -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<string>;
'blocked-uuids': ReadonlyArray<ServiceIdString>;

View file

@ -13,6 +13,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
'audio-notification',
'audioMessage',
'auto-download-update',
'autoConvertEmoji',
'badge-count-muted-conversations',
'call-ringtone-notification',
'call-system-notification',

View file

@ -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 =>

View file

@ -38,6 +38,7 @@ installCallback('syncRequest');
installSetting('alwaysRelayCalls');
installSetting('audioMessage');
installSetting('audioNotification');
installSetting('autoConvertEmoji');
installSetting('autoDownloadUpdate');
installSetting('autoLaunch');
installSetting('callRingtoneNotification');

View file

@ -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}

View file

@ -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
),