diff options
Diffstat (limited to 'src/screens/Search/modules')
-rw-r--r-- | src/screens/Search/modules/ExploreFeedPreviews.tsx | 264 | ||||
-rw-r--r-- | src/screens/Search/modules/ExploreRecommendations.tsx | 123 | ||||
-rw-r--r-- | src/screens/Search/modules/ExploreSuggestedAccounts.tsx | 228 | ||||
-rw-r--r-- | src/screens/Search/modules/ExploreTrendingTopics.tsx | 278 | ||||
-rw-r--r-- | src/screens/Search/modules/ExploreTrendingVideos.tsx | 234 |
5 files changed, 1127 insertions, 0 deletions
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/modules/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx new file mode 100644 index 000000000..4cf84269a --- /dev/null +++ b/src/screens/Search/modules/ExploreRecommendations.tsx @@ -0,0 +1,123 @@ +import {View} from 'react-native' +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import { + DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} 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 +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noRecs = !isLoading && !error && !trending?.suggested?.length + const allFeeds = trending?.suggested && isAllFeeds(trending.suggested) + + return error || noRecs ? 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]}> + <Hashtag_Stroke2_Corner0_Rounded + size="lg" + fill={t.palette.primary_500} + style={{marginLeft: -2}} + /> + <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> + <Trans>Recommended</Trans> + </Text> + </View> + {!allFeeds ? ( + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> + <Trans> + Content from across the network we think you might like. + </Trans> + </Text> + ) : ( + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> + <Trans>Feeds we think you might like.</Trans> + </Text> + )} + </View> + </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(RECOMMENDATIONS_COUNT) + .fill(0) + .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) + ) : !trending?.suggested ? null : ( + <> + {trending.suggested.map(topic => ( + <TrendingTopicLink + key={topic.link} + topic={topic} + onPress={() => { + logger.metric( + 'recommendedTopic:click', + {context: 'explore'}, + {statsig: true}, + ) + }}> + {({hovered}) => ( + <TrendingTopic + topic={topic} + style={[ + hovered && [ + t.atoms.border_contrast_high, + t.atoms.bg_contrast_25, + ], + ]} + /> + )} + </TrendingTopicLink> + ))} + </> + )} + </View> + </View> + </> + ) +} + +function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) { + return topics.every(topic => { + const segments = topic.link.split('/').slice(1) + return segments[0] === 'profile' && segments[2] === 'feed' + }) +} 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/modules/ExploreTrendingVideos.tsx b/src/screens/Search/modules/ExploreTrendingVideos.tsx new file mode 100644 index 000000000..54eb73312 --- /dev/null +++ b/src/screens/Search/modules/ExploreTrendingVideos.tsx @@ -0,0 +1,234 @@ +import {useMemo} from 'react' +import {ScrollView, View} from 'react-native' +import {AppBskyEmbedVideo, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {VIDEO_FEED_URI} from '#/lib/constants' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {ButtonIcon} from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' +import { + CompactVideoPostCard, + CompactVideoPostCardPlaceholder, +} from '#/components/VideoPostCard' + +const CARD_WIDTH = 100 + +const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` +const FEED_PARAMS: { + feedCacheKey: 'explore' +} = { + feedCacheKey: 'explore', +} + +export function ExploreTrendingVideos() { + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) + + // Refetch on tab change if nothing else is using this query. + const queryClient = useQueryClient() + useFocusEffect(() => { + return () => { + const query = queryClient + .getQueryCache() + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) + if (query && query.getObserversCount() <= 1) { + query.fetch() + } + } + }) + + // 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 + } + + return ( + <View style={[a.pb_xl]}> + <BlockDrawerGesture> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + decelerationRate="fast" + snapToInterval={CARD_WIDTH + tokens.space.sm}> + <View + style={[ + a.pt_lg, + a.flex_row, + a.gap_sm, + { + paddingLeft: gutters.paddingLeft, + paddingRight: gutters.paddingRight, + }, + ]}> + {isLoading ? ( + Array(10) + .fill(0) + .map((_, i) => ( + <View key={i} style={[{width: CARD_WIDTH}]}> + <CompactVideoPostCardPlaceholder /> + </View> + )) + ) : error || !data ? ( + <Text> + <Trans>Whoops! Trending videos failed to load.</Trans> + </Text> + ) : ( + <VideoCards data={data} /> + )} + </View> + </ScrollView> + </BlockDrawerGesture> + + {/* {!isSavedAlready && ( + <View + style={[ + gutters, + a.pt_lg, + a.flex_row, + a.align_center, + a.justify_between, + a.gap_xl, + ]}> + <Text style={[a.flex_1, a.text_sm, a.leading_snug]}> + <Trans> + Pin the trending videos feed to your home screen for easy access + </Trans> + </Text> + <Button + disabled={isPinPending} + label={_(msg`Pin`)} + size="small" + variant="outline" + color="secondary" + onPress={pinFeed}> + <ButtonText>{_(msg`Pin`)}</ButtonText> + <ButtonIcon icon={Pin} position="right" /> + </Button> + </View> + )} */} + </View> + ) +} + +function VideoCards({ + data, +}: { + data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> +}) { + const t = useTheme() + const {_} = useLingui() + const items = useMemo(() => { + return data.pages + .flatMap(page => page.slices) + .map(slice => slice.items[0]) + .filter(Boolean) + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) + .slice(0, 8) + }, [data]) + const href = useMemo(() => { + const urip = new AtUri(VIDEO_FEED_URI) + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') + }, []) + + return ( + <> + {items.map(item => ( + <View key={item.post.uri} style={[{width: CARD_WIDTH}]}> + <CompactVideoPostCard + post={item.post} + moderation={item.moderation} + sourceContext={{ + type: 'feedgen', + uri: VIDEO_FEED_URI, + sourceInterstitial: 'explore', + }} + onInteract={() => { + logger.metric( + 'videoCard:click', + {context: 'interstitial:explore'}, + {statsig: true}, + ) + }} + /> + </View> + ))} + + <View style={[{width: CARD_WIDTH * 2}]}> + <Link + to={href} + label={_(msg`View more`)} + style={[ + a.justify_center, + a.align_center, + a.flex_1, + a.rounded_md, + t.atoms.bg_contrast_25, + ]}> + {({pressed}) => ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_md, + { + opacity: pressed ? 0.6 : 1, + }, + ]}> + <Text style={[a.text_md]}> + <Trans>View more</Trans> + </Text> + <View + style={[ + a.align_center, + a.justify_center, + a.rounded_full, + { + width: 34, + height: 34, + backgroundColor: t.palette.primary_500, + }, + ]}> + <ButtonIcon icon={ChevronRight} /> + </View> + </View> + )} + </Link> + </View> + </> + ) +} |