about summary refs log tree commit diff
path: root/src/view/screens/Search/Search.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/screens/Search/Search.tsx')
-rw-r--r--src/view/screens/Search/Search.tsx639
1 files changed, 639 insertions, 0 deletions
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
new file mode 100644
index 000000000..08f4fbd70
--- /dev/null
+++ b/src/view/screens/Search/Search.tsx
@@ -0,0 +1,639 @@
+import React from 'react'
+import {
+  View,
+  StyleSheet,
+  ActivityIndicator,
+  RefreshControl,
+  TextInput,
+  Pressable,
+} from 'react-native'
+import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views'
+import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {logger} from '#/logger'
+import {
+  NativeStackScreenProps,
+  SearchTabNavigatorParams,
+} from 'lib/routes/types'
+import {Text} from '#/view/com/util/text/Text'
+import {NotificationFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Post} from '#/view/com/post/Post'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
+import {HITSLOP_10} from '#/lib/constants'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useSession} from '#/state/session'
+import {useMyFollowsQuery} from '#/state/queries/my-follows'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useSetDrawerOpen} from '#/state/shell'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {MagnifyingGlassIcon} from '#/lib/icons'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {SearchResultCard} from '#/view/shell/desktop/Search'
+import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
+import {useStores} from '#/state'
+import {isWeb} from '#/platform/detection'
+
+function Loader() {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <CenteredView
+      style={[
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+        pal.border,
+      ]}
+      sideBorders={!isMobile}>
+      <ActivityIndicator />
+    </CenteredView>
+  )
+}
+
+// TODO refactor how to translate?
+function EmptyState({message, error}: {message: string; error?: string}) {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+
+  return (
+    <CenteredView
+      sideBorders={!isMobile}
+      style={[
+        pal.border,
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+      ]}>
+      <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
+        <Text style={[pal.text]}>
+          <Trans>{message}</Trans>
+        </Text>
+
+        {error && (
+          <>
+            <View
+              style={[
+                {
+                  marginVertical: 12,
+                  height: 1,
+                  width: '100%',
+                  backgroundColor: pal.text.color,
+                  opacity: 0.2,
+                },
+              ]}
+            />
+
+            <Text style={[pal.textLight]}>
+              <Trans>Error:</Trans> {error}
+            </Text>
+          </>
+        )}
+      </View>
+    </CenteredView>
+  )
+}
+
+function SearchScreenSuggestedFollows() {
+  const pal = usePalette('default')
+  const {currentAccount} = useSession()
+  const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
+  const [suggestions, setSuggestions] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
+
+  React.useEffect(() => {
+    async function getSuggestions() {
+      // TODO not quite right, doesn't fetch your follows
+      const friends = await getSuggestedFollowsByActor(
+        currentAccount!.did,
+      ).then(friendsRes => friendsRes.suggestions)
+
+      if (!friends) return // :(
+
+      const friendsOfFriends = (
+        await Promise.all(
+          friends
+            .slice(0, 4)
+            .map(friend =>
+              getSuggestedFollowsByActor(friend.did).then(
+                foafsRes => foafsRes.suggestions,
+              ),
+            ),
+        )
+      ).flat()
+
+      setSuggestions(
+        // dedupe
+        friendsOfFriends.filter(f => !friends.find(f2 => f.did === f2.did)),
+      )
+      setDataUpdatedAt(Date.now())
+    }
+
+    try {
+      getSuggestions()
+    } catch (e) {
+      logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
+        error: e,
+      })
+    }
+  }, [
+    currentAccount,
+    setSuggestions,
+    setDataUpdatedAt,
+    getSuggestedFollowsByActor,
+  ])
+
+  return suggestions.length ? (
+    <FlatList
+      data={suggestions}
+      renderItem={({item}) => (
+        <ProfileCardWithFollowBtn
+          profile={item}
+          noBg
+          dataUpdatedAt={dataUpdatedAt}
+        />
+      )}
+      keyExtractor={item => item.did}
+      // @ts-ignore web only -prf
+      desktopFixedHeight
+      contentContainerStyle={{paddingBottom: 1200}}
+    />
+  ) : (
+    <CenteredView
+      style={[pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]}>
+      <NotificationFeedLoadingPlaceholder />
+    </CenteredView>
+  )
+}
+
+type SearchResultSlice =
+  | {
+      type: 'post'
+      key: string
+      post: AppBskyFeedDefs.PostView
+    }
+  | {
+      type: 'loadingMore'
+      key: string
+    }
+
+function SearchScreenPostResults({query}: {query: string}) {
+  const pal = usePalette('default')
+  const [isPTR, setIsPTR] = React.useState(false)
+  const {
+    isFetched,
+    data: results,
+    isFetching,
+    error,
+    refetch,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+    dataUpdatedAt,
+  } = useSearchPostsQuery({query})
+
+  const onPullToRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [setIsPTR, refetch])
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, error, hasNextPage, fetchNextPage])
+
+  const posts = React.useMemo(() => {
+    return results?.pages.flatMap(page => page.posts) || []
+  }, [results])
+  const items = React.useMemo(() => {
+    let items: SearchResultSlice[] = []
+
+    for (const post of posts) {
+      items.push({
+        type: 'post',
+        key: post.uri,
+        post,
+      })
+    }
+
+    if (isFetchingNextPage) {
+      items.push({
+        type: 'loadingMore',
+        key: 'loadingMore',
+      })
+    }
+
+    return items
+  }, [posts, isFetchingNextPage])
+
+  return error ? (
+    <EmptyState
+      message="We're sorry, but your search could not be completed. Please try again in a few minutes."
+      error={error.toString()}
+    />
+  ) : (
+    <>
+      {isFetched ? (
+        <>
+          {posts.length ? (
+            <FlatList
+              data={items}
+              renderItem={({item}) => {
+                if (item.type === 'post') {
+                  return <Post post={item.post} dataUpdatedAt={dataUpdatedAt} />
+                } else {
+                  return <Loader />
+                }
+              }}
+              keyExtractor={item => item.key}
+              refreshControl={
+                <RefreshControl
+                  refreshing={isPTR}
+                  onRefresh={onPullToRefresh}
+                  tintColor={pal.colors.text}
+                  titleColor={pal.colors.text}
+                />
+              }
+              onEndReached={onEndReached}
+              // @ts-ignore web only -prf
+              desktopFixedHeight
+              contentContainerStyle={{paddingBottom: 100}}
+            />
+          ) : (
+            <EmptyState message={`No results found for ${query}`} />
+          )}
+        </>
+      ) : (
+        <Loader />
+      )}
+    </>
+  )
+}
+
+function SearchScreenUserResults({query}: {query: string}) {
+  const [isFetched, setIsFetched] = React.useState(false)
+  const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
+  const [results, setResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const search = useActorAutocompleteFn()
+  // fuzzy search relies on followers
+  const {isFetched: isFollowsFetched} = useMyFollowsQuery()
+
+  React.useEffect(() => {
+    async function getResults() {
+      const results = await search({query, limit: 30})
+
+      if (results) {
+        setDataUpdatedAt(Date.now())
+        setResults(results)
+        setIsFetched(true)
+      }
+    }
+
+    if (query && isFollowsFetched) {
+      getResults()
+    } else {
+      setResults([])
+      setIsFetched(false)
+    }
+  }, [query, isFollowsFetched, setDataUpdatedAt, search])
+
+  return isFetched ? (
+    <>
+      {results.length ? (
+        <FlatList
+          data={results}
+          renderItem={({item}) => (
+            <ProfileCardWithFollowBtn
+              profile={item}
+              noBg
+              dataUpdatedAt={dataUpdatedAt}
+            />
+          )}
+          keyExtractor={item => item.did}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={`No results found for ${query}`} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+
+const SECTIONS = ['Posts', 'Users']
+export function SearchScreenInner({query}: {query?: string}) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setDrawerSwipeDisabled(index > 0)
+    },
+    [setDrawerSwipeDisabled, setMinimalShellMode],
+  )
+
+  return query ? (
+    <Pager
+      tabBarPosition="top"
+      onPageSelected={onPageSelected}
+      renderTabBar={props => (
+        <CenteredView sideBorders style={pal.border}>
+          <TabBar items={SECTIONS} {...props} />
+        </CenteredView>
+      )}
+      initialPage={0}>
+      <View>
+        <SearchScreenPostResults query={query} />
+      </View>
+      <View>
+        <SearchScreenUserResults query={query} />
+      </View>
+    </Pager>
+  ) : (
+    <View>
+      <CenteredView sideBorders style={pal.border}>
+        <Text
+          type="title"
+          style={[
+            pal.text,
+            pal.border,
+            {
+              display: 'flex',
+              paddingVertical: 12,
+              paddingHorizontal: 18,
+              fontWeight: 'bold',
+            },
+          ]}>
+          <Trans>Suggested Follows</Trans>
+        </Text>
+      </CenteredView>
+      <SearchScreenSuggestedFollows />
+    </View>
+  )
+}
+
+export function SearchScreenDesktop(
+  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const {isDesktop} = useWebMediaQueries()
+
+  return isDesktop ? (
+    <SearchScreenInner query={props.route.params?.q} />
+  ) : (
+    <SearchScreenMobile {...props} />
+  )
+}
+
+export function SearchScreenMobile(
+  _props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  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 search = useActorAutocompleteFn()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const store = useStores()
+  const {isTablet} = useWebMediaQueries()
+
+  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
+    undefined,
+  )
+  const [isFetching, setIsFetching] = React.useState<boolean>(false)
+  const [query, setQuery] = React.useState<string>('')
+  const [searchResults, setSearchResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const [inputIsFocused, setInputIsFocused] = React.useState(false)
+  const [showAutocompleteResults, setShowAutocompleteResults] =
+    React.useState(false)
+
+  const onPressMenu = React.useCallback(() => {
+    track('ViewHeader:MenuButtonClicked')
+    setDrawerOpen(true)
+  }, [track, setDrawerOpen])
+  const onPressCancelSearch = React.useCallback(() => {
+    textInput.current?.blur()
+    setQuery('')
+    setShowAutocompleteResults(false)
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
+  }, [textInput])
+  const onPressClearQuery = React.useCallback(() => {
+    setQuery('')
+    setShowAutocompleteResults(false)
+  }, [setQuery])
+  const onChangeText = React.useCallback(
+    async (text: string) => {
+      setQuery(text)
+
+      if (text.length > 0) {
+        setIsFetching(true)
+        setShowAutocompleteResults(true)
+
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+
+        searchDebounceTimeout.current = setTimeout(async () => {
+          const results = await search({query: text, limit: 30})
+
+          if (results) {
+            setSearchResults(results)
+            setIsFetching(false)
+          }
+        }, 300)
+      } else {
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
+        setIsFetching(false)
+        setShowAutocompleteResults(false)
+      }
+    },
+    [setQuery, search, setSearchResults],
+  )
+  const onSubmit = React.useCallback(() => {
+    setShowAutocompleteResults(false)
+  }, [setShowAutocompleteResults])
+
+  const onSoftReset = React.useCallback(() => {
+    onPressCancelSearch()
+  }, [onPressCancelSearch])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      const softResetSub = store.onScreenSoftReset(onSoftReset)
+
+      setMinimalShellMode(false)
+
+      return () => {
+        softResetSub.remove()
+      }
+    }, [store, onSoftReset, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={{flex: 1}}>
+      <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}>
+        <Pressable
+          testID="viewHeaderBackOrMenuBtn"
+          onPress={onPressMenu}
+          hitSlop={HITSLOP_10}
+          style={styles.headerMenuBtn}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Menu`)}
+          accessibilityHint="Access navigation links and settings">
+          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
+        </Pressable>
+
+        <View
+          style={[
+            {backgroundColor: pal.colors.backgroundLight},
+            styles.headerSearchContainer,
+          ]}>
+          <MagnifyingGlassIcon
+            style={[pal.icon, styles.headerSearchIcon]}
+            size={21}
+          />
+          <TextInput
+            testID="searchTextInput"
+            ref={textInput}
+            placeholder="Search"
+            placeholderTextColor={pal.colors.textLight}
+            selectTextOnFocus
+            returnKeyType="search"
+            value={query}
+            style={[pal.text, styles.headerSearchInput]}
+            keyboardAppearance={theme.colorScheme}
+            onFocus={() => setInputIsFocused(true)}
+            onBlur={() => setInputIsFocused(false)}
+            onChangeText={onChangeText}
+            onSubmitEditing={onSubmit}
+            autoFocus={false}
+            accessibilityRole="search"
+            accessibilityLabel={_(msg`Search`)}
+            accessibilityHint=""
+            autoCorrect={false}
+            autoCapitalize="none"
+          />
+          {query ? (
+            <Pressable
+              testID="searchTextInputClearBtn"
+              onPress={onPressClearQuery}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Clear search query`)}
+              accessibilityHint="">
+              <FontAwesomeIcon
+                icon="xmark"
+                size={16}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </Pressable>
+          ) : undefined}
+        </View>
+
+        {query || inputIsFocused ? (
+          <View style={styles.headerCancelBtn}>
+            <Pressable onPress={onPressCancelSearch} accessibilityRole="button">
+              <Text style={[pal.text]}>
+                <Trans>Cancel</Trans>
+              </Text>
+            </Pressable>
+          </View>
+        ) : undefined}
+      </CenteredView>
+
+      {showAutocompleteResults && moderationOpts ? (
+        <>
+          {isFetching ? (
+            <Loader />
+          ) : (
+            <ScrollView style={{height: '100%'}}>
+              {searchResults.length ? (
+                searchResults.map((item, i) => (
+                  <SearchResultCard
+                    key={item.did}
+                    profile={item}
+                    moderation={moderateProfile(item, moderationOpts)}
+                    style={i === 0 ? {borderTopWidth: 0} : {}}
+                  />
+                ))
+              ) : (
+                <EmptyState message={`No results found for ${query}`} />
+              )}
+
+              <View style={{height: 200}} />
+            </ScrollView>
+          )}
+        </>
+      ) : (
+        <SearchScreenInner query={query} />
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 12,
+    paddingVertical: 4,
+  },
+  headerMenuBtn: {
+    width: 30,
+    height: 30,
+    borderRadius: 30,
+    marginRight: 6,
+    paddingBottom: 2,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  headerSearchContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+  },
+  headerSearchIcon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  headerSearchInput: {
+    flex: 1,
+    fontSize: 17,
+  },
+  headerCancelBtn: {
+    paddingLeft: 10,
+  },
+})