diff --git a/package.json b/package.json index c6a35f0503..68d9626b2e 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/react-native-mmkv": "2.12.5", - "@bsky.app/sift": "^0.2.4", + "@bsky.app/sift": "^0.2.5", "@bsky.app/tapper": "^0.4.0", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/data": "^1.2.1", @@ -181,6 +181,7 @@ "expo-web-browser": "~15.0.10", "fast-deep-equal": "^3.1.3", "fast-text-encoding": "^1.0.6", + "fuse.js": "^7.1.0", "hls.js": "^1.6.2", "idb-keyval": "^6.2.2", "js-sha256": "^0.9.0", diff --git a/src/components/Autocomplete/AutocompleteItemEmoji.tsx b/src/components/Autocomplete/AutocompleteItemEmoji.tsx new file mode 100644 index 0000000000..f8dc65e11e --- /dev/null +++ b/src/components/Autocomplete/AutocompleteItemEmoji.tsx @@ -0,0 +1,29 @@ +import {SiftItem} from '@bsky.app/sift' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {type AutocompleteItemProps} from './types' + +export function AutocompleteItemEmoji({ + active, + props, + item, +}: AutocompleteItemProps) { + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (item.type !== 'emoji' || !moderationOpts) return null + + return ( + [ + a.px_md, + a.py_sm, + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], + ]}> + {item.value} + + ) +} diff --git a/src/components/Autocomplete/index.tsx b/src/components/Autocomplete/index.tsx index 9c5af55d19..4dabf25fae 100644 --- a/src/components/Autocomplete/index.tsx +++ b/src/components/Autocomplete/index.tsx @@ -1,4 +1,5 @@ export * from './Autocomplete' +export * from './AutocompleteItemEmoji' export * from './AutocompleteItemProfile' export * from './useAutocomplete' export * from './util' diff --git a/src/components/Autocomplete/useAutocomplete.ts b/src/components/Autocomplete/useAutocomplete.ts index e87c61f14c..65726b0d09 100644 --- a/src/components/Autocomplete/useAutocomplete.ts +++ b/src/components/Autocomplete/useAutocomplete.ts @@ -1,8 +1,11 @@ import {useCallback} from 'react' import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {type Emoji} from '@emoji-mart/data' import {keepPreviousData, useQuery} from '@tanstack/react-query' +import Fuse from 'fuse.js' import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' +import {useGetEmojis} from '#/lib/useGetEmojis' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' @@ -18,6 +21,30 @@ const DEFAULT_MOD_OPTS = { prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, } +/* + * Lazily loaded Fuse instance for emoji search. Built once on first search, + * then reused for all subsequent searches. + */ +let emojiFuseInstance: Fuse | null = null +function useEmojiSearch() { + const getEmojis = useGetEmojis() + + return useCallback( + async (query: string, limit: number = 8) => { + if (!emojiFuseInstance) { + const data = await getEmojis() + emojiFuseInstance = new Fuse(Object.values(data.emojis), { + keys: ['search'], + threshold: 0.3, + }) + } + + return emojiFuseInstance.search(query, {limit}) + }, + [getEmojis], + ) +} + export function useAutocomplete({ type, query, @@ -29,6 +56,7 @@ export function useAutocomplete({ }) { const agent = useAgent() const moderationOpts = useModerationOpts() + const emojiSearch = useEmojiSearch() return useQuery({ staleTime: STALE.MINUTES.ONE, @@ -58,6 +86,14 @@ export function useAutocomplete({ value: '@' + profile.handle, profile, })) + } else if (type === 'emoji') { + const results = await emojiSearch(query, limit || 8) + return results.map(result => ({ + key: result.item.id, + type: 'emoji' as const, + value: result.item.skins[0].native, + emoji: result.item.skins[0].native, + })) } return [] diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 5e6fd8e85f..d0c54286fa 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,3 +1,9 @@ +/** + * TODO + * + * Native + * - make sure we limit the height of autocomplete so it doesn't go off screen + */ import { useCallback, useEffect, @@ -36,6 +42,7 @@ import { import {normalizeTextStyles} from '#/alf/typography' import { Autocomplete as AutocompleteBase, + AutocompleteItemEmoji, AutocompleteItemProfile, parseAutocompleteItemType, useAutocomplete, @@ -371,6 +378,7 @@ export function Composer({ ])} onBlur={e => { rest.onBlur?.(e) + setActiveFacet(null) }} onKeyPress={IS_WEB ? onKeyPressWeb : undefined} onScroll={e => { @@ -429,6 +437,15 @@ function AutocompleteInner({ useOnKeyboard('keyboardDidShow', updatePosition) useOnKeyboard('keyboardDidHide', updatePosition) + useEffect(() => { + if (activeFacet?.type === 'emoji' && activeFacet.raw.endsWith(':')) { + if (data?.[0]) { + activeFacet.replace(data[0].value, {noTrailingSpace: true}) + onDismiss() + } + } + }, [data, activeFacet]) + return data && data.length ? ( } + if (props.item.type === 'emoji') { + return + } return }} onSelect={item => { diff --git a/src/lib/useGetEmojis/getEmojis.ts b/src/lib/useGetEmojis/getEmojis.ts new file mode 100644 index 0000000000..3118f437e9 --- /dev/null +++ b/src/lib/useGetEmojis/getEmojis.ts @@ -0,0 +1,5 @@ +import Emojis, {type EmojiMartData} from '@emoji-mart/data' + +export async function getEmojis(): Promise { + return Emojis as EmojiMartData +} diff --git a/src/lib/useGetEmojis/getEmojis.web.ts b/src/lib/useGetEmojis/getEmojis.web.ts new file mode 100644 index 0000000000..b4fbf0dfbe --- /dev/null +++ b/src/lib/useGetEmojis/getEmojis.web.ts @@ -0,0 +1,5 @@ +import {type EmojiMartData} from '@emoji-mart/data' + +export async function getEmojis(): Promise { + return (await import('@emoji-mart/data')).default as EmojiMartData +} diff --git a/src/lib/useGetEmojis/index.ts b/src/lib/useGetEmojis/index.ts new file mode 100644 index 0000000000..4c70c724ff --- /dev/null +++ b/src/lib/useGetEmojis/index.ts @@ -0,0 +1,12 @@ +import {useCallback} from 'react' + +import {getEmojis} from './getEmojis' + +let emojis: Awaited> | null = null + +export function useGetEmojis() { + return useCallback(async () => { + emojis ??= await getEmojis() + return emojis + }, []) +} diff --git a/yarn.lock b/yarn.lock index 92b22dcf93..a0d06ede75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2413,10 +2413,10 @@ resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg== -"@bsky.app/sift@^0.2.4": - version "0.2.4" - resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.2.4.tgz#cfdc67d5236b3fb4d26b9b5d482420d3f804cfe1" - integrity sha512-2tUoKhTjULMPgqhIUiNaNPyyTh2vPhOMRT/2S1HWhalEsBNeMeoNqUs7KWgOtgcDtT4breGkHmR1XOo2tzYmgA== +"@bsky.app/sift@^0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.2.5.tgz#6790c71f65fe4aee421f43769ab0f6b0904f0b83" + integrity sha512-ImuEEp0eNoTspjjUaBfZe4QPTs/DdtMxOq2fMNvyIBz844/uDstkgTvuM4WJ1RCw5+mT0ThiovNYItuyigU3eQ== "@bsky.app/tapper@^0.4.0": version "0.4.0" @@ -9991,6 +9991,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuse.js@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.1.0.tgz#306228b4befeee11e05b027087c2744158527d09" + integrity sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -14579,7 +14584,7 @@ react-test-renderer@19.1.0: react-is "^19.1.0" scheduler "^0.26.0" -react-textarea-autosize@^8.5.9: +react-textarea-autosize@^8.5.3: version "8.5.9" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz#ab8627b09aa04d8a2f45d5b5cd94c84d1d4a8893" integrity sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==