Files
bsky-social-app/src/screens/Search/Shell.tsx
T
2026-07-08 10:14:39 -07:00

835 lines
26 KiB
TypeScript

import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
type StyleProp,
type TextInput,
View,
type ViewStyle,
} from 'react-native'
import {setStringAsync} from 'expo-clipboard'
import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {type NavigationProp, type SearchParams} from '#/lib/routes/types'
import {listenSoftReset} from '#/state/events'
import {
unstableCacheProfileView,
useProfilesQuery,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {
countActiveFilters,
definedFilterParams,
filtersToLegacyParams,
filtersToRouteParams,
hasActiveFilters,
parseHistoryEntry,
readSearchFilters,
type SearchFilters,
serializeHistoryEntry,
withoutFilterParams,
} from '#/screens/Search/searchParams'
import {makeSearchQuery} from '#/screens/Search/utils'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {useAutocomplete} from '#/components/Autocomplete'
import {Button, ButtonIcon} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import * as Layout from '#/components/Layout'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {account, useStorage} from '#/storage'
import type * as bsky from '#/types/bsky'
import {AdvancedSearchDialog} from './components/AdvancedSearchDialog'
import {AutocompleteResults} from './components/AutocompleteResults'
import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition'
import {SearchAutocompleteInput} from './components/SearchAutocompleteInput'
import {SearchHistory} from './components/SearchHistory'
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
import {Explore} from './Explore'
import {SearchResults} from './SearchResults'
/**
* Derived from the route param so the two can't drift. NonNullable because the
* tab param is optional on the route but the handlers below always work with a
* concrete value.
*/
type TabParam = NonNullable<SearchParams['tab']>
// Map tab parameter to tab index
function getTabIndex(tabParam?: TabParam) {
switch (tabParam) {
case 'feed':
return 3 // Feeds tab
case 'user':
case 'profile':
return 2 // People tab
case 'latest':
return 1 // Latest tab
default:
return 0 // Top tab
}
}
export function SearchScreenShell({
queryParam,
testID,
fixedParams,
navButton = 'menu',
inputPlaceholder,
isExplore,
}: {
queryParam: string
testID: string
fixedParams?: SearchFilters
navButton?: 'back' | 'menu'
inputPlaceholder?: string
isExplore?: boolean
}) {
const ax = useAnalytics()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const route = useRoute()
const textInput = useRef<TextInput>(null)
const {t: l} = useLingui()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
const advancedSearchV2Enabled =
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
// Get tab parameter from route params
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text
setSearchText(text)
}, [])
const {items: autocompleteItems, isFetching: isAutocompleteFetching} =
useAutocomplete({
type: 'profile',
/*
* On web the dropdown (SearchAutocompleteInput) owns its own autocomplete
* query; only the native inline list consumes this one, so pass an empty
* query on web to keep the hook a no-op instead of doing wasted work.
*/
query: IS_NATIVE ? searchText : '',
})
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(
(q: string, searchFilters: SearchFilters = {}) => {
if (!q) return
/*
* Store the query plus any advanced-search filters. Term-only searches
* serialize to a plain string (back-compatible with existing history);
* filtered searches serialize to JSON. Dedupe on the serialized form.
*/
const item = serializeHistoryEntry(q, searchFilters)
const newSearchHistory = [
item,
...termHistory.filter(search => search !== item),
].slice(0, 6)
setTermHistory(newSearchHistory)
},
[termHistory, setTermHistory],
)
const updateProfileHistory = useCallback(
(item: bsky.profile.AnyProfileView) => {
const newAccountHistory = [
item.did,
...accountHistory.filter(p => p !== item.did),
].slice(0, 10)
setAccountHistory(newAccountHistory)
},
[accountHistory, setAccountHistory],
)
const deleteSearchHistoryItem = useCallback(
(item: string) => {
setTermHistory(termHistory.filter(search => search !== item))
},
[termHistory, setTermHistory],
)
const deleteProfileHistoryItem = useCallback(
(item: bsky.profile.AnyProfileView) => {
setAccountHistory(accountHistory.filter(p => p !== item.did))
},
[accountHistory, setAccountHistory],
)
const {query, queryWithParams, filters, setFilters, hasFilters} =
useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback(
(lang: string) => {
setFilters({...filters, lang: lang || undefined})
},
[filters, setFilters],
)
// web only - measure header height for sticky positioning
const [headerHeight, setHeaderHeight] = useState(0)
const headerRef = useRef(null)
useLayoutEffect(() => {
if (IS_WEB) {
if (!headerRef.current) return
const measurement = (headerRef.current as Element).getBoundingClientRect()
setHeaderHeight(measurement.height)
}
}, [])
/*
* On native, navigating to an already-mounted Search screen with a new `q`
* (e.g. from a post hashtag/search link) updates the route param without
* remounting, so re-sync the input. Web handles this via the focus effect
* below, which fires on back/forward navigation.
*/
useEffect(() => {
if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam)
}
}, [queryParam, updateSearchText])
useFocusEffect(
useNonReactiveCallback(() => {
if (IS_WEB) {
updateSearchText(queryParam)
}
}),
)
const onPressClearQuery = useCallback(() => {
scrollToTopWeb()
/*
* Clearing the query also resets any advanced-search filters, then keeps
* the input focused so the user can immediately type a new search.
*/
if (IS_WEB) {
/*
* Replace the param set so q/tab/filters drop out of the URL instead of
* serializing as the literal string "undefined". fixedParams live on the
* route already and are preserved by withoutSearchParams.
*/
const parameters = withoutSearchParams(
route.params as Record<string, unknown>,
)
// @ts-expect-error route is not typesafe
navigation.replace(route.name, parameters)
} else {
updateSearchText('')
navigation.setParams({...filtersToRouteParams({}), q: ''})
}
textInput.current?.focus()
}, [updateSearchText, navigation, route])
const onChangeText = useCallback(
(text: string) => {
scrollToTopWeb()
updateSearchText(text)
},
[updateSearchText],
)
const navigateToItem = useCallback(
(item: string, itemFilters: SearchFilters = filters) => {
scrollToTopWeb()
setShowAutocomplete(false)
updateSearchHistory(item, itemFilters)
if (IS_WEB) {
const nextParams = {
...withoutFilterParams(route.params as Record<string, unknown>),
...definedFilterParams(itemFilters),
q: item,
}
// @ts-expect-error route is not typesafe
navigation.push(route.name, nextParams)
} else {
textInput.current?.blur()
navigation.setParams({...filtersToRouteParams(itemFilters), q: item})
}
},
[updateSearchHistory, navigation, route, filters],
)
const onPressCancelSearch = useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
if (IS_WEB) {
/*
* Empty params resets the URL to be /search rather than /search?q=
* Also clear tab and advanced-search filter parameters.
*/
const parameters = withoutSearchParams(
route.params as Record<string, unknown>,
)
// @ts-expect-error route is not typesafe
navigation.replace(route.name, parameters)
} else {
updateSearchText('')
navigation.setParams({
...filtersToRouteParams({}),
q: '',
tab: undefined,
})
}
}, [
setShowAutocomplete,
updateSearchText,
navigation,
route.params,
route.name,
])
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
filterCount: countActiveFilters(filters),
})
navigateToItem(searchTextRef.current)
}
const onSubmitAdvanced = useCallback(
(text: string, nextFilters: SearchFilters) => {
scrollToTopWeb()
setShowAutocomplete(false)
updateSearchText(text)
updateSearchHistory(text, nextFilters)
ax.metric('search:query', {
source: 'typed',
filterCount: countActiveFilters(nextFilters),
})
if (IS_WEB) {
/*
* Build a fresh param set so removed filters drop out of the URL.
* Only defined filters are included - undefined values would serialize
* as the literal string "undefined".
*/
const nextParams = {
...withoutFilterParams(route.params as Record<string, unknown>),
...definedFilterParams(nextFilters),
q: text,
}
// @ts-expect-error route is not typesafe
navigation.push(route.name, nextParams)
} else {
textInput.current?.blur()
// setParams merges, so pass undefined for absent keys to clear them.
navigation.setParams({...filtersToRouteParams(nextFilters), q: text})
}
},
[ax, navigation, route, updateSearchText, updateSearchHistory],
)
const onAutocompleteResultPress = useCallback(() => {
if (IS_WEB) {
setShowAutocomplete(false)
} else {
textInput.current?.blur()
}
}, [])
const handleHistoryItemClick = useCallback(
(item: string) => {
/*
* History entries may carry advanced-search filters (JSON-encoded);
* term-only entries are plain strings. Restore both the query and
* filters.
*/
const {q, filters: itemFilters} = parseHistoryEntry(item)
updateSearchText(q)
navigateToItem(q, itemFilters)
},
[navigateToItem, updateSearchText],
)
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],
)
/**
* Web only. Selecting a profile from the anchored autocomplete dropdown.
*/
const onSelectProfile = useCallback(
(profile: bsky.profile.AnyProfileView, position: number) => {
ax.metric('search:autocomplete:press', {
profileDid: profile.did,
position,
})
handleProfileClick(profile)
navigation.navigate('Profile', {name: profile.handle})
},
[ax, handleProfileClick, navigation],
)
/**
* Web only. Selecting the "Search for X" row from the anchored autocomplete
* dropdown. This runs the typed query as-is (not a suggested profile), so it
* is attributed to `typed` rather than `autocomplete`.
*/
const onSelectSearch = useCallback(
(value: string) => {
ax.metric('search:query', {
source: 'typed',
filterCount: countActiveFilters(filters),
})
updateSearchText(value)
navigateToItem(value)
},
[ax, filters, navigateToItem, updateSearchText],
)
const onSoftReset = useCallback(() => {
if (IS_WEB) {
/*
* Empty params resets the URL to be /search rather than /search?q=
* Also clear tab and advanced-search filter parameters.
*/
const parameters = withoutSearchParams(
route.params as Record<string, unknown>,
)
// @ts-expect-error route is not typesafe
navigation.replace(route.name, parameters)
} else {
updateSearchText('')
navigation.setParams({
...filtersToRouteParams({}),
q: '',
tab: undefined,
})
textInput.current?.focus()
}
}, [navigation, route.name, route.params, updateSearchText])
useFocusEffect(
useCallback(() => {
return listenSoftReset(onSoftReset)
}, [onSoftReset]),
)
const onSearchInputFocus = useCallback(() => {
if (IS_WEB) {
// 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(
(tab?: TabParam) => {
textInput.current?.focus()
// If a tab is specified, set the tab parameter
if (tab) {
if (IS_WEB) {
navigation.setParams({...route.params, tab})
} else {
navigation.setParams({tab})
}
}
},
[navigation, route],
)
const onShareSearch = useCallback(() => {
const url = new URL('https://bsky.app')
url.pathname = '/search'
if (query) url.searchParams.set('q', query)
for (const [key, value] of Object.entries(definedFilterParams(filters))) {
url.searchParams.set(key, value)
}
ax.metric('search:shareLink:press', {
filterCount: countActiveFilters(filters),
})
setStringAsync(url.toString()).then(
() => Toast.show(l`Copied link to clipboard`, {type: 'success'}),
() => Toast.show(l`Failed to copy link`, {type: 'error'}),
)
}, [ax, query, filters, l])
const showHeader = !gtMobile || navButton !== 'menu'
return (
<Layout.Screen testID={testID}>
<View
ref={headerRef}
onLayout={evt => {
if (IS_WEB) 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>
{isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>}
</Layout.Header.TitleText>
</Layout.Header.Content>
{showFilters && !advancedSearchV2Enabled ? (
<SearchLanguageDropdown
value={filters.lang ?? ''}
onChange={onChangeLang}
/>
) : showFilters && advancedSearchV2Enabled ? (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<AdvancedSearchDialog
disabled={activeTab > 1}
q={searchText}
filters={filters}
onSubmit={onSubmitAdvanced}
/>
<Button
accessibilityRole="button"
size="small"
color="secondary"
shape="round"
label={l`Share this search`}
onPress={onShareSearch}>
<ButtonIcon icon={ShareIcon} />
</Button>
</View>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
</View>
)}
<View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
<View style={[a.gap_sm]}>
{searchV2Enabled && query && !showAutocomplete && (
<DetectedLanguagesAdmonition
query={query}
filters={filters}
sort={activeTab === 1 ? 'latest' : 'top'}
enabled={activeTab === 0 || activeTab === 1}
onPressLanguage={onChangeLang}
/>
)}
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_sm]}>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{showAutocomplete && (
<Button
label={l`Cancel search`}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.px_sm]}
onPress={onPressCancelSearch}
hitSlop={HITSLOP_10}>
<ButtonIcon icon={ArrowLeftIcon} size="lg" />
</Button>
)}
<View style={[a.flex_1]}>
<SearchAutocompleteInput
testID="searchScreenInput"
ref={textInput}
value={searchText}
onFocus={onSearchInputFocus}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit('typed')}
placeholder={inputPlaceholder ?? l`Search`}
hitSlop={{...HITSLOP_20, top: 0}}
hotkey={true}
fixedParams={Boolean(fixedParams)}
onSelectProfile={onSelectProfile}
onSelectSearch={onSelectSearch}
/>
</View>
</View>
{showFilters && !showHeader && advancedSearchV2Enabled ? (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<AdvancedSearchDialog
disabled={activeTab > 1}
q={searchText}
filters={filters}
onSubmit={onSubmitAdvanced}
/>
<Button
accessibilityRole="button"
size="small"
color="secondary"
shape="round"
label={l`Share this search`}
onPress={onShareSearch}>
<ButtonIcon icon={ShareIcon} />
</Button>
</View>
) : null}
</View>
{showFilters && !showHeader && !advancedSearchV2Enabled && (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<SearchLanguageDropdown
value={filters.lang ?? ''}
onChange={onChangeLang}
/>
</View>
)}
</View>
</View>
</Layout.Center>
</View>
<View
style={{
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
flex: 1,
}}>
{searchText.length > 0 && IS_NATIVE ? (
<AutocompleteResults
items={autocompleteItems}
isFetching={isAutocompleteFetching}
searchText={searchText}
onSubmit={onSubmit('autocomplete')}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
/>
) : (
<SearchHistory
searchHistory={termHistory}
selectedProfiles={
accountHistoryProfiles?.profiles.filter(p =>
accountHistory.includes(p.did),
) ?? []
}
onItemClick={handleHistoryItemClick}
onProfileClick={handleProfileClick}
onRemoveItemClick={deleteSearchHistoryItem}
onRemoveProfileClick={deleteProfileHistoryItem}
/>
)}
</View>
<View
style={{
display: showAutocomplete ? 'none' : 'flex',
flex: 1,
}}>
<SearchScreenInner
key={filters.lang ?? ''}
activeTab={activeTab}
setActiveTab={setActiveTab}
query={query}
queryWithParams={queryWithParams}
filters={filters}
hasFilters={hasFilters}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
</View>
</Layout.Screen>
)
}
let SearchScreenInner = ({
activeTab,
setActiveTab,
query,
queryWithParams,
filters,
hasFilters,
headerHeight,
focusSearchInput,
}: {
activeTab: number
setActiveTab: React.Dispatch<React.SetStateAction<number>>
query: string
queryWithParams: string
filters: SearchFilters
hasFilters: boolean
headerHeight: number
focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => {
const t = useTheme()
const {hasSession} = useSession()
const {gtTablet} = useBreakpoints()
const onPageSelected = (index: number) => {
setActiveTab(index)
}
return query || hasFilters ? (
<SearchResults
query={query}
queryWithParams={queryWithParams}
filters={filters}
hasFilters={hasFilters}
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_bold]}>
<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?: SearchFilters
}) {
const navigation = useNavigation<NavigationProp>()
const route = useRoute()
// Free text only - structured filters live in sibling route params now.
const query = initialQuery
const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
// fixedParams (e.g. ProfileSearch's author) always win and can't be cleared.
return {...fromRoute, ...fixedParams}
}, [route.params, fixedParams])
const setFilters = useCallback(
(next: SearchFilters) => {
const merged = {...next, ...fixedParams}
if (IS_WEB) {
/*
* Replace the param set so removed filters drop out of the URL instead
* of serializing as the literal string "undefined".
*/
const nextParams = {
...withoutFilterParams(route.params as Record<string, unknown>),
...definedFilterParams(merged),
}
// @ts-expect-error route is not typesafe
navigation.replace(route.name, nextParams)
} else {
navigation.setParams(filtersToRouteParams(merged))
}
},
[navigation, route, fixedParams],
)
return useMemo(
() => ({
query,
queryWithParams: makeSearchQuery(query, filtersToLegacyParams(filters)),
filters,
setFilters,
hasFilters: hasActiveFilters(filters),
}),
[query, filters, setFilters],
)
}
function scrollToTopWeb() {
if (IS_WEB) {
window.scrollTo(0, 0)
}
}
function withoutSearchParams(
routeParams: Record<string, unknown> | undefined,
): Record<string, unknown> {
const parameters = withoutFilterParams(routeParams)
delete parameters.q
delete parameters.tab
return parameters
}