about summary refs log tree commit diff
path: root/src/view/shell/desktop/Search.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/shell/desktop/Search.tsx')
-rw-r--r--src/view/shell/desktop/Search.tsx209
1 files changed, 153 insertions, 56 deletions
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index caecea4a8..f899431b6 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -1,56 +1,150 @@
 import React from 'react'
-import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native'
+import {
+  ViewStyle,
+  TextInput,
+  View,
+  StyleSheet,
+  TouchableOpacity,
+  ActivityIndicator,
+} from 'react-native'
 import {useNavigation, StackActions} from '@react-navigation/native'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ProfileModeration,
+} from '@atproto/api'
+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 {UserAvatar} from '#/view/com/util/UserAvatar'
+import {useActorAutocompleteFn} 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')
-  const textInput = React.useRef<TextInput>(null)
-  const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
-  const [query, setQuery] = React.useState<string>('')
-  const autocompleteView = React.useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
+
+  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 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[]
+  >([])
 
-  // initial setup
-  React.useEffect(() => {
-    if (store.me.did) {
-      autocompleteView.setup()
-    }
-  }, [autocompleteView, store.me.did])
+  const moderationOpts = useModerationOpts()
+  const search = useActorAutocompleteFn()
 
-  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 (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 {
-        autocompleteView.setActive(false)
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
+        setIsFetching(false)
+        setIsActive(false)
       }
     },
-    [setQuery, autocompleteView, isInputFocused],
+    [setQuery, search, setSearchResults],
   )
 
   const onPressCancelSearch = React.useCallback(() => {
     setQuery('')
-    autocompleteView.setActive(false)
-  }, [setQuery, autocompleteView])
-
+    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}))
-    autocompleteView.setActive(false)
-  }, [query, navigation, autocompleteView])
+  }, [query, navigation, setSearchResults])
 
   return (
     <View style={[styles.container, pal.view]}>
@@ -63,19 +157,16 @@ export const DesktopSearch = observer(function DesktopSearch() {
           />
           <TextInput
             testID="searchTextInput"
-            ref={textInput}
-            placeholder="Search"
+            placeholder={_(msg`Search`)}
             placeholderTextColor={pal.colors.textLight}
             selectTextOnFocus
             returnKeyType="search"
             value={query}
             style={[pal.textLight, styles.input]}
-            onFocus={() => setIsInputFocused(true)}
-            onBlur={() => setIsInputFocused(false)}
-            onChangeText={onChangeQuery}
+            onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             accessibilityRole="search"
-            accessibilityLabel="Search"
+            accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
           />
           {query ? (
@@ -83,11 +174,11 @@ export const DesktopSearch = observer(function DesktopSearch() {
               <TouchableOpacity
                 onPress={onPressCancelSearch}
                 accessibilityRole="button"
-                accessibilityLabel="Cancel search"
+                accessibilityLabel={_(msg`Cancel search`)}
                 accessibilityHint="Exits inputting search query"
                 onAccessibilityEscape={onPressCancelSearch}>
                 <Text type="lg" style={[pal.link]}>
-                  Cancel
+                  <Trans>Cancel</Trans>
                 </Text>
               </TouchableOpacity>
             </View>
@@ -95,32 +186,42 @@ export const DesktopSearch = observer(function DesktopSearch() {
         </View>
       </View>
 
-      {query !== '' && (
+      {query !== '' && isActive && moderationOpts && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {autocompleteView.suggestions.length ? (
+          {isFetching ? (
+            <View style={{padding: 8}}>
+              <ActivityIndicator />
+            </View>
+          ) : (
             <>
-              {autocompleteView.suggestions.map((item, i) => (
-                <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
-              ))}
+              {searchResults.length ? (
+                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 {query}</Trans>
+                  </Text>
+                </View>
+              )}
             </>
-          ) : (
-            <View>
-              <Text style={[pal.textLight, styles.noResults]}>
-                No results found for {autocompleteView.prefix}
-              </Text>
-            </View>
           )}
         </View>
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
     position: 'relative',
     width: 300,
-    paddingBottom: 18,
   },
   search: {
     paddingHorizontal: 16,
@@ -150,15 +251,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',