Files
bsky-social-app/src/screens/Search/SearchResults.tsx
T
2026-08-17 09:55:45 -07:00

863 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import {useCallOnce} from '#/lib/once'
import {
cleanError,
isNetworkError,
shouldRetryError,
} from '#/lib/strings/errors'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useStarterPackSearch} from '#/state/queries/starter-pack-search'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import {
hasPostOnlyFilters,
type SearchFilters,
} from '#/screens/Search/searchParams'
import {atoms as a, useTheme, web} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {SearchError} from '#/components/SearchError'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import {type app} from '#/lexicons'
import type * as bsky from '#/types/bsky'
let SearchResults = ({
query,
filters,
hasFilters,
fromMe,
activeTab,
onPageSelected,
headerHeight,
}: {
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
/*
* People/Feeds visibility keys off post-only filters: a `lang` filter applies
* to people and feeds too, so it must not hide those tabs (which would also
* regress the non-v2 legacy language dropdown). Other filters are post-only.
*/
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable,
)
const sections = useMemo(() => {
if (!query && !hasFilters) return []
/*
* People and Feeds tabs only make sense without post-restricting filters -
* those filters don't apply to actors or feeds.
*/
const noFilters = !hasPostFilters
return [
{
title: l`Top`,
component: (
<SearchScreenPostResults
hasFilters={hasFilters}
query={query}
filters={filters}
sort="top"
active={activePage === 0}
/>
),
},
{
title: l`Latest`,
component: (
<SearchScreenPostResults
hasFilters={hasFilters}
query={query}
filters={filters}
sort="latest"
active={activePage === 1}
/>
),
},
noFilters && {
title: l`People`,
component: (
<SearchScreenUserResults query={query} active={activePage === 2} />
),
},
noFilters && {
title: l`Feeds`,
component: (
<SearchScreenFeedsResults query={query} active={activePage === 3} />
),
},
noFilters &&
isStarterPacksEnabled && {
title: l`Starter packs`,
component: (
<SearchScreenStarterPackResults
query={query}
active={activePage === 4}
/>
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [
l,
query,
filters,
hasFilters,
hasPostFilters,
activePage,
isStarterPacksEnabled,
])
// There may be fewer tabs after changing the search options.
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
return (
<Pager
key={tabShape}
onPageSelected={onPageSelected}
renderTabBar={props => (
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
<TabBar items={sections.map(section => section.title)} {...props} />
</Layout.Center>
)}
initialPage={selectedPage}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
)
}
SearchResults = memo(SearchResults)
export {SearchResults}
function Loader() {
return (
<Layout.Content>
<View style={[a.py_xl]}>
<ActivityIndicator />
</View>
</Layout.Content>
)
}
function EmptyState({
messageText,
error,
children,
}: {
messageText: React.ReactNode
error?: string
children?: React.ReactNode
}) {
const t = useTheme()
return (
<Layout.Content>
<View style={[a.p_xl]}>
<View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
<Text style={[a.text_md]}>{messageText}</Text>
{error && (
<>
<View
style={[
{
marginVertical: 12,
height: 1,
width: '100%',
backgroundColor: t.atoms.text.color,
opacity: 0.2,
},
]}
/>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Error: {error}</Trans>
</Text>
</>
)}
{children}
</View>
</View>
</Layout.Content>
)
}
function NoResultsText({
hasFilters = false,
query,
}: {
hasFilters?: boolean
query: string
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<>
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
{hasFilters ? (
query ? (
<Trans>
No results found for “
<Text style={[a.text_lg, a.font_medium]}>{query}</Text>” with
advanced search filters applied.
</Trans>
) : (
<Trans>
No results found for your query with advanced search filters
applied.
</Trans>
)
) : (
<Trans>
No results found for “
<Text style={[a.text_lg, a.font_medium]}>{query}</Text>”.
</Trans>
)}
</Text>
{'\n\n'}
<Text
style={[
a.mt_lg,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{hasFilters ? (
<Trans>Try a different search term or remove some filters.</Trans>
) : (
<Trans>Try a different search term.</Trans>
)}
</Text>
{'\n\n'}
<Text
style={[
a.mt_lg,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
<Trans context="english-only-resource">
Learn more about{' '}
<InlineLinkText
label={l({
message: 'Read about how to use advanced search filters',
context: 'english-only-resource',
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
how to use advanced search
</InlineLinkText>
.
</Trans>
</Text>
</>
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: app.bsky.feed.defs.PostView
}
| {
type: 'loadingMore'
key: string
}
let SearchScreenPostResults = ({
hasFilters = false,
query,
filters,
sort,
active,
}: {
hasFilters: boolean
query: string
filters?: SearchFilters
sort?: 'top' | 'latest'
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const {hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false)
const trackPostView = usePostViewTracking('SearchResults')
const augmentedV2Query = useMemo(() => {
return augmentSearchQuery(query || '')
}, [query])
const v2 = useSearchPostsV2Query({
query: augmentedV2Query,
filters,
sort,
enabled: active,
})
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = v2
const t = useTheme()
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const posts = useMemo(() => {
return results?.pages.flatMap(page => page.posts) || []
}, [results])
const items = useMemo(() => {
let temp: SearchResultSlice[] = []
const seenUris = new Set()
for (const post of posts) {
if (seenUris.has(post.uri)) {
continue
}
temp.push({
type: 'post',
key: post.uri,
post,
})
seenUris.add(post.uri)
}
if (isFetchingNextPage) {
temp.push({
type: 'loadingMore',
key: 'loadingMore',
})
}
return temp
}, [posts, isFetchingNextPage])
const closeAllActiveElements = useCloseAllActiveElements()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const fireTracking = useCallOnce(() => {
if (sort) {
// ts only
ax.metric('search:results:loaded', {
tab: sort,
initialCount: items.length,
})
}
})
if (isFetched && sort) {
fireTracking()
}
const showSignIn = () => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'none'})
}
const showCreateAccount = () => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'new'})
}
if (!hasSession) {
return (
<SearchError title={l`Search is currently unavailable when logged out`}>
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
<Trans>
<InlineLinkText label={l`Sign in`} to="#" onPress={showSignIn}>
Sign in
</InlineLinkText>
<Text style={t.atoms.text_contrast_medium}> or </Text>
<InlineLinkText
label={l`Create an account`}
to={'#'}
onPress={showCreateAccount}>
create an account
</InlineLinkText>
<Text> </Text>
<Text style={t.atoms.text_contrast_medium}>
to search for news, sports, politics, and everything else
happening on Bluesky.
</Text>
</Trans>
</Text>
</SearchError>
)
}
return error ? (
<EmptyState
messageText={
shouldRetryError(error) || isNetworkError(error)
? l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`
: l`We’re sorry, but your search could not be completed.`
}
error={cleanError(error)}
/>
) : (
<>
{isFetched ? (
<>
{posts.length ? (
<List
data={items}
renderItem={({
item,
index,
}: {
item: SearchResultSlice
index: number
}) => {
if (item.type === 'post') {
return (
<SearchPost from={sort} position={index} post={item.post} />
)
} else {
return null
}
}}
keyExtractor={(item: SearchResultSlice) => item.key}
refreshing={isPTR}
onRefresh={() => {
void onPullToRefresh()
}}
onEndReached={onEndReached}
onItemSeen={(item: SearchResultSlice) => {
if (item.type === 'post') {
trackPostView(item.post)
}
}}
desktopFixedHeight
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
/>
}
/>
) : (
<EmptyState
messageText={
<NoResultsText hasFilters={hasFilters} query={query} />
}
/>
)}
</>
) : (
<Loader />
)}
</>
)
}
SearchScreenPostResults = memo(SearchScreenPostResults)
function SearchPost({
from,
position,
post,
}: {
from: Metrics['search:result:press']['tab']
position: Metrics['search:result:press']['position']
post: app.bsky.feed.defs.PostView
}) {
const ax = useAnalytics()
const onBeforePress = useCallback(() => {
ax.metric('search:result:press', {
tab: from,
resultType: 'post',
position,
uri: post.uri,
})
}, [ax, from, position, post])
return <Post post={post} onBeforePress={onBeforePress} />
}
let SearchScreenUserResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const {hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useActorSearch({
query,
enabled: active,
})
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (!hasSession) return
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage, hasSession])
const profiles = useMemo(() => {
return results?.pages.flatMap(page => page.actors) || []
}, [results])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'people',
initialCount: profiles.length,
})
})
if (isFetched) {
fireTracking()
}
if (error) {
return (
<EmptyState
messageText={
shouldRetryError(error) || isNetworkError(error)
? l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`
: l`We’re sorry, but your search could not be completed.`
}
error={error.toString()}
/>
)
}
return isFetched && profiles ? (
<>
{profiles.length ? (
<List
data={profiles}
renderItem={({
item,
index,
}: {
item: bsky.profile.AnyProfileView
index: number
}) => <SearchScreenProfileButton position={index} profile={item} />}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
refreshing={isPTR}
onRefresh={() => void onPullToRefresh()}
onEndReached={onEndReached}
desktopFixedHeight
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage && hasSession}
isFetchingNextPage={isFetchingNextPage}
/>
}
/>
) : (
<EmptyState messageText={<NoResultsText query={query} />} />
)}
</>
) : (
<Loader />
)
}
SearchScreenUserResults = memo(SearchScreenUserResults)
function SearchScreenProfileButton({
position,
profile,
}: {
position: number
profile: bsky.profile.AnyProfileView
}) {
const ax = useAnalytics()
const handlePress = () => {
ax.metric('search:result:press', {
tab: 'people',
resultType: 'profile',
position,
uri: profile.did,
})
}
return <ProfileCardWithFollowBtn profile={profile} onPress={handlePress} />
}
let SearchScreenFeedsResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {
data: results,
isFetched,
isFetching,
error,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = usePopularFeedsSearch({
query,
enabled: active,
})
const feeds = useMemo(() => {
return results?.pages.flatMap(page => page.feeds) || []
}, [results])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'feeds',
initialCount: feeds.length,
})
})
if (isFetched) {
fireTracking()
}
return isFetched ? (
<>
{feeds.length || hasNextPage ? (
<List
data={feeds}
renderItem={({
item,
index,
}: {
item: app.bsky.feed.defs.GeneratorView
index: number
}) => (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<SearchFeedCard position={index} view={item} />
</View>
)}
keyExtractor={(item: app.bsky.feed.defs.GeneratorView) => item.uri}
onEndReached={onEndReached}
desktopFixedHeight
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
}
/>
) : (
<EmptyState messageText={<NoResultsText query={query} />} />
)}
</>
) : (
<Loader />
)
}
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
function SearchFeedCard({
position,
view,
}: {
position: number
view: app.bsky.feed.defs.GeneratorView
}) {
const ax = useAnalytics()
const handleOnPress = () => {
ax.metric('search:result:press', {
tab: 'feeds',
resultType: 'feed',
position,
uri: view.uri,
})
}
return <FeedCard.Default view={view} onPress={handleOnPress} />
}
let SearchScreenStarterPackResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const [isPTR, setIsPTR] = useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useStarterPackSearch({
query,
enabled: active,
})
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const starterPacks = useMemo(() => {
return results?.pages.flatMap(page => page.starterPacks) || []
}, [results])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'starterPacks',
initialCount: starterPacks.length,
})
})
if (isFetched) {
fireTracking()
}
if (error) {
return (
<EmptyState
messageText={
shouldRetryError(error) || isNetworkError(error)
? l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`
: l`We’re sorry, but your search could not be completed.`
}
error={cleanError(error)}
/>
)
}
return isFetched ? (
<>
{starterPacks.length ? (
<List
data={starterPacks}
renderItem={({
item,
index,
}: {
item: app.bsky.graph.defs.StarterPackView
index: number
}) => (
<View style={[a.px_lg, a.pb_lg, index === 0 && a.pt_lg]}>
<SearchStarterPack position={index} view={item} />
</View>
)}
keyExtractor={(item: app.bsky.graph.defs.StarterPackView) => item.uri}
refreshing={isPTR}
onRefresh={() => void onPullToRefresh()}
onEndReached={onEndReached}
desktopFixedHeight
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
}
/>
) : (
<EmptyState messageText={<NoResultsText query={query} />} />
)}
</>
) : (
<Loader />
)
}
SearchScreenStarterPackResults = memo(SearchScreenStarterPackResults)
function SearchStarterPack({
position,
view,
}: {
position: number
view: app.bsky.graph.defs.StarterPackView
}) {
const ax = useAnalytics()
const handleOnPress = () => {
ax.metric('search:result:press', {
tab: 'starterPacks',
resultType: 'starterPack',
position,
uri: view.uri,
})
}
return <StarterPackCard view={view} onPress={handleOnPress} />
}