about summary refs log tree commit diff
path: root/src/screens/Search/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Search/modules')
-rw-r--r--src/screens/Search/modules/ExploreFeedPreviews.tsx264
-rw-r--r--src/screens/Search/modules/ExploreRecommendations.tsx123
-rw-r--r--src/screens/Search/modules/ExploreSuggestedAccounts.tsx228
-rw-r--r--src/screens/Search/modules/ExploreTrendingTopics.tsx278
-rw-r--r--src/screens/Search/modules/ExploreTrendingVideos.tsx234
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 && <> &middot; </>}
+                  {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>
+    </>
+  )
+}