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-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",
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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: () => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue