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: ( ), }, { title: l`Latest`, component: ( ), }, noFilters && { title: l`People`, component: ( ), }, noFilters && { title: l`Feeds`, component: ( ), }, noFilters && isStarterPacksEnabled && { title: l`Starter packs`, component: ( ), }, ].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 ( ( section.title)} {...props} /> )} initialPage={selectedPage}> {sections.map((section, i) => ( {section.component} ))} ) } SearchResults = memo(SearchResults) export {SearchResults} function Loader() { return ( ) } function EmptyState({ messageText, error, children, }: { messageText: React.ReactNode error?: string children?: React.ReactNode }) { const t = useTheme() return ( {messageText} {error && ( <> Error: {error} )} {children} ) } function NoResultsText({ hasFilters = false, query, }: { hasFilters?: boolean query: string }) { const t = useTheme() const {t: l} = useLingui() return ( <> {hasFilters ? ( query ? ( No results found for “ {query}” with advanced search filters applied. ) : ( No results found for your query with advanced search filters applied. ) ) : ( No results found for “ {query}”. )} {'\n\n'} {hasFilters ? ( Try a different search term or remove some filters. ) : ( Try a different search term. )} {'\n\n'} Learn more about{' '} how to use advanced search . ) } 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 ( Sign in or create an account to search for news, sports, politics, and everything else happening on Bluesky. ) } return error ? ( ) : ( <> {isFetched ? ( <> {posts.length ? ( { if (item.type === 'post') { return ( ) } 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={ } /> ) : ( } /> )} ) : ( )} ) } 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 } 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 ( ) } return isFetched && profiles ? ( <> {profiles.length ? ( } keyExtractor={(item: bsky.profile.AnyProfileView) => item.did} refreshing={isPTR} onRefresh={() => void onPullToRefresh()} onEndReached={onEndReached} desktopFixedHeight ListFooterComponent={ } /> ) : ( } /> )} ) : ( ) } 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 } 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 ? ( ( )} keyExtractor={(item: app.bsky.feed.defs.GeneratorView) => item.uri} onEndReached={onEndReached} desktopFixedHeight ListFooterComponent={ } /> ) : ( } /> )} ) : ( ) } 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 } 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 ( ) } return isFetched ? ( <> {starterPacks.length ? ( ( )} keyExtractor={(item: app.bsky.graph.defs.StarterPackView) => item.uri} refreshing={isPTR} onRefresh={() => void onPullToRefresh()} onEndReached={onEndReached} desktopFixedHeight ListFooterComponent={ } /> ) : ( } /> )} ) : ( ) } 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 }