about summary refs log tree commit diff
path: root/src/components/VideoPostCard.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/VideoPostCard.tsx')
-rw-r--r--src/components/VideoPostCard.tsx540
1 files changed, 540 insertions, 0 deletions
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>
+  )
+}