about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-15 14:39:22 -0800
committerGitHub <noreply@github.com>2023-11-15 14:39:22 -0800
commitd5ea31920caa2eade6015ad59122f06a8b280ab9 (patch)
tree59b5b1a7817bbb7315bf8cfea80911a3d875a6c0 /src
parent839e8e8d0ade22ce47678229a98fe602c31601c3 (diff)
downloadvoidsky-d5ea31920caa2eade6015ad59122f06a8b280ab9.tar.zst
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>
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/actor-autocomplete.ts169
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx14
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx22
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx8
-rw-r--r--src/view/shell/desktop/Search.tsx4
5 files changed, 72 insertions, 145 deletions
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 1bfa13f81..57f30f9c5 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<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,
-        })
-      }
+  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
-  } else {
-    return follows
   }
+  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 7690a5876..4c31da338 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 9ccd717fb..bb54a2042 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({
     <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>
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index c6b773d86..1f7412561 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<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: () => {
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 115e0f7ae..d1598c3d3 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) => {