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-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",

View File

@ -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(

View File

@ -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(

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

View File

@ -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: () => {

View File

@ -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) => {

View File

@ -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"