about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/actor-autocomplete.ts62
-rw-r--r--src/view/shell/desktop/Search.tsx163
2 files changed, 178 insertions, 47 deletions
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 62c4781c4..1bfa13f81 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -1,8 +1,12 @@
+import React from 'react'
 import {AppBskyActorDefs, BskyAgent} from '@atproto/api'
-import {useQuery} from '@tanstack/react-query'
-import {useSession} from '../session'
-import {useMyFollowsQuery} from './my-follows'
+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'
+import {useMyFollowsQuery} from '#/state/queries/my-follows'
 
 export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
 
@@ -22,6 +26,58 @@ export function useActorAutocompleteQuery(prefix: string) {
   })
 }
 
+export function useActorSearch() {
+  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)
+            }
+          }
+        }
+      } catch (e) {
+        logger.error('useActorSearch: searchActorsTypeahead failed', {error: e})
+      }
+
+      return searchResults
+    },
+    [agent, followsSearch, queryClient],
+  )
+}
+
 export class ActorAutocomplete {
   // state
   isLoading = false
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index f54858b8a..115e0f7ae 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -1,59 +1,136 @@
 import React from 'react'
-import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native'
+import {
+  ViewStyle,
+  TextInput,
+  View,
+  StyleSheet,
+  TouchableOpacity,
+} from 'react-native'
 import {useNavigation, StackActions} from '@react-navigation/native'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ProfileModeration,
+} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {s} from '#/lib/styles'
+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 {usePalette} from 'lib/hooks/usePalette'
 import {MagnifyingGlassIcon2} from 'lib/icons'
 import {NavigationProp} from 'lib/routes/types'
-import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {Text} from 'view/com/util/text/Text'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {useActorSearch} from '#/state/queries/actor-autocomplete'
+import {useModerationOpts} from '#/state/queries/preferences'
 
-export const DesktopSearch = observer(function DesktopSearch() {
-  const store = useStores()
+export function SearchResultCard({
+  profile,
+  style,
+  moderation,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  style: ViewStyle
+  moderation: ProfileModeration
+}) {
   const pal = usePalette('default')
+
+  return (
+    <Link
+      href={makeProfileLink(profile)}
+      title={profile.handle}
+      asAnchor
+      anchorNoUnderline>
+      <View
+        style={[
+          pal.border,
+          style,
+          {
+            borderTopWidth: 1,
+            flexDirection: 'row',
+            alignItems: 'center',
+            gap: 12,
+            paddingVertical: 8,
+            paddingHorizontal: 12,
+          },
+        ]}>
+        <UserAvatar
+          size={40}
+          avatar={profile.avatar}
+          moderation={moderation.avatar}
+        />
+        <View style={{flex: 1}}>
+          <Text
+            type="lg"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
+            )}
+          </Text>
+          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+            {sanitizeHandle(profile.handle, '@')}
+          </Text>
+        </View>
+      </View>
+    </Link>
+  )
+}
+
+export const DesktopSearch = observer(function DesktopSearch() {
   const {_} = useLingui()
-  const textInput = React.useRef<TextInput>(null)
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
+    undefined,
+  )
   const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
   const [query, setQuery] = React.useState<string>('')
-  const autocompleteView = React.useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
-  const navigation = useNavigation<NavigationProp>()
+  const [searchResults, setSearchResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
 
-  // initial setup
-  React.useEffect(() => {
-    if (store.me.did) {
-      autocompleteView.setup()
-    }
-  }, [autocompleteView, store.me.did])
+  const moderationOpts = useModerationOpts()
+  const search = useActorSearch()
 
-  const onChangeQuery = React.useCallback(
-    (text: string) => {
+  const onChangeText = React.useCallback(
+    async (text: string) => {
       setQuery(text)
+
       if (text.length > 0 && isInputFocused) {
-        autocompleteView.setActive(true)
-        autocompleteView.setPrefix(text)
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+
+        searchDebounceTimeout.current = setTimeout(async () => {
+          const results = await search({query: text})
+
+          if (results) {
+            setSearchResults(results)
+          }
+        }, 300)
       } else {
-        autocompleteView.setActive(false)
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
       }
     },
-    [setQuery, autocompleteView, isInputFocused],
+    [setQuery, isInputFocused, search, setSearchResults],
   )
 
   const onPressCancelSearch = React.useCallback(() => {
-    setQuery('')
-    autocompleteView.setActive(false)
-  }, [setQuery, autocompleteView])
+    onChangeText('')
+  }, [onChangeText])
 
   const onSubmit = React.useCallback(() => {
     navigation.dispatch(StackActions.push('Search', {q: query}))
-    autocompleteView.setActive(false)
-  }, [query, navigation, autocompleteView])
+  }, [query, navigation])
 
   return (
     <View style={[styles.container, pal.view]}>
@@ -66,7 +143,6 @@ export const DesktopSearch = observer(function DesktopSearch() {
           />
           <TextInput
             testID="searchTextInput"
-            ref={textInput}
             placeholder="Search"
             placeholderTextColor={pal.colors.textLight}
             selectTextOnFocus
@@ -75,7 +151,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
             style={[pal.textLight, styles.input]}
             onFocus={() => setIsInputFocused(true)}
             onBlur={() => setIsInputFocused(false)}
-            onChangeText={onChangeQuery}
+            onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             accessibilityRole="search"
             accessibilityLabel={_(msg`Search`)}
@@ -100,16 +176,19 @@ export const DesktopSearch = observer(function DesktopSearch() {
 
       {query !== '' && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {autocompleteView.suggestions.length ? (
-            <>
-              {autocompleteView.suggestions.map((item, i) => (
-                <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
-              ))}
-            </>
+          {searchResults.length && moderationOpts ? (
+            searchResults.map((item, i) => (
+              <SearchResultCard
+                key={item.did}
+                profile={item}
+                moderation={moderateProfile(item, moderationOpts)}
+                style={i === 0 ? {borderTopWidth: 0} : {}}
+              />
+            ))
           ) : (
             <View>
               <Text style={[pal.textLight, styles.noResults]}>
-                <Trans>No results found for {autocompleteView.prefix}</Trans>
+                <Trans>No results found for {query}</Trans>
               </Text>
             </View>
           )}
@@ -153,15 +232,11 @@ const styles = StyleSheet.create({
     paddingVertical: 7,
   },
   resultsContainer: {
-    // @ts-ignore supported by web
-    // position: 'fixed',
     marginTop: 10,
-
     flexDirection: 'column',
     width: 300,
     borderWidth: 1,
     borderRadius: 6,
-    paddingVertical: 4,
   },
   noResults: {
     textAlign: 'center',