about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/queries/actor-autocomplete.ts8
-rw-r--r--src/view/screens/Search/Search.tsx210
-rw-r--r--src/view/shell/desktop/Search.tsx106
3 files changed, 154 insertions, 170 deletions
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 0b022dd49..98b5aa17e 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -1,6 +1,6 @@
 import React from 'react'
 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
-import {useQuery, useQueryClient} from '@tanstack/react-query'
+import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query'
 
 import {isJustAMute} from '#/lib/moderation'
 import {logger} from '#/logger'
@@ -16,7 +16,10 @@ const DEFAULT_MOD_OPTS = {
 const RQKEY_ROOT = 'actor-autocomplete'
 export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
 
-export function useActorAutocompleteQuery(prefix: string) {
+export function useActorAutocompleteQuery(
+  prefix: string,
+  maintainData?: boolean,
+) {
   const moderationOpts = useModerationOpts()
   const {getAgent} = useAgent()
 
@@ -40,6 +43,7 @@ export function useActorAutocompleteQuery(prefix: string) {
       },
       [moderationOpts],
     ),
+    placeholderData: maintainData ? keepPreviousData : undefined,
   })
 }
 
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 2cc0766d2..ee9e69433 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -27,7 +27,7 @@ import {s} from '#/lib/styles'
 import {logger} from '#/logger'
 import {isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
-import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useActorSearch} from '#/state/queries/actor-search'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useSearchPostsQuery} from '#/state/queries/search-posts'
@@ -35,6 +35,7 @@ import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
 import {useSession} from '#/state/session'
 import {useSetDrawerOpen} from '#/state/shell'
 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
+import {useNonReactiveCallback} from 'lib/hooks/useNonReactiveCallback'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {
   NativeStackScreenProps,
@@ -308,7 +309,7 @@ function SearchScreenUserResults({
   const {_} = useLingui()
 
   const {data: results, isFetched} = useActorSearch({
-    query,
+    query: query,
     enabled: active,
   })
 
@@ -478,43 +479,25 @@ export function SearchScreen(
   const {track} = useAnalytics()
   const setDrawerOpen = useSetDrawerOpen()
   const moderationOpts = useModerationOpts()
-  const search = useActorAutocompleteFn()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
 
-  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
-    undefined,
-  )
-  const [isFetching, setIsFetching] = React.useState<boolean>(false)
-  const [query, setQuery] = React.useState<string>(props.route?.params?.q || '')
-  const [searchResults, setSearchResults] = React.useState<
-    AppBskyActorDefs.ProfileViewBasic[]
-  >([])
-  const [inputIsFocused, setInputIsFocused] = React.useState(false)
-  const [showAutocompleteResults, setShowAutocompleteResults] =
-    React.useState(false)
-  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
-
-  /**
-   * The Search screen's `q` param
-   */
-  const queryParam = props.route?.params?.q
+  // Query terms
+  const queryParam = props.route?.params?.q ?? ''
+  const [searchText, setSearchText] = React.useState<string>(queryParam)
+  const {data: autocompleteData, isFetching: isAutocompleteFetching} =
+    useActorAutocompleteQuery(searchText, true)
 
-  /**
-   * If `true`, this means we received new instructions from the router. This
-   * is handled in a effect, and used to update the value of `query` locally
-   * within this screen.
-   */
-  const routeParamsMismatch = queryParam && queryParam !== query
+  const [showAutocomplete, setShowAutocomplete] = React.useState(false)
+  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
 
-  React.useEffect(() => {
-    if (queryParam && routeParamsMismatch) {
-      // reset immediately and let local state take over
-      navigation.setParams({q: ''})
-      // update query for next search
-      setQuery(queryParam)
-    }
-  }, [queryParam, routeParamsMismatch, navigation])
+  useFocusEffect(
+    useNonReactiveCallback(() => {
+      if (isWeb) {
+        setSearchText(queryParam)
+      }
+    }),
+  )
 
   React.useEffect(() => {
     const loadSearchHistory = async () => {
@@ -536,60 +519,45 @@ export function SearchScreen(
     setDrawerOpen(true)
   }, [track, setDrawerOpen])
 
-  const onPressCancelSearch = React.useCallback(() => {
-    scrollToTopWeb()
-    textInput.current?.blur()
-    setQuery('')
-    setShowAutocompleteResults(false)
-    if (searchDebounceTimeout.current)
-      clearTimeout(searchDebounceTimeout.current)
-  }, [textInput])
-
   const onPressClearQuery = React.useCallback(() => {
     scrollToTopWeb()
-    setQuery('')
-    setShowAutocompleteResults(false)
-  }, [setQuery])
-
-  const onChangeText = React.useCallback(
-    async (text: string) => {
-      scrollToTopWeb()
-
-      setQuery(text)
-
-      if (text.length > 0) {
-        setIsFetching(true)
-        setShowAutocompleteResults(true)
-
-        if (searchDebounceTimeout.current) {
-          clearTimeout(searchDebounceTimeout.current)
-        }
+    setSearchText('')
+    textInput.current?.focus()
+  }, [])
 
-        searchDebounceTimeout.current = setTimeout(async () => {
-          const results = await search({query: text, limit: 30})
+  const onPressCancelSearch = React.useCallback(() => {
+    scrollToTopWeb()
 
-          if (results) {
-            setSearchResults(results)
-            setIsFetching(false)
-          }
-        }, 300)
+    if (showAutocomplete) {
+      textInput.current?.blur()
+      setShowAutocomplete(false)
+      setSearchText(queryParam)
+    } else {
+      // If we just `setParams` and set `q` to an empty string, the URL still displays `q=`, which isn't pretty.
+      // However, `.replace()` on native has a "push" animation that we don't want. So we need to handle these
+      // differently.
+      if (isWeb) {
+        navigation.replace('Search', {})
       } else {
-        if (searchDebounceTimeout.current) {
-          clearTimeout(searchDebounceTimeout.current)
-        }
-        setSearchResults([])
-        setIsFetching(false)
-        setShowAutocompleteResults(false)
+        setSearchText('')
+        navigation.setParams({q: ''})
       }
-    },
-    [setQuery, search, setSearchResults],
-  )
+    }
+  }, [showAutocomplete, navigation, queryParam])
+
+  const onChangeText = React.useCallback(async (text: string) => {
+    scrollToTopWeb()
+    setSearchText(text)
+  }, [])
 
   const updateSearchHistory = React.useCallback(
     async (newQuery: string) => {
       newQuery = newQuery.trim()
-      if (newQuery && !searchHistory.includes(newQuery)) {
-        let newHistory = [newQuery, ...searchHistory]
+      if (newQuery) {
+        let newHistory = [
+          newQuery,
+          ...searchHistory.filter(q => q !== newQuery),
+        ]
 
         if (newHistory.length > 5) {
           newHistory = newHistory.slice(0, 5)
@@ -609,11 +577,30 @@ export function SearchScreen(
     [searchHistory, setSearchHistory],
   )
 
+  const navigateToItem = React.useCallback(
+    (item: string) => {
+      scrollToTopWeb()
+      setShowAutocomplete(false)
+      updateSearchHistory(item)
+
+      if (isWeb) {
+        navigation.push('Search', {q: item})
+      } else {
+        textInput.current?.blur()
+        navigation.setParams({q: item})
+      }
+    },
+    [updateSearchHistory, navigation],
+  )
+
   const onSubmit = React.useCallback(() => {
-    scrollToTopWeb()
-    setShowAutocompleteResults(false)
-    updateSearchHistory(query)
-  }, [query, setShowAutocompleteResults, updateSearchHistory])
+    navigateToItem(searchText)
+  }, [navigateToItem, searchText])
+
+  const handleHistoryItemClick = (item: string) => {
+    setSearchText(item)
+    navigateToItem(item)
+  }
 
   const onSoftReset = React.useCallback(() => {
     scrollToTopWeb()
@@ -621,9 +608,9 @@ export function SearchScreen(
   }, [onPressCancelSearch])
 
   const queryMaybeHandle = React.useMemo(() => {
-    const match = MATCH_HANDLE.exec(query)
+    const match = MATCH_HANDLE.exec(queryParam)
     return match && match[1]
-  }, [query])
+  }, [queryParam])
 
   useFocusEffect(
     React.useCallback(() => {
@@ -632,11 +619,6 @@ export function SearchScreen(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
-  const handleHistoryItemClick = (item: React.SetStateAction<string>) => {
-    setQuery(item)
-    onSubmit()
-  }
-
   const handleRemoveHistoryItem = (itemToRemove: string) => {
     const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
     setSearchHistory(updatedHistory)
@@ -688,17 +670,21 @@ export function SearchScreen(
             ref={textInput}
             placeholder={_(msg`Search`)}
             placeholderTextColor={pal.colors.textLight}
-            selectTextOnFocus
+            selectTextOnFocus={isNative}
             returnKeyType="search"
-            value={query}
+            value={searchText}
             style={[pal.text, styles.headerSearchInput]}
             keyboardAppearance={theme.colorScheme}
-            onFocus={() => setInputIsFocused(true)}
-            onBlur={() => {
-              // HACK
-              // give 100ms to not stop click handlers in the search history
-              // -prf
-              setTimeout(() => setInputIsFocused(false), 100)
+            onFocus={() => {
+              if (isWeb) {
+                // Prevent a jump on iPad by ensuring that
+                // the initial focused render has no result list.
+                requestAnimationFrame(() => {
+                  setShowAutocomplete(true)
+                })
+              } else {
+                setShowAutocomplete(true)
+              }
             }}
             onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
@@ -710,7 +696,7 @@ export function SearchScreen(
             autoComplete="off"
             autoCapitalize="none"
           />
-          {query ? (
+          {showAutocomplete ? (
             <Pressable
               testID="searchTextInputClearBtn"
               onPress={onPressClearQuery}
@@ -727,7 +713,7 @@ export function SearchScreen(
           ) : undefined}
         </View>
 
-        {query || inputIsFocused ? (
+        {(queryParam || showAutocomplete) && (
           <View style={styles.headerCancelBtn}>
             <Pressable
               onPress={onPressCancelSearch}
@@ -738,12 +724,13 @@ export function SearchScreen(
               </Text>
             </Pressable>
           </View>
-        ) : undefined}
+        )}
       </CenteredView>
 
-      {showAutocompleteResults ? (
+      {showAutocomplete && searchText.length > 0 ? (
         <>
-          {isFetching || !moderationOpts ? (
+          {(isAutocompleteFetching && !autocompleteData?.length) ||
+          !moderationOpts ? (
             <Loader />
           ) : (
             <ScrollView
@@ -753,12 +740,12 @@ export function SearchScreen(
               keyboardShouldPersistTaps="handled"
               keyboardDismissMode="on-drag">
               <SearchLinkCard
-                label={_(msg`Search for "${query}"`)}
+                label={_(msg`Search for "${searchText}"`)}
                 onPress={isNative ? onSubmit : undefined}
                 to={
                   isNative
                     ? undefined
-                    : `/search?q=${encodeURIComponent(query)}`
+                    : `/search?q=${encodeURIComponent(searchText)}`
                 }
                 style={{borderBottomWidth: 1}}
               />
@@ -770,11 +757,18 @@ export function SearchScreen(
                 />
               ) : null}
 
-              {searchResults.map(item => (
+              {autocompleteData?.map(item => (
                 <SearchProfileCard
                   key={item.did}
                   profile={item}
                   moderation={moderateProfile(item, moderationOpts)}
+                  onPress={() => {
+                    if (isWeb) {
+                      setShowAutocomplete(false)
+                    } else {
+                      textInput.current?.blur()
+                    }
+                  }}
                 />
               ))}
 
@@ -782,7 +776,7 @@ export function SearchScreen(
             </ScrollView>
           )}
         </>
-      ) : !query && inputIsFocused ? (
+      ) : !queryParam && showAutocomplete ? (
         <CenteredView
           sideBorders={isTabletOrDesktop}
           // @ts-ignore web only -prf
@@ -826,10 +820,8 @@ export function SearchScreen(
             )}
           </View>
         </CenteredView>
-      ) : routeParamsMismatch ? (
-        <ActivityIndicator />
       ) : (
-        <SearchScreenInner query={query} />
+        <SearchScreenInner query={queryParam} />
       )}
     </View>
   )
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 0c5bd452f..52f28cc63 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -1,33 +1,35 @@
 import React from 'react'
 import {
-  ViewStyle,
-  TextInput,
-  View,
+  ActivityIndicator,
   StyleSheet,
+  TextInput,
   TouchableOpacity,
-  ActivityIndicator,
+  View,
+  ViewStyle,
 } from 'react-native'
-import {useNavigation, StackActions} from '@react-navigation/native'
 import {
   AppBskyActorDefs,
   moderateProfile,
   ModerationDecision,
 } from '@atproto/api'
-import {Trans, msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {StackActions, useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
 
-import {s} from '#/lib/styles'
+import {makeProfileLink} from '#/lib/routes/links'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
-import {makeProfileLink} from '#/lib/routes/links'
-import {Link} from '#/view/com/util/Link'
+import {s} from '#/lib/styles'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
+import {useModerationOpts} from '#/state/queries/preferences'
 import {usePalette} from 'lib/hooks/usePalette'
 import {MagnifyingGlassIcon2} from 'lib/icons'
 import {NavigationProp} from 'lib/routes/types'
-import {Text} from 'view/com/util/text/Text'
+import {precacheProfile} from 'state/queries/profile'
+import {Link} from '#/view/com/util/Link'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {Text} from 'view/com/util/text/Text'
 
 export const MATCH_HANDLE =
   /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
@@ -84,11 +86,19 @@ export function SearchLinkCard({
 export function SearchProfileCard({
   profile,
   moderation,
+  onPress: onPressInner,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
   moderation: ModerationDecision
+  onPress: () => void
 }) {
   const pal = usePalette('default')
+  const queryClient = useQueryClient()
+
+  const onPress = React.useCallback(() => {
+    precacheProfile(queryClient, profile)
+    onPressInner()
+  }, [queryClient, profile, onPressInner])
 
   return (
     <Link
@@ -96,7 +106,8 @@ export function SearchProfileCard({
       href={makeProfileLink(profile)}
       title={profile.handle}
       asAnchor
-      anchorNoUnderline>
+      anchorNoUnderline
+      onBeforePress={onPress}>
       <View
         style={[
           pal.border,
@@ -138,63 +149,35 @@ export function DesktopSearch() {
   const {_} = useLingui()
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
-  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
-    undefined,
-  )
   const [isActive, setIsActive] = React.useState<boolean>(false)
-  const [isFetching, setIsFetching] = React.useState<boolean>(false)
   const [query, setQuery] = React.useState<string>('')
-  const [searchResults, setSearchResults] = React.useState<
-    AppBskyActorDefs.ProfileViewBasic[]
-  >([])
+  const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
+    query,
+    true,
+  )
 
   const moderationOpts = useModerationOpts()
-  const search = useActorAutocompleteFn()
-
-  const onChangeText = React.useCallback(
-    async (text: string) => {
-      setQuery(text)
-
-      if (text.length > 0) {
-        setIsFetching(true)
-        setIsActive(true)
 
-        if (searchDebounceTimeout.current)
-          clearTimeout(searchDebounceTimeout.current)
-
-        searchDebounceTimeout.current = setTimeout(async () => {
-          const results = await search({query: text})
-
-          if (results) {
-            setSearchResults(results)
-            setIsFetching(false)
-          }
-        }, 300)
-      } else {
-        if (searchDebounceTimeout.current)
-          clearTimeout(searchDebounceTimeout.current)
-        setSearchResults([])
-        setIsFetching(false)
-        setIsActive(false)
-      }
-    },
-    [setQuery, search, setSearchResults],
-  )
+  const onChangeText = React.useCallback((text: string) => {
+    setQuery(text)
+    setIsActive(text.length > 0)
+  }, [])
 
   const onPressCancelSearch = React.useCallback(() => {
     setQuery('')
     setIsActive(false)
-    if (searchDebounceTimeout.current)
-      clearTimeout(searchDebounceTimeout.current)
   }, [setQuery])
+
   const onSubmit = React.useCallback(() => {
     setIsActive(false)
     if (!query.length) return
-    setSearchResults([])
-    if (searchDebounceTimeout.current)
-      clearTimeout(searchDebounceTimeout.current)
     navigation.dispatch(StackActions.push('Search', {q: query}))
-  }, [query, navigation, setSearchResults])
+  }, [query, navigation])
+
+  const onSearchProfileCardPress = React.useCallback(() => {
+    setQuery('')
+    setIsActive(false)
+  }, [])
 
   const queryMaybeHandle = React.useMemo(() => {
     const match = MATCH_HANDLE.exec(query)
@@ -246,7 +229,7 @@ export function DesktopSearch() {
 
       {query !== '' && isActive && moderationOpts && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {isFetching ? (
+          {isFetching && !autocompleteData?.length ? (
             <View style={{padding: 8}}>
               <ActivityIndicator />
             </View>
@@ -255,7 +238,11 @@ export function DesktopSearch() {
               <SearchLinkCard
                 label={_(msg`Search for "${query}"`)}
                 to={`/search?q=${encodeURIComponent(query)}`}
-                style={{borderBottomWidth: 1}}
+                style={
+                  queryMaybeHandle || (autocompleteData?.length ?? 0) > 0
+                    ? {borderBottomWidth: 1}
+                    : undefined
+                }
               />
 
               {queryMaybeHandle ? (
@@ -265,11 +252,12 @@ export function DesktopSearch() {
                 />
               ) : null}
 
-              {searchResults.map(item => (
+              {autocompleteData?.map(item => (
                 <SearchProfileCard
                   key={item.did}
                   profile={item}
                   moderation={moderateProfile(item, moderationOpts)}
+                  onPress={onSearchProfileCardPress}
                 />
               ))}
             </>