Files
bsky-social-app/src/screens/Search/SearchResults.tsx
2026-03-05 10:16:20 -08:00

613 lines
15 KiB
TypeScript
Raw Permalink 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 {type AppBskyFeedDefs} from '@atproto/api'
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} from '#/lib/strings/errors'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
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 {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 * as bsky from '#/types/bsky'
let SearchResults = ({
query,
queryWithParams,
activeTab,
onPageSelected,
headerHeight,
initialPage = 0,
}: {
query: string
queryWithParams: string
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
initialPage?: number
}): React.ReactNode => {
const {t: l} = useLingui()
const sections = useMemo(() => {
if (!queryWithParams) return []
const noParams = queryWithParams === query
return [
{
title: l`Top`,
component: (
<SearchScreenPostResults
query={queryWithParams}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: l`Latest`,
component: (
<SearchScreenPostResults
query={queryWithParams}
sort="latest"
active={activeTab === 1}
/>
),
},
noParams && {
title: l`People`,
component: (
<SearchScreenUserResults query={query} active={activeTab === 2} />
),
},
noParams && {
title: l`Feeds`,
component: (
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [l, query, queryWithParams, activeTab])
// There may be fewer tabs after changing the search options.
const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage
return (
<Pager
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({
query,
}: {
sort?: 'top' | 'latest' | 'people' | 'feeds'
query: string
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<>
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
<Trans>
No results found for “
<Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text>
”.
</Trans>
</Text>
{'\n\n'}
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans context="english-only-resource">
Try a different search term, or{' '}
<InlineLinkText
label={l({
message: 'read about how to use search filters',
context: 'english-only-resource',
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
read about how to use search filters
</InlineLinkText>
.
</Trans>
</Text>
</>
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: AppBskyFeedDefs.PostView
}
| {
type: 'loadingMore'
key: string
}
let SearchScreenPostResults = ({
query,
sort,
active,
}: {
query: string
sort?: 'top' | 'latest'
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false)
const trackPostView = usePostViewTracking('SearchResults')
const augmentedQuery = useMemo(() => {
return augmentSearchQuery(query || '', {did: currentAccount?.did})
}, [query, currentAccount])
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
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={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`}
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 query={query} />} />
)}
</>
) : (
<Loader />
)}
</>
)
}
SearchScreenPostResults = memo(SearchScreenPostResults)
function SearchPost({
from,
position,
post,
}: {
from: Metrics['search:result:press']['tab']
position: Metrics['search:result:press']['position']
post: AppBskyFeedDefs.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={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`}
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} = usePopularFeedsSearch({
query,
enabled: active,
})
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'feeds',
initialCount: results?.length ?? 0,
})
})
if (isFetched) {
fireTracking()
}
return isFetched && results ? (
<>
{results.length ? (
<List
data={results}
renderItem={({
item,
index,
}: {
item: AppBskyFeedDefs.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: AppBskyFeedDefs.GeneratorView) => item.uri}
desktopFixedHeight
ListFooterComponent={<ListFooter />}
/>
) : (
<EmptyState messageText={<NoResultsText query={query} />} />
)}
</>
) : (
<Loader />
)
}
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
function SearchFeedCard({
position,
view,
}: {
position: number
view: AppBskyFeedDefs.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} />
}