about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/view/screens/Search/Search.tsx483
-rw-r--r--src/view/shell/desktop/Search.tsx30
2 files changed, 293 insertions, 220 deletions
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 1524c2446..2335549af 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
 import {List} from '#/view/com/util/List'
 import {Text} from '#/view/com/util/text/Text'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
-import {
-  MATCH_HANDLE,
-  SearchLinkCard,
-  SearchProfileCard,
-} from '#/view/shell/desktop/Search'
+import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
 import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {atoms as a} from '#/alf'
 
@@ -156,7 +152,7 @@ function useSuggestedFollows(): [
   return [items, onEndReached]
 }
 
-function SearchScreenSuggestedFollows() {
+let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
   const [suggestions, onEndReached] = useSuggestedFollows()
 
@@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() {
     </CenteredView>
   )
 }
+SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
 
 type SearchResultSlice =
   | {
@@ -192,7 +189,7 @@ type SearchResultSlice =
       key: string
     }
 
-function SearchScreenPostResults({
+let SearchScreenPostResults = ({
   query,
   sort,
   active,
@@ -200,7 +197,7 @@ function SearchScreenPostResults({
   query: string
   sort?: 'top' | 'latest'
   active: boolean
-}) {
+}): React.ReactNode => {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const [isPTR, setIsPTR] = React.useState(false)
@@ -298,14 +295,15 @@ function SearchScreenPostResults({
     </>
   )
 }
+SearchScreenPostResults = React.memo(SearchScreenPostResults)
 
-function SearchScreenUserResults({
+let SearchScreenUserResults = ({
   query,
   active,
 }: {
   query: string
   active: boolean
-}) {
+}): React.ReactNode => {
   const {_} = useLingui()
 
   const {data: results, isFetched} = useActorSearch({
@@ -334,8 +332,9 @@ function SearchScreenUserResults({
     <Loader />
   )
 }
+SearchScreenUserResults = React.memo(SearchScreenUserResults)
 
-export function SearchScreenInner({query}: {query?: string}) {
+let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) {
     </CenteredView>
   )
 }
+SearchScreenInner = React.memo(SearchScreenInner)
 
 export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
   const navigation = useNavigation<NavigationProp>()
-  const theme = useTheme()
   const textInput = React.useRef<TextInput>(null)
   const {_} = useLingui()
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const setDrawerOpen = useSetDrawerOpen()
-  const moderationOpts = useModerationOpts()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
 
@@ -584,21 +582,27 @@ export function SearchScreen(
     navigateToItem(searchText)
   }, [navigateToItem, searchText])
 
-  const handleHistoryItemClick = (item: string) => {
-    setSearchText(item)
-    navigateToItem(item)
-  }
+  const onAutocompleteResultPress = React.useCallback(() => {
+    if (isWeb) {
+      setShowAutocomplete(false)
+    } else {
+      textInput.current?.blur()
+    }
+  }, [])
+
+  const handleHistoryItemClick = React.useCallback(
+    (item: string) => {
+      setSearchText(item)
+      navigateToItem(item)
+    },
+    [navigateToItem],
+  )
 
   const onSoftReset = React.useCallback(() => {
     scrollToTopWeb()
     onPressCancelSearch()
   }, [onPressCancelSearch])
 
-  const queryMaybeHandle = React.useMemo(() => {
-    const match = MATCH_HANDLE.exec(queryParam)
-    return match && match[1]
-  }, [queryParam])
-
   useFocusEffect(
     React.useCallback(() => {
       setMinimalShellMode(false)
@@ -606,15 +610,19 @@ export function SearchScreen(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
-  const handleRemoveHistoryItem = (itemToRemove: string) => {
-    const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
-    setSearchHistory(updatedHistory)
-    AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch(
-      e => {
+  const handleRemoveHistoryItem = React.useCallback(
+    (itemToRemove: string) => {
+      const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
+      setSearchHistory(updatedHistory)
+      AsyncStorage.setItem(
+        'searchHistory',
+        JSON.stringify(updatedHistory),
+      ).catch(e => {
         logger.error('Failed to update search history', {message: e})
-      },
-    )
-  }
+      })
+    },
+    [searchHistory],
+  )
 
   return (
     <View style={isWeb ? null : {flex: 1}}>
@@ -642,81 +650,15 @@ export function SearchScreen(
             />
           </Pressable>
         )}
-
-        <Pressable
-          // This only exists only for extra hitslop so don't expose it to the a11y tree.
-          accessible={false}
-          focusable={false}
-          // @ts-ignore web-only
-          tabIndex={-1}
-          style={[
-            {backgroundColor: pal.colors.backgroundLight},
-            styles.headerSearchContainer,
-            isWeb && {
-              // @ts-ignore web only
-              cursor: 'default',
-            },
-          ]}
-          onPress={() => {
-            textInput.current?.focus()
-          }}>
-          <MagnifyingGlassIcon
-            style={[pal.icon, styles.headerSearchIcon]}
-            size={21}
-          />
-          <TextInput
-            testID="searchTextInput"
-            ref={textInput}
-            placeholder={_(msg`Search`)}
-            placeholderTextColor={pal.colors.textLight}
-            returnKeyType="search"
-            value={searchText}
-            style={[pal.text, styles.headerSearchInput]}
-            keyboardAppearance={theme.colorScheme}
-            selectTextOnFocus={isNative}
-            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)
-                if (isIOS) {
-                  // We rely on selectTextOnFocus, but it's broken on iOS:
-                  // https://github.com/facebook/react-native/issues/41988
-                  textInput.current?.setSelection(0, searchText.length)
-                  // We still rely on selectTextOnFocus for it to be instant on Android.
-                }
-              }
-            }}
-            onChangeText={onChangeText}
-            onSubmitEditing={onSubmit}
-            autoFocus={false}
-            accessibilityRole="search"
-            accessibilityLabel={_(msg`Search`)}
-            accessibilityHint=""
-            autoCorrect={false}
-            autoComplete="off"
-            autoCapitalize="none"
-          />
-          {showAutocomplete && searchText.length > 0 && (
-            <Pressable
-              testID="searchTextInputClearBtn"
-              onPress={onPressClearQuery}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Clear search query`)}
-              accessibilityHint=""
-              hitSlop={HITSLOP_10}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            </Pressable>
-          )}
-        </Pressable>
+        <SearchInputBox
+          textInput={textInput}
+          searchText={searchText}
+          showAutocomplete={showAutocomplete}
+          setShowAutocomplete={setShowAutocomplete}
+          onChangeText={onChangeText}
+          onSubmit={onSubmit}
+          onPressClearQuery={onPressClearQuery}
+        />
         {showAutocomplete && (
           <View style={[styles.headerCancelBtn]}>
             <Pressable
@@ -730,104 +672,247 @@ export function SearchScreen(
           </View>
         )}
       </CenteredView>
+      <View
+        style={{
+          display: showAutocomplete ? 'flex' : 'none',
+          flex: 1,
+        }}>
+        {searchText.length > 0 ? (
+          <AutocompleteResults
+            isAutocompleteFetching={isAutocompleteFetching}
+            autocompleteData={autocompleteData}
+            searchText={searchText}
+            onSubmit={onSubmit}
+            onResultPress={onAutocompleteResultPress}
+          />
+        ) : (
+          <SearchHistory
+            searchHistory={searchHistory}
+            onItemClick={handleHistoryItemClick}
+            onRemoveItemClick={handleRemoveHistoryItem}
+          />
+        )}
+      </View>
+      <View
+        style={{
+          display: showAutocomplete ? 'none' : 'flex',
+          flex: 1,
+        }}>
+        <SearchScreenInner query={queryParam} />
+      </View>
+    </View>
+  )
+}
 
-      {showAutocomplete && searchText.length > 0 ? (
-        <>
-          {(isAutocompleteFetching && !autocompleteData?.length) ||
-          !moderationOpts ? (
-            <Loader />
-          ) : (
-            <ScrollView
-              style={{height: '100%'}}
-              // @ts-ignore web only -prf
-              dataSet={{stableGutters: '1'}}
-              keyboardShouldPersistTaps="handled"
-              keyboardDismissMode="on-drag">
-              <SearchLinkCard
-                label={_(msg`Search for "${searchText}"`)}
-                onPress={isNative ? onSubmit : undefined}
-                to={
-                  isNative
-                    ? undefined
-                    : `/search?q=${encodeURIComponent(searchText)}`
-                }
-                style={{borderBottomWidth: 1}}
-              />
-
-              {queryMaybeHandle ? (
-                <SearchLinkCard
-                  label={_(msg`Go to @${queryMaybeHandle}`)}
-                  to={`/profile/${queryMaybeHandle}`}
-                />
-              ) : null}
-
-              {autocompleteData?.map(item => (
-                <SearchProfileCard
-                  key={item.did}
-                  profile={item}
-                  moderation={moderateProfile(item, moderationOpts)}
-                  onPress={() => {
-                    if (isWeb) {
-                      setShowAutocomplete(false)
-                    } else {
-                      textInput.current?.blur()
-                    }
-                  }}
-                />
-              ))}
-
-              <View style={{height: 200}} />
-            </ScrollView>
-          )}
-        </>
-      ) : !queryParam && showAutocomplete ? (
-        <CenteredView
-          sideBorders={isTabletOrDesktop}
+let SearchInputBox = ({
+  textInput,
+  searchText,
+  showAutocomplete,
+  setShowAutocomplete,
+  onChangeText,
+  onSubmit,
+  onPressClearQuery,
+}: {
+  textInput: React.RefObject<TextInput>
+  searchText: string
+  showAutocomplete: boolean
+  setShowAutocomplete: (show: boolean) => void
+  onChangeText: (text: string) => void
+  onSubmit: () => void
+  onPressClearQuery: () => void
+}): React.ReactNode => {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const theme = useTheme()
+  return (
+    <Pressable
+      // This only exists only for extra hitslop so don't expose it to the a11y tree.
+      accessible={false}
+      focusable={false}
+      // @ts-ignore web-only
+      tabIndex={-1}
+      style={[
+        {backgroundColor: pal.colors.backgroundLight},
+        styles.headerSearchContainer,
+        isWeb && {
+          // @ts-ignore web only
+          cursor: 'default',
+        },
+      ]}
+      onPress={() => {
+        textInput.current?.focus()
+      }}>
+      <MagnifyingGlassIcon
+        style={[pal.icon, styles.headerSearchIcon]}
+        size={21}
+      />
+      <TextInput
+        testID="searchTextInput"
+        ref={textInput}
+        placeholder={_(msg`Search`)}
+        placeholderTextColor={pal.colors.textLight}
+        returnKeyType="search"
+        value={searchText}
+        style={[pal.text, styles.headerSearchInput]}
+        keyboardAppearance={theme.colorScheme}
+        selectTextOnFocus={isNative}
+        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)
+            if (isIOS) {
+              // We rely on selectTextOnFocus, but it's broken on iOS:
+              // https://github.com/facebook/react-native/issues/41988
+              textInput.current?.setSelection(0, searchText.length)
+              // We still rely on selectTextOnFocus for it to be instant on Android.
+            }
+          }
+        }}
+        onChangeText={onChangeText}
+        onSubmitEditing={onSubmit}
+        autoFocus={false}
+        accessibilityRole="search"
+        accessibilityLabel={_(msg`Search`)}
+        accessibilityHint=""
+        autoCorrect={false}
+        autoComplete="off"
+        autoCapitalize="none"
+      />
+      {showAutocomplete && searchText.length > 0 && (
+        <Pressable
+          testID="searchTextInputClearBtn"
+          onPress={onPressClearQuery}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Clear search query`)}
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}>
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={pal.textLight as FontAwesomeIconStyle}
+          />
+        </Pressable>
+      )}
+    </Pressable>
+  )
+}
+SearchInputBox = React.memo(SearchInputBox)
+
+let AutocompleteResults = ({
+  isAutocompleteFetching,
+  autocompleteData,
+  searchText,
+  onSubmit,
+  onResultPress,
+}: {
+  isAutocompleteFetching: boolean
+  autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
+  searchText: string
+  onSubmit: () => void
+  onResultPress: () => void
+}): React.ReactNode => {
+  const moderationOpts = useModerationOpts()
+  const {_} = useLingui()
+  return (
+    <>
+      {(isAutocompleteFetching && !autocompleteData?.length) ||
+      !moderationOpts ? (
+        <Loader />
+      ) : (
+        <ScrollView
+          style={{height: '100%'}}
           // @ts-ignore web only -prf
-          style={{
-            height: isWeb ? '100vh' : undefined,
-          }}>
-          <View style={styles.searchHistoryContainer}>
-            {searchHistory.length > 0 && (
-              <View style={styles.searchHistoryContent}>
-                <Text style={[pal.text, styles.searchHistoryTitle]}>
-                  <Trans>Recent Searches</Trans>
-                </Text>
-                {searchHistory.map((historyItem, index) => (
-                  <View
-                    key={index}
-                    style={[
-                      a.flex_row,
-                      a.mt_md,
-                      a.justify_center,
-                      a.justify_between,
-                    ]}>
-                    <Pressable
-                      accessibilityRole="button"
-                      onPress={() => handleHistoryItemClick(historyItem)}
-                      style={[a.flex_1, a.py_sm]}>
-                      <Text style={pal.text}>{historyItem}</Text>
-                    </Pressable>
-                    <Pressable
-                      accessibilityRole="button"
-                      onPress={() => handleRemoveHistoryItem(historyItem)}
-                      style={[a.px_md, a.py_xs, a.justify_center]}>
-                      <FontAwesomeIcon
-                        icon="xmark"
-                        size={16}
-                        style={pal.textLight as FontAwesomeIconStyle}
-                      />
-                    </Pressable>
-                  </View>
-                ))}
+          dataSet={{stableGutters: '1'}}
+          keyboardShouldPersistTaps="handled"
+          keyboardDismissMode="on-drag">
+          <SearchLinkCard
+            label={_(msg`Search for "${searchText}"`)}
+            onPress={isNative ? onSubmit : undefined}
+            to={
+              isNative
+                ? undefined
+                : `/search?q=${encodeURIComponent(searchText)}`
+            }
+            style={{borderBottomWidth: 1}}
+          />
+          {autocompleteData?.map(item => (
+            <SearchProfileCard
+              key={item.did}
+              profile={item}
+              moderation={moderateProfile(item, moderationOpts)}
+              onPress={onResultPress}
+            />
+          ))}
+          <View style={{height: 200}} />
+        </ScrollView>
+      )}
+    </>
+  )
+}
+AutocompleteResults = React.memo(AutocompleteResults)
+
+function SearchHistory({
+  searchHistory,
+  onItemClick,
+  onRemoveItemClick,
+}: {
+  searchHistory: string[]
+  onItemClick: (item: string) => void
+  onRemoveItemClick: (item: string) => void
+}) {
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const pal = usePalette('default')
+  return (
+    <CenteredView
+      sideBorders={isTabletOrDesktop}
+      // @ts-ignore web only -prf
+      style={{
+        height: isWeb ? '100vh' : undefined,
+      }}>
+      <View style={styles.searchHistoryContainer}>
+        {searchHistory.length > 0 && (
+          <View style={styles.searchHistoryContent}>
+            <Text style={[pal.text, styles.searchHistoryTitle]}>
+              <Trans>Recent Searches</Trans>
+            </Text>
+            {searchHistory.map((historyItem, index) => (
+              <View
+                key={index}
+                style={[
+                  a.flex_row,
+                  a.mt_md,
+                  a.justify_center,
+                  a.justify_between,
+                ]}>
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={() => onItemClick(historyItem)}
+                  hitSlop={HITSLOP_10}
+                  style={[a.flex_1, a.py_sm]}>
+                  <Text style={pal.text}>{historyItem}</Text>
+                </Pressable>
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={() => onRemoveItemClick(historyItem)}
+                  hitSlop={HITSLOP_10}
+                  style={[a.px_md, a.py_xs, a.justify_center]}>
+                  <FontAwesomeIcon
+                    icon="xmark"
+                    size={16}
+                    style={pal.textLight as FontAwesomeIconStyle}
+                  />
+                </Pressable>
               </View>
-            )}
+            ))}
           </View>
-        </CenteredView>
-      ) : (
-        <SearchScreenInner query={queryParam} />
-      )}
-    </View>
+        )}
+      </View>
+    </CenteredView>
   )
 }
 
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 52f28cc63..683d4421a 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {Text} from 'view/com/util/text/Text'
 
-export const MATCH_HANDLE =
-  /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
-
-export function SearchLinkCard({
+let SearchLinkCard = ({
   label,
   to,
   onPress,
@@ -44,7 +41,7 @@ export function SearchLinkCard({
   to?: string
   onPress?: () => void
   style?: ViewStyle
-}) {
+}): React.ReactNode => {
   const pal = usePalette('default')
 
   const inner = (
@@ -82,8 +79,10 @@ export function SearchLinkCard({
     </Link>
   )
 }
+SearchLinkCard = React.memo(SearchLinkCard)
+export {SearchLinkCard}
 
-export function SearchProfileCard({
+let SearchProfileCard = ({
   profile,
   moderation,
   onPress: onPressInner,
@@ -91,7 +90,7 @@ export function SearchProfileCard({
   profile: AppBskyActorDefs.ProfileViewBasic
   moderation: ModerationDecision
   onPress: () => void
-}) {
+}): React.ReactNode => {
   const pal = usePalette('default')
   const queryClient = useQueryClient()
 
@@ -144,6 +143,8 @@ export function SearchProfileCard({
     </Link>
   )
 }
+SearchProfileCard = React.memo(SearchProfileCard)
+export {SearchProfileCard}
 
 export function DesktopSearch() {
   const {_} = useLingui()
@@ -179,11 +180,6 @@ export function DesktopSearch() {
     setIsActive(false)
   }, [])
 
-  const queryMaybeHandle = React.useMemo(() => {
-    const match = MATCH_HANDLE.exec(query)
-    return match && match[1]
-  }, [query])
-
   return (
     <View style={[styles.container, pal.view]}>
       <View
@@ -239,19 +235,11 @@ export function DesktopSearch() {
                 label={_(msg`Search for "${query}"`)}
                 to={`/search?q=${encodeURIComponent(query)}`}
                 style={
-                  queryMaybeHandle || (autocompleteData?.length ?? 0) > 0
+                  (autocompleteData?.length ?? 0) > 0
                     ? {borderBottomWidth: 1}
                     : undefined
                 }
               />
-
-              {queryMaybeHandle ? (
-                <SearchLinkCard
-                  label={_(msg`Go to @${queryMaybeHandle}`)}
-                  to={`/profile/${queryMaybeHandle}`}
-                />
-              ) : null}
-
               {autocompleteData?.map(item => (
                 <SearchProfileCard
                   key={item.did}