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
Paul Frazee 2023-11-15 14:39:22 -08:00 committed by GitHub
parent 839e8e8d0a
commit d5ea31920c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 73 additions and 152 deletions

View File

@ -106,7 +106,6 @@
"expo-system-ui": "~2.4.0", "expo-system-ui": "~2.4.0",
"expo-updates": "~0.18.12", "expo-updates": "~0.18.12",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"fuse.js": "^7.0.0",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"lande": "^1.0.10", "lande": "^1.0.10",

View File

@ -1,8 +1,6 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs, BskyAgent} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery, useQueryClient} from '@tanstack/react-query'
import AwaitLock from 'await-lock'
import Fuse from 'fuse.js'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@ -13,151 +11,78 @@ export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
export function useActorAutocompleteQuery(prefix: string) { export function useActorAutocompleteQuery(prefix: string) {
const {agent} = useSession() const {agent} = useSession()
const {data: follows, isFetching} = useMyFollowsQuery() const {data: follows, isFetching} = useMyFollowsQuery()
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
// cached for 1 min
staleTime: 60 * 1000,
queryKey: RQKEY(prefix || ''), queryKey: RQKEY(prefix || ''),
async queryFn() { async queryFn() {
const res = await agent.searchActorsTypeahead({ const res = prefix
term: prefix, ? await agent.searchActorsTypeahead({
limit: 8, term: prefix,
}) limit: 8,
return computeSuggestions(prefix, follows, res.data.actors) })
: 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 queryClient = useQueryClient()
const {agent} = useSession() const {agent} = useSession()
const {data: follows} = useMyFollowsQuery() 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( return React.useCallback(
async ({query}: {query: string}) => { async ({query}: {query: string}) => {
let searchResults: AppBskyActorDefs.ProfileViewBasic[] = [] let res
if (query) {
if (followsSearch) { try {
const results = followsSearch.search(query) res = await queryClient.fetchQuery({
searchResults = results.map(({item}) => item) // cached for 1 min
} staleTime: 60 * 1000,
queryKey: RQKEY(query || ''),
try { queryFn: () =>
const res = await queryClient.fetchQuery({ agent.searchActorsTypeahead({
// cached for 1 min term: query,
staleTime: 60 * 1000, limit: 8,
queryKey: ['search', query], }),
queryFn: () => })
agent.searchActorsTypeahead({ } catch (e) {
term: query, logger.error('useActorSearch: searchActorsTypeahead failed', {
limit: 8, error: e,
}), })
})
if (res.data.actors) {
for (const actor of res.data.actors) {
if (!searchResults.find(item => item.handle === actor.handle)) {
searchResults.push(actor)
}
}
} }
} 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( function computeSuggestions(
prefix: string, prefix: string,
follows: AppBskyActorDefs.ProfileViewBasic[] = [], follows: AppBskyActorDefs.ProfileViewBasic[] | undefined,
searched: AppBskyActorDefs.ProfileViewBasic[] = [], searched: AppBskyActorDefs.ProfileViewBasic[] = [],
) { ) {
if (prefix) { let items: AppBskyActorDefs.ProfileViewBasic[] = []
const items: AppBskyActorDefs.ProfileViewBasic[] = [] if (follows) {
for (const item of follows) { items = follows.filter(follow => prefixMatch(prefix, follow)).slice(0, 8)
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
} }
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( function prefixMatch(

View File

@ -17,9 +17,7 @@ import {isUriImage, blobToDataUri} from 'lib/media/util'
import {Emoji} from './web/EmojiPicker.web' import {Emoji} from './web/EmojiPicker.web'
import {LinkDecorator} from './web/LinkDecorator' import {LinkDecorator} from './web/LinkDecorator'
import {generateJSON} from '@tiptap/html' import {generateJSON} from '@tiptap/html'
import {ActorAutocomplete} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useSession} from '#/state/session'
import {useMyFollowsQuery} from '#/state/queries/my-follows'
export interface TextInputRef { export interface TextInputRef {
focus: () => void focus: () => void
@ -52,15 +50,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
TextInputProps, TextInputProps,
ref, ref,
) { ) {
const {agent} = useSession() const autocomplete = useActorAutocompleteFn()
const autocomplete = React.useMemo(
() => new ActorAutocomplete(agent),
[agent],
)
const {data: follows} = useMyFollowsQuery()
if (follows) {
autocomplete.setFollows(follows)
}
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
const extensions = React.useMemo( const extensions = React.useMemo(

View File

@ -1,4 +1,4 @@
import React, {useEffect} from 'react' import React, {useEffect, useRef} from 'react'
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 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 {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme' import {useGrapheme} from '../hooks/useGrapheme'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {Trans} from '@lingui/macro'
import {AppBskyActorDefs} from '@atproto/api'
export const Autocomplete = observer(function AutocompleteImpl({ export const Autocomplete = observer(function AutocompleteImpl({
prefix, prefix,
@ -19,7 +21,13 @@ export const Autocomplete = observer(function AutocompleteImpl({
const positionInterp = useAnimatedValue(0) const positionInterp = useAnimatedValue(0)
const {getGraphemeString} = useGrapheme() const {getGraphemeString} = useGrapheme()
const isActive = !!prefix 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(() => { useEffect(() => {
Animated.timing(positionInterp, { Animated.timing(positionInterp, {
@ -44,8 +52,8 @@ export const Autocomplete = observer(function AutocompleteImpl({
<Animated.View style={topAnimStyle}> <Animated.View style={topAnimStyle}>
{isActive ? ( {isActive ? (
<View style={[pal.view, styles.container, pal.border]}> <View style={[pal.view, styles.container, pal.border]}>
{suggestions?.length ? ( {suggestionsRef.current?.length ? (
suggestions.slice(0, 5).map(item => { suggestionsRef.current.slice(0, 5).map(item => {
// Eventually use an average length // Eventually use an average length
const MAX_CHARS = 40 const MAX_CHARS = 40
const MAX_HANDLE_CHARS = 20 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]}> <Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
No result {isFetching ? (
<Trans>Loading...</Trans>
) : (
<Trans>No result</Trans>
)}
</Text> </Text>
)} )}
</View> </View>

View File

@ -12,7 +12,7 @@ import {
SuggestionProps, SuggestionProps,
SuggestionKeyDownProps, SuggestionKeyDownProps,
} from '@tiptap/suggestion' } from '@tiptap/suggestion'
import {ActorAutocomplete} from '#/state/queries/actor-autocomplete' import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
@ -25,12 +25,12 @@ interface MentionListRef {
export function createSuggestion({ export function createSuggestion({
autocomplete, autocomplete,
}: { }: {
autocomplete: ActorAutocomplete autocomplete: ActorAutocompleteFn
}): Omit<SuggestionOptions, 'editor'> { }): Omit<SuggestionOptions, 'editor'> {
return { return {
async items({query}) { async items({query}) {
await autocomplete.query(query) const suggestions = await autocomplete({query})
return autocomplete.suggestions.slice(0, 8) return suggestions.slice(0, 8)
}, },
render: () => { render: () => {

View File

@ -26,7 +26,7 @@ import {MagnifyingGlassIcon2} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar' 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' import {useModerationOpts} from '#/state/queries/preferences'
export function SearchResultCard({ export function SearchResultCard({
@ -98,7 +98,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
>([]) >([])
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const search = useActorSearch() const search = useActorAutocompleteFn()
const onChangeText = React.useCallback( const onChangeText = React.useCallback(
async (text: string) => { async (text: string) => {

View File

@ -10560,11 +10560,6 @@ funpermaproxy@^1.1.0:
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557" resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ== 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: gensync@^1.0.0-beta.2:
version "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" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"