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