Translate emoji completions in composer
This commit is contained in:
parent
6a20d91b71
commit
e802ea0dc7
12 changed files with 216 additions and 273 deletions
|
@ -60,7 +60,6 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { useEmojiSearch } from '../hooks/useEmojiSearch';
|
|
||||||
import type { LinkPreviewForUIType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewForUIType } from '../types/message/LinkPreviews';
|
||||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||||
import type { DraftEditMessageType } from '../model-types.d';
|
import type { DraftEditMessageType } from '../model-types.d';
|
||||||
|
@ -79,6 +78,9 @@ import { dropNull } from '../util/dropNull';
|
||||||
import { SimpleQuillWrapper } from './SimpleQuillWrapper';
|
import { SimpleQuillWrapper } from './SimpleQuillWrapper';
|
||||||
import type { EmojiSkinTone } from './fun/data/emojis';
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji';
|
import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji';
|
||||||
|
import { useFunEmojiSearch } from './fun/useFunEmojiSearch';
|
||||||
|
import type { EmojiCompletionOptions } from '../quill/emoji/completion';
|
||||||
|
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer';
|
||||||
|
|
||||||
Quill.register(
|
Quill.register(
|
||||||
{
|
{
|
||||||
|
@ -192,7 +194,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [emojiCompletionElement, setEmojiCompletionElement] =
|
const [emojiCompletionElement, setEmojiCompletionElement] =
|
||||||
React.useState<JSX.Element>();
|
React.useState<JSX.Element | null>();
|
||||||
const [formattingChooserElement, setFormattingChooserElement] =
|
const [formattingChooserElement, setFormattingChooserElement] =
|
||||||
React.useState<JSX.Element>();
|
React.useState<JSX.Element>();
|
||||||
const [lastSelectionRange, setLastSelectionRange] =
|
const [lastSelectionRange, setLastSelectionRange] =
|
||||||
|
@ -770,7 +772,8 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
const callbacksRef = React.useRef(unstaleCallbacks);
|
const callbacksRef = React.useRef(unstaleCallbacks);
|
||||||
callbacksRef.current = unstaleCallbacks;
|
callbacksRef.current = unstaleCallbacks;
|
||||||
|
|
||||||
const search = useEmojiSearch(i18n.getLocale());
|
const emojiSearch = useFunEmojiSearch();
|
||||||
|
const emojiLocalizer = useFunEmojiLocalizer();
|
||||||
|
|
||||||
const reactQuill = React.useMemo(
|
const reactQuill = React.useMemo(
|
||||||
() => {
|
() => {
|
||||||
|
@ -839,8 +842,9 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
onPickEmoji: (emoji: EmojiPickDataType) =>
|
onPickEmoji: (emoji: EmojiPickDataType) =>
|
||||||
callbacksRef.current.onPickEmoji(emoji),
|
callbacksRef.current.onPickEmoji(emoji),
|
||||||
emojiSkinToneDefault,
|
emojiSkinToneDefault,
|
||||||
search,
|
emojiSearch,
|
||||||
},
|
emojiLocalizer,
|
||||||
|
} satisfies EmojiCompletionOptions,
|
||||||
autoSubstituteAsciiEmojis: {
|
autoSubstituteAsciiEmojis: {
|
||||||
emojiSkinToneDefault,
|
emojiSkinToneDefault,
|
||||||
} satisfies AutoSubstituteAsciiEmojisOptions,
|
} satisfies AutoSubstituteAsciiEmojisOptions,
|
||||||
|
|
|
@ -23,7 +23,6 @@ import { dataByCategory } from './lib';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import { isSingleGrapheme } from '../../util/grapheme';
|
import { isSingleGrapheme } from '../../util/grapheme';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { useEmojiSearch } from '../../hooks/useEmojiSearch';
|
|
||||||
import { FunStaticEmoji } from '../fun/FunEmoji';
|
import { FunStaticEmoji } from '../fun/FunEmoji';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import {
|
import {
|
||||||
|
@ -35,7 +34,9 @@ import {
|
||||||
getEmojiVariantByParentKeyAndSkinTone,
|
getEmojiVariantByParentKeyAndSkinTone,
|
||||||
isEmojiEnglishShortName,
|
isEmojiEnglishShortName,
|
||||||
EMOJI_SKIN_TONE_TO_NUMBER,
|
EMOJI_SKIN_TONE_TO_NUMBER,
|
||||||
|
getEmojiParentByKey,
|
||||||
} from '../fun/data/emojis';
|
} from '../fun/data/emojis';
|
||||||
|
import { useFunEmojiSearch } from '../fun/useFunEmojiSearch';
|
||||||
|
|
||||||
export type EmojiPickDataType = {
|
export type EmojiPickDataType = {
|
||||||
skinTone: EmojiSkinTone;
|
skinTone: EmojiSkinTone;
|
||||||
|
@ -122,7 +123,7 @@ export const EmojiPicker = React.memo(
|
||||||
const [selectedTone, setSelectedTone] =
|
const [selectedTone, setSelectedTone] =
|
||||||
React.useState(emojiSkinToneDefault);
|
React.useState(emojiSkinToneDefault);
|
||||||
|
|
||||||
const search = useEmojiSearch(i18n.getLocale());
|
const emojiSearch = useFunEmojiSearch();
|
||||||
|
|
||||||
const handleToggleSearch = React.useCallback(
|
const handleToggleSearch = React.useCallback(
|
||||||
(
|
(
|
||||||
|
@ -261,7 +262,13 @@ export const EmojiPicker = React.memo(
|
||||||
|
|
||||||
const emojiGrid = React.useMemo(() => {
|
const emojiGrid = React.useMemo(() => {
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
return chunk(search(searchText), COL_COUNT);
|
return chunk(
|
||||||
|
emojiSearch(searchText).map(result => {
|
||||||
|
const parent = getEmojiParentByKey(result.parentKey);
|
||||||
|
return parent.englishShortNameDefault;
|
||||||
|
}),
|
||||||
|
COL_COUNT
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = flatMap(renderableCategories, cat =>
|
const chunks = flatMap(renderableCategories, cat =>
|
||||||
|
@ -272,7 +279,7 @@ export const EmojiPicker = React.memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
return [...chunk(firstRecent, COL_COUNT), ...chunks];
|
return [...chunk(firstRecent, COL_COUNT), ...chunks];
|
||||||
}, [firstRecent, renderableCategories, searchText, search]);
|
}, [firstRecent, renderableCategories, searchText, emojiSearch]);
|
||||||
|
|
||||||
const rowCount = emojiGrid.length;
|
const rowCount = emojiGrid.length;
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
// Camelcase disabled due to emoji-datasource using snake_case
|
// Camelcase disabled due to emoji-datasource using snake_case
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
import {
|
import {
|
||||||
compact,
|
compact,
|
||||||
flatMap,
|
flatMap,
|
||||||
|
@ -12,9 +11,7 @@ import {
|
||||||
map,
|
map,
|
||||||
mapValues,
|
mapValues,
|
||||||
sortBy,
|
sortBy,
|
||||||
take,
|
|
||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
import type { LocaleEmojiType } from '../../types/emoji';
|
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import {
|
import {
|
||||||
EMOJI_SKIN_TONE_TO_KEY,
|
EMOJI_SKIN_TONE_TO_KEY,
|
||||||
|
@ -155,103 +152,6 @@ export function getEmojiData(
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchFnType = (query: string, count?: number) => Array<string>;
|
|
||||||
|
|
||||||
export type SearchEmojiListType = ReadonlyArray<
|
|
||||||
Pick<LocaleEmojiType, 'shortName' | 'rank' | 'tags'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type CachedSearchFnType = Readonly<{
|
|
||||||
localeEmoji: SearchEmojiListType;
|
|
||||||
fn: SearchFnType;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
let cachedSearchFn: CachedSearchFnType | undefined;
|
|
||||||
|
|
||||||
export function createSearch(localeEmoji: SearchEmojiListType): SearchFnType {
|
|
||||||
if (cachedSearchFn && cachedSearchFn.localeEmoji === localeEmoji) {
|
|
||||||
return cachedSearchFn.fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownSet = new Set<string>();
|
|
||||||
|
|
||||||
const knownEmoji = localeEmoji.filter(({ shortName }) => {
|
|
||||||
knownSet.add(shortName);
|
|
||||||
return dataByShortName[shortName] != null;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of data) {
|
|
||||||
if (!knownSet.has(entry.short_name)) {
|
|
||||||
knownEmoji.push({
|
|
||||||
shortName: entry.short_name,
|
|
||||||
rank: 0,
|
|
||||||
tags: entry.short_names,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxShortNameLength = 0;
|
|
||||||
for (const { shortName } of knownEmoji) {
|
|
||||||
maxShortNameLength = Math.max(maxShortNameLength, shortName.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fuse = new Fuse(knownEmoji, {
|
|
||||||
shouldSort: false,
|
|
||||||
threshold: 0.2,
|
|
||||||
minMatchCharLength: 1,
|
|
||||||
keys: ['shortName', 'tags'],
|
|
||||||
includeScore: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fuseExactPrefix = new Fuse(knownEmoji, {
|
|
||||||
// We re-rank and sort manually below
|
|
||||||
shouldSort: false,
|
|
||||||
threshold: 0, // effectively a prefix search
|
|
||||||
minMatchCharLength: 2,
|
|
||||||
keys: ['shortName', 'tags'],
|
|
||||||
includeScore: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fn = (query: string, count = 0): Array<string> => {
|
|
||||||
// when we only have 2 characters, do an exact prefix match
|
|
||||||
// to avoid matching on emoticon, like :-P
|
|
||||||
const fuseIndex = query.length === 2 ? fuseExactPrefix : fuse;
|
|
||||||
|
|
||||||
const rawResults = fuseIndex.search(query.substr(0, 32));
|
|
||||||
|
|
||||||
const rankedResults = rawResults.map(entry => {
|
|
||||||
const rank = entry.item.rank || 1e9;
|
|
||||||
|
|
||||||
// Rank exact prefix matches in [0,1] range
|
|
||||||
if (entry.item.shortName.startsWith(query)) {
|
|
||||||
return {
|
|
||||||
score: entry.item.shortName.length / maxShortNameLength,
|
|
||||||
item: entry.item,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other matches in [1,], ordered by score and rank
|
|
||||||
return {
|
|
||||||
score: 1 + (entry.score ?? 0) + rank / knownEmoji.length,
|
|
||||||
item: entry.item,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = rankedResults
|
|
||||||
.sort((a, b) => a.score - b.score)
|
|
||||||
.map(result => result.item.shortName);
|
|
||||||
|
|
||||||
if (count) {
|
|
||||||
return take(results, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
cachedSearchFn = { localeEmoji, fn };
|
|
||||||
return fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortNames = new Set([
|
const shortNames = new Set([
|
||||||
...map(data, 'short_name'),
|
...map(data, 'short_name'),
|
||||||
...compact<string>(flatMap(data, 'short_names')),
|
...compact<string>(flatMap(data, 'short_names')),
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
FunEmojiSearchIndexEntry,
|
FunEmojiSearchIndexEntry,
|
||||||
} from '../useFunEmojiSearch';
|
} from '../useFunEmojiSearch';
|
||||||
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer';
|
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer';
|
||||||
|
import { removeDiacritics } from '../../../util/removeDiacritics';
|
||||||
|
|
||||||
// Import emoji-datasource dynamically to avoid costly typechecking.
|
// Import emoji-datasource dynamically to avoid costly typechecking.
|
||||||
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
|
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
|
||||||
|
@ -551,6 +552,26 @@ export function emojiVariantConstant(input: string): EmojiVariantData {
|
||||||
return getEmojiVariantByKey(key);
|
return getEmojiVariantByKey(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** For displaying in the ui */
|
||||||
|
export function normalizeShortNameCompletionDisplay(shortName: string): string {
|
||||||
|
return removeDiacritics(shortName)
|
||||||
|
.normalize('NFD')
|
||||||
|
.replaceAll(' ', '_')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For matching in search utils */
|
||||||
|
export function normalizeShortNameCompletionQuery(query: string): string {
|
||||||
|
return removeDiacritics(query)
|
||||||
|
.normalize('NFD')
|
||||||
|
.replaceAll(/[\s_-]+/gi, ' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emojify
|
* Emojify
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -186,7 +186,9 @@ export function FunPanelEmojis({
|
||||||
return [
|
return [
|
||||||
toGridSectionNode(
|
toGridSectionNode(
|
||||||
FunSectionCommon.SearchResults,
|
FunSectionCommon.SearchResults,
|
||||||
searchEmojis(searchQuery)
|
searchEmojis(searchQuery).map(result => {
|
||||||
|
return result.parentKey;
|
||||||
|
})
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@ import {
|
||||||
FunSubNavScroller,
|
FunSubNavScroller,
|
||||||
} from '../base/FunSubNav';
|
} from '../base/FunSubNav';
|
||||||
import {
|
import {
|
||||||
|
type EmojiParentKey,
|
||||||
emojiVariantConstant,
|
emojiVariantConstant,
|
||||||
getEmojiParentKeyByValue,
|
getEmojiParentKeyByValue,
|
||||||
isEmojiParentValue,
|
isEmojiParentValue,
|
||||||
|
@ -228,7 +229,11 @@ export function FunPanelStickers({
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
if (searchQuery !== '') {
|
if (searchQuery !== '') {
|
||||||
const emojiKeys = new Set(searchEmojis(searchQuery));
|
const emojiKeys = new Set<EmojiParentKey>();
|
||||||
|
|
||||||
|
for (const result of searchEmojis(searchQuery)) {
|
||||||
|
emojiKeys.add(result.parentKey);
|
||||||
|
}
|
||||||
|
|
||||||
const allStickers = installedStickerPacks.flatMap(pack => pack.stickers);
|
const allStickers = installedStickerPacks.flatMap(pack => pack.stickers);
|
||||||
const matchingStickers = allStickers.filter(sticker => {
|
const matchingStickers = allStickers.filter(sticker => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { useCallback } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis';
|
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis';
|
||||||
import {
|
import {
|
||||||
getEmojiParentByKey,
|
getEmojiParentByKey,
|
||||||
|
@ -28,26 +28,33 @@ export function createFunEmojiLocalizerIndex(
|
||||||
|
|
||||||
const variantKey = getEmojiVariantKeyByValue(entry.emoji);
|
const variantKey = getEmojiVariantKeyByValue(entry.emoji);
|
||||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||||
index.set(parentKey, entry.tags.at(0) ?? entry.shortName);
|
const localizedShortName = entry.tags.at(0) ?? entry.shortName;
|
||||||
|
index.set(parentKey, localizedShortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal exported for tests */
|
||||||
|
export function _createFunEmojiLocalizer(
|
||||||
|
emojiLocalizerIndex: FunEmojiLocalizerIndex
|
||||||
|
): FunEmojiLocalizer {
|
||||||
|
return variantKey => {
|
||||||
|
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||||
|
const localeShortName = emojiLocalizerIndex.get(parentKey);
|
||||||
|
if (localeShortName != null) {
|
||||||
|
return localeShortName;
|
||||||
|
}
|
||||||
|
// Fallback to english short name
|
||||||
|
const parent = getEmojiParentByKey(parentKey);
|
||||||
|
return parent.englishShortNameDefault;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useFunEmojiLocalizer(): FunEmojiLocalizer {
|
export function useFunEmojiLocalizer(): FunEmojiLocalizer {
|
||||||
const { emojiLocalizerIndex } = useFunEmojiLocalization();
|
const { emojiLocalizerIndex } = useFunEmojiLocalization();
|
||||||
const emojiLocalizer: FunEmojiLocalizer = useCallback(
|
const emojiLocalizer = useMemo(() => {
|
||||||
variantKey => {
|
return _createFunEmojiLocalizer(emojiLocalizerIndex);
|
||||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
}, [emojiLocalizerIndex]);
|
||||||
const localeShortName = emojiLocalizerIndex.get(parentKey);
|
|
||||||
if (localeShortName != null) {
|
|
||||||
return localeShortName;
|
|
||||||
}
|
|
||||||
// Fallback to english short name
|
|
||||||
const parent = getEmojiParentByKey(parentKey);
|
|
||||||
return parent.englishShortNameDefault;
|
|
||||||
},
|
|
||||||
[emojiLocalizerIndex]
|
|
||||||
);
|
|
||||||
return emojiLocalizer;
|
return emojiLocalizer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
getEmojiParentByKey,
|
getEmojiParentByKey,
|
||||||
getEmojiParentKeyByValue,
|
getEmojiParentKeyByValue,
|
||||||
isEmojiParentValue,
|
isEmojiParentValue,
|
||||||
|
normalizeShortNameCompletionQuery,
|
||||||
} from './data/emojis';
|
} from './data/emojis';
|
||||||
import type { LocaleEmojiListType } from '../../types/emoji';
|
import type { LocaleEmojiListType } from '../../types/emoji';
|
||||||
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider';
|
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider';
|
||||||
|
@ -23,10 +24,14 @@ export type FunEmojiSearchIndexEntry = Readonly<{
|
||||||
|
|
||||||
export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>;
|
export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>;
|
||||||
|
|
||||||
|
export type FunEmojiSearchResult = Readonly<{
|
||||||
|
parentKey: EmojiParentKey;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type FunEmojiSearch = (
|
export type FunEmojiSearch = (
|
||||||
query: string,
|
query: string,
|
||||||
limit?: number
|
limit?: number
|
||||||
) => ReadonlyArray<EmojiParentKey>;
|
) => ReadonlyArray<FunEmojiSearchResult>;
|
||||||
|
|
||||||
export function createFunEmojiSearchIndex(
|
export function createFunEmojiSearchIndex(
|
||||||
localeEmojiList: LocaleEmojiListType
|
localeEmojiList: LocaleEmojiListType
|
||||||
|
@ -44,8 +49,10 @@ export function createFunEmojiSearchIndex(
|
||||||
results.push({
|
results.push({
|
||||||
key: parentKey,
|
key: parentKey,
|
||||||
rank: localeEmoji.rank,
|
rank: localeEmoji.rank,
|
||||||
shortName: localeEmoji.shortName,
|
shortName: normalizeShortNameCompletionQuery(localeEmoji.shortName),
|
||||||
shortNames: localeEmoji.tags,
|
shortNames: localeEmoji.tags.map(tag => {
|
||||||
|
return normalizeShortNameCompletionQuery(tag);
|
||||||
|
}),
|
||||||
emoticon: emoji.emoticonDefault,
|
emoticon: emoji.emoticonDefault,
|
||||||
emoticons: emoji.emoticons,
|
emoticons: emoji.emoticons,
|
||||||
});
|
});
|
||||||
|
@ -67,6 +74,7 @@ const FuseFuzzyOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
|
||||||
minMatchCharLength: 1,
|
minMatchCharLength: 1,
|
||||||
keys: FuseKeys,
|
keys: FuseKeys,
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
|
const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
|
||||||
|
@ -75,49 +83,48 @@ const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
|
||||||
minMatchCharLength: 1,
|
minMatchCharLength: 1,
|
||||||
keys: FuseKeys,
|
keys: FuseKeys,
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createFunEmojiSearch(
|
/** @internal exported for tests */
|
||||||
|
export function _createFunEmojiSearch(
|
||||||
emojiSearchIndex: FunEmojiSearchIndex
|
emojiSearchIndex: FunEmojiSearchIndex
|
||||||
): FunEmojiSearch {
|
): FunEmojiSearch {
|
||||||
const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex);
|
const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex);
|
||||||
const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex);
|
const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex);
|
||||||
const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, fuseIndex);
|
const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, fuseIndex);
|
||||||
|
|
||||||
return function emojiSearch(query, limit = 200) {
|
return function emojiSearch(rawQuery, limit = 200) {
|
||||||
|
const query = normalizeShortNameCompletionQuery(rawQuery);
|
||||||
|
|
||||||
// Prefer exact matches at 2 characters
|
// Prefer exact matches at 2 characters
|
||||||
const fuse = query.length < 2 ? fuseExact : fuseFuzzy;
|
const fuse = query.length < 2 ? fuseExact : fuseFuzzy;
|
||||||
|
|
||||||
const rawResults = fuse.search(query.substring(0, 32), {
|
const rawResults = fuse.search(query.substring(0, 32));
|
||||||
limit: limit * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rankedResults = rawResults.map(result => {
|
// Note: lodash's sortBy() only calls each iteratee once
|
||||||
|
const sortedResults = sortBy(rawResults, result => {
|
||||||
const rank = result.item.rank ?? 1e9;
|
const rank = result.item.rank ?? 1e9;
|
||||||
|
|
||||||
|
const localizedQueryMatch =
|
||||||
|
result.item.shortNames.at(0) ?? result.item.shortName;
|
||||||
|
|
||||||
// Exact prefix matches in [0,1] range
|
// Exact prefix matches in [0,1] range
|
||||||
if (result.item.shortName.startsWith(query)) {
|
if (localizedQueryMatch.startsWith(query)) {
|
||||||
return {
|
// Note: localizedQueryMatch will always be <= in length to the query
|
||||||
score: result.item.shortName.length / query.length,
|
const matchRatio = query.length / localizedQueryMatch.length; // 1-0
|
||||||
item: result.item,
|
return 1 - matchRatio;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryScore = result.score ?? 0; // 0-1
|
||||||
|
const rankScore = rank / emojiSearchIndex.length; // 0-1
|
||||||
|
|
||||||
// Other matches in [1,], ordered by score and rank
|
// Other matches in [1,], ordered by score and rank
|
||||||
return {
|
return 1 + queryScore + rankScore;
|
||||||
score: 1 + (result.score ?? 0) + rank / emojiSearchIndex.length,
|
|
||||||
item: result.item,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedResults = sortBy(rankedResults, result => {
|
return sortedResults.slice(0, limit).map(result => {
|
||||||
return result.score;
|
return { parentKey: result.item.key };
|
||||||
});
|
|
||||||
|
|
||||||
const truncatedResults = sortedResults.slice(0, limit);
|
|
||||||
|
|
||||||
return truncatedResults.map(result => {
|
|
||||||
return result.item.key;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -125,7 +132,7 @@ function createFunEmojiSearch(
|
||||||
export function useFunEmojiSearch(): FunEmojiSearch {
|
export function useFunEmojiSearch(): FunEmojiSearch {
|
||||||
const { emojiSearchIndex } = useFunEmojiLocalization();
|
const { emojiSearchIndex } = useFunEmojiLocalization();
|
||||||
const emojiSearch = useMemo(() => {
|
const emojiSearch = useMemo(() => {
|
||||||
return createFunEmojiSearch(emojiSearchIndex);
|
return _createFunEmojiSearch(emojiSearchIndex);
|
||||||
}, [emojiSearchIndex]);
|
}, [emojiSearchIndex]);
|
||||||
return emojiSearch;
|
return emojiSearch;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
import { data, createSearch } from '../components/emoji/lib';
|
|
||||||
import type { SearchEmojiListType } from '../components/emoji/lib';
|
|
||||||
import { drop } from '../util/drop';
|
|
||||||
import * as log from '../logging/log';
|
|
||||||
|
|
||||||
const uninitialized: SearchEmojiListType = data.map(
|
|
||||||
({ short_name: shortName, short_names: shortNames }) => {
|
|
||||||
return {
|
|
||||||
shortName,
|
|
||||||
rank: 0,
|
|
||||||
tags: shortNames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultSearch = createSearch(uninitialized);
|
|
||||||
|
|
||||||
export function useEmojiSearch(
|
|
||||||
locale: string
|
|
||||||
): ReturnType<typeof createSearch> {
|
|
||||||
const searchRef = useRef(defaultSearch);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let canceled = false;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
let result: SearchEmojiListType | undefined;
|
|
||||||
try {
|
|
||||||
result = await window.SignalContext.getLocalizedEmojiList(locale);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to get localized emoji list for ${locale}`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
if (result === undefined) {
|
|
||||||
try {
|
|
||||||
result = await window.SignalContext.getLocalizedEmojiList('en');
|
|
||||||
} catch (error) {
|
|
||||||
log.error('Failed to get fallback localized emoji list');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canceled && result !== undefined) {
|
|
||||||
searchRef.current = createSearch(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(run());
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
return useCallback((...args) => {
|
|
||||||
return searchRef.current?.(...args);
|
|
||||||
}, []);
|
|
||||||
}
|
|
|
@ -12,25 +12,27 @@ import classNames from 'classnames';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { VirtualElement } from '@popperjs/core';
|
import type { VirtualElement } from '@popperjs/core';
|
||||||
import { convertShortName, isShortName } from '../../components/emoji/lib';
|
import { convertShortName, isShortName } from '../../components/emoji/lib';
|
||||||
import type { SearchFnType } from '../../components/emoji/lib';
|
|
||||||
import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
|
import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
|
||||||
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
|
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
|
||||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { FunStaticEmoji } from '../../components/fun/FunEmoji';
|
import { FunStaticEmoji } from '../../components/fun/FunEmoji';
|
||||||
import { strictAssert } from '../../util/assert';
|
|
||||||
import {
|
import {
|
||||||
EmojiSkinTone,
|
EmojiSkinTone,
|
||||||
getEmojiParentKeyByEnglishShortName,
|
getEmojiParentByKey,
|
||||||
getEmojiVariantByParentKeyAndSkinTone,
|
getEmojiVariantByParentKeyAndSkinTone,
|
||||||
isEmojiEnglishShortName,
|
normalizeShortNameCompletionDisplay,
|
||||||
} from '../../components/fun/data/emojis';
|
} from '../../components/fun/data/emojis';
|
||||||
|
import type { FunEmojiSearchResult } from '../../components/fun/useFunEmojiSearch';
|
||||||
|
import { type FunEmojiSearch } from '../../components/fun/useFunEmojiSearch';
|
||||||
|
import { type FunEmojiLocalizer } from '../../components/fun/useFunEmojiLocalizer';
|
||||||
|
|
||||||
export type EmojiCompletionOptions = {
|
export type EmojiCompletionOptions = {
|
||||||
onPickEmoji: (emoji: EmojiPickDataType) => void;
|
onPickEmoji: (emoji: EmojiPickDataType) => void;
|
||||||
setEmojiPickerElement: (element: JSX.Element | null) => void;
|
setEmojiPickerElement: (element: JSX.Element | null) => void;
|
||||||
emojiSkinToneDefault: EmojiSkinTone | null;
|
emojiSkinToneDefault: EmojiSkinTone | null;
|
||||||
search: SearchFnType;
|
emojiSearch: FunEmojiSearch;
|
||||||
|
emojiLocalizer: FunEmojiLocalizer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InsertEmojiOptionsType = Readonly<{
|
export type InsertEmojiOptionsType = Readonly<{
|
||||||
|
@ -42,7 +44,7 @@ export type InsertEmojiOptionsType = Readonly<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class EmojiCompletion {
|
export class EmojiCompletion {
|
||||||
results: Array<string>;
|
results: ReadonlyArray<FunEmojiSearchResult>;
|
||||||
|
|
||||||
index: number;
|
index: number;
|
||||||
|
|
||||||
|
@ -197,7 +199,7 @@ export class EmojiCompletion {
|
||||||
return PASS_THROUGH;
|
return PASS_THROUGH;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showEmojiResults = this.options.search(leftTokenText, 10);
|
const showEmojiResults = this.options.emojiSearch(leftTokenText, 10);
|
||||||
|
|
||||||
if (showEmojiResults.length > 0) {
|
if (showEmojiResults.length > 0) {
|
||||||
this.results = showEmojiResults;
|
this.results = showEmojiResults;
|
||||||
|
@ -229,7 +231,7 @@ export class EmojiCompletion {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emoji = this.results[this.index];
|
const result = this.results[this.index];
|
||||||
const [leafText] = this.getCurrentLeafTextPartitions();
|
const [leafText] = this.getCurrentLeafTextPartitions();
|
||||||
|
|
||||||
const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText);
|
const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText);
|
||||||
|
@ -239,9 +241,10 @@ export class EmojiCompletion {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, tokenText] = tokenTextMatch;
|
const [, tokenText] = tokenTextMatch;
|
||||||
|
const parent = getEmojiParentByKey(result.parentKey);
|
||||||
|
|
||||||
this.insertEmoji({
|
this.insertEmoji({
|
||||||
shortName: emoji,
|
shortName: parent.englishShortNameDefault,
|
||||||
index: range.index - tokenText.length - 1,
|
index: range.index - tokenText.length - 1,
|
||||||
range: tokenText.length + 1,
|
range: tokenText.length + 1,
|
||||||
withTrailingSpace: true,
|
withTrailingSpace: true,
|
||||||
|
@ -362,26 +365,30 @@ export class EmojiCompletion {
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-expanded
|
aria-expanded
|
||||||
aria-activedescendant={`emoji-result--${
|
aria-activedescendant={`emoji-result--${
|
||||||
emojiResults.length ? emojiResults[emojiResultsIndex] : ''
|
emojiResults.length
|
||||||
|
? emojiResults[emojiResultsIndex].parentKey
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{emojiResults.map((emoji, index) => {
|
{emojiResults.map((result, index) => {
|
||||||
strictAssert(
|
|
||||||
isEmojiEnglishShortName(emoji),
|
|
||||||
'Must be valid english short name'
|
|
||||||
);
|
|
||||||
const emojiParentKey = getEmojiParentKeyByEnglishShortName(emoji);
|
|
||||||
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
||||||
emojiParentKey,
|
result.parentKey,
|
||||||
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
|
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const localeShortName = this.options.emojiLocalizer(
|
||||||
|
emojiVariant.key
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalized =
|
||||||
|
normalizeShortNameCompletionDisplay(localeShortName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={emoji}
|
key={result.parentKey}
|
||||||
id={`emoji-result--${emoji}`}
|
id={`emoji-result--${result.parentKey}`}
|
||||||
role="option button"
|
role="option button"
|
||||||
aria-selected={emojiResultsIndex === index}
|
aria-selected={emojiResultsIndex === index}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -401,7 +408,7 @@ export class EmojiCompletion {
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
<div className="module-composition-input__suggestions__row__short-name">
|
<div className="module-composition-input__suggestions__row__short-name">
|
||||||
:{emoji}:
|
:{normalized}:
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,8 +9,53 @@ import type {
|
||||||
EmojiCompletionOptions,
|
EmojiCompletionOptions,
|
||||||
InsertEmojiOptionsType,
|
InsertEmojiOptionsType,
|
||||||
} from '../../../quill/emoji/completion';
|
} from '../../../quill/emoji/completion';
|
||||||
import { createSearch } from '../../../components/emoji/lib';
|
import {
|
||||||
import { EmojiSkinTone } from '../../../components/fun/data/emojis';
|
EmojiSkinTone,
|
||||||
|
emojiVariantConstant,
|
||||||
|
getEmojiParentKeyByVariantKey,
|
||||||
|
} from '../../../components/fun/data/emojis';
|
||||||
|
import {
|
||||||
|
_createFunEmojiSearch,
|
||||||
|
createFunEmojiSearchIndex,
|
||||||
|
} from '../../../components/fun/useFunEmojiSearch';
|
||||||
|
import {
|
||||||
|
_createFunEmojiLocalizer,
|
||||||
|
createFunEmojiLocalizerIndex,
|
||||||
|
} from '../../../components/fun/useFunEmojiLocalizer';
|
||||||
|
import type { LocaleEmojiListType } from '../../../types/emoji';
|
||||||
|
|
||||||
|
const EMOJI_VARIANTS = {
|
||||||
|
SMILE: emojiVariantConstant('\u{1F604}'),
|
||||||
|
SMILE_CAT: emojiVariantConstant('\u{1F638}'),
|
||||||
|
FRIEND_SHRIMP: emojiVariantConstant('\u{1F364}'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PARENT_KEYS = {
|
||||||
|
SMILE: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.SMILE.key),
|
||||||
|
SMILE_CAT: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.SMILE_CAT.key),
|
||||||
|
FRIED_SHRIMP: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.FRIEND_SHRIMP.key),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EMOJI_LIST: LocaleEmojiListType = [
|
||||||
|
{
|
||||||
|
emoji: EMOJI_VARIANTS.SMILE.value,
|
||||||
|
shortName: 'smile',
|
||||||
|
tags: [],
|
||||||
|
rank: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: EMOJI_VARIANTS.SMILE_CAT.value,
|
||||||
|
shortName: 'smile_cat',
|
||||||
|
tags: [],
|
||||||
|
rank: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: EMOJI_VARIANTS.FRIEND_SHRIMP.value,
|
||||||
|
shortName: 'fried_shrimp',
|
||||||
|
tags: [],
|
||||||
|
rank: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe('emojiCompletion', () => {
|
describe('emojiCompletion', () => {
|
||||||
let emojiCompletion: EmojiCompletion;
|
let emojiCompletion: EmojiCompletion;
|
||||||
|
@ -29,14 +74,19 @@ describe('emojiCompletion', () => {
|
||||||
setSelection: sinon.stub(),
|
setSelection: sinon.stub(),
|
||||||
updateContents: sinon.stub(),
|
updateContents: sinon.stub(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchIndex = createFunEmojiSearchIndex(EMOJI_LIST);
|
||||||
|
const localizerIndex = createFunEmojiLocalizerIndex(EMOJI_LIST);
|
||||||
|
|
||||||
|
const emojiSearch = _createFunEmojiSearch(searchIndex);
|
||||||
|
const emojiLocalizer = _createFunEmojiLocalizer(localizerIndex);
|
||||||
|
|
||||||
const options: EmojiCompletionOptions = {
|
const options: EmojiCompletionOptions = {
|
||||||
onPickEmoji: sinon.stub(),
|
onPickEmoji: sinon.stub(),
|
||||||
setEmojiPickerElement: sinon.stub(),
|
setEmojiPickerElement: sinon.stub(),
|
||||||
emojiSkinToneDefault: EmojiSkinTone.None,
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
search: createSearch([
|
emojiSearch,
|
||||||
{ shortName: 'smile', tags: [], rank: 0 },
|
emojiLocalizer,
|
||||||
{ shortName: 'smile_cat', tags: [], rank: 0 },
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
@ -64,7 +114,7 @@ describe('emojiCompletion', () => {
|
||||||
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
|
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
emojiCompletion.results = ['joy'];
|
emojiCompletion.results = [{ parentKey: PARENT_KEYS.SMILE }];
|
||||||
emojiCompletion.index = 5;
|
emojiCompletion.index = 5;
|
||||||
insertEmojiStub = sinon
|
insertEmojiStub = sinon
|
||||||
.stub(emojiCompletion, 'insertEmoji')
|
.stub(emojiCompletion, 'insertEmoji')
|
||||||
|
@ -171,7 +221,7 @@ describe('emojiCompletion', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the results and renders', () => {
|
it('stores the results and renders', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 10);
|
assert.equal(emojiCompletion.results.length, 2);
|
||||||
assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
|
assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -353,7 +403,10 @@ describe('emojiCompletion', () => {
|
||||||
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
|
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
emojiCompletion.results = ['smile', 'smile_cat'];
|
emojiCompletion.results = [
|
||||||
|
{ parentKey: PARENT_KEYS.SMILE },
|
||||||
|
{ parentKey: PARENT_KEYS.SMILE_CAT },
|
||||||
|
];
|
||||||
emojiCompletion.index = 1;
|
emojiCompletion.index = 1;
|
||||||
insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
|
insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1994,6 +1994,27 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2023-01-18T22:32:43.901Z"
|
"updated": "2023-01-18T22:32:43.901Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/fun/FunGif.tsx",
|
||||||
|
"line": " const ref = useRef<HTMLVideoElement>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-03-21T23:22:07.920Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/fun/FunGif.tsx",
|
||||||
|
"line": " const timerRef = useRef<ReturnType<typeof setTimeout>>();",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-03-21T23:22:07.920Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/fun/FunGif.tsx",
|
||||||
|
"line": " const videoRef = useRef<HTMLVideoElement>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-03-24T18:57:50.198Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/fun/base/FunImage.tsx",
|
"path": "ts/components/fun/base/FunImage.tsx",
|
||||||
|
@ -2127,27 +2148,6 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2025-02-19T20:14:46.879Z"
|
"updated": "2025-02-19T20:14:46.879Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/components/fun/FunGif.tsx",
|
|
||||||
"line": " const ref = useRef<HTMLVideoElement>(null);",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2025-03-21T23:22:07.920Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/components/fun/FunGif.tsx",
|
|
||||||
"line": " const timerRef = useRef<ReturnType<typeof setTimeout>>();",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2025-03-21T23:22:07.920Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/components/fun/FunGif.tsx",
|
|
||||||
"line": " const videoRef = useRef<HTMLVideoElement>(null);",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2025-03-24T18:57:50.198Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/stickers/StickerButton.tsx",
|
"path": "ts/components/stickers/StickerButton.tsx",
|
||||||
|
@ -2156,14 +2156,6 @@
|
||||||
"updated": "2022-06-14T22:04:43.988Z",
|
"updated": "2022-06-14T22:04:43.988Z",
|
||||||
"reasonDetail": "Handling outside click"
|
"reasonDetail": "Handling outside click"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/hooks/useEmojiSearch.ts",
|
|
||||||
"line": " const searchRef = useRef(defaultSearch);",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2024-03-16T18:34:38.165Z",
|
|
||||||
"reasonDetail": "Quill requires an immutable reference to the search function"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/hooks/useIntersectionObserver.ts",
|
"path": "ts/hooks/useIntersectionObserver.ts",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue