about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx15
-rw-r--r--src/App.web.tsx7
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/components/GradientFill.tsx8
-rw-r--r--src/components/TrendingTopics.tsx223
-rw-r--r--src/components/interstitials/Trending.tsx111
-rw-r--r--src/lib/routes/types.ts3
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Search/components/ExploreRecommendations.tsx95
-rw-r--r--src/screens/Search/components/ExploreTrendingTopics.tsx102
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx27
-rw-r--r--src/screens/Topic.tsx204
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/state/preferences/trending.tsx69
-rw-r--r--src/state/queries/index.ts1
-rw-r--r--src/state/queries/service-config.ts32
-rw-r--r--src/state/queries/trending/useTrendingTopics.ts49
-rw-r--r--src/state/trending-config.tsx70
-rw-r--r--src/storage/schema.ts1
-rw-r--r--src/view/com/posts/PostFeed.tsx25
-rw-r--r--src/view/screens/Search/Explore.tsx48
-rw-r--r--src/view/shell/desktop/Feeds.tsx32
-rw-r--r--src/view/shell/desktop/RightNav.tsx44
-rw-r--r--src/view/shell/desktop/SidebarTrendingTopics.tsx104
26 files changed, 1248 insertions, 37 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 39ab7ca92..780295ddc 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -57,6 +57,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as TrendingConfigProvider} from '#/state/trending-config'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
@@ -143,12 +144,14 @@ function InnerApp() {
                                       <BackgroundNotificationPreferencesProvider>
                                         <MutedThreadsProvider>
                                           <ProgressGuideProvider>
-                                            <GestureHandlerRootView
-                                              style={s.h100pct}>
-                                              <TestCtrls />
-                                              <Shell />
-                                              <NuxDialogs />
-                                            </GestureHandlerRootView>
+                                            <TrendingConfigProvider>
+                                              <GestureHandlerRootView
+                                                style={s.h100pct}>
+                                                <TestCtrls />
+                                                <Shell />
+                                                <NuxDialogs />
+                                              </GestureHandlerRootView>
+                                            </TrendingConfigProvider>
                                           </ProgressGuideProvider>
                                         </MutedThreadsProvider>
                                       </BackgroundNotificationPreferencesProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 8d13a826e..8a2e13600 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -47,6 +47,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as TrendingConfigProvider} from '#/state/trending-config'
 import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
@@ -127,8 +128,10 @@ function InnerApp() {
                                         <MutedThreadsProvider>
                                           <SafeAreaProvider>
                                             <ProgressGuideProvider>
-                                              <Shell />
-                                              <NuxDialogs />
+                                              <TrendingConfigProvider>
+                                                <Shell />
+                                                <NuxDialogs />
+                                              </TrendingConfigProvider>
                                             </ProgressGuideProvider>
                                           </SafeAreaProvider>
                                         </MutedThreadsProvider>
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7443128d2..18705c5ff 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -100,6 +100,7 @@ import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
 import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
 import {SettingsScreen} from './screens/Settings/Settings'
 import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
+import TopicScreen from './screens/Topic'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -377,6 +378,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title(msg`Hashtag`)}}
       />
       <Stack.Screen
+        name="Topic"
+        getComponent={() => TopicScreen}
+        options={{title: title(msg`Topic`)}}
+      />
+      <Stack.Screen
         name="MessagesConversation"
         getComponent={() => MessagesConversationScreen}
         options={{title: title(msg`Chat`), requireAuth: true}}
diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx
index 3dff404d7..9ad6ed7dc 100644
--- a/src/components/GradientFill.tsx
+++ b/src/components/GradientFill.tsx
@@ -1,11 +1,13 @@
 import {LinearGradient} from 'expo-linear-gradient'
 
-import {atoms as a, tokens} from '#/alf'
+import {atoms as a, tokens, ViewStyleProp} from '#/alf'
 
 export function GradientFill({
   gradient,
-}: {
+  style,
+}: ViewStyleProp & {
   gradient:
+    | typeof tokens.gradients.primary
     | typeof tokens.gradients.sky
     | typeof tokens.gradients.midnight
     | typeof tokens.gradients.sunrise
@@ -26,7 +28,7 @@ export function GradientFill({
       }
       start={{x: 0, y: 0}}
       end={{x: 1, y: 1}}
-      style={[a.absolute, a.inset_0]}
+      style={[a.absolute, a.inset_0, style]}
     />
   )
 }
diff --git a/src/components/TrendingTopics.tsx b/src/components/TrendingTopics.tsx
new file mode 100644
index 000000000..6881f24bd
--- /dev/null
+++ b/src/components/TrendingTopics.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AtUri} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+// import {makeProfileLink} from '#/lib/routes/links'
+// import {feedUriToHref} from '#/lib/strings/url-helpers'
+// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote'
+// import {UserAvatar} from '#/view/com/util/UserAvatar'
+import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function TrendingTopic({
+  topic: raw,
+  size,
+  style,
+}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) {
+  const t = useTheme()
+  const topic = useTopic(raw)
+
+  const isSmall = size === 'small'
+  // const hasAvi = topic.type === 'feed' || topic.type === 'profile'
+  // const aviSize = isSmall ? 16 : 20
+  // const iconSize = isSmall ? 16 : 20
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.rounded_full,
+        a.border,
+        t.atoms.border_contrast_medium,
+        t.atoms.bg,
+        isSmall
+          ? [
+              {
+                paddingVertical: 5,
+                paddingHorizontal: 10,
+              },
+            ]
+          : [a.py_sm, a.px_md],
+        style,
+        /*
+        {
+          padding: 6,
+          gap: hasAvi ? 4 : 2,
+        },
+        a.pr_md,
+       */
+      ]}>
+      {/*
+      <View
+        style={[
+          a.align_center,
+          a.justify_center,
+          a.rounded_full,
+          a.overflow_hidden,
+          {
+            width: aviSize,
+            height: aviSize,
+          },
+        ]}>
+        {topic.type === 'tag' ? (
+          <Hashtag width={iconSize} />
+        ) : topic.type === 'topic' ? (
+          <Quote width={iconSize - 2} />
+        ) : topic.type === 'feed' ? (
+          <UserAvatar
+            type="user"
+            size={aviSize}
+            avatar=""
+          />
+        ) : (
+          <UserAvatar
+            type="user"
+            size={aviSize}
+            avatar=""
+          />
+        )}
+      </View>
+        */}
+
+      <Text
+        style={[
+          a.font_bold,
+          a.leading_tight,
+          isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
+        ]}
+        numberOfLines={1}>
+        {topic.displayName}
+      </Text>
+    </View>
+  )
+}
+
+export function TrendingTopicSkeleton({
+  size = 'large',
+  index = 0,
+}: {
+  size?: 'large' | 'small'
+  index?: number
+}) {
+  const t = useTheme()
+  const isSmall = size === 'small'
+  return (
+    <View
+      style={[
+        a.rounded_full,
+        a.border,
+        t.atoms.border_contrast_medium,
+        t.atoms.bg_contrast_25,
+        isSmall
+          ? {
+              width: index % 2 === 0 ? 75 : 90,
+              height: 27,
+            }
+          : {
+              width: index % 2 === 0 ? 90 : 110,
+              height: 36,
+            },
+      ]}
+    />
+  )
+}
+
+export function TrendingTopicLink({
+  topic: raw,
+  children,
+  ...rest
+}: {
+  topic: TrendingTopic
+} & Omit<LinkProps, 'to' | 'label'>) {
+  const topic = useTopic(raw)
+
+  return (
+    <InternalLink label={topic.label} to={topic.url} {...rest}>
+      {children}
+    </InternalLink>
+  )
+}
+
+type ParsedTrendingTopic =
+  | {
+      type: 'topic' | 'tag' | 'unknown'
+      label: string
+      displayName: string
+      url: string
+      uri: undefined
+    }
+  | {
+      type: 'profile' | 'feed'
+      label: string
+      displayName: string
+      url: string
+      uri: AtUri
+    }
+
+export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
+  const {_} = useLingui()
+  return React.useMemo(() => {
+    const {topic: displayName, link} = raw
+
+    if (link.startsWith('/search')) {
+      return {
+        type: 'topic',
+        label: _(msg`Browse posts about ${displayName}`),
+        displayName,
+        uri: undefined,
+        url: link,
+      }
+    } else if (link.startsWith('/hashtag')) {
+      return {
+        type: 'tag',
+        label: _(msg`Browse posts tagged with ${displayName}`),
+        displayName,
+        // displayName: displayName.replace(/^#/, ''),
+        uri: undefined,
+        url: link,
+      }
+    }
+
+    /*
+    if (!link.startsWith('at://')) {
+      // above logic
+    } else {
+      const urip = new AtUri(link)
+      switch (urip.collection) {
+        case 'app.bsky.actor.profile': {
+          return {
+            type: 'profile',
+            label: _(msg`View ${displayName}'s profile`),
+            displayName,
+            uri: urip,
+            url: makeProfileLink({did: urip.host, handle: urip.host}),
+          }
+        }
+        case 'app.bsky.feed.generator': {
+          return {
+            type: 'feed',
+            label: _(msg`Browse the ${displayName} feed`),
+            displayName,
+            uri: urip,
+            url: feedUriToHref(link),
+          }
+        }
+      }
+    }
+     */
+
+    return {
+      type: 'unknown',
+      label: _(msg`Browse topic ${displayName}`),
+      displayName,
+      uri: undefined,
+      url: link,
+    }
+  }, [_, raw])
+}
diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx
new file mode 100644
index 000000000..3944d92f0
--- /dev/null
+++ b/src/components/interstitials/Trending.tsx
@@ -0,0 +1,111 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+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 Graph} from '#/components/icons/Trending2'
+import * as Prompt from '#/components/Prompt'
+import {
+  TrendingTopic,
+  TrendingTopicLink,
+  TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+export function TrendingInterstitial() {
+  const {enabled} = useTrendingConfig()
+  const {trendingDisabled} = useTrendingSettings()
+  return enabled && !trendingDisabled ? <Inner /> : null
+}
+
+export function Inner() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const gutters = useGutters(['wide', 'base'])
+  const trendingPrompt = Prompt.usePromptControl()
+  const {setTrendingDisabled} = useTrendingSettingsApi()
+  const {data: trending, error, isLoading} = useTrendingTopics()
+  const noTopics = !isLoading && !error && !trending?.topics?.length
+
+  return error || noTopics ? null : (
+    <View
+      style={[
+        gutters,
+        a.gap_lg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+        <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+          <Graph size="lg" />
+          <Text style={[a.text_lg, a.font_heavy]}>
+            <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>
+
+        <Button
+          label={_(msg`Hide trending topics`)}
+          size="tiny"
+          variant="outline"
+          color="secondary"
+          shape="round"
+          onPress={() => trendingPrompt.open()}>
+          <ButtonIcon icon={X} />
+        </Button>
+      </View>
+
+      <View style={[a.flex_row, a.flex_wrap, {rowGap: 8, columnGap: 6}]}>
+        {isLoading ? (
+          Array(TRENDING_TOPICS_COUNT)
+            .fill(0)
+            .map((_n, i) => <TrendingTopicSkeleton key={i} index={i} />)
+        ) : !trending?.topics ? null : (
+          <>
+            {trending.topics.map(topic => (
+              <TrendingTopicLink key={topic.link} topic={topic}>
+                {({hovered}) => (
+                  <TrendingTopic
+                    topic={topic}
+                    style={[
+                      hovered && [
+                        t.atoms.border_contrast_high,
+                        t.atoms.bg_contrast_25,
+                      ],
+                    ]}
+                  />
+                )}
+              </TrendingTopicLink>
+            ))}
+          </>
+        )}
+      </View>
+
+      <Prompt.Basic
+        control={trendingPrompt}
+        title={_(msg`Hide trending topics?`)}
+        description={_(msg`You can update this later from your settings.`)}
+        confirmButtonCta={_(msg`Hide`)}
+        onConfirm={() => setTrendingDisabled(true)}
+      />
+    </View>
+  )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 238e4be4c..d720886e9 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -47,6 +47,7 @@ export type CommonNavigatorParams = {
   AppIconSettings: undefined
   Search: {q?: string}
   Hashtag: {tag: string; author?: string}
+  Topic: {topic: string}
   MessagesConversation: {conversation: string; embed?: string}
   MessagesSettings: undefined
   NotificationSettings: undefined
@@ -92,6 +93,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Feeds: undefined
   Notifications: undefined
   Hashtag: {tag: string; author?: string}
+  Topic: {topic: string}
   Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
 }
 
@@ -105,6 +107,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Notifications: undefined
   MyProfileTab: undefined
   Hashtag: {tag: string; author?: string}
+  Topic: {topic: string}
   MessagesTab: undefined
   Messages: {animation?: 'push' | 'pop'}
   Start: {name: string; rkey: string}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index a6c249254..455a70345 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -4,3 +4,4 @@ export type Gate =
   | 'debug_subscriptions'
   | 'new_postonboarding'
   | 'remove_show_latest_button'
+  | 'trending_topics_beta'
diff --git a/src/routes.ts b/src/routes.ts
index 188665d84..7cd7c0880 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -53,6 +53,7 @@ export const router = new Router({
   CopyrightPolicy: '/support/copyright',
   // hashtags
   Hashtag: '/hashtag/:tag',
+  Topic: '/topic/:topic',
   // DMs
   Messages: '/messages',
   MessagesSettings: '/messages/settings',
diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx
new file mode 100644
index 000000000..e253cfb5a
--- /dev/null
+++ b/src/screens/Search/components/ExploreRecommendations.tsx
@@ -0,0 +1,95 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from '#/platform/detection'
+import {useTrendingSettings} from '#/state/preferences/trending'
+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'
+
+export function ExploreRecommendations() {
+  const {enabled} = useTrendingConfig()
+  const {trendingDisabled} = useTrendingSettings()
+  return enabled && !trendingDisabled ? <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
+
+  return error || noRecs ? null : (
+    <>
+      <View
+        style={[
+          isWeb
+            ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
+            : [{flexDirection: 'row-reverse'}, 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>
+          <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}>
+                  {({hovered}) => (
+                    <TrendingTopic
+                      topic={topic}
+                      style={[
+                        hovered && [
+                          t.atoms.border_contrast_high,
+                          t.atoms.bg_contrast_25,
+                        ],
+                      ]}
+                    />
+                  )}
+                </TrendingTopicLink>
+              ))}
+            </>
+          )}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx
new file mode 100644
index 000000000..be347dcd4
--- /dev/null
+++ b/src/screens/Search/components/ExploreTrendingTopics.tsx
@@ -0,0 +1,102 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from '#/platform/detection'
+import {useTrendingSettings} 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 {GradientFill} from '#/components/GradientFill'
+import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
+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 gutters = useGutters([0, 'compact'])
+  const {data: trending, error, isLoading} = useTrendingTopics()
+  const noTopics = !isLoading && !error && !trending?.topics?.length
+
+  return error || noTopics ? null : (
+    <>
+      <View
+        style={[
+          isWeb
+            ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
+            : [{flexDirection: 'row-reverse'}, 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>
+      </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}>
+                  {({hovered}) => (
+                    <TrendingTopic
+                      topic={topic}
+                      style={[
+                        hovered && [
+                          t.atoms.border_contrast_high,
+                          t.atoms.bg_contrast_25,
+                        ],
+                      ]}
+                    />
+                  )}
+                </TrendingTopicLink>
+              ))}
+            </>
+          )}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index 17f8fa506..bdbe1d191 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -9,6 +9,11 @@ import {
   useInAppBrowser,
   useSetInAppBrowser,
 } from '#/state/preferences/in-app-browser'
+import {
+  useTrendingSettings,
+  useTrendingSettingsApi,
+} from '#/state/preferences/trending'
+import {useTrendingConfig} from '#/state/trending-config'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import * as Toggle from '#/components/forms/Toggle'
 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
@@ -16,6 +21,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 {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
 import * as Layout from '#/components/Layout'
 
@@ -29,6 +35,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
   const setAutoplayDisabledPref = useSetAutoplayDisabled()
   const inAppBrowserPref = useInAppBrowser()
   const setUseInAppBrowser = useSetInAppBrowser()
+  const {enabled: trendingEnabled} = useTrendingConfig()
+  const {trendingDisabled} = useTrendingSettings()
+  const {setTrendingDisabled} = useTrendingSettingsApi()
 
   return (
     <Layout.Screen>
@@ -104,6 +113,24 @@ export function ContentAndMediaSettingsScreen({}: Props) {
               <Toggle.Platform />
             </SettingsList.Item>
           </Toggle.Item>
+          {trendingEnabled && (
+            <>
+              <SettingsList.Divider />
+              <Toggle.Item
+                name="show_trending_topics"
+                label={_(msg`Enable trending topics`)}
+                value={!trendingDisabled}
+                onChange={value => setTrendingDisabled(!value)}>
+                <SettingsList.Item>
+                  <SettingsList.ItemIcon icon={Graph} />
+                  <SettingsList.ItemText>
+                    <Trans>Enable trending topics</Trans>
+                  </SettingsList.ItemText>
+                  <Toggle.Platform />
+                </SettingsList.Item>
+              </Toggle.Item>
+            </>
+          )}
         </SettingsList.Container>
       </Layout.Content>
     </Layout.Screen>
diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx
new file mode 100644
index 000000000..6cd69f05f
--- /dev/null
+++ b/src/screens/Topic.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {shareUrl} from '#/lib/sharing'
+import {cleanError} from '#/lib/strings/errors'
+import {enforceLen} from '#/lib/strings/helpers'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
+import {Post} from '#/view/com/post/Post'
+import {List} from '#/view/com/util/List'
+import {atoms as a, web} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import * as Layout from '#/components/Layout'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+
+const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
+  return <Post post={item} />
+}
+
+const keyExtractor = (item: PostView, index: number) => {
+  return `${item.uri}-${index}`
+}
+
+export default function TopicScreen({
+  route,
+}: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) {
+  const {topic} = route.params
+  const {_} = useLingui()
+
+  const headerTitle = React.useMemo(() => {
+    return enforceLen(decodeURIComponent(topic), 24, true, 'middle')
+  }, [topic])
+
+  const onShare = React.useCallback(() => {
+    const url = new URL('https://bsky.app')
+    url.pathname = `/topic/${topic}`
+    shareUrl(url.toString())
+  }, [topic])
+
+  const [activeTab, setActiveTab] = React.useState(0)
+  const setMinimalShellMode = useSetMinimalShellMode()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setActiveTab(index)
+    },
+    [setMinimalShellMode],
+  )
+
+  const sections = React.useMemo(() => {
+    return [
+      {
+        title: _(msg`Top`),
+        component: (
+          <TopicScreenTab topic={topic} sort="top" active={activeTab === 0} />
+        ),
+      },
+      {
+        title: _(msg`Latest`),
+        component: (
+          <TopicScreenTab
+            topic={topic}
+            sort="latest"
+            active={activeTab === 1}
+          />
+        ),
+      },
+    ]
+  }, [_, topic, activeTab])
+
+  return (
+    <Layout.Screen>
+      <Layout.Header.Outer noBottomBorder>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot>
+          <Button
+            label={_(msg`Share`)}
+            size="small"
+            variant="ghost"
+            color="primary"
+            shape="round"
+            onPress={onShare}
+            hitSlop={HITSLOP_10}
+            style={[{right: -3}]}>
+            <ButtonIcon icon={Share} size="md" />
+          </Button>
+        </Layout.Header.Slot>
+      </Layout.Header.Outer>
+      <Pager
+        onPageSelected={onPageSelected}
+        renderTabBar={props => (
+          <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
+            <TabBar items={sections.map(section => section.title)} {...props} />
+          </Layout.Center>
+        )}
+        initialPage={0}>
+        {sections.map((section, i) => (
+          <View key={i}>{section.component}</View>
+        ))}
+      </Pager>
+    </Layout.Screen>
+  )
+}
+
+function TopicScreenTab({
+  topic,
+  sort,
+  active,
+}: {
+  topic: string
+  sort: 'top' | 'latest'
+  active: boolean
+}) {
+  const {_} = useLingui()
+  const initialNumToRender = useInitialNumToRender()
+  const [isPTR, setIsPTR] = React.useState(false)
+
+  const {
+    data,
+    isFetched,
+    isFetchingNextPage,
+    isLoading,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useSearchPostsQuery({
+    query: decodeURIComponent(topic),
+    sort,
+    enabled: active,
+  })
+
+  const posts = React.useMemo(() => {
+    return data?.pages.flatMap(page => page.posts) || []
+  }, [data])
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [refetch])
+
+  const onEndReached = React.useCallback(() => {
+    if (isFetchingNextPage || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
+
+  return (
+    <>
+      {posts.length < 1 ? (
+        <ListMaybePlaceholder
+          isLoading={isLoading || !isFetched}
+          isError={isError}
+          onRetry={refetch}
+          emptyType="results"
+          emptyMessage={_(msg`We couldn't find any results for that topic.`)}
+        />
+      ) : (
+        <List
+          data={posts}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          refreshing={isPTR}
+          onRefresh={onRefresh}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={4}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          ListFooterComponent={
+            <ListFooter
+              isFetchingNextPage={isFetchingNextPage}
+              error={cleanError(error)}
+              onRetry={fetchNextPage}
+            />
+          }
+          initialNumToRender={initialNumToRender}
+          windowSize={11}
+        />
+      )}
+    </>
+  )
+}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index f70d77463..0a9e5b2c0 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -125,6 +125,7 @@ const schema = z.object({
   subtitlesEnabled: z.boolean().optional(),
   /** @deprecated */
   mutedThreads: z.array(z.string()),
+  trendingDisabled: z.boolean().optional(),
 })
 export type Schema = z.infer<typeof schema>
 
@@ -170,6 +171,7 @@ export const defaults: Schema = {
   kawaii: false,
   hasCheckedForStarterPack: false,
   subtitlesEnabled: true,
+  trendingDisabled: false,
 }
 
 export function tryParse(rawData: string): Schema | undefined {
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index c7eaf2726..8530a8d0c 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -10,6 +10,7 @@ import {Provider as KawaiiProvider} from './kawaii'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
 import {Provider as SubtitlesProvider} from './subtitles'
+import {Provider as TrendingSettingsProvider} from './trending'
 import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
 
 export {
@@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
                   <AutoplayProvider>
                     <UsedStarterPacksProvider>
                       <SubtitlesProvider>
-                        <KawaiiProvider>{children}</KawaiiProvider>
+                        <TrendingSettingsProvider>
+                          <KawaiiProvider>{children}</KawaiiProvider>
+                        </TrendingSettingsProvider>
                       </SubtitlesProvider>
                     </UsedStarterPacksProvider>
                   </AutoplayProvider>
diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx
new file mode 100644
index 000000000..bf5d8f13c
--- /dev/null
+++ b/src/state/preferences/trending.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = {
+  trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
+}
+type ApiContext = {
+  setTrendingDisabled(
+    hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
+  ): void
+}
+
+const StateContext = React.createContext<StateContext>({
+  trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
+})
+const ApiContext = React.createContext<ApiContext>({
+  setTrendingDisabled() {},
+})
+
+function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
+  const [value, _set] = React.useState(() => {
+    return Boolean(persisted.get(key))
+  })
+  const set = React.useCallback<
+    (value: Exclude<persisted.Schema[T], undefined>) => void
+  >(
+    hidden => {
+      _set(Boolean(hidden))
+      persisted.write(key, hidden)
+    },
+    [key, _set],
+  )
+  React.useEffect(() => {
+    return persisted.onUpdate(key, hidden => {
+      _set(Boolean(hidden))
+    })
+  }, [key, _set])
+
+  return [value, set] as const
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [trendingDisabled, setTrendingDisabled] =
+    usePersistedBooleanValue('trendingDisabled')
+
+  /*
+   * Context
+   */
+  const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
+  const api = React.useMemo(
+    () => ({setTrendingDisabled}),
+    [setTrendingDisabled],
+  )
+
+  return (
+    <StateContext.Provider value={state}>
+      <ApiContext.Provider value={api}>{children}</ApiContext.Provider>
+    </StateContext.Provider>
+  )
+}
+
+export function useTrendingSettings() {
+  return React.useContext(StateContext)
+}
+
+export function useTrendingSettingsApi() {
+  return React.useContext(ApiContext)
+}
diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts
index 0635bf316..d4b9d94c4 100644
--- a/src/state/queries/index.ts
+++ b/src/state/queries/index.ts
@@ -6,6 +6,7 @@ export const STALE = {
   MINUTES: {
     ONE: 1e3 * 60,
     FIVE: 1e3 * 60 * 5,
+    THIRTY: 1e3 * 60 * 30,
   },
   HOURS: {
     ONE: 1e3 * 60 * 60,
diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts
new file mode 100644
index 000000000..9a9db7865
--- /dev/null
+++ b/src/state/queries/service-config.ts
@@ -0,0 +1,32 @@
+import {useQuery} from '@tanstack/react-query'
+
+import {STALE} from '#/state/queries'
+import {useAgent} from '#/state/session'
+
+type ServiceConfig = {
+  checkEmailConfirmed: boolean
+  topicsEnabled: boolean
+}
+
+export function useServiceConfigQuery() {
+  const agent = useAgent()
+  return useQuery<ServiceConfig>({
+    refetchOnWindowFocus: true,
+    staleTime: STALE.MINUTES.FIVE,
+    queryKey: ['service-config'],
+    queryFn: async () => {
+      try {
+        const {data} = await agent.api.app.bsky.unspecced.getConfig()
+        return {
+          checkEmailConfirmed: Boolean(data.checkEmailConfirmed),
+          topicsEnabled: Boolean(data.topicsEnabled),
+        }
+      } catch (e) {
+        return {
+          checkEmailConfirmed: false,
+          topicsEnabled: false,
+        }
+      }
+    },
+  })
+}
diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts
new file mode 100644
index 000000000..310f64e9f
--- /dev/null
+++ b/src/state/queries/trending/useTrendingTopics.ts
@@ -0,0 +1,49 @@
+import React from 'react'
+import {AppBskyUnspeccedDefs} from '@atproto/api'
+import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords'
+import {useQuery} from '@tanstack/react-query'
+
+import {STALE} from '#/state/queries'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAgent} from '#/state/session'
+
+export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic
+
+export const DEFAULT_LIMIT = 14
+
+export const trendingTopicsQueryKey = ['trending-topics']
+
+export function useTrendingTopics() {
+  const agent = useAgent()
+  const {data: preferences} = usePreferencesQuery()
+  const mutedWords = React.useMemo(() => {
+    return preferences?.moderationPrefs?.mutedWords || []
+  }, [preferences?.moderationPrefs])
+
+  return useQuery({
+    refetchOnWindowFocus: true,
+    staleTime: STALE.MINUTES.THIRTY,
+    queryKey: trendingTopicsQueryKey,
+    async queryFn() {
+      const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({
+        limit: DEFAULT_LIMIT,
+      })
+
+      const {topics, suggested} = data
+      return {
+        topics: topics.filter(t => {
+          return !hasMutedWord({
+            mutedWords,
+            text: t.topic + ' ' + t.displayName + ' ' + t.description,
+          })
+        }),
+        suggested: suggested.filter(t => {
+          return !hasMutedWord({
+            mutedWords,
+            text: t.topic + ' ' + t.displayName + ' ' + t.description,
+          })
+        }),
+      }
+    },
+  })
+}
diff --git a/src/state/trending-config.tsx b/src/state/trending-config.tsx
new file mode 100644
index 000000000..a7694993f
--- /dev/null
+++ b/src/state/trending-config.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {useLanguagePrefs} from '#/state/preferences/languages'
+import {useServiceConfigQuery} from '#/state/queries/service-config'
+import {device} from '#/storage'
+
+type Context = {
+  enabled: boolean
+}
+
+const Context = React.createContext<Context>({
+  enabled: false,
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const gate = useGate()
+  const langPrefs = useLanguagePrefs()
+  const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
+  const ctx = React.useMemo<Context>(() => {
+    if (__DEV__) {
+      return {enabled: true}
+    }
+
+    /*
+     * Only English during beta period
+     */
+    if (
+      !!langPrefs.contentLanguages.length &&
+      !langPrefs.contentLanguages.includes('en')
+    ) {
+      return {enabled: false}
+    }
+
+    /*
+     * While loading, use cached value
+     */
+    const cachedEnabled = device.get(['trendingBetaEnabled'])
+    if (isInitialLoad) {
+      return {enabled: Boolean(cachedEnabled)}
+    }
+
+    /*
+     * Doing an extra check here to reduce hits to statsig. If it's disabled on
+     * the server, we can exit early.
+     */
+    const enabled = Boolean(config?.topicsEnabled)
+    if (!enabled) {
+      // cache for next reload
+      device.set(['trendingBetaEnabled'], enabled)
+      return {enabled: false}
+    }
+
+    /*
+     * Service is enabled, but also check statsig in case we're rolling back.
+     */
+    const gateEnabled = gate('trending_topics_beta')
+    const _enabled = enabled && gateEnabled
+
+    // update cache
+    device.set(['trendingBetaEnabled'], _enabled)
+
+    return {enabled: _enabled}
+  }, [isInitialLoad, config, gate, langPrefs.contentLanguages])
+  return <Context.Provider value={ctx}>{children}</Context.Provider>
+}
+
+export function useTrendingConfig() {
+  return React.useContext(Context)
+}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index cf410c77d..cfca9131c 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -8,4 +8,5 @@ export type Device = {
   geolocation?: {
     countryCode: string | undefined
   }
+  trendingBetaEnabled: boolean
 }
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 10eb47d0a..7860d568d 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -23,6 +23,7 @@ import {logger} from '#/logger'
 import {isIOS, isWeb} from '#/platform/detection'
 import {listenPostCreated} from '#/state/events'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {useTrendingSettings} from '#/state/preferences/trending'
 import {STALE} from '#/state/queries'
 import {
   FeedDescriptor,
@@ -34,7 +35,9 @@ import {
 } from '#/state/queries/post-feed'
 import {useSession} from '#/state/session'
 import {useProgressGuide} from '#/state/shell/progress-guide'
+import {useBreakpoints} from '#/alf'
 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
+import {TrendingInterstitial} from '#/components/interstitials/Trending'
 import {List, ListRef} from '../util/List'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -90,6 +93,10 @@ type FeedRow =
       type: 'interstitialProgressGuide'
       key: string
     }
+  | {
+      type: 'interstitialTrending'
+      key: string
+    }
 
 export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
   if (feedRow.type === 'sliceItem') {
@@ -156,6 +163,7 @@ let PostFeed = ({
   const checkForNewRef = React.useRef<(() => void) | null>(null)
   const lastFetchRef = React.useRef<number>(Date.now())
   const [feedType, feedUri, feedTab] = feed.split('|')
+  const {gtTablet} = useBreakpoints()
 
   const opts = React.useMemo(
     () => ({enabled, ignoreFilterFor}),
@@ -259,6 +267,8 @@ let PostFeed = ({
   const showProgressIntersitial =
     (followProgressGuide || followAndLikeProgressGuide) && !isDesktop
 
+  const {trendingDisabled} = useTrendingSettings()
+
   const feedItems: FeedRow[] = React.useMemo(() => {
     let feedKind: 'following' | 'discover' | 'profile' | undefined
     if (feedType === 'following') {
@@ -304,7 +314,16 @@ let PostFeed = ({
                     type: 'interstitialProgressGuide',
                     key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
                   })
-                } else if (sliceIndex === 20) {
+                } else if (
+                  sliceIndex === 15 &&
+                  !gtTablet &&
+                  !trendingDisabled
+                ) {
+                  arr.push({
+                    type: 'interstitialTrending',
+                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                  })
+                } else if (sliceIndex === 30) {
                   arr.push({
                     type: 'interstitialFollows',
                     key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
@@ -390,6 +409,8 @@ let PostFeed = ({
     feedTab,
     hasSession,
     showProgressIntersitial,
+    trendingDisabled,
+    gtTablet,
   ])
 
   // events
@@ -476,6 +497,8 @@ let PostFeed = ({
         return <SuggestedFollows feed={feed} />
       } else if (row.type === 'interstitialProgressGuide') {
         return <ProgressGuide />
+      } else if (row.type === 'interstitialTrending') {
+        return <TrendingInterstitial />
       } else if (row.type === 'sliceItem') {
         const slice = row.slice
         if (slice.isFallbackMarker) {
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index bd2ebe5d5..378ea59a4 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -24,6 +24,8 @@ import {
   ProfileCardFeedLoadingPlaceholder,
 } from '#/view/com/util/LoadingPlaceholder'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
+import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
 import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
 import {Button} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
@@ -240,6 +242,14 @@ type ExploreScreenItems =
       icon: React.ComponentType<SVGIconProps>
     }
   | {
+      type: 'trendingTopics'
+      key: string
+    }
+  | {
+      type: 'recommendations'
+      key: string
+    }
+  | {
       type: 'profile'
       key: string
       profile: AppBskyActorDefs.ProfileView
@@ -325,17 +335,27 @@ export function Explore() {
   ])
 
   const items = React.useMemo<ExploreScreenItems[]>(() => {
-    const i: ExploreScreenItems[] = [
-      {
-        type: 'header',
-        key: 'suggested-follows-header',
-        title: _(msg`Suggested accounts`),
-        description: _(
-          msg`Follow more accounts to get connected to your interests and build your network.`,
-        ),
-        icon: Person,
-      },
-    ]
+    const i: ExploreScreenItems[] = []
+
+    i.push({
+      type: 'trendingTopics',
+      key: `trending-topics`,
+    })
+
+    i.push({
+      type: 'recommendations',
+      key: `recommendations`,
+    })
+
+    i.push({
+      type: 'header',
+      key: 'suggested-follows-header',
+      title: _(msg`Suggested accounts`),
+      description: _(
+        msg`Follow more accounts to get connected to your interests and build your network.`,
+      ),
+      icon: Person,
+    })
 
     if (profiles) {
       // Currently the responses contain duplicate items.
@@ -490,6 +510,12 @@ export function Explore() {
             />
           )
         }
+        case 'trendingTopics': {
+          return <ExploreTrendingTopics />
+        }
+        case 'recommendations': {
+          return <ExploreRecommendations />
+        }
         case 'profile': {
           return (
             <View style={[a.border_b, t.atoms.border_contrast_low]}>
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 83b5420ce..1d515df55 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -14,7 +14,7 @@ import {createStaticClick, InlineLinkText} from '#/components/Link'
 export function DesktopFeeds() {
   const t = useTheme()
   const {_} = useLingui()
-  const {data: pinnedFeedInfos} = usePinnedFeedsInfos()
+  const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos()
   const selectedFeed = useSelectedFeed()
   const setSelectedFeed = useSetSelectedFeed()
   const navigation = useNavigation<NavigationProp>()
@@ -25,14 +25,40 @@ export function DesktopFeeds() {
     return getCurrentRoute(state)
   })
 
-  if (!pinnedFeedInfos) {
+  if (isLoading) {
+    return (
+      <View
+        style={[
+          {
+            gap: 12,
+          },
+        ]}>
+        {Array(5)
+          .fill(0)
+          .map((_, i) => (
+            <View
+              key={i}
+              style={[
+                a.rounded_sm,
+                t.atoms.bg_contrast_25,
+                {
+                  height: 16,
+                  width: i % 2 === 0 ? '60%' : '80%',
+                },
+              ]}
+            />
+          ))}
+      </View>
+    )
+  }
+
+  if (error || !pinnedFeedInfos) {
     return null
   }
 
   return (
     <View
       style={[
-        a.flex_1,
         web({
           gap: 10,
           /*
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 895d16021..363294aa5 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -1,6 +1,8 @@
+import React from 'react'
 import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/core'
 
 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -8,17 +10,41 @@ import {useKawaiiMode} from '#/state/preferences/kawaii'
 import {useSession} from '#/state/session'
 import {DesktopFeeds} from '#/view/shell/desktop/Feeds'
 import {DesktopSearch} from '#/view/shell/desktop/Search'
+import {SidebarTrendingTopics} from '#/view/shell/desktop/SidebarTrendingTopics'
 import {atoms as a, useGutters, useTheme, web} from '#/alf'
+import {Divider} from '#/components/Divider'
 import {InlineLinkText} from '#/components/Link'
 import {ProgressGuideList} from '#/components/ProgressGuide/List'
 import {Text} from '#/components/Typography'
 
+function useWebQueryParams() {
+  const navigation = useNavigation()
+  const [params, setParams] = React.useState<Record<string, string>>({})
+
+  React.useEffect(() => {
+    return navigation.addListener('state', e => {
+      try {
+        const {state} = e.data
+        const lastRoute = state.routes[state.routes.length - 1]
+        const {params} = lastRoute
+        setParams(params)
+      } catch (e) {}
+    })
+  }, [navigation, setParams])
+
+  return params
+}
+
 export function DesktopRightNav({routeName}: {routeName: string}) {
   const t = useTheme()
   const {_} = useLingui()
   const {hasSession, currentAccount} = useSession()
   const kawaii = useKawaiiMode()
   const gutters = useGutters(['base', 0, 'base', 'wide'])
+  const isSearchScreen = routeName === 'Search'
+  const webqueryParams = useWebQueryParams()
+  const searchQuery = webqueryParams?.q
+  const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery)
 
   const {isTablet} = useWebMediaQueries()
   if (isTablet) {
@@ -29,6 +55,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
     <View
       style={[
         gutters,
+        a.gap_lg,
         web({
           position: 'fixed',
           left: '50%',
@@ -43,21 +70,18 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
           overflowY: 'auto',
         }),
       ]}>
-      {routeName !== 'Search' && (
-        <View style={[a.pb_lg]}>
-          <DesktopSearch />
-        </View>
-      )}
+      {!isSearchScreen && <DesktopSearch />}
+
       {hasSession && (
         <>
-          <ProgressGuideList style={[a.pb_xl]} />
-          <View
-            style={[a.pb_lg, a.mb_lg, a.border_b, t.atoms.border_contrast_low]}>
-            <DesktopFeeds />
-          </View>
+          <ProgressGuideList />
+          <DesktopFeeds />
+          <Divider />
         </>
       )}
 
+      {showTrending && <SidebarTrendingTopics />}
+
       <Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
         {hasSession && (
           <>
diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx
new file mode 100644
index 000000000..e22fad54d
--- /dev/null
+++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx
@@ -0,0 +1,104 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+  useTrendingSettings,
+  useTrendingSettingsApi,
+} from '#/state/preferences/trending'
+import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
+import {useTrendingConfig} from '#/state/trending-config'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import * as Prompt from '#/components/Prompt'
+import {
+  TrendingTopic,
+  TrendingTopicLink,
+  TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+const TRENDING_LIMIT = 6
+
+export function SidebarTrendingTopics() {
+  const {enabled} = useTrendingConfig()
+  const {trendingDisabled} = useTrendingSettings()
+  return !enabled ? null : trendingDisabled ? null : <Inner />
+}
+
+function Inner() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const trendingPrompt = Prompt.usePromptControl()
+  const {setTrendingDisabled} = useTrendingSettingsApi()
+  const {data: trending, error, isLoading} = useTrendingTopics()
+  const noTopics = !isLoading && !error && !trending?.topics?.length
+
+  return error || noTopics ? null : (
+    <>
+      <View style={[a.gap_sm, {paddingBottom: 2}]}>
+        <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+          <Graph size="sm" />
+          <Text
+            style={[
+              a.flex_1,
+              a.text_sm,
+              a.font_bold,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>Trending</Trans>
+          </Text>
+          <Button
+            label={_(msg`Hide trending topics`)}
+            size="tiny"
+            variant="ghost"
+            color="secondary"
+            shape="round"
+            onPress={() => trendingPrompt.open()}>
+            <ButtonIcon icon={X} />
+          </Button>
+        </View>
+
+        <View style={[a.flex_row, a.flex_wrap, {gap: '6px 4px'}]}>
+          {isLoading ? (
+            Array(TRENDING_LIMIT)
+              .fill(0)
+              .map((_n, i) => (
+                <TrendingTopicSkeleton key={i} size="small" index={i} />
+              ))
+          ) : !trending?.topics ? null : (
+            <>
+              {trending.topics.slice(0, TRENDING_LIMIT).map(topic => (
+                <TrendingTopicLink key={topic.link} topic={topic}>
+                  {({hovered}) => (
+                    <TrendingTopic
+                      size="small"
+                      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={() => setTrendingDisabled(true)}
+      />
+      <Divider />
+    </>
+  )
+}