about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Dialog/index.tsx4
-rw-r--r--src/components/Grid.tsx59
-rw-r--r--src/components/Layout/Header/index.tsx6
-rw-r--r--src/components/LinearGradientBackground.tsx14
-rw-r--r--src/components/Lists.tsx12
-rw-r--r--src/components/RichText.tsx10
-rw-r--r--src/components/VideoPostCard.tsx540
-rw-r--r--src/components/feeds/PostFeedVideoGridRow.tsx67
-rw-r--r--src/components/interstitials/TrendingVideos.tsx231
9 files changed, 933 insertions, 10 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index c424321be..597964e29 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {List, ListMethods, ListProps} from '#/view/com/util/List'
 import {atoms as a, useTheme} from '#/alf'
+import {useThemeName} from '#/alf/util/useColorModeTheme'
 import {Context, useDialogContext} from '#/components/Dialog/context'
 import {
   DialogControlProps,
@@ -55,7 +56,8 @@ export function Outer({
   nativeOptions,
   testID,
 }: React.PropsWithChildren<DialogOuterProps>) {
-  const t = useTheme()
+  const themeName = useThemeName()
+  const t = useTheme(themeName)
   const ref = React.useRef<BottomSheetNativeComponent>(null)
   const closeCallbacks = React.useRef<(() => void)[]>([])
   const {setDialogIsOpen, setFullyExpandedCount} =
diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx
new file mode 100644
index 000000000..d424634de
--- /dev/null
+++ b/src/components/Grid.tsx
@@ -0,0 +1,59 @@
+import {createContext, useContext, useMemo} from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, ViewStyleProp} from '#/alf'
+
+const Context = createContext({
+  gap: 0,
+})
+
+export function Row({
+  children,
+  gap = 0,
+  style,
+}: ViewStyleProp & {
+  children: React.ReactNode
+  gap?: number
+}) {
+  return (
+    <Context.Provider value={useMemo(() => ({gap}), [gap])}>
+      <View
+        style={[
+          a.flex_row,
+          a.flex_1,
+          {
+            marginLeft: -gap / 2,
+            marginRight: -gap / 2,
+          },
+          style,
+        ]}>
+        {children}
+      </View>
+    </Context.Provider>
+  )
+}
+
+export function Col({
+  children,
+  width = 1,
+  style,
+}: ViewStyleProp & {
+  children: React.ReactNode
+  width?: number
+}) {
+  const {gap} = useContext(Context)
+  return (
+    <View
+      style={[
+        a.flex_col,
+        {
+          paddingLeft: gap / 2,
+          paddingRight: gap / 2,
+          width: `${width * 100}%`,
+        },
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index 2d0fc149e..d38cf9d94 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
         shape="square"
         onPress={onPressBack}
         hitSlop={HITSLOP_30}
-        style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
+        style={[
+          {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
+          a.bg_transparent,
+          style,
+        ]}
         {...props}>
         <ButtonIcon icon={ArrowLeft} size="lg" />
       </Button>
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx
index 724df43f3..9b28b897c 100644
--- a/src/components/LinearGradientBackground.tsx
+++ b/src/components/LinearGradientBackground.tsx
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
 
 export function LinearGradientBackground({
   style,
+  gradient = 'sky',
   children,
+  start,
+  end,
 }: {
-  style: StyleProp<ViewStyle>
-  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+  gradient?: keyof typeof gradients
+  children?: React.ReactNode
+  start?: [number, number]
+  end?: [number, number]
 }) {
-  const gradient = gradients.sky.values.map(([_, color]) => {
+  const colors = gradients[gradient].values.map(([_, color]) => {
     return color
   }) as [string, string, ...string[]]
 
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
   }
 
   return (
-    <LinearGradient colors={gradient} style={style}>
+    <LinearGradient colors={colors} style={style} start={start} end={end}>
       {children}
     </LinearGradient>
   )
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index 2d7b13b25..5c602249b 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -20,6 +20,7 @@ export function ListFooter({
   style,
   showEndMessage = false,
   endMessageText,
+  renderEndMessage,
 }: {
   isFetchingNextPage?: boolean
   hasNextPage?: boolean
@@ -29,6 +30,7 @@ export function ListFooter({
   style?: StyleProp<ViewStyle>
   showEndMessage?: boolean
   endMessageText?: string
+  renderEndMessage?: () => React.ReactNode
 }) {
   const t = useTheme()
 
@@ -48,9 +50,13 @@ export function ListFooter({
       ) : error ? (
         <ListFooterMaybeError error={error} onRetry={onRetry} />
       ) : !hasNextPage && showEndMessage ? (
-        <Text style={[a.text_sm, t.atoms.text_contrast_low]}>
-          {endMessageText ?? <Trans>You have reached the end</Trans>}
-        </Text>
+        renderEndMessage ? (
+          renderEndMessage()
+        ) : (
+          <Text style={[a.text_sm, t.atoms.text_contrast_low]}>
+            {endMessageText ?? <Trans>You have reached the end</Trans>}
+          </Text>
+        )
       ) : null}
     </View>
   )
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 6d7e50e48..4edd9f88e 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography'
 const WORD_WRAP = {wordWrap: 1}
 
 export type RichTextProps = TextStyleProp &
-  Pick<TextProps, 'selectable'> & {
+  Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
     value: RichTextAPI | string
     testID?: string
     numberOfLines?: number
@@ -43,6 +43,8 @@ export function RichText({
   onLinkPress,
   interactiveStyle,
   emojiMultiplier = 1.85,
+  onLayout,
+  onTextLayout,
 }: RichTextProps) {
   const richText = React.useMemo(
     () =>
@@ -70,6 +72,8 @@ export function RichText({
           selectable={selectable}
           testID={testID}
           style={[plainStyles, {fontSize}]}
+          onLayout={onLayout}
+          onTextLayout={onTextLayout}
           // @ts-ignore web only -prf
           dataSet={WORD_WRAP}>
           {text}
@@ -83,6 +87,8 @@ export function RichText({
         testID={testID}
         style={plainStyles}
         numberOfLines={numberOfLines}
+        onLayout={onLayout}
+        onTextLayout={onTextLayout}
         // @ts-ignore web only -prf
         dataSet={WORD_WRAP}>
         {text}
@@ -163,6 +169,8 @@ export function RichText({
       testID={testID}
       style={plainStyles}
       numberOfLines={numberOfLines}
+      onLayout={onLayout}
+      onTextLayout={onTextLayout}
       // @ts-ignore web only -prf
       dataSet={WORD_WRAP}>
       {els}
diff --git a/src/components/VideoPostCard.tsx b/src/components/VideoPostCard.tsx
new file mode 100644
index 000000000..008274969
--- /dev/null
+++ b/src/components/VideoPostCard.tsx
@@ -0,0 +1,540 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {LinearGradient} from 'expo-linear-gradient'
+import {
+  AppBskyActorDefs,
+  AppBskyEmbedVideo,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ModerationDecision,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useTheme} from '#/alf'
+import {BLUE_HUE} from '#/alf/util/colorGeneration'
+import {select} from '#/alf/util/themeSelector'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
+import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
+import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
+import {Link} from '#/components/Link'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import * as Hider from '#/components/moderation/Hider'
+import {Text} from '#/components/Typography'
+
+function getBlackColor(t: ReturnType<typeof useTheme>) {
+  return select(t.name, {
+    light: t.palette.black,
+    dark: t.atoms.bg_contrast_25.backgroundColor,
+    dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
+  })
+}
+
+export function VideoPostCard({
+  post,
+  sourceContext,
+  moderation,
+  onInteract,
+}: {
+  post: AppBskyFeedDefs.PostView
+  sourceContext: VideoFeedSourceContext
+  moderation: ModerationDecision
+  /**
+   * Callback for metrics etc
+   */
+  onInteract?: () => void
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const embed = post.embed
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const listModUi = moderation.ui('contentList')
+
+  const mergedModui = useMemo(() => {
+    const modui = moderation.ui('contentList')
+    const mediaModui = moderation.ui('contentMedia')
+    modui.alerts = [...modui.alerts, ...mediaModui.alerts]
+    modui.blurs = [...modui.blurs, ...mediaModui.blurs]
+    modui.filters = [...modui.filters, ...mediaModui.filters]
+    modui.informs = [...modui.informs, ...mediaModui.informs]
+    return modui
+  }, [moderation])
+
+  /**
+   * Filtering should be done at a higher level, such as `PostFeed` or
+   * `PostFeedVideoGridRow`, but we need to protect here as well.
+   */
+  if (!AppBskyEmbedVideo.isView(embed)) return null
+
+  const author = post.author
+  const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
+  const likeCount = post?.likeCount ?? 0
+  const repostCount = post?.repostCount ?? 0
+  const {thumbnail} = embed
+  const black = getBlackColor(t)
+
+  const textAndAuthor = (
+    <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
+      {text && (
+        <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
+          {text}
+        </Text>
+      )}
+      <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+        <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+          <UserAvatar type="user" size={20} avatar={post.author.avatar} />
+          <MediaInsetBorder />
+        </View>
+        <Text
+          style={[
+            a.flex_1,
+            a.text_sm,
+            a.leading_tight,
+            t.atoms.text_contrast_medium,
+          ]}
+          numberOfLines={1}>
+          {sanitizeHandle(post.author.handle, '@')}
+        </Text>
+      </View>
+    </View>
+  )
+
+  return (
+    <Link
+      accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
+      label={_(msg`Video from ${author.handle}: ${text}`)}
+      to={{
+        screen: 'VideoFeed',
+        params: {
+          ...sourceContext,
+          initialPostUri: post.uri,
+        },
+      }}
+      onPress={() => {
+        onInteract?.()
+      }}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_col,
+        {
+          alignItems: undefined,
+          justifyContent: undefined,
+        },
+      ]}>
+      <Hider.Outer modui={mergedModui}>
+        <Hider.Mask>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+              blurRadius={100}
+            />
+            <MediaInsetBorder />
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.justify_center,
+                  a.align_center,
+                  {
+                    backgroundColor: 'black',
+                    opacity: 0.2,
+                  },
+                ]}
+              />
+              <View style={[a.align_center, a.gap_xs]}>
+                <Eye size="lg" fill="white" />
+                <Text style={[a.text_sm, {color: 'white'}]}>
+                  {_(msg`Hidden`)}
+                </Text>
+              </View>
+            </View>
+          </View>
+          {listModUi.blur ? (
+            <VideoPostCardTextPlaceholder author={post.author} />
+          ) : (
+            textAndAuthor
+          )}
+        </Hider.Mask>
+        <Hider.Content>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+            />
+            <MediaInsetBorder />
+
+            <View style={[a.absolute, a.inset_0]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.pt_2xl,
+                  {
+                    top: 'auto',
+                  },
+                ]}>
+                <LinearGradient
+                  colors={[black, 'rgba(0, 0, 0, 0)']}
+                  locations={[0.02, 1]}
+                  start={{x: 0, y: 1}}
+                  end={{x: 0, y: 0}}
+                  style={[a.absolute, a.inset_0, {opacity: 0.9}]}
+                />
+
+                <View
+                  style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
+                  {likeCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Heart size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, likeCount)}
+                      </Text>
+                    </View>
+                  )}
+                  {repostCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Repost size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, repostCount)}
+                      </Text>
+                    </View>
+                  )}
+                </View>
+              </View>
+            </View>
+          </View>
+          {textAndAuthor}
+        </Hider.Content>
+      </Hider.Outer>
+    </Link>
+  )
+}
+
+export function VideoPostCardPlaceholder() {
+  const t = useTheme()
+  const black = getBlackColor(t)
+
+  return (
+    <View style={[a.flex_1]}>
+      <View
+        style={[
+          a.rounded_md,
+          a.overflow_hidden,
+          {
+            backgroundColor: black,
+            aspectRatio: 9 / 16,
+          },
+        ]}>
+        <MediaInsetBorder />
+      </View>
+      <VideoPostCardTextPlaceholder />
+    </View>
+  )
+}
+
+export function VideoPostCardTextPlaceholder({
+  author,
+}: {
+  author?: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const t = useTheme()
+
+  return (
+    <View style={[a.flex_1]}>
+      <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            t.atoms.bg_contrast_50,
+            {
+              height: 14,
+            },
+          ]}
+        />
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            t.atoms.bg_contrast_50,
+            {
+              height: 14,
+              width: '70%',
+            },
+          ]}
+        />
+        {author ? (
+          <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+            <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+              <UserAvatar type="user" size={20} avatar={author.avatar} />
+              <MediaInsetBorder />
+            </View>
+            <Text
+              style={[
+                a.flex_1,
+                a.text_sm,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+              ]}
+              numberOfLines={1}>
+              {sanitizeHandle(author.handle, '@')}
+            </Text>
+          </View>
+        ) : (
+          <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+            <View
+              style={[
+                a.rounded_full,
+                t.atoms.bg_contrast_50,
+                {
+                  width: 20,
+                  height: 20,
+                },
+              ]}
+            />
+            <View
+              style={[
+                a.rounded_xs,
+                t.atoms.bg_contrast_25,
+                {
+                  height: 12,
+                  width: '75%',
+                },
+              ]}
+            />
+          </View>
+        )}
+      </View>
+    </View>
+  )
+}
+
+export function CompactVideoPostCard({
+  post,
+  sourceContext,
+  moderation,
+  onInteract,
+}: {
+  post: AppBskyFeedDefs.PostView
+  sourceContext: VideoFeedSourceContext
+  moderation: ModerationDecision
+  /**
+   * Callback for metrics etc
+   */
+  onInteract?: () => void
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const embed = post.embed
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const mergedModui = useMemo(() => {
+    const modui = moderation.ui('contentList')
+    const mediaModui = moderation.ui('contentMedia')
+    modui.alerts = [...modui.alerts, ...mediaModui.alerts]
+    modui.blurs = [...modui.blurs, ...mediaModui.blurs]
+    modui.filters = [...modui.filters, ...mediaModui.filters]
+    modui.informs = [...modui.informs, ...mediaModui.informs]
+    return modui
+  }, [moderation])
+
+  /**
+   * Filtering should be done at a higher level, such as `PostFeed` or
+   * `PostFeedVideoGridRow`, but we need to protect here as well.
+   */
+  if (!AppBskyEmbedVideo.isView(embed)) return null
+
+  const likeCount = post?.likeCount ?? 0
+  const {thumbnail} = embed
+  const black = getBlackColor(t)
+
+  return (
+    <Link
+      label={_(msg`View video`)}
+      to={{
+        screen: 'VideoFeed',
+        params: {
+          ...sourceContext,
+          initialPostUri: post.uri,
+        },
+      }}
+      onPress={() => {
+        onInteract?.()
+      }}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_col,
+        {
+          alignItems: undefined,
+          justifyContent: undefined,
+        },
+      ]}>
+      <Hider.Outer modui={mergedModui}>
+        <Hider.Mask>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+              blurRadius={100}
+            />
+            <MediaInsetBorder />
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.justify_center,
+                  a.align_center,
+                  {
+                    backgroundColor: 'black',
+                    opacity: 0.2,
+                  },
+                ]}
+              />
+              <View style={[a.align_center, a.gap_xs]}>
+                <Eye size="lg" fill="white" />
+                <Text style={[a.text_sm, {color: 'white'}]}>
+                  {_(msg`Hidden`)}
+                </Text>
+              </View>
+            </View>
+          </View>
+        </Hider.Mask>
+        <Hider.Content>
+          <View
+            style={[
+              a.justify_center,
+              a.rounded_md,
+              a.overflow_hidden,
+              {
+                backgroundColor: black,
+                aspectRatio: 9 / 16,
+              },
+            ]}>
+            <Image
+              source={{uri: thumbnail}}
+              style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
+              accessibilityIgnoresInvertColors
+            />
+            <MediaInsetBorder />
+
+            <View style={[a.absolute, a.inset_0]}>
+              <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
+                <View
+                  style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
+                  <UserAvatar
+                    type="user"
+                    size={20}
+                    avatar={post.author.avatar}
+                  />
+                  <MediaInsetBorder />
+                </View>
+              </View>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  a.pt_2xl,
+                  {
+                    top: 'auto',
+                  },
+                ]}>
+                <LinearGradient
+                  colors={[black, 'rgba(0, 0, 0, 0)']}
+                  locations={[0.02, 1]}
+                  start={{x: 0, y: 1}}
+                  end={{x: 0, y: 0}}
+                  style={[a.absolute, a.inset_0, {opacity: 0.9}]}
+                />
+
+                <View
+                  style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
+                  {likeCount > 0 && (
+                    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+                      <Heart size="sm" fill="white" />
+                      <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
+                        {formatCount(i18n, likeCount)}
+                      </Text>
+                    </View>
+                  )}
+                </View>
+              </View>
+            </View>
+          </View>
+        </Hider.Content>
+      </Hider.Outer>
+    </Link>
+  )
+}
+
+export function CompactVideoPostCardPlaceholder() {
+  const t = useTheme()
+  const black = getBlackColor(t)
+
+  return (
+    <View style={[a.flex_1]}>
+      <View
+        style={[
+          a.rounded_md,
+          a.overflow_hidden,
+          {
+            backgroundColor: black,
+            aspectRatio: 9 / 16,
+          },
+        ]}>
+        <MediaInsetBorder />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/feeds/PostFeedVideoGridRow.tsx b/src/components/feeds/PostFeedVideoGridRow.tsx
new file mode 100644
index 000000000..7f9898083
--- /dev/null
+++ b/src/components/feeds/PostFeedVideoGridRow.tsx
@@ -0,0 +1,67 @@
+import {View} from 'react-native'
+import {AppBskyEmbedVideo} from '@atproto/api'
+
+import {logEvent} from '#/lib/statsig/statsig'
+import {FeedPostSliceItem} from '#/state/queries/post-feed'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useGutters} from '#/alf'
+import * as Grid from '#/components/Grid'
+import {
+  VideoPostCard,
+  VideoPostCardPlaceholder,
+} from '#/components/VideoPostCard'
+
+export function PostFeedVideoGridRow({
+  items: slices,
+  sourceContext,
+}: {
+  items: FeedPostSliceItem[]
+  sourceContext: VideoFeedSourceContext
+}) {
+  const gutters = useGutters(['base', 'base', 0, 'base'])
+  const posts = slices
+    .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
+    .map(slice => ({
+      post: slice.post,
+      moderation: slice.moderation,
+    }))
+
+  /**
+   * This should not happen because we should be filtering out posts without
+   * videos within the `PostFeed` component.
+   */
+  if (posts.length !== slices.length) return null
+
+  return (
+    <View style={[gutters]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <Grid.Row gap={a.gap_sm.gap}>
+          {posts.map(post => (
+            <Grid.Col key={post.post.uri} width={1 / 2}>
+              <VideoPostCard
+                post={post.post}
+                sourceContext={sourceContext}
+                moderation={post.moderation}
+                onInteract={() => {
+                  logEvent('videoCard:click', {context: 'feed'})
+                }}
+              />
+            </Grid.Col>
+          ))}
+        </Grid.Row>
+      </View>
+    </View>
+  )
+}
+
+export function PostFeedVideoGridRowPlaceholder() {
+  const gutters = useGutters(['base', 'base', 0, 'base'])
+  return (
+    <View style={[gutters]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <VideoPostCardPlaceholder />
+        <VideoPostCardPlaceholder />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx
new file mode 100644
index 000000000..126d6f417
--- /dev/null
+++ b/src/components/interstitials/TrendingVideos.tsx
@@ -0,0 +1,231 @@
+import React, {useEffect} 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 {useQueryClient} from '@tanstack/react-query'
+
+import {VIDEO_FEED_URI} from '#/lib/constants'
+import {makeCustomFeedLink} from '#/lib/routes/links'
+import {logEvent} from '#/lib/statsig/statsig'
+import {useTrendingSettingsApi} from '#/state/preferences/trending'
+import {usePostFeedQuery} from '#/state/queries/post-feed'
+import {RQKEY} from '#/state/queries/post-feed'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {atoms as a, useGutters, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import {Link} from '#/components/Link'
+import * as Prompt from '#/components/Prompt'
+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: 'discover'
+} = {
+  feedCacheKey: 'discover',
+}
+
+export function TrendingVideos() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const gutters = useGutters([0, 'base'])
+  const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
+
+  // Refetch on unmount if nothing else is using this query.
+  const queryClient = useQueryClient()
+  useEffect(() => {
+    return () => {
+      const query = queryClient
+        .getQueryCache()
+        .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
+      if (query && query.getObserversCount() <= 1) {
+        query.fetch()
+      }
+    }
+  }, [queryClient])
+
+  const {setTrendingVideoDisabled} = useTrendingSettingsApi()
+  const trendingPrompt = Prompt.usePromptControl()
+
+  const onConfirmHide = React.useCallback(() => {
+    setTrendingVideoDisabled(true)
+    logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
+  }, [setTrendingVideoDisabled])
+
+  if (error) {
+    return null
+  }
+
+  return (
+    <View
+      style={[
+        a.pt_lg,
+        a.pb_lg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <View
+        style={[
+          gutters,
+          a.pb_sm,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+        ]}>
+        <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
+          <Graph />
+          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+            <Trans>Trending Videos</Trans>
+          </Text>
+        </View>
+        <Button
+          label={_(msg`Dismiss this section`)}
+          size="tiny"
+          variant="ghost"
+          color="secondary"
+          shape="round"
+          onPress={() => trendingPrompt.open()}>
+          <ButtonIcon icon={X} />
+        </Button>
+      </View>
+
+      <BlockDrawerGesture>
+        <ScrollView
+          horizontal
+          showsHorizontalScrollIndicator={false}
+          decelerationRate="fast"
+          snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
+          <View
+            style={[
+              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>
+
+      <Prompt.Basic
+        control={trendingPrompt}
+        title={_(msg`Hide trending videos?`)}
+        description={_(msg`You can update this later from your settings.`)}
+        confirmButtonCta={_(msg`Hide`)}
+        onConfirm={onConfirmHide}
+      />
+    </View>
+  )
+}
+
+function VideoCards({
+  data,
+}: {
+  data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const items = React.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 = React.useMemo(() => {
+    const urip = new AtUri(VIDEO_FEED_URI)
+    return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
+  }, [])
+
+  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: 'discover',
+            }}
+            onInteract={() => {
+              logEvent('videoCard:click', {
+                context: 'interstitial:discover',
+              })
+            }}
+          />
+        </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,
+          ]}>
+          {({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>
+    </>
+  )
+}