diff --git a/package.json b/package.json index 61a06983..88d0c15e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 1bfa13f8..57f30f9c 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -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({ + // 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 +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( diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 7690a587..4c31da33 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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( diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 9ccd717f..bb54a204 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -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({ {isActive ? ( - {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({ }) ) : ( - No result + {isFetching ? ( + Loading... + ) : ( + No result + )} )} diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index c6b773d8..1f741256 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -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 { 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: () => { diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 115e0f7a..d1598c3d 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -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) => { diff --git a/yarn.lock b/yarn.lock index c3d6aa6b..9e75b3e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"