Good spot, working autcomplete and emojis
This commit is contained in:
@@ -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 (
|
||||
<SiftItem
|
||||
{...props}
|
||||
style={s => [
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||
]}>
|
||||
<Text>{item.value}</Text>
|
||||
</SiftItem>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './Autocomplete'
|
||||
export * from './AutocompleteItemEmoji'
|
||||
export * from './AutocompleteItemProfile'
|
||||
export * from './useAutocomplete'
|
||||
export * from './util'
|
||||
|
||||
@@ -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<Emoji> | 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 []
|
||||
|
||||
@@ -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 ? (
|
||||
<AutocompleteBase
|
||||
sift={sift}
|
||||
@@ -437,6 +454,9 @@ function AutocompleteInner({
|
||||
if (props.item.type === 'profile') {
|
||||
return <AutocompleteItemProfile {...props} />
|
||||
}
|
||||
if (props.item.type === 'emoji') {
|
||||
return <AutocompleteItemEmoji {...props} />
|
||||
}
|
||||
return <View />
|
||||
}}
|
||||
onSelect={item => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
|
||||
|
||||
export async function getEmojis(): Promise<EmojiMartData> {
|
||||
return Emojis as EmojiMartData
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {type EmojiMartData} from '@emoji-mart/data'
|
||||
|
||||
export async function getEmojis(): Promise<EmojiMartData> {
|
||||
return (await import('@emoji-mart/data')).default as EmojiMartData
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {getEmojis} from './getEmojis'
|
||||
|
||||
let emojis: Awaited<ReturnType<typeof getEmojis>> | null = null
|
||||
|
||||
export function useGetEmojis() {
|
||||
return useCallback(async () => {
|
||||
emojis ??= await getEmojis()
|
||||
return emojis
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user