diff --git a/src/components/interstitials/FeedTrendingTopics.tsx b/src/components/interstitials/FeedTrendingTopics.tsx new file mode 100644 index 0000000000..a9666a82be --- /dev/null +++ b/src/components/interstitials/FeedTrendingTopics.tsx @@ -0,0 +1,280 @@ +import {useMemo} from 'react' +import {Pressable, View} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' +import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useTrendingSettings} from '#/state/preferences/trending' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' +import {useTrendingConfig} from '#/state/service-config' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {formatCount} from '#/view/com/util/numeric/format' +import { + atoms as a, + useGutters, + useLayoutBreakpoints, + useTheme, + type ViewStyleProp, +} from '#/alf' +import {alpha} from '#/alf/utils' +import {AvatarStack} from '#/components/AvatarStack' +import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' +import {Link} from '#/components/Link' +import {SubtleHover} from '#/components/SubtleHover' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' + +const TOPIC_COUNT = 3 + +export function FeedTrendingTopicsInterstitial() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + const {rightNavVisible} = useLayoutBreakpoints() + + return enabled && !trendingDisabled && !rightNavVisible ? : null +} + +function Inner() { + const t = useTheme() + const {t: l} = useLingui() + const gutters = useGutters([0, 'base']) + const ax = useAnalytics() + const { + data: trending, + error, + isLoading, + isRefetching, + } = useGetTrendsQuery({limit: TOPIC_COUNT}) + const noTopics = !isLoading && !error && !trending?.trends?.length + + const shadowColor = alpha(t.palette.primary_100, 0.5) + + const gradient = { + values: [ + [0, t.atoms.bg.backgroundColor], + [0.1, t.palette.primary_25], + [0.9, t.palette.primary_25], + [1, t.atoms.bg.backgroundColor], + ], + hover_value: t.palette.white, + } + + if (error || noTopics) { + return null + } + + return ( + + c[1]) as [string, string, ...string[]]} + locations={ + gradient.values.map(c => c[0]) as [number, number, ...number[]] + } + style={[a.absolute, a.inset_0]} + /> + + + + + Trending + + + + + See more + + + + + {isLoading || isRefetching + ? Array.from({length: TOPIC_COUNT}).map((_, i) => ( + + )) + : trending?.trends?.map((trend, index) => ( + { + ax.metric('trendingTopic:click', {context: 'interstitial'}) + }} + /> + ))} + + + ) +} + +function TrendRow({ + trend, + rank, + onPress, +}: ViewStyleProp & { + trend: AppBskyUnspeccedDefs.TrendView + rank: number + children?: React.ReactNode + onPress?: () => void +}) { + const t = useTheme() + const {t: l, i18n} = useLingui() + + const actors = useModerateTrendingActors(trend.actors) + + return ( + + {({hovered, pressed}) => ( + <> + + + + + {rank}. + + + + + {trend.displayName} + + + {actors.length > 0 ? ( + + ) : null} + + {trend.postCount >= 1000 ? ( + 1K+ posts + ) : ( + + {formatCount(i18n, trend.postCount)}{' '} + {plural(trend.postCount, {one: 'post', other: 'posts'})} + + )} + + + + + + )} + + ) +} +function TrendingTopicRowSkeleton({rank}: {rank: number}) { + const t = useTheme() + + return ( + + + + + + + + + + + + ) +} + +function useModerateTrendingActors( + actors: AppBskyUnspeccedDefs.TrendView['actors'], +) { + const moderationOpts = useModerationOpts() + + return useMemo(() => { + if (!moderationOpts) return [] + + return actors + .filter(actor => { + const decision = moderateProfile(actor, moderationOpts) + return !decision.ui('avatar').filter && !decision.ui('avatar').blur + }) + .slice(0, 3) + }, [actors, moderationOpts]) +} diff --git a/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx index 96a9bf3650..de45b308b1 100644 --- a/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx +++ b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx @@ -3,11 +3,9 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useTrendingSettings} from '#/state/preferences/trending' import {atoms as a, useLayoutBreakpoints} from '#/alf' import {Button} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' -import {TrendingInterstitial} from '#/components/interstitials/Trending' import * as Toast from '#/components/Toast' import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide' import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context' @@ -17,16 +15,10 @@ import {type LiveEventFeed} from '#/features/liveEvents/types' export function DiscoverFeedLiveEventFeedsAndTrendingBanner() { const events = useUserPreferencedLiveEvents() const {rightNavVisible} = useLayoutBreakpoints() - const {trendingDisabled} = useTrendingSettings() if (!events.feeds.length) { - if (!rightNavVisible && !trendingDisabled) { - // only show trending on mobile when live event banner is not shown - return - } else { - // no feed, no trending - return null - } + // no feed + return null } // On desktop, we show in the sidebar diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index 21fa738f28..757e868d1f 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -28,10 +28,8 @@ function dedupe(trends: T[]): T[] { }) } -export const createGetTrendsQueryKey = (props: QueryProps = {}) => [ - 'trends', - props.limit ?? DEFAULT_LIMIT, -] +export const createGetTrendsQueryKey = (limit?: number) => + limit === undefined ? ['trends'] : ['trends', {limit}] export function useGetTrendsQuery(props: QueryProps = {}) { const agent = useAgent() @@ -45,7 +43,7 @@ export function useGetTrendsQuery(props: QueryProps = {}) { enabled: !!preferences, refetchOnWindowFocus: props.refetchOnWindowFocus, staleTime: STALE.MINUTES.THREE, - queryKey: createGetTrendsQueryKey({limit}), + queryKey: createGetTrendsQueryKey(limit), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const {data} = await agent.app.bsky.unspecced.getTrends( diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 42274790c7..2c947ff3ec 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -60,6 +60,7 @@ import { PostFeedVideoGridRow, PostFeedVideoGridRowPlaceholder, } from '#/components/feeds/PostFeedVideoGridRow' +import {FeedTrendingTopicsInterstitial} from '#/components/interstitials/FeedTrendingTopics' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' @@ -140,6 +141,10 @@ type FeedRow = type: 'interstitialTrending' key: string } + | { + type: 'interstitialFeedTrendingTopics' + key: string + } | { type: 'interstitialTrendingVideos' key: string @@ -540,6 +545,11 @@ let PostFeed = ({ key: 'composerPrompt-' + sliceIndex, }) } + } else if (sliceIndex === 1) { + arr.push({ + type: 'interstitialFeedTrendingTopics', + key: 'interstitialFeedTrendingTopics-' + sliceIndex, + }) } else if (sliceIndex === 15) { if (areVideoFeedsEnabled && !trendingVideoDisabled) { arr.push({ @@ -791,6 +801,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialTrending') { return + } else if (row.type === 'interstitialFeedTrendingTopics') { + return } else if (row.type === 'liveEventFeedsAndTrendingBanner') { return } else if (row.type === 'composerPrompt') {