diff options
Diffstat (limited to 'src/screens')
18 files changed, 3463 insertions, 228 deletions
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index d0b0cacca..e725b7b80 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,12 +1,12 @@ import React from 'react' import {View} from 'react-native' import { - AppBskyActorProfile, - AppBskyGraphDefs, + type AppBskyActorProfile, + type AppBskyGraphDefs, AppBskyGraphStarterpack, - Un$Typed, + type Un$Typed, } from '@atproto/api' -import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {type SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -46,7 +46,7 @@ import {IconCircle} from '#/components/IconCircle' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' diff --git a/src/screens/Profile/ProfileSearch.tsx b/src/screens/Profile/ProfileSearch.tsx index d91dc973e..6247e3979 100644 --- a/src/screens/Profile/ProfileSearch.tsx +++ b/src/screens/Profile/ProfileSearch.tsx @@ -2,11 +2,14 @@ import {useMemo} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' -import {SearchScreenShell} from '#/view/screens/Search/Search' +import {SearchScreenShell} from '#/screens/Search/Shell' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'> export const ProfileSearchScreen = ({route}: Props) => { diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx new file mode 100644 index 000000000..86877677a --- /dev/null +++ b/src/screens/Search/Explore.tsx @@ -0,0 +1,923 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {View, type ViewabilityConfig, type ViewToken} from 'react-native' +import { + type AppBskyActorDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGate} from '#/lib/statsig/statsig' +import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {type MetricEvents} from '#/logger/metrics' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery' +import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery' +import {useProgressGuide} from '#/state/shell/progress-guide' +import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' +import {PostFeedItem} from '#/view/com/posts/PostFeedItem' +import {ViewFullThread} from '#/view/com/posts/ViewFullThread' +import {List} from '#/view/com/util/List' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import { + StarterPackCard, + StarterPackCardSkeleton, +} from '#/screens/Search/components/StarterPackCard' +import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' +import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' +import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {StarterPack} from '#/components/icons/StarterPack' +import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import * as ModuleHeader from './components/ModuleHeader' +import { + type FeedPreviewItem, + useFeedPreviews, +} from './modules/ExploreFeedPreviews' +import { + SuggestedAccountsTabBar, + SuggestedProfileCard, + useLoadEnoughProfiles, +} from './modules/ExploreSuggestedAccounts' + +function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Button + label={_(msg`Load more`)} + onPress={item.onLoadMore} + style={[a.relative, a.w_full]}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.justify_center, + a.px_lg, + a.py_md, + a.gap_sm, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <Text + style={[ + a.leading_snug, + hovered ? t.atoms.text : t.atoms.text_contrast_medium, + ]}> + {item.message} + </Text> + {item.isLoadingMore ? ( + <Loader size="sm" /> + ) : ( + <ChevronDownIcon + size="sm" + style={hovered ? t.atoms.text : t.atoms.text_contrast_medium} + /> + )} + </View> + )} + </Button> + ) +} + +type ExploreScreenItems = + | { + type: 'topBorder' + key: string + } + | { + type: 'header' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'tabbedHeader' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'trendingTopics' + key: string + } + | { + type: 'trendingVideos' + key: string + } + | { + type: 'recommendations' + key: string + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileView + recId?: number + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + message: string + isLoadingMore: boolean + onLoadMore: () => void + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + | { + type: 'starterPack' + key: string + view: AppBskyGraphDefs.StarterPackView + } + | { + type: 'starterPackSkeleton' + key: string + } + | FeedPreviewItem + +export function Explore({ + focusSearchInput, + headerHeight, +}: { + focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void + headerHeight: number +}) { + const {_} = useLingui() + const t = useTheme() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const moderationOpts = useModerationOpts() + const gate = useGate() + const guide = useProgressGuide('follow-10') + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + const { + data: suggestedProfiles, + hasNextPage: hasNextSuggestedProfilesPage, + isLoading: isLoadingSuggestedProfiles, + isFetchingNextPage: isFetchingNextSuggestedProfilesPage, + error: suggestedProfilesError, + fetchNextPage: fetchNextSuggestedProfilesPage, + } = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10}) + const { + data: interestProfiles, + hasNextPage: hasNextInterestProfilesPage, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + error: interestProfilesError, + fetchNextPage: fetchNextInterestProfilesPage, + } = useActorSearchPaginated({ + query: selectedInterest || '', + enabled: !!selectedInterest, + limit: 10, + }) + const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({ + interest: selectedInterest, + data: interestProfiles, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + hasNextPage: hasNextInterestProfilesPage, + fetchNextPage: fetchNextInterestProfilesPage, + }) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 10}) + + const profiles: typeof suggestedProfiles & typeof interestProfiles = + !selectedInterest ? suggestedProfiles : interestProfiles + const hasNextProfilesPage = !selectedInterest + ? hasNextSuggestedProfilesPage + : hasNextInterestProfilesPage + const isLoadingProfiles = !selectedInterest + ? isLoadingSuggestedProfiles + : !canShowSuggestedProfiles + const isFetchingNextProfilesPage = !selectedInterest + ? isFetchingNextSuggestedProfilesPage + : !canShowSuggestedProfiles + const profilesError = !selectedInterest + ? suggestedProfilesError + : interestProfilesError + const fetchNextProfilesPage = !selectedInterest + ? fetchNextSuggestedProfilesPage + : fetchNextInterestProfilesPage + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return + try { + await fetchNextProfilesPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) + const { + data: suggestedSPs, + isLoading: isLoadingSuggestedSPs, + error: suggestedSPsError, + } = useSuggestedStarterPacksQuery() + + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false) + const onLoadMoreFeeds = useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + if (!hasPressedLoadMoreFeeds) { + setHasPressedLoadMoreFeeds(true) + return + } + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + hasPressedLoadMoreFeeds, + ]) + + const {data: suggestedFeeds} = useGetSuggestedFeedsQuery() + const { + data: feedPreviewSlices, + query: { + isPending: isPendingFeedPreviews, + isFetchingNextPage: isFetchingNextPageFeedPreviews, + fetchNextPage: fetchNextPageFeedPreviews, + hasNextPage: hasNextPageFeedPreviews, + error: feedPreviewSlicesError, + }, + } = useFeedPreviews(suggestedFeeds?.feeds ?? []) + + const onLoadMoreFeedPreviews = useCallback(async () => { + if ( + isPendingFeedPreviews || + isFetchingNextPageFeedPreviews || + !hasNextPageFeedPreviews || + feedPreviewSlicesError + ) + return + try { + await fetchNextPageFeedPreviews() + } catch (err) { + logger.error('Failed to load more feed previews', {message: err}) + } + }, [ + isPendingFeedPreviews, + isFetchingNextPageFeedPreviews, + hasNextPageFeedPreviews, + feedPreviewSlicesError, + fetchNextPageFeedPreviews, + ]) + + const items = useMemo<ExploreScreenItems[]>(() => { + const i: ExploreScreenItems[] = [] + + const addTopBorder = () => { + i.push({type: 'topBorder', key: 'top-border'}) + } + + const addTrendingTopicsModule = () => { + i.push({ + type: 'trendingTopics', + key: `trending-topics`, + }) + + // temp - disable trending videos + // if (isNative) { + // i.push({ + // type: 'trendingVideos', + // key: `trending-videos`, + // }) + // } + } + + const addSuggestedFollowsModule = () => { + i.push({ + type: 'tabbedHeader', + key: 'suggested-accounts-header', + title: _(msg`Suggested Accounts`), + icon: Person, + searchButton: { + label: _(msg`Search for more accounts`), + metricsTag: 'suggestedAccounts', + tab: 'user', + }, + }) + + if (!canShowSuggestedProfiles) { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } else if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + if (profiles !== undefined) { + if (profiles.pages.length > 0 && moderationOpts) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const profileItems: ExploreScreenItems[] = [] + for (const page of profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did) && !actor.viewer?.following) { + seen.add(actor.did) + profileItems.push({ + type: 'profile', + key: actor.did, + profile: actor, + recId: page.recId, + }) + } + } + } + + if (profileItems.length === 0) { + if (!hasNextProfilesPage) { + // no items! remove the header + i.pop() + } + } else { + i.push(...profileItems) + } + if (hasNextProfilesPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + message: _(msg`Load more suggested accounts`), + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + }) + } + } else { + console.log('no pages') + } + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } + } + } + + const addSuggestedFeedsModule = () => { + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Discover Feeds`), + icon: ListSparkle, + searchButton: { + label: _(msg`Search for more feeds`), + metricsTag: 'suggestedFeeds', + tab: 'feed', + }, + }) + + if (feeds && preferences) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const feedItems: ExploreScreenItems[] = [] + for (const page of feeds.pages) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + feedItems.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + // feeds errors can occur during pagination, so feeds is truthy + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + if (feedItems.length === 0) { + if (!hasNextFeedsPage) { + i.pop() + } + } else { + // This query doesn't follow the limit very well, so the first press of the + // load more button just unslices the array back to ~10 items + if (!hasPressedLoadMoreFeeds) { + i.push(...feedItems.slice(0, 3)) + } else { + i.push(...feedItems) + } + } + if (hasNextFeedsPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + message: _(msg`Load more suggested feeds`), + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + }) + } + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + } + + const addSuggestedStarterPacksModule = () => { + i.push({ + type: 'header', + key: 'suggested-starterPacks-header', + title: _(msg`Starter Packs`), + icon: StarterPack, + }) + + if (isLoadingSuggestedSPs) { + Array.from({length: 3}).forEach((_, index) => + i.push({ + type: 'starterPackSkeleton', + key: `starterPackSkeleton-${index}`, + }), + ) + } else if (suggestedSPsError || !suggestedSPs) { + // just get rid of the section + i.pop() + } else { + suggestedSPs.starterPacks.map(s => { + i.push({ + type: 'starterPack', + key: s.uri, + view: s, + }) + }) + } + } + + const addFeedPreviews = () => { + i.push(...feedPreviewSlices) + if (isFetchingNextPageFeedPreviews) { + i.push({ + type: 'preview:loading', + key: 'preview-loading-more', + }) + } + } + + // Dynamic module ordering + + addTopBorder() + + if (guide?.guide === 'follow-10' && !guide.isComplete) { + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + addTrendingTopicsModule() + } else { + addTrendingTopicsModule() + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + } + + if (gate('explore_show_suggested_feeds')) { + addSuggestedFeedsModule() + } + + addFeedPreviews() + + return i + }, [ + _, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + hasNextProfilesPage, + hasNextFeedsPage, + guide, + gate, + moderationOpts, + hasPressedLoadMoreFeeds, + suggestedSPs, + isLoadingSuggestedSPs, + suggestedSPsError, + feedPreviewSlices, + isFetchingNextPageFeedPreviews, + canShowSuggestedProfiles, + ]) + + const renderItem = useCallback( + ({item, index}: {item: ExploreScreenItems; index: number}) => { + switch (item.type) { + case 'topBorder': + return ( + <View + style={[ + a.w_full, + t.atoms.border_contrast_low, + a.border_t, + headerHeight && + web({ + position: 'sticky', + top: headerHeight, + }), + ]} + /> + ) + case 'header': { + return ( + <ModuleHeader.Container> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + ) + } + case 'tabbedHeader': { + return ( + <View style={[a.pb_md]}> + <ModuleHeader.Container style={[a.pb_xs]}> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + <SuggestedAccountsTabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + /> + </View> + ) + } + case 'trendingTopics': { + return ( + <View style={[a.pb_md]}> + <ExploreTrendingTopics /> + </View> + ) + } + case 'trendingVideos': { + return <ExploreTrendingVideos /> + } + case 'recommendations': { + return <ExploreRecommendations /> + } + case 'profile': { + return ( + <SuggestedProfileCard + profile={item.profile} + moderationOpts={moderationOpts!} + recId={item.recId} + position={index} + /> + ) + } + case 'feed': { + return ( + <View + style={[ + a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default view={item.feed} /> + </View> + ) + } + case 'starterPack': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCard view={item.view} /> + </View> + ) + } + case 'starterPackSkeleton': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCardSkeleton /> + </View> + ) + } + case 'loadMore': { + return ( + <View style={[a.border_t, t.atoms.border_contrast_low]}> + <LoadMore item={item} /> + </View> + ) + } + case 'profilePlaceholder': { + return ( + <> + {Array.from({length: 3}).map((_, index) => ( + <View + style={[ + a.px_lg, + a.py_lg, + a.border_t, + t.atoms.border_contrast_low, + ]} + key={index}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.AvatarPlaceholder /> + <ProfileCard.NameAndHandlePlaceholder /> + </ProfileCard.Header> + <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> + </ProfileCard.Outer> + </View> + ))} + </> + ) + } + case 'feedPlaceholder': { + return <FeedFeedLoadingPlaceholder /> + } + case 'error': + case 'preview:error': { + return ( + <View + style={[ + a.border_t, + a.pt_md, + a.px_md, + t.atoms.border_contrast_low, + ]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_lg, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <CircleInfo size="md" fill={t.palette.negative_400} /> + <View style={[a.flex_1, a.gap_sm]}> + <Text style={[a.font_bold, a.leading_snug]}> + {item.message} + </Text> + <Text + style={[ + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {item.error} + </Text> + </View> + </View> + </View> + ) + } + // feed previews + case 'preview:empty': { + return null // what should we do here? + } + case 'preview:loading': { + return ( + <View style={[a.py_2xl, a.flex_1, a.align_center]}> + <Loader size="lg" /> + </View> + ) + } + case 'preview:header': { + return ( + <ModuleHeader.Container + headerHeight={headerHeight} + style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}> + <ModuleHeader.FeedLink feed={item.feed}> + <ModuleHeader.FeedAvatar feed={item.feed} /> + <View style={[a.flex_1, a.gap_xs]}> + <ModuleHeader.TitleText style={[a.text_lg]}> + {item.feed.displayName} + </ModuleHeader.TitleText> + <ModuleHeader.SubtitleText> + <Trans> + By {sanitizeHandle(item.feed.creator.handle, '@')} + </Trans> + </ModuleHeader.SubtitleText> + </View> + </ModuleHeader.FeedLink> + <ModuleHeader.PinButton feed={item.feed} /> + </ModuleHeader.Container> + ) + } + case 'preview:footer': { + return <View style={[a.w_full, a.pt_2xl]} /> + } + case 'preview:sliceItem': { + const slice = item.slice + const indexInSlice = item.indexInSlice + const subItem = slice.items[indexInSlice] + return ( + <PostFeedItem + post={subItem.post} + record={subItem.record} + reason={indexInSlice === 0 ? slice.reason : undefined} + feedContext={slice.feedContext} + moderation={subItem.moderation} + parentAuthor={subItem.parentAuthor} + showReplyTo={item.showReplyTo} + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} + isThreadLastChild={ + isThreadChildAt(slice.items, indexInSlice) && + slice.items.length === indexInSlice + 1 + } + isParentBlocked={subItem.isParentBlocked} + isParentNotFound={subItem.isParentNotFound} + hideTopBorder={item.hideTopBorder} + rootPost={slice.items[0].post} + /> + ) + } + case 'preview:sliceViewFullThread': { + return <ViewFullThread uri={item.uri} /> + } + case 'preview:loadMoreError': { + return ( + <LoadMoreRetryBtn + label={_( + msg`There was an issue fetching posts. Tap here to try again.`, + )} + onPress={fetchNextPageFeedPreviews} + /> + ) + } + } + }, + [ + t, + focusSearchInput, + moderationOpts, + selectedInterest, + _, + fetchNextPageFeedPreviews, + headerHeight, + ], + ) + + const stickyHeaderIndices = useMemo( + () => + items.reduce( + (acc, curr) => + ['topBorder', 'preview:header'].includes(curr.type) + ? acc.concat(items.indexOf(curr)) + : acc, + [] as number[], + ), + [items], + ) + + // track headers and report module viewability + const alreadyReportedRef = useRef<Map<string, string>>(new Map()) + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: ViewToken<ExploreScreenItems>[] + changed: ViewToken<ExploreScreenItems>[] + }) => { + for (const {item} of viewableItems.filter(vi => vi.isViewable)) { + let module: MetricEvents['explore:module:seen']['module'] + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { + module = item.type + } else if (item.type === 'profile') { + module = 'suggestedAccounts' + } else if (item.type === 'feed') { + module = 'suggestedFeeds' + } else if (item.type === 'preview:header') { + module = `feed:feedgen|${item.feed.uri}` + } else { + continue + } + if (!alreadyReportedRef.current.has(module)) { + alreadyReportedRef.current.set(module, module) + logger.metric('explore:module:seen', {module}) + } + } + }, + [], + ) + + return ( + <List + data={items} + renderItem={renderItem} + keyExtractor={item => item.key} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + stickyHeaderIndices={native(stickyHeaderIndices)} + viewabilityConfig={viewabilityConfig} + onViewableItemsChanged={onViewableItemsChanged} + onEndReached={onLoadMoreFeedPreviews} + onEndReachedThreshold={2} + /> + ) +} + +const viewabilityConfig: ViewabilityConfig = { + itemVisiblePercentThreshold: 100, +} diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx new file mode 100644 index 000000000..bb51d2deb --- /dev/null +++ b/src/screens/Search/SearchResults.tsx @@ -0,0 +1,338 @@ +import {memo, useCallback, useMemo, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyFeedDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +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 {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 {Text} from '#/components/Typography' + +let SearchResults = ({ + query, + queryWithParams, + activeTab, + onPageSelected, + headerHeight, +}: { + query: string + queryWithParams: string + activeTab: number + onPageSelected: (page: number) => void + headerHeight: number +}): React.ReactNode => { + const {_} = useLingui() + + const sections = useMemo(() => { + if (!queryWithParams) return [] + const noParams = queryWithParams === query + return [ + { + title: _(msg`Top`), + component: ( + <SearchScreenPostResults + query={queryWithParams} + sort="top" + active={activeTab === 0} + /> + ), + }, + { + title: _(msg`Latest`), + component: ( + <SearchScreenPostResults + query={queryWithParams} + sort="latest" + active={activeTab === 1} + /> + ), + }, + noParams && { + title: _(msg`People`), + component: ( + <SearchScreenUserResults query={query} active={activeTab === 2} /> + ), + }, + noParams && { + title: _(msg`Feeds`), + component: ( + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> + ), + }, + ].filter(Boolean) as { + title: string + component: React.ReactNode + }[] + }, [_, query, queryWithParams, activeTab]) + + 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={0}> + {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({message, error}: {message: string; error?: string}) { + 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]}>{message}</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> + </> + )} + </View> + </View> + </Layout.Content> + ) +} + +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 {_} = useLingui() + const {currentAccount} = useSession() + const [isPTR, setIsPTR] = useState(false) + + 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 onPullToRefresh = useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = useCallback(() => { + if (isFetching || !hasNextPage || error) return + 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]) + + return error ? ( + <EmptyState + message={_( + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, + )} + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <List + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} /> + } else { + return null + } + }} + keyExtractor={item => item.key} + refreshing={isPTR} + onRefresh={onPullToRefresh} + onEndReached={onEndReached} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} +SearchScreenPostResults = memo(SearchScreenPostResults) + +let SearchScreenUserResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + + const {data: results, isFetched} = useActorSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + <List + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn profile={item} noBg /> + )} + keyExtractor={item => item.did} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} +SearchScreenUserResults = memo(SearchScreenUserResults) + +let SearchScreenFeedsResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + + const {data: results, isFetched} = usePopularFeedsSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + <List + data={results} + renderItem={({item}) => ( + <View + style={[ + a.border_b, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default view={item} /> + </View> + )} + keyExtractor={item => item.uri} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} +SearchScreenFeedsResults = memo(SearchScreenFeedsResults) 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) + } +} diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx new file mode 100644 index 000000000..58a0dec77 --- /dev/null +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -0,0 +1,71 @@ +import {memo} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isNative} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' +import {atoms as a, native} from '#/alf' +import * as Layout from '#/components/Layout' + +let AutocompleteResults = ({ + isAutocompleteFetching, + autocompleteData, + searchText, + onSubmit, + onResultPress, + onProfileClick, +}: { + isAutocompleteFetching: boolean + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined + searchText: string + onSubmit: () => void + onResultPress: () => void + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void +}): React.ReactNode => { + const moderationOpts = useModerationOpts() + const {_} = useLingui() + return ( + <> + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( + <Layout.Content> + <View style={[a.py_xl]}> + <ActivityIndicator /> + </View> + </Layout.Content> + ) : ( + <Layout.Content + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${searchText}"`)} + onPress={native(onSubmit)} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(searchText)}` + } + style={{borderBottomWidth: 1}} + /> + {autocompleteData?.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + onPress={() => { + onProfileClick(item) + onResultPress() + }} + /> + ))} + <View style={{height: 200}} /> + </Layout.Content> + )} + </> + ) +} +AutocompleteResults = memo(AutocompleteResults) +export {AutocompleteResults} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx deleted file mode 100644 index a010ad8dc..000000000 --- a/src/screens/Search/components/ExploreTrendingTopics.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import { - useTrendingSettings, - useTrendingSettingsApi, -} from '#/state/preferences/trending' -import { - DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' -import {useTrendingConfig} from '#/state/trending-config' -import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' -import * as Prompt from '#/components/Prompt' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' -import {Text} from '#/components/Typography' - -export function ExploreTrendingTopics() { - const {enabled} = useTrendingConfig() - const {trendingDisabled} = useTrendingSettings() - return enabled && !trendingDisabled ? <Inner /> : null -} - -function Inner() { - const t = useTheme() - const {_} = useLingui() - const gutters = useGutters([0, 'compact']) - const {data: trending, error, isLoading} = useTrendingTopics() - const noTopics = !isLoading && !error && !trending?.topics?.length - const {setTrendingDisabled} = useTrendingSettingsApi() - const trendingPrompt = Prompt.usePromptControl() - - const onConfirmHide = React.useCallback(() => { - logEvent('trendingTopics:hide', {context: 'explore:trending'}) - setTrendingDisabled(true) - }, [setTrendingDisabled]) - - return error || noTopics ? null : ( - <> - <View - style={[ - a.flex_row, - isWeb - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [a.p_lg, a.pt_2xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Trending - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> - <Trans>Trending</Trans> - </Text> - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> - <GradientFill gradient={tokens.gradients.primary} /> - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> - <Trans>BETA</Trans> - </Text> - </View> - </View> - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans>What people are posting about.</Trans> - </Text> - </View> - <Button - label={_(msg`Hide trending topics`)} - size="small" - variant="ghost" - color="secondary" - shape="round" - onPress={() => trendingPrompt.open()}> - <ButtonIcon icon={X} /> - </Button> - </View> - - <View style={[a.pt_md, a.pb_lg]}> - <View - style={[ - a.flex_row, - a.justify_start, - a.flex_wrap, - {rowGap: 8, columnGap: 6}, - gutters, - ]}> - {isLoading ? ( - Array(TRENDING_TOPICS_COUNT) - .fill(0) - .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) - ) : !trending?.topics ? null : ( - <> - {trending.topics.map(topic => ( - <TrendingTopicLink - key={topic.link} - topic={topic} - onPress={() => { - logEvent('trendingTopic:click', {context: 'explore'}) - }}> - {({hovered}) => ( - <TrendingTopic - topic={topic} - style={[ - hovered && [ - t.atoms.border_contrast_high, - t.atoms.bg_contrast_25, - ], - ]} - /> - )} - </TrendingTopicLink> - ))} - </> - )} - </View> - </View> - - <Prompt.Basic - control={trendingPrompt} - title={_(msg`Hide trending topics?`)} - description={_(msg`You can update this later from your settings.`)} - confirmButtonCta={_(msg`Hide`)} - onConfirm={onConfirmHide} - /> - </> - ) -} diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx new file mode 100644 index 000000000..cbd0a856b --- /dev/null +++ b/src/screens/Search/components/ModuleHeader.tsx @@ -0,0 +1,170 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {type AppBskyFeedDefs, AtUri} from '@atproto/api' + +import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import { + atoms as a, + native, + useGutters, + useTheme, + type ViewStyleProp, + web, +} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {sizes as iconSizes} from '#/components/icons/common' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' +import {Link} from '#/components/Link' +import {Text, type TextProps} from '#/components/Typography' + +export function Container({ + style, + children, + headerHeight, +}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) { + const t = useTheme() + const gutters = useGutters([0, 'base']) + return ( + <View + style={[ + gutters, + a.flex_row, + a.align_center, + a.pt_2xl, + a.pb_md, + a.gap_sm, + t.atoms.bg, + headerHeight && web({position: 'sticky', top: headerHeight}), + style, + ]}> + {children} + </View> + ) +} + +export function FeedLink({ + feed, + children, +}: { + feed: AppBskyFeedDefs.GeneratorView + children?: React.ReactNode +}) { + const t = useTheme() + const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri]) + return ( + <Link + to={makeCustomFeedLink(did, rkey)} + label={feed.displayName} + style={[a.flex_1]}> + {({focused, hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + {gap: 10}, + a.rounded_md, + a.p_xs, + {marginLeft: -6}, + (focused || hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + {children} + </View> + )} + </Link> + ) +} + +export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return <UserAvatar type="algo" size={38} avatar={feed.avatar} /> +} + +export function Icon({ + icon: Comp, + size = 'lg', +}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) { + const iconSize = iconSizes[size] + + return ( + <View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}> + <Comp width={iconSize} /> + </View> + ) +} + +export function TitleText({style, ...props}: TextProps) { + return ( + <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} /> + ) +} + +export function SubtitleText({style, ...props}: TextProps) { + const t = useTheme() + return ( + <Text + style={[ + t.atoms.text_contrast_medium, + a.leading_tight, + a.flex_1, + a.text_sm, + style, + ]} + {...props} + /> + ) +} + +export function SearchButton({ + label, + metricsTag, + onPress, +}: { + label: string + metricsTag: 'suggestedAccounts' | 'suggestedFeeds' + onPress?: () => void +}) { + return ( + <Button + label={label} + size="small" + variant="ghost" + color="secondary" + shape="round" + PressableComponent={native(PressableScale)} + onPress={() => { + logger.metric( + 'explore:module:searchButtonPress', + {module: metricsTag}, + {statsig: true}, + ) + onPress?.() + }} + style={[ + { + right: -4, + }, + ]}> + <ButtonIcon icon={SearchIcon} size="lg" /> + </Button> + ) +} + +export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return ( + <View style={[a.z_20, {marginRight: -6}]}> + <FeedCard.SaveButton + pin + view={feed} + size="large" + color="secondary" + variant="ghost" + shape="square" + text={false} + /> + </View> + ) +} diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx new file mode 100644 index 000000000..5e62f2cd0 --- /dev/null +++ b/src/screens/Search/components/SearchHistory.tsx @@ -0,0 +1,169 @@ +import {Pressable, ScrollView, StyleSheet, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createHitslop, HITSLOP_10} from '#/lib/constants' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {Link} from '#/view/com/util/Link' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function SearchHistory({ + searchHistory, + selectedProfiles, + onItemClick, + onProfileClick, + onRemoveItemClick, + onRemoveProfileClick, +}: { + searchHistory: string[] + selectedProfiles: bsky.profile.AnyProfileView[] + onItemClick: (item: string) => void + onProfileClick: (profile: bsky.profile.AnyProfileView) => void + onRemoveItemClick: (item: string) => void + onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void +}) { + const {gtMobile} = useBreakpoints() + const t = useTheme() + const {_} = useLingui() + + return ( + <Layout.Content + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled"> + <View style={[a.w_full, a.px_md]}> + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + <Text style={[a.text_md, a.font_bold, a.p_md]}> + <Trans>Recent Searches</Trans> + </Text> + )} + {selectedProfiles.length > 0 && ( + <View + style={[ + styles.selectedProfilesContainer, + !gtMobile && styles.selectedProfilesContainerMobile, + ]}> + <BlockDrawerGesture> + <ScrollView + horizontal + keyboardShouldPersistTaps="handled" + style={[ + a.flex_row, + a.flex_nowrap, + {marginHorizontal: tokens.space._2xl * -1}, + ]} + contentContainerStyle={[a.px_2xl, a.border_0]}> + {selectedProfiles.slice(0, 5).map((profile, index) => ( + <View + key={index} + style={[ + styles.profileItem, + !gtMobile && styles.profileItemMobile, + ]}> + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline + onBeforePress={() => onProfileClick(profile)} + style={[a.align_center, a.w_full]}> + <UserAvatar + avatar={profile.avatar} + type={profile.associated?.labeler ? 'labeler' : 'user'} + size={60} + /> + <Text + emoji + style={[a.text_xs, a.text_center, styles.profileName]} + numberOfLines={1}> + {sanitizeDisplayName( + profile.displayName || profile.handle, + )} + </Text> + </Link> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Remove profile`)} + accessibilityHint={_( + msg`Removes profile from search history`, + )} + onPress={() => onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + <XIcon size="xs" style={t.atoms.text_contrast_low} /> + </Pressable> + </View> + ))} + </ScrollView> + </BlockDrawerGesture> + </View> + )} + {searchHistory.length > 0 && ( + <View style={[a.pl_md, a.pr_xs, a.mt_md]}> + {searchHistory.slice(0, 5).map((historyItem, index) => ( + <View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}> + <Pressable + accessibilityRole="button" + onPress={() => onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_md]}> + <Text style={[a.text_md]}>{historyItem}</Text> + </Pressable> + <Button + label={_(msg`Remove ${historyItem}`)} + onPress={() => onRemoveItemClick(historyItem)} + size="small" + variant="ghost" + color="secondary" + shape="round"> + <ButtonIcon icon={XIcon} /> + </Button> + </View> + ))} + </View> + )} + </View> + </Layout.Content> + ) +} + +const styles = StyleSheet.create({ + selectedProfilesContainer: { + marginTop: 10, + paddingHorizontal: 12, + height: 80, + }, + selectedProfilesContainerMobile: { + height: 100, + }, + profileItem: { + alignItems: 'center', + marginRight: 15, + width: 78, + }, + profileItemMobile: { + width: 70, + }, + profileName: { + width: 78, + marginTop: 6, + }, + profileRemoveBtn: { + position: 'absolute', + top: 0, + right: 5, + backgroundColor: 'white', + borderRadius: 10, + width: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/screens/Search/components/SearchLanguageDropdown.tsx b/src/screens/Search/components/SearchLanguageDropdown.tsx new file mode 100644 index 000000000..5c5a4b74f --- /dev/null +++ b/src/screens/Search/components/SearchLanguageDropdown.tsx @@ -0,0 +1,120 @@ +import {useMemo} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {languageName} from '#/locale/helpers' +import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' +import {atoms as a, native, platform, tokens} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import * as Menu from '#/components/Menu' + +export function SearchLanguageDropdown({ + value, + onChange, +}: { + value: string + onChange(value: string): void +}) { + const {_} = useLingui() + const {appLanguage, contentLanguages} = useLanguagePrefs() + + const languages = useMemo(() => { + return LANGUAGES.filter( + (lang, index, self) => + Boolean(lang.code2) && // reduce to the code2 varieties + index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) + ) + .map(l => ({ + label: languageName(l, appLanguage), + value: l.code2, + key: l.code2 + l.code3, + })) + .sort((a, b) => { + // prioritize user's languages + const aIsUser = contentLanguages.includes(a.value) + const bIsUser = contentLanguages.includes(b.value) + if (aIsUser && !bIsUser) return -1 + if (bIsUser && !aIsUser) return 1 + // prioritize "common" langs in the network + const aIsCommon = !!APP_LANGUAGES.find( + al => + // skip `ast`, because it uses a 3-letter code which conflicts with `as` + // it begins with `a` anyway so still is top of the list + al.code2 !== 'ast' && al.code2.startsWith(a.value), + ) + const bIsCommon = !!APP_LANGUAGES.find( + al => + // ditto + al.code2 !== 'ast' && al.code2.startsWith(b.value), + ) + if (aIsCommon && !bIsCommon) return -1 + if (bIsCommon && !aIsCommon) return 1 + // fall back to alphabetical + return a.label.localeCompare(b.label) + }) + }, [appLanguage, contentLanguages]) + + const currentLanguageLabel = + languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) + + return ( + <Menu.Root> + <Menu.Trigger + label={_( + msg`Filter search by language (currently: ${currentLanguageLabel})`, + )}> + {({props}) => ( + <Button + {...props} + label={props.accessibilityLabel} + size="small" + color={platform({native: 'primary', default: 'secondary'})} + variant={platform({native: 'ghost', default: 'solid'})} + style={native([ + a.py_sm, + a.px_sm, + {marginRight: tokens.space.sm * -1}, + ])}> + <ButtonIcon icon={EarthIcon} /> + <ButtonText>{currentLanguageLabel}</ButtonText> + <ButtonIcon + icon={platform({ + native: ChevronUpDownIcon, + default: ChevronDownIcon, + })} + /> + </Button> + )} + </Menu.Trigger> + <Menu.Outer> + <Menu.LabelText> + <Trans>Filter search by language</Trans> + </Menu.LabelText> + <Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}> + <Menu.ItemText> + <Trans>All languages</Trans> + </Menu.ItemText> + <Menu.ItemRadio selected={value === ''} /> + </Menu.Item> + <Menu.Divider /> + <Menu.Group> + {languages.map(lang => ( + <Menu.Item + key={lang.key} + label={lang.label} + onPress={() => onChange(lang.value)}> + <Menu.ItemText>{lang.label}</Menu.ItemText> + <Menu.ItemRadio selected={value === lang.value} /> + </Menu.Item> + ))} + </Menu.Group> + </Menu.Outer> + </Menu.Root> + ) +} diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx new file mode 100644 index 000000000..9520dd5a7 --- /dev/null +++ b/src/screens/Search/components/StarterPackCard.tsx @@ -0,0 +1,296 @@ +import React from 'react' +import {View} from 'react-native' +import { + type AppBskyGraphDefs, + AppBskyGraphStarterpack, + moderateProfile, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {ButtonText} from '#/components/Button' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Link} from '#/components/Link' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard' +import {Text} from '#/components/Typography' +import * as bsky from '#/types/bsky' + +export function StarterPackCard({ + view, +}: { + view: AppBskyGraphDefs.StarterPackView +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {gtPhone} = useBreakpoints() + const link = useStarterPackLink({view}) + + if ( + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( + view.record, + AppBskyGraphStarterpack.isRecord, + ) + ) { + return null + } + + const profileCount = gtPhone ? 11 : 8 + const profiles = view.listItemsSample + ?.slice(0, profileCount) + .map(item => item.subject) + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}> + <Link + to={link.to} + label={link.label} + style={[a.absolute, a.inset_0]} + onHoverIn={link.precache} + onPress={link.precache}> + <View /> + </Link> + </View> + + <AvatarStack + profiles={profiles ?? []} + numPending={profileCount} + total={view.list?.listItemCount} + /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1]}> + <Text + emoji + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {view.record.name} + </Text> + <Text + emoji + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {view.creator?.did === currentAccount?.did + ? _(msg`By you`) + : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} + </Text> + </View> + <Link + to={link.to} + label={link.label} + onHoverIn={link.precache} + onPress={link.precache} + variant="solid" + color="secondary" + size="small" + style={[a.z_50]}> + <ButtonText> + <Trans>Open pack</Trans> + </ButtonText> + </Link> + </View> + </View> + ) +} + +export function AvatarStack({ + profiles, + numPending, + total, +}: { + profiles: bsky.profile.AnyProfileView[] + numPending: number + total?: number +}) { + const t = useTheme() + const {gtPhone} = useBreakpoints() + const moderationOpts = useModerationOpts() + const computedTotal = (total ?? numPending) - numPending + const circlesCount = numPending + 1 // add total at end + const widthPerc = 100 / circlesCount + const [size, setSize] = React.useState<number | null>(null) + + const isPending = (numPending && profiles.length === 0) || !moderationOpts + + const items = isPending + ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.relative, + {width: `${100 - widthPerc * 0.2}%`}, + ]}> + {items.map((item, i) => ( + <View + key={item.key} + style={[ + { + width: `${widthPerc}%`, + zIndex: 100 - i, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + onLayout={e => setSize(e.nativeEvent.layout.width)} + style={[ + a.rounded_full, + t.atoms.bg_contrast_25, + { + paddingTop: '100%', + }, + ]}> + {size && item.profile ? ( + <UserAvatar + size={size} + avatar={item.profile.avatar} + type={item.profile.associated?.labeler ? 'labeler' : 'user'} + moderation={item.moderation.ui('avatar')} + style={[a.absolute, a.inset_0]} + /> + ) : ( + <MediaInsetBorder style={[a.rounded_full]} /> + )} + </View> + </View> + </View> + ))} + <View + style={[ + { + width: `${widthPerc}%`, + zIndex: 1, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + style={[ + { + paddingTop: '100%', + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.rounded_full, + a.align_center, + a.justify_center, + { + backgroundColor: t.atoms.text_contrast_low.color, + }, + ]}> + {computedTotal > 0 ? ( + <Text + style={[ + gtPhone ? a.text_md : a.text_sm, + a.font_bold, + a.leading_snug, + {color: 'white'}, + ]}> + <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12"> + +{computedTotal} + </Trans> + </Text> + ) : ( + <Plus fill="white" /> + )} + </View> + </View> + </View> + </View> + </View> + ) +} + +export function StarterPackCardSkeleton() { + const t = useTheme() + const {gtPhone} = useBreakpoints() + + const profileCount = gtPhone ? 11 : 8 + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <AvatarStack profiles={[]} numPending={profileCount} /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <LoadingPlaceholder width={180} height={18} /> + <LoadingPlaceholder width={120} height={14} /> + </View> + + <LoadingPlaceholder width={100} height={33} /> + </View> + </View> + ) +} diff --git a/src/screens/Search/index.tsx b/src/screens/Search/index.tsx new file mode 100644 index 000000000..429f1e5c7 --- /dev/null +++ b/src/screens/Search/index.tsx @@ -0,0 +1,13 @@ +import { + type NativeStackScreenProps, + type SearchTabNavigatorParams, +} from '#/lib/routes/types' +import {SearchScreenShell} from './Shell' + +export function SearchScreen( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const queryParam = props.route?.params?.q ?? '' + + return <SearchScreenShell queryParam={queryParam} testID="searchScreen" /> +} diff --git a/src/screens/Search/modules/ExploreFeedPreviews.tsx b/src/screens/Search/modules/ExploreFeedPreviews.tsx new file mode 100644 index 000000000..30aa00a3f --- /dev/null +++ b/src/screens/Search/modules/ExploreFeedPreviews.tsx @@ -0,0 +1,264 @@ +import {useMemo} from 'react' +import {type AppBskyFeedDefs, moderatePost} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useInfiniteQuery} from '@tanstack/react-query' + +import {CustomFeedAPI} from '#/lib/api/feed/custom' +import {aggregateUserInterests} from '#/lib/api/feed/utils' +import {FeedTuner} from '#/lib/api/feed-manip' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + type FeedPostSlice, + type FeedPostSliceItem, +} from '#/state/queries/post-feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +const RQKEY_ROOT = 'feed-previews' +const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds] + +const LIMIT = 8 // sliced to 6, overfetch to account for moderation + +export type FeedPreviewItem = + | { + type: 'topBorder' + key: string + } + | { + type: 'preview:loading' + key: string + } + | { + type: 'preview:error' + key: string + message: string + error: string + } + | { + type: 'preview:loadMoreError' + key: string + } + | { + type: 'preview:empty' + key: string + } + | { + type: 'preview:header' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'preview:footer' + key: string + } + // copied from PostFeed.tsx + | { + type: 'preview:sliceItem' + key: string + slice: FeedPostSlice + indexInSlice: number + showReplyTo: boolean + hideTopBorder: boolean + } + | { + type: 'preview:sliceViewFullThread' + key: string + uri: string + } + +export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) { + const uris = feeds.map(feed => feed.uri) + const {_} = useLingui() + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const userInterests = aggregateUserInterests(preferences) + const moderationOpts = useModerationOpts() + const enabled = feeds.length > 0 + + const query = useInfiniteQuery({ + enabled, + queryKey: RQKEY(uris), + queryFn: async ({pageParam}) => { + const feed = feeds[pageParam] + const api = new CustomFeedAPI({ + agent, + feedParams: {feed: feed.uri}, + userInterests, + }) + const data = await api.fetch({cursor: undefined, limit: LIMIT}) + return { + feed, + posts: data.feed, + } + }, + initialPageParam: 0, + getNextPageParam: (_p, _a, count) => + count < feeds.length ? count + 1 : undefined, + }) + + const {data, isFetched, isError, isPending, error} = query + + return { + query, + data: useMemo<FeedPreviewItem[]>(() => { + const items: FeedPreviewItem[] = [] + + if (!enabled) return items + + const isEmpty = + !isPending && !data?.pages?.some(page => page.posts.length) + + if (isFetched) { + if (isError && isEmpty) { + items.push({ + type: 'preview:error', + key: 'error', + message: _(msg`An error occurred while fetching the feed.`), + error: cleanError(error), + }) + } else if (isEmpty) { + items.push({ + type: 'preview:empty', + key: 'empty', + }) + } else if (data) { + for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) { + const page = data.pages[pageIndex] + // default feed tuner - we just want it to slice up the feed + const tuner = new FeedTuner([]) + const slices: FeedPreviewItem[] = [] + + let rowIndex = 0 + for (const item of tuner.tune(page.posts)) { + if (item.isFallbackMarker) continue + + const moderations = item.items.map(item => + moderatePost(item.post, moderationOpts!), + ) + + // apply moderation filters + item.items = item.items.filter((_, i) => { + return !moderations[i]?.ui('contentList').filter + }) + + const slice = { + _reactKey: item._reactKey, + _isFeedPostSlice: true, + isFallbackMarker: false, + isIncompleteThread: item.isIncompleteThread, + feedContext: item.feedContext, + reason: item.reason, + feedPostUri: item.feedPostUri, + items: item.items.slice(0, 6).map((subItem, i) => { + const feedPostSliceItem: FeedPostSliceItem = { + _reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`, + uri: subItem.post.uri, + post: subItem.post, + record: subItem.record, + moderation: moderations[i], + parentAuthor: subItem.parentAuthor, + isParentBlocked: subItem.isParentBlocked, + isParentNotFound: subItem.isParentNotFound, + } + return feedPostSliceItem + }), + } + if (slice.isIncompleteThread && slice.items.length >= 3) { + const beforeLast = slice.items.length - 2 + const last = slice.items.length - 1 + slices.push({ + type: 'preview:sliceItem', + key: slice.items[0]._reactKey, + slice: slice, + indexInSlice: 0, + showReplyTo: false, + hideTopBorder: rowIndex === 0, + }) + slices.push({ + type: 'preview:sliceViewFullThread', + key: slice._reactKey + '-viewFullThread', + uri: slice.items[0].uri, + }) + slices.push({ + type: 'preview:sliceItem', + key: slice.items[beforeLast]._reactKey, + slice: slice, + indexInSlice: beforeLast, + showReplyTo: + slice.items[beforeLast].parentAuthor?.did !== + slice.items[beforeLast].post.author.did, + hideTopBorder: false, + }) + slices.push({ + type: 'preview:sliceItem', + key: slice.items[last]._reactKey, + slice: slice, + indexInSlice: last, + showReplyTo: false, + hideTopBorder: false, + }) + } else { + for (let i = 0; i < slice.items.length; i++) { + slices.push({ + type: 'preview:sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + hideTopBorder: i === 0 && rowIndex === 0, + }) + } + } + + rowIndex++ + } + + if (slices.length > 0) { + if (pageIndex > 0) { + items.push({ + type: 'topBorder', + key: `topBorder-${page.feed.uri}`, + }) + } + items.push( + { + type: 'preview:footer', + key: `footer-${page.feed.uri}`, + }, + { + type: 'preview:header', + key: `header-${page.feed.uri}`, + feed: page.feed, + }, + ...slices, + ) + } + } + } else if (isError && !isEmpty) { + items.push({ + type: 'preview:loadMoreError', + key: 'loadMoreError', + }) + } + } else { + items.push({ + type: 'preview:loading', + key: 'loading', + }) + } + + return items + }, [ + enabled, + data, + isFetched, + isError, + isPending, + moderationOpts, + _, + error, + ]), + } +} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx index 602bab87d..4cf84269a 100644 --- a/src/screens/Search/components/ExploreRecommendations.tsx +++ b/src/screens/Search/modules/ExploreRecommendations.tsx @@ -1,8 +1,8 @@ import {View} from 'react-native' -import {AppBskyUnspeccedDefs} from '@atproto/api' +import {type AppBskyUnspeccedDefs} from '@atproto/api' import {Trans} from '@lingui/macro' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import { DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, @@ -18,6 +18,8 @@ import { } from '#/components/TrendingTopics' import {Text} from '#/components/Typography' +// Note: This module is not currently used and may be removed in the future. + export function ExploreRecommendations() { const {enabled} = useTrendingConfig() return enabled ? <Inner /> : null @@ -86,7 +88,11 @@ function Inner() { key={topic.link} topic={topic} onPress={() => { - logEvent('recommendedTopic:click', {context: 'explore'}) + logger.metric( + 'recommendedTopic:click', + {context: 'explore'}, + {statsig: true}, + ) }}> {({hovered}) => ( <TrendingTopic diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx new file mode 100644 index 000000000..070d75910 --- /dev/null +++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx @@ -0,0 +1,228 @@ +import {memo, useEffect} from 'react' +import {View} from 'react-native' +import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {type InfiniteData} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import { + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import * as ProfileCard from '#/components/ProfileCard' +import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function useLoadEnoughProfiles({ + interest, + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, +}: { + interest: string | null + data?: InfiniteData<AppBskyActorSearchActors.OutputSchema> + isLoading: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise<unknown> +}) { + const profileCount = + data?.pages.flatMap(page => + page.actors.filter(actor => !actor.viewer?.following), + ).length || 0 + const isAnyLoading = isLoading || isFetchingNextPage + const isEnoughProfiles = profileCount > 3 + const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest + useEffect(() => { + if (shouldFetchMore && !isAnyLoading) { + logger.info('Not enough suggested accounts - fetching more') + fetchNextPage() + } + }, [shouldFetchMore, fetchNextPage, isAnyLoading, interest]) + + return { + isReady: !shouldFetchMore, + } +} + +export function SuggestedAccountsTabBar({ + selectedInterest, + onSelectInterest, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + return ( + <BlockDrawerGesture> + <Tabs + interests={['all', ...interests]} + selectedInterest={selectedInterest || 'all'} + onSelectTab={tab => { + logger.metric( + 'explore:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + hasSearchText={false} + interestsDisplayNames={{ + all: _(msg`All`), + ...interestsDisplayNames, + }} + TabComponent={Tab} + /> + </BlockDrawerGesture> + ) +} + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + <View + key={interest} + onLayout={e => + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + <Button + label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} + onPress={() => onSelectTab(index)}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.rounded_full, + a.px_lg, + a.py_sm, + a.border, + active || hovered || pressed || focused + ? [ + t.atoms.bg_contrast_25, + {borderColor: t.atoms.bg_contrast_25.backgroundColor}, + ] + : [t.atoms.bg, t.atoms.border_contrast_low], + ]}> + <Text + style={[ + /* TODO: medium weight */ + active || hovered || pressed || focused + ? t.atoms.text + : t.atoms.text_contrast_medium, + ]}> + {interestsDisplayName} + </Text> + </View> + )} + </Button> + </View> + ) +} +Tab = memo(Tab) + +/** + * Profile card for suggested accounts. Note: border is on the bottom edge + */ +let SuggestedProfileCard = ({ + profile, + moderationOpts, + recId, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + recId?: number + position: number +}): React.ReactNode => { + const t = useTheme() + return ( + <ProfileCard.Link + profile={profile} + style={[a.flex_1]} + onPress={() => { + logger.metric( + 'suggestedUser:press', + { + logContext: 'Explore', + recId, + position, + }, + {statsig: true}, + ) + }}> + <View + style={[ + a.w_full, + a.py_lg, + a.px_lg, + a.border_t, + t.atoms.border_contrast_low, + a.flex_1, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + withIcon={false} + logContext="ExploreSuggestedAccounts" + onFollow={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Explore', + location: 'Card', + recId, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={2} /> + </ProfileCard.Outer> + </View> + </ProfileCard.Link> + ) +} +SuggestedProfileCard = memo(SuggestedProfileCard) +export {SuggestedProfileCard} diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx new file mode 100644 index 000000000..88d16b393 --- /dev/null +++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx @@ -0,0 +1,278 @@ +import {Pressable, View} from 'react-native' +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useTrendingSettings} from '#/state/preferences/trending' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' +import {useTrendingConfig} from '#/state/trending-config' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {formatCount} from '#/view/com/util/numeric/format' +import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame' +import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +const TOPIC_COUNT = 5 + +export function ExploreTrendingTopics() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? <Inner /> : null +} + +function Inner() { + const {data: trending, error, isLoading} = useGetTrendsQuery() + const noTopics = !isLoading && !error && !trending?.trends?.length + + return isLoading ? ( + Array.from({length: TOPIC_COUNT}).map((__, i) => ( + <TrendingTopicRowSkeleton key={i} withPosts={i === 0} /> + )) + ) : error || !trending?.trends || noTopics ? null : ( + <> + {trending.trends.map((trend, index) => ( + <TrendRow + key={trend.link} + trend={trend} + rank={index + 1} + onPress={() => { + logger.metric('trendingTopic:click', {context: 'explore'}) + }} + /> + ))} + </> + ) +} + +export function TrendRow({ + trend, + rank, + children, + onPress, +}: ViewStyleProp & { + trend: AppBskyUnspeccedDefs.TrendView + rank: number + children?: React.ReactNode + onPress?: () => void +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const gutters = useGutters([0, 'base']) + + const category = useCategoryDisplayName(trend?.category || 'other') + const age = Math.floor( + (Date.now() - new Date(trend.startedAt || Date.now()).getTime()) / + (1000 * 60 * 60), + ) + const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age + const postCount = trend.postCount + ? _( + plural(trend.postCount, { + other: `${formatCount(i18n, trend.postCount)} posts`, + }), + ) + : null + + return ( + <Link + testID={trend.link} + label={_(msg`Browse topic ${trend.displayName}`)} + to={trend.link} + onPress={onPress} + style={[a.border_b, t.atoms.border_contrast_low]} + PressableComponent={Pressable}> + {({hovered, pressed}) => ( + <> + <View + style={[ + gutters, + a.w_full, + a.py_lg, + a.flex_row, + a.gap_2xs, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <View style={[a.flex_row]}> + <Text + style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}> + <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'> + {rank}. + </Trans> + </Text> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {trend.displayName} + </Text> + </View> + <View + style={[ + a.flex_row, + a.gap_sm, + a.align_center, + {paddingLeft: 20}, + ]}> + {trend.actors.length > 0 && ( + <AvatarStack size={20} profiles={trend.actors} /> + )} + <Text + style={[ + a.text_sm, + t.atoms.text_contrast_medium, + web(a.leading_snug), + ]} + numberOfLines={1}> + {postCount} + {postCount && category && <> · </>} + {category} + </Text> + </View> + </View> + <View style={[a.flex_shrink_0]}> + <TrendingIndicator type={badgeType} /> + </View> + </View> + + {children} + </> + )} + </Link> + ) +} + +type TrendingIndicatorType = 'hot' | 'new' | number + +function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) { + const t = useTheme() + const {_} = useLingui() + const pillStyles = [ + a.flex_row, + a.align_center, + a.gap_xs, + a.rounded_full, + a.px_sm, + { + height: 28, + }, + ] + + let Icon: React.ComponentType<SVGIconProps> | null = null + let text: string | null = null + let color: string | null = null + let backgroundColor: string | null = null + + switch (type) { + case 'skeleton': { + return ( + <View + style={[ + pillStyles, + {backgroundColor: t.palette.contrast_25, width: 65, height: 28}, + ]} + /> + ) + } + case 'hot': { + Icon = FlameIcon + color = + t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950 + backgroundColor = + t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200 + text = _(msg`Hot`) + break + } + case 'new': { + Icon = TrendingIcon + text = _(msg`New`) + color = t.palette.positive_700 + backgroundColor = t.palette.positive_50 + break + } + default: { + text = _( + msg({ + message: `${type}h ago`, + comment: + 'trending topic time spent trending. should be as short as possible to fit in a pill', + }), + ) + color = t.atoms.text_contrast_medium.color + backgroundColor = t.atoms.bg_contrast_25.backgroundColor + break + } + } + + return ( + <View style={[pillStyles, {backgroundColor}]}> + {Icon && <Icon size="sm" style={{color}} />} + <Text style={[a.text_sm, {color}]}>{text}</Text> + </View> + ) +} + +function useCategoryDisplayName( + category: AppBskyUnspeccedDefs.TrendView['category'], +) { + const {_} = useLingui() + + switch (category) { + case 'sports': + return _(msg`Sports`) + case 'politics': + return _(msg`Politics`) + case 'video-games': + return _(msg`Video Games`) + case 'pop-culture': + return _(msg`Entertainment`) + case 'news': + return _(msg`News`) + case 'other': + default: + return null + } +} + +export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { + const t = useTheme() + const gutters = useGutters([0, 'base']) + + return ( + <View + style={[ + gutters, + a.w_full, + a.py_lg, + a.flex_row, + a.gap_2xs, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <View style={[a.flex_1, a.gap_sm]}> + <View style={[a.flex_row, a.align_center]}> + <View style={[{width: 20}]}> + <LoadingPlaceholder + width={12} + height={12} + style={[a.rounded_full]} + /> + </View> + <LoadingPlaceholder width={90} height={18} /> + </View> + <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}> + <LoadingPlaceholder width={70} height={18} /> + <LoadingPlaceholder width={40} height={18} /> + <LoadingPlaceholder width={60} height={18} /> + </View> + </View> + <View style={[a.flex_shrink_0]}> + <TrendingIndicator type="skeleton" /> + </View> + </View> + ) +} diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/modules/ExploreTrendingVideos.tsx index 00fa76dbf..54eb73312 100644 --- a/src/screens/Search/components/ExploreTrendingVideos.tsx +++ b/src/screens/Search/modules/ExploreTrendingVideos.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {useMemo} from 'react' import {ScrollView, View} from 'react-native' import {AppBskyEmbedVideo, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -8,18 +8,12 @@ import {useQueryClient} from '@tanstack/react-query' import {VIDEO_FEED_URI} from '#/lib/constants' import {makeCustomFeedLink} from '#/lib/routes/links' -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import {useSavedFeeds} from '#/state/queries/feed' +import {logger} from '#/logger' import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' -import {useAddSavedFeedsMutation} from '#/state/queries/preferences' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' +import {ButtonIcon} from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' import { @@ -37,7 +31,6 @@ const FEED_PARAMS: { } export function ExploreTrendingVideos() { - const t = useTheme() const {_} = useLingui() const gutters = useGutters([0, 'base']) const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) @@ -55,30 +48,30 @@ export function ExploreTrendingVideos() { } }) - const {data: saved} = useSavedFeeds() - const isSavedAlready = React.useMemo(() => { - return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) - }, [saved]) - - const {mutateAsync: addSavedFeeds, isPending: isPinPending} = - useAddSavedFeedsMutation() - const pinFeed = React.useCallback( - (e: any) => { - e.preventDefault() - - addSavedFeeds([ - { - type: 'feed', - value: VIDEO_FEED_URI, - pinned: true, - }, - ]) - - // prevent navigation - return false - }, - [addSavedFeeds], - ) + // const {data: saved} = useSavedFeeds() + // const isSavedAlready = useMemo(() => { + // return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) + // }, [saved]) + + // const {mutateAsync: addSavedFeeds, isPending: isPinPending} = + // useAddSavedFeedsMutation() + // const pinFeed = useCallback( + // (e: any) => { + // e.preventDefault() + + // addSavedFeeds([ + // { + // type: 'feed', + // value: VIDEO_FEED_URI, + // pinned: true, + // }, + // ]) + + // // prevent navigation + // return false + // }, + // [addSavedFeeds], + // ) if (error) { return null @@ -86,38 +79,6 @@ export function ExploreTrendingVideos() { return ( <View style={[a.pb_xl]}> - <View - style={[ - a.flex_row, - isWeb - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [a.p_lg, a.pt_xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Graph - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> - <Trans>Trending Videos</Trans> - </Text> - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> - <GradientFill gradient={tokens.gradients.primary} /> - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> - <Trans>BETA</Trans> - </Text> - </View> - </View> - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans>Popular videos in your network.</Trans> - </Text> - </View> - </View> - <BlockDrawerGesture> <ScrollView horizontal @@ -153,7 +114,7 @@ export function ExploreTrendingVideos() { </ScrollView> </BlockDrawerGesture> - {!isSavedAlready && ( + {/* {!isSavedAlready && ( <View style={[ gutters, @@ -179,7 +140,7 @@ export function ExploreTrendingVideos() { <ButtonIcon icon={Pin} position="right" /> </Button> </View> - )} + )} */} </View> ) } @@ -191,7 +152,7 @@ function VideoCards({ }) { const t = useTheme() const {_} = useLingui() - const items = React.useMemo(() => { + const items = useMemo(() => { return data.pages .flatMap(page => page.slices) .map(slice => slice.items[0]) @@ -199,7 +160,7 @@ function VideoCards({ .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) .slice(0, 8) }, [data]) - const href = React.useMemo(() => { + const href = useMemo(() => { const urip = new AtUri(VIDEO_FEED_URI) return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') }, []) @@ -217,9 +178,11 @@ function VideoCards({ sourceInterstitial: 'explore', }} onInteract={() => { - logEvent('videoCard:click', { - context: 'interstitial:explore', - }) + logger.metric( + 'videoCard:click', + {context: 'interstitial:explore'}, + {statsig: true}, + ) }} /> </View> diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index e28c98803..57b86fb2b 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -1,8 +1,8 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' @@ -22,7 +22,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' |