From c71a4f83609cfa54d6bc943b5fd528c0428ff959 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 14:23:01 -0700
Subject: [PATCH] Create new trending feeds interstitial (#11207)
---
.../interstitials/FeedTrendingTopics.tsx | 280 ++++++++++++++++++
...verFeedLiveEventFeedsAndTrendingBanner.tsx | 12 +-
.../queries/trending/useGetTrendsQuery.ts | 8 +-
src/view/com/posts/PostFeed.tsx | 12 +
4 files changed, 297 insertions(+), 15 deletions(-)
create mode 100644 src/components/interstitials/FeedTrendingTopics.tsx
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') {