about summary refs log tree commit diff
path: root/src/screens/Search/Shell.tsx
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-04-03 03:21:15 +0300
committerGitHub <noreply@github.com>2025-04-02 17:21:15 -0700
commit87da619aaa92e0ec762e68c13b24e58a25da10a8 (patch)
tree4da902d3ca43a226f6da8e5c090ab33c2df3297a /src/screens/Search/Shell.tsx
parent8d1f97b5ffac5d86762f1d4e9384ff3097acbc52 (diff)
downloadvoidsky-87da619aaa92e0ec762e68c13b24e58a25da10a8.tar.zst
[Explore] Base (#8053)
* migrate to #/screens

* rm unneeded import

* block drawer gesture on recent profiles

* rm recommendations (#8056)

* [Explore] Disable Trending videos (#8054)

* remove giant header

* disable

* [Explore] Dynamic module ordering (#8066)

* Dynamic module ordering

* [Explore] New headers, metrics (#8067)

* new sticky headers

* improve spacing between modules

* view metric on modules

* update metrics names

* [Explore] Suggested accounts module (#8072)

* use modern profile card, update load more

* add tab bar

* tabbed suggested accounts

* [Explore] Discover feeds module (#8073)

* cap number of feeds to 3

* change feed pin button

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* restore statsig to log events

* filter out followed profiles, make suer enough are loaded (#8090)

* [Explore] Trending topics (#8055)

* redesigned trending topics

* rm borders on web

* get post count / age / ranking from api

* spacing tweaks

* fetch more topics then slice

* use api data for avis/category

* rm top border

* Integrate new SDK, part out components

* Clean up

* Use status field

* Bump SDK

* Send up interests and langs

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Clean up module spacing and borders

(cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c)
(cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1)

* Switch back border ordering

(cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9)

* [Explore] Starter Packs (#8095)

* Temp WIP

(cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18)

* New SP card

* Load state

* Revert change

* Cleanup

* Interests and caching

* Count total

* Format

* Caching

* [Explore] Feed previews module (#8075)

* wip new hook

* get fetching working, maybe

* get feed previews rendering!

* fix header height

* working pin button

* extract out FeedLink

* add loader

* only make preview:header sticky

* Fix headers

* Header tweaks

* Fix moderation filter

* Fix threading

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Space it out

* Fix query key

* Mock new endpoint, filter saved feeds

* Make sure we're pinning, lower cache time

* add news category

* Remove log

* Improve suggested accounts load state

* Integrate new app view endpoint

* fragment

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* lint

* maybe fix this

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src/screens/Search/Shell.tsx')
-rw-r--r--src/screens/Search/Shell.tsx535
1 files changed, 535 insertions, 0 deletions
diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx
new file mode 100644
index 000000000..e930b8289
--- /dev/null
+++ b/src/screens/Search/Shell.tsx
@@ -0,0 +1,535 @@
+import {
+  memo,
+  useCallback,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import {
+  type StyleProp,
+  type TextInput,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {HITSLOP_20} from '#/lib/constants'
+import {HITSLOP_10} from '#/lib/constants'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {MagnifyingGlassIcon} from '#/lib/icons'
+import {type NavigationProp} from '#/lib/routes/types'
+import {isWeb} from '#/platform/detection'
+import {listenSoftReset} from '#/state/events'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
+import {
+  unstableCacheProfileView,
+  useProfilesQuery,
+} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {
+  makeSearchQuery,
+  type Params,
+  parseSearchQuery,
+} from '#/screens/Search/utils'
+import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {SearchInput} from '#/components/forms/SearchInput'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import {account, useStorage} from '#/storage'
+import type * as bsky from '#/types/bsky'
+import {AutocompleteResults} from './components/AutocompleteResults'
+import {SearchHistory} from './components/SearchHistory'
+import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
+import {Explore} from './Explore'
+import {SearchResults} from './SearchResults'
+
+export function SearchScreenShell({
+  queryParam,
+  testID,
+  fixedParams,
+  navButton = 'menu',
+  inputPlaceholder,
+}: {
+  queryParam: string
+  testID: string
+  fixedParams?: Params
+  navButton?: 'back' | 'menu'
+  inputPlaceholder?: string
+}) {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const navigation = useNavigation<NavigationProp>()
+  const route = useRoute()
+  const textInput = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
+
+  // Query terms
+  const [searchText, setSearchText] = useState<string>(queryParam)
+  const {data: autocompleteData, isFetching: isAutocompleteFetching} =
+    useActorAutocompleteQuery(searchText, true)
+
+  const [showAutocomplete, setShowAutocomplete] = useState(false)
+
+  const [termHistory = [], setTermHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchTermHistory',
+  ] as const)
+  const [accountHistory = [], setAccountHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchAccountHistory',
+  ])
+
+  const {data: accountHistoryProfiles} = useProfilesQuery({
+    handles: accountHistory,
+    maintainData: true,
+  })
+
+  const updateSearchHistory = useCallback(
+    async (item: string) => {
+      if (!item) return
+      const newSearchHistory = [
+        item,
+        ...termHistory.filter(search => search !== item),
+      ].slice(0, 6)
+      setTermHistory(newSearchHistory)
+    },
+    [termHistory, setTermHistory],
+  )
+
+  const updateProfileHistory = useCallback(
+    async (item: bsky.profile.AnyProfileView) => {
+      const newAccountHistory = [
+        item.did,
+        ...accountHistory.filter(p => p !== item.did),
+      ].slice(0, 5)
+      setAccountHistory(newAccountHistory)
+    },
+    [accountHistory, setAccountHistory],
+  )
+
+  const deleteSearchHistoryItem = useCallback(
+    async (item: string) => {
+      setTermHistory(termHistory.filter(search => search !== item))
+    },
+    [termHistory, setTermHistory],
+  )
+  const deleteProfileHistoryItem = useCallback(
+    async (item: bsky.profile.AnyProfileView) => {
+      setAccountHistory(accountHistory.filter(p => p !== item.did))
+    },
+    [accountHistory, setAccountHistory],
+  )
+
+  const {params, query, queryWithParams} = useQueryManager({
+    initialQuery: queryParam,
+    fixedParams,
+  })
+  const showFilters = Boolean(queryWithParams && !showAutocomplete)
+
+  // web only - measure header height for sticky positioning
+  const [headerHeight, setHeaderHeight] = useState(0)
+  const headerRef = useRef(null)
+  useLayoutEffect(() => {
+    if (isWeb) {
+      if (!headerRef.current) return
+      const measurement = (headerRef.current as Element).getBoundingClientRect()
+      setHeaderHeight(measurement.height)
+    }
+  }, [])
+
+  useFocusEffect(
+    useNonReactiveCallback(() => {
+      if (isWeb) {
+        setSearchText(queryParam)
+      }
+    }),
+  )
+
+  const onPressClearQuery = useCallback(() => {
+    scrollToTopWeb()
+    setSearchText('')
+    textInput.current?.focus()
+  }, [])
+
+  const onChangeText = useCallback(async (text: string) => {
+    scrollToTopWeb()
+    setSearchText(text)
+  }, [])
+
+  const navigateToItem = useCallback(
+    (item: string) => {
+      scrollToTopWeb()
+      setShowAutocomplete(false)
+      updateSearchHistory(item)
+
+      if (isWeb) {
+        // @ts-expect-error route is not typesafe
+        navigation.push(route.name, {...route.params, q: item})
+      } else {
+        textInput.current?.blur()
+        navigation.setParams({q: item})
+      }
+    },
+    [updateSearchHistory, navigation, route],
+  )
+
+  const onPressCancelSearch = useCallback(() => {
+    scrollToTopWeb()
+    textInput.current?.blur()
+    setShowAutocomplete(false)
+    if (isWeb) {
+      // Empty params resets the URL to be /search rather than /search?q=
+
+      const {q: _q, ...parameters} = (route.params ?? {}) as {
+        [key: string]: string
+      }
+      // @ts-expect-error route is not typesafe
+      navigation.replace(route.name, parameters)
+    } else {
+      setSearchText('')
+      navigation.setParams({q: ''})
+    }
+  }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
+
+  const onSubmit = useCallback(() => {
+    navigateToItem(searchText)
+  }, [navigateToItem, searchText])
+
+  const onAutocompleteResultPress = useCallback(() => {
+    if (isWeb) {
+      setShowAutocomplete(false)
+    } else {
+      textInput.current?.blur()
+    }
+  }, [])
+
+  const handleHistoryItemClick = useCallback(
+    (item: string) => {
+      setSearchText(item)
+      navigateToItem(item)
+    },
+    [navigateToItem],
+  )
+
+  const handleProfileClick = useCallback(
+    (profile: bsky.profile.AnyProfileView) => {
+      unstableCacheProfileView(queryClient, profile)
+      // Slight delay to avoid updating during push nav animation.
+      setTimeout(() => {
+        updateProfileHistory(profile)
+      }, 400)
+    },
+    [updateProfileHistory, queryClient],
+  )
+
+  const onSoftReset = useCallback(() => {
+    if (isWeb) {
+      // Empty params resets the URL to be /search rather than /search?q=
+
+      const {q: _q, ...parameters} = (route.params ?? {}) as {
+        [key: string]: string
+      }
+      // @ts-expect-error route is not typesafe
+      navigation.replace(route.name, parameters)
+    } else {
+      setSearchText('')
+      navigation.setParams({q: ''})
+      textInput.current?.focus()
+    }
+  }, [navigation, route])
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+      return listenSoftReset(onSoftReset)
+    }, [onSoftReset, setMinimalShellMode]),
+  )
+
+  const onSearchInputFocus = useCallback(() => {
+    if (isWeb) {
+      // Prevent a jump on iPad by ensuring that
+      // the initial focused render has no result list.
+      requestAnimationFrame(() => {
+        setShowAutocomplete(true)
+      })
+    } else {
+      setShowAutocomplete(true)
+    }
+  }, [setShowAutocomplete])
+
+  const focusSearchInput = useCallback(() => {
+    textInput.current?.focus()
+  }, [])
+
+  const showHeader = !gtMobile || navButton !== 'menu'
+
+  return (
+    <Layout.Screen testID={testID}>
+      <View
+        ref={headerRef}
+        onLayout={evt => {
+          if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
+        }}
+        style={[
+          a.relative,
+          a.z_10,
+          web({
+            position: 'sticky',
+            top: 0,
+          }),
+        ]}>
+        <Layout.Center style={t.atoms.bg}>
+          {showHeader && (
+            <View
+              // HACK: shift up search input. we can't remove the top padding
+              // on the search input because it messes up the layout animation
+              // if we add it only when the header is hidden
+              style={{marginBottom: tokens.space.xs * -1}}>
+              <Layout.Header.Outer noBottomBorder>
+                {navButton === 'menu' ? (
+                  <Layout.Header.MenuButton />
+                ) : (
+                  <Layout.Header.BackButton />
+                )}
+                <Layout.Header.Content align="left">
+                  <Layout.Header.TitleText>
+                    <Trans>Search</Trans>
+                  </Layout.Header.TitleText>
+                </Layout.Header.Content>
+                {showFilters ? (
+                  <SearchLanguageDropdown
+                    value={params.lang}
+                    onChange={params.setLang}
+                  />
+                ) : (
+                  <Layout.Header.Slot />
+                )}
+              </Layout.Header.Outer>
+            </View>
+          )}
+          <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
+            <View style={[a.gap_sm]}>
+              <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
+                <View style={[a.flex_1]}>
+                  <SearchInput
+                    ref={textInput}
+                    value={searchText}
+                    onFocus={onSearchInputFocus}
+                    onChangeText={onChangeText}
+                    onClearText={onPressClearQuery}
+                    onSubmitEditing={onSubmit}
+                    placeholder={
+                      inputPlaceholder ??
+                      _(msg`Search for posts, users, or feeds`)
+                    }
+                    hitSlop={{...HITSLOP_20, top: 0}}
+                  />
+                </View>
+                {showAutocomplete && (
+                  <Button
+                    label={_(msg`Cancel search`)}
+                    size="large"
+                    variant="ghost"
+                    color="secondary"
+                    style={[a.px_sm]}
+                    onPress={onPressCancelSearch}
+                    hitSlop={HITSLOP_10}>
+                    <ButtonText>
+                      <Trans>Cancel</Trans>
+                    </ButtonText>
+                  </Button>
+                )}
+              </View>
+
+              {showFilters && !showHeader && (
+                <View
+                  style={[
+                    a.flex_row,
+                    a.align_center,
+                    a.justify_between,
+                    a.gap_sm,
+                  ]}>
+                  <SearchLanguageDropdown
+                    value={params.lang}
+                    onChange={params.setLang}
+                  />
+                </View>
+              )}
+            </View>
+          </View>
+        </Layout.Center>
+      </View>
+
+      <View
+        style={{
+          display: showAutocomplete && !fixedParams ? 'flex' : 'none',
+          flex: 1,
+        }}>
+        {searchText.length > 0 ? (
+          <AutocompleteResults
+            isAutocompleteFetching={isAutocompleteFetching}
+            autocompleteData={autocompleteData}
+            searchText={searchText}
+            onSubmit={onSubmit}
+            onResultPress={onAutocompleteResultPress}
+            onProfileClick={handleProfileClick}
+          />
+        ) : (
+          <SearchHistory
+            searchHistory={termHistory}
+            selectedProfiles={accountHistoryProfiles?.profiles || []}
+            onItemClick={handleHistoryItemClick}
+            onProfileClick={handleProfileClick}
+            onRemoveItemClick={deleteSearchHistoryItem}
+            onRemoveProfileClick={deleteProfileHistoryItem}
+          />
+        )}
+      </View>
+      <View
+        style={{
+          display: showAutocomplete ? 'none' : 'flex',
+          flex: 1,
+        }}>
+        <SearchScreenInner
+          query={query}
+          queryWithParams={queryWithParams}
+          headerHeight={headerHeight}
+          focusSearchInput={focusSearchInput}
+        />
+      </View>
+    </Layout.Screen>
+  )
+}
+
+let SearchScreenInner = ({
+  query,
+  queryWithParams,
+  headerHeight,
+  focusSearchInput,
+}: {
+  query: string
+  queryWithParams: string
+  headerHeight: number
+  focusSearchInput: () => void
+}): React.ReactNode => {
+  const t = useTheme()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {hasSession} = useSession()
+  const {gtTablet} = useBreakpoints()
+  const [activeTab, setActiveTab] = useState(0)
+  const {_} = useLingui()
+
+  const onPageSelected = useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setActiveTab(index)
+    },
+    [setMinimalShellMode],
+  )
+
+  return queryWithParams ? (
+    <SearchResults
+      query={query}
+      queryWithParams={queryWithParams}
+      activeTab={activeTab}
+      headerHeight={headerHeight}
+      onPageSelected={onPageSelected}
+    />
+  ) : hasSession ? (
+    <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
+  ) : (
+    <Layout.Center>
+      <View style={a.flex_1}>
+        {gtTablet && (
+          <View
+            style={[
+              a.border_b,
+              t.atoms.border_contrast_low,
+              a.px_lg,
+              a.pt_sm,
+              a.pb_lg,
+            ]}>
+            <Text style={[a.text_2xl, a.font_heavy]}>
+              <Trans>Search</Trans>
+            </Text>
+          </View>
+        )}
+
+        <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
+          <MagnifyingGlassIcon
+            strokeWidth={3}
+            size={60}
+            style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
+          />
+          <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+            <Trans>Find posts, users, and feeds on Bluesky</Trans>
+          </Text>
+        </View>
+      </View>
+    </Layout.Center>
+  )
+}
+SearchScreenInner = memo(SearchScreenInner)
+
+function useQueryManager({
+  initialQuery,
+  fixedParams,
+}: {
+  initialQuery: string
+  fixedParams?: Params
+}) {
+  const {query, params: initialParams} = useMemo(() => {
+    return parseSearchQuery(initialQuery || '')
+  }, [initialQuery])
+  const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
+  const [lang, setLang] = useState(initialParams.lang || '')
+
+  if (initialQuery !== prevInitialQuery) {
+    // handle new queryParam change (from manual search entry)
+    setPrevInitialQuery(initialQuery)
+    setLang(initialParams.lang || '')
+  }
+
+  const params = useMemo(
+    () => ({
+      // default stuff
+      ...initialParams,
+      // managed stuff
+      lang,
+      ...fixedParams,
+    }),
+    [lang, initialParams, fixedParams],
+  )
+  const handlers = useMemo(
+    () => ({
+      setLang,
+    }),
+    [setLang],
+  )
+
+  return useMemo(() => {
+    return {
+      query,
+      queryWithParams: makeSearchQuery(query, params),
+      params: {
+        ...params,
+        ...handlers,
+      },
+    }
+  }, [query, params, handlers])
+}
+
+function scrollToTopWeb() {
+  if (isWeb) {
+    window.scrollTo(0, 0)
+  }
+}