Autocomplete updates (react-query refactor) (#1911)
* Unify the autocomplete code; drop fuse * Persist autocomplete results while they're in progress * Commit lockfile * Use ReturnType helper --------- Co-authored-by: Eric Bailey <git@esb.lol>zio/stable
parent
839e8e8d0a
commit
d5ea31920c
|
@ -106,7 +106,6 @@
|
|||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^7.0.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import React from 'react'
|
||||
import {AppBskyActorDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import AwaitLock from 'await-lock'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useSession} from '#/state/session'
|
||||
|
@ -13,151 +11,78 @@ export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
|
|||
export function useActorAutocompleteQuery(prefix: string) {
|
||||
const {agent} = useSession()
|
||||
const {data: follows, isFetching} = useMyFollowsQuery()
|
||||
|
||||
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
|
||||
// cached for 1 min
|
||||
staleTime: 60 * 1000,
|
||||
queryKey: RQKEY(prefix || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.searchActorsTypeahead({
|
||||
term: prefix,
|
||||
limit: 8,
|
||||
})
|
||||
return computeSuggestions(prefix, follows, res.data.actors)
|
||||
const res = prefix
|
||||
? await agent.searchActorsTypeahead({
|
||||
term: prefix,
|
||||
limit: 8,
|
||||
})
|
||||
: undefined
|
||||
return computeSuggestions(prefix, follows, res?.data.actors)
|
||||
},
|
||||
enabled: !isFetching && !!prefix,
|
||||
enabled: !isFetching,
|
||||
})
|
||||
}
|
||||
|
||||
export function useActorSearch() {
|
||||
export type ActorAutocompleteFn = ReturnType<typeof useActorAutocompleteFn>
|
||||
export function useActorAutocompleteFn() {
|
||||
const queryClient = useQueryClient()
|
||||
const {agent} = useSession()
|
||||
const {data: follows} = useMyFollowsQuery()
|
||||
|
||||
const followsSearch = React.useMemo(() => {
|
||||
if (!follows) return undefined
|
||||
|
||||
return new Fuse(follows, {
|
||||
includeScore: true,
|
||||
keys: ['displayName', 'handle'],
|
||||
})
|
||||
}, [follows])
|
||||
|
||||
return React.useCallback(
|
||||
async ({query}: {query: string}) => {
|
||||
let searchResults: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
|
||||
if (followsSearch) {
|
||||
const results = followsSearch.search(query)
|
||||
searchResults = results.map(({item}) => item)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await queryClient.fetchQuery({
|
||||
// cached for 1 min
|
||||
staleTime: 60 * 1000,
|
||||
queryKey: ['search', query],
|
||||
queryFn: () =>
|
||||
agent.searchActorsTypeahead({
|
||||
term: query,
|
||||
limit: 8,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.data.actors) {
|
||||
for (const actor of res.data.actors) {
|
||||
if (!searchResults.find(item => item.handle === actor.handle)) {
|
||||
searchResults.push(actor)
|
||||
}
|
||||
}
|
||||
let res
|
||||
if (query) {
|
||||
try {
|
||||
res = await queryClient.fetchQuery({
|
||||
// cached for 1 min
|
||||
staleTime: 60 * 1000,
|
||||
queryKey: RQKEY(query || ''),
|
||||
queryFn: () =>
|
||||
agent.searchActorsTypeahead({
|
||||
term: query,
|
||||
limit: 8,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('useActorSearch: searchActorsTypeahead failed', {
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('useActorSearch: searchActorsTypeahead failed', {error: e})
|
||||
}
|
||||
|
||||
return searchResults
|
||||
return computeSuggestions(query, follows, res?.data.actors)
|
||||
},
|
||||
[agent, followsSearch, queryClient],
|
||||
[agent, follows, queryClient],
|
||||
)
|
||||
}
|
||||
|
||||
export class ActorAutocomplete {
|
||||
// state
|
||||
isLoading = false
|
||||
isActive = false
|
||||
prefix = ''
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
suggestions: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public follows?: AppBskyActorDefs.ProfileViewBasic[] | undefined,
|
||||
) {}
|
||||
|
||||
setFollows(follows: AppBskyActorDefs.ProfileViewBasic[]) {
|
||||
this.follows = follows
|
||||
}
|
||||
|
||||
async query(prefix: string) {
|
||||
const origPrefix = prefix.trim().toLocaleLowerCase()
|
||||
this.prefix = origPrefix
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
if (this.prefix) {
|
||||
if (this.prefix !== origPrefix) {
|
||||
return // another prefix was set before we got our chance
|
||||
}
|
||||
|
||||
// start with follow results
|
||||
this.suggestions = computeSuggestions(this.prefix, this.follows)
|
||||
|
||||
// ask backend
|
||||
const res = await this.agent.searchActorsTypeahead({
|
||||
term: this.prefix,
|
||||
limit: 8,
|
||||
})
|
||||
this.suggestions = computeSuggestions(
|
||||
this.prefix,
|
||||
this.follows,
|
||||
res.data.actors,
|
||||
)
|
||||
} else {
|
||||
this.suggestions = computeSuggestions(this.prefix, this.follows)
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeSuggestions(
|
||||
prefix: string,
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[] = [],
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[] | undefined,
|
||||
searched: AppBskyActorDefs.ProfileViewBasic[] = [],
|
||||
) {
|
||||
if (prefix) {
|
||||
const items: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
for (const item of follows) {
|
||||
if (prefixMatch(prefix, item)) {
|
||||
items.push(item)
|
||||
}
|
||||
if (items.length >= 8) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const item of searched) {
|
||||
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||
items.push({
|
||||
did: item.did,
|
||||
handle: item.handle,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
} else {
|
||||
return follows
|
||||
let items: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
if (follows) {
|
||||
items = follows.filter(follow => prefixMatch(prefix, follow)).slice(0, 8)
|
||||
}
|
||||
for (const item of searched) {
|
||||
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||
items.push({
|
||||
did: item.did,
|
||||
handle: item.handle,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function prefixMatch(
|
||||
|
|
|
@ -17,9 +17,7 @@ import {isUriImage, blobToDataUri} from 'lib/media/util'
|
|||
import {Emoji} from './web/EmojiPicker.web'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
import {ActorAutocomplete} from '#/state/queries/actor-autocomplete'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useMyFollowsQuery} from '#/state/queries/my-follows'
|
||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
|
@ -52,15 +50,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||
TextInputProps,
|
||||
ref,
|
||||
) {
|
||||
const {agent} = useSession()
|
||||
const autocomplete = React.useMemo(
|
||||
() => new ActorAutocomplete(agent),
|
||||
[agent],
|
||||
)
|
||||
const {data: follows} = useMyFollowsQuery()
|
||||
if (follows) {
|
||||
autocomplete.setFollows(follows)
|
||||
}
|
||||
const autocomplete = useActorAutocompleteFn()
|
||||
|
||||
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
||||
const extensions = React.useMemo(
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
|
@ -7,6 +7,8 @@ import {Text} from 'view/com/util/text/Text'
|
|||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {useGrapheme} from '../hooks/useGrapheme'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
export const Autocomplete = observer(function AutocompleteImpl({
|
||||
prefix,
|
||||
|
@ -19,7 +21,13 @@ export const Autocomplete = observer(function AutocompleteImpl({
|
|||
const positionInterp = useAnimatedValue(0)
|
||||
const {getGraphemeString} = useGrapheme()
|
||||
const isActive = !!prefix
|
||||
const {data: suggestions} = useActorAutocompleteQuery(prefix)
|
||||
const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
|
||||
const suggestionsRef = useRef<
|
||||
AppBskyActorDefs.ProfileViewBasic[] | undefined
|
||||
>(undefined)
|
||||
if (suggestions) {
|
||||
suggestionsRef.current = suggestions
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(positionInterp, {
|
||||
|
@ -44,8 +52,8 @@ export const Autocomplete = observer(function AutocompleteImpl({
|
|||
<Animated.View style={topAnimStyle}>
|
||||
{isActive ? (
|
||||
<View style={[pal.view, styles.container, pal.border]}>
|
||||
{suggestions?.length ? (
|
||||
suggestions.slice(0, 5).map(item => {
|
||||
{suggestionsRef.current?.length ? (
|
||||
suggestionsRef.current.slice(0, 5).map(item => {
|
||||
// Eventually use an average length
|
||||
const MAX_CHARS = 40
|
||||
const MAX_HANDLE_CHARS = 20
|
||||
|
@ -84,7 +92,11 @@ export const Autocomplete = observer(function AutocompleteImpl({
|
|||
})
|
||||
) : (
|
||||
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
|
||||
No result
|
||||
{isFetching ? (
|
||||
<Trans>Loading...</Trans>
|
||||
) : (
|
||||
<Trans>No result</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
SuggestionProps,
|
||||
SuggestionKeyDownProps,
|
||||
} from '@tiptap/suggestion'
|
||||
import {ActorAutocomplete} from '#/state/queries/actor-autocomplete'
|
||||
import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
|
@ -25,12 +25,12 @@ interface MentionListRef {
|
|||
export function createSuggestion({
|
||||
autocomplete,
|
||||
}: {
|
||||
autocomplete: ActorAutocomplete
|
||||
autocomplete: ActorAutocompleteFn
|
||||
}): Omit<SuggestionOptions, 'editor'> {
|
||||
return {
|
||||
async items({query}) {
|
||||
await autocomplete.query(query)
|
||||
return autocomplete.suggestions.slice(0, 8)
|
||||
const suggestions = await autocomplete({query})
|
||||
return suggestions.slice(0, 8)
|
||||
},
|
||||
|
||||
render: () => {
|
||||
|
|
|
@ -26,7 +26,7 @@ import {MagnifyingGlassIcon2} from 'lib/icons'
|
|||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useActorSearch} from '#/state/queries/actor-autocomplete'
|
||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
|
||||
export function SearchResultCard({
|
||||
|
@ -98,7 +98,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||
>([])
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const search = useActorSearch()
|
||||
const search = useActorAutocompleteFn()
|
||||
|
||||
const onChangeText = React.useCallback(
|
||||
async (text: string) => {
|
||||
|
|
|
@ -10560,11 +10560,6 @@ funpermaproxy@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
|
||||
integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==
|
||||
|
||||
fuse.js@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
|
||||
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
|
||||
|
||||
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"
|
||||
|
|
Loading…
Reference in New Issue