about summary refs log tree commit diff
path: root/src/components/VideoPostCard.tsx
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2025-01-19 17:17:41 -0800
committerGitHub <noreply@github.com>2025-01-19 17:17:41 -0800
commit34582edf3ea17789684100172d6dd496220482b0 (patch)
treeca29a927bf015107a60a867d8266274c8a78de49 /src/components/VideoPostCard.tsx
parentcb020655504dd0d39f8e91fd517f14dc4a82c307 (diff)
downloadvoidsky-34582edf3ea17789684100172d6dd496220482b0.tar.zst
yolo (#7499)
* tweaks to constants (#7478)

* add did

* use correct did

* typo

* tweak

* Prevent Drawer gesture conflicting with Suggestions scroll (#7468)

* Extract BlockDrawerGeesture

* Block drawer when scrolling interstitials

(cherry picked from commit 9e3f2f43745eed9c71cb985e48135b7363d91aa9)

* yolo interstitial

* yolo mode

* right swipe

* fix nav gesture

* vibe controls

* collapsible post text

* rm blurview, cover for tall videos

* smarter video source handling

* use thumbnails, improve perf significantly

* better android loading

* improve aspect ratio

* optimize source changes

* rm spinner on ios

* whoops, remove debug `false`

* FIX WRONG VIDEOS SHOWING UP

* don't spring on way down

* release video players when leaving screen

* remove jank animation

* Add grid

* improve contract, fix double tap

* Filter out posts without videos

* Only do grid on native

* Pipe through feedSourceUri and link to feed

* Handle passed through params

* Partial revert, just filter posts to start at index

* Clean up cards, remove entry interstitial

* Tweak handle

* Change constant name

* Rename some things

* Make types legit

* Clean up more naming

* Add placeholder for grid view

* Handle web, set up new organization

* Begin work on Header

* Replace types

* Squashed commit of the following:

commit 3d1be4c0f19789dd3c5a3572ec1acd744a2edb80
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:08:05 2025 +0000

    extend animation

commit c9f199413b018efcbd9d8d2a58dd05eb41e7acb7
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:01:24 2025 +0000

    fix gap

commit 22e520795f50efda176f21a5e967cb27d0cdd907
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:50:16 2025 +0000

    thinner bar, format time

commit c32427f21405294ed3567545629a2964c4af59fe
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:47:57 2025 +0000

    fix 2 in 3 screens

commit cbf84c08d64ca0a08ba9070ef5db918f89aa4296
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:45:46 2025 +0000

    rm unneeded var

commit 7e0e100177bb1cd0e64c0841bb7685c7f1eb857f
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:41:18 2025 +0000

    scrubberrrrr

* use white with opacity rather than gray

* Simultaneous gesture

* cleanup attempt

* fix jank

* link to profile on press

* fix jitter fr this time

* mostly fix android flicker

* Maybe fix row generation

* Add content hider to video card

* emoji in post text

* reduce update rate

* fix type error

* Fix grid layout trailing single item

* Add Discover interstitial, settings, includes pin for now

* Explore interstitial, handle dimissal, pinning, compact card

* Only use grid placeholder on native

* Update events

* Add feature gate

* android nav bar fixes + lower update speed

* fix interval + decel rate on interstitials

* attempt to fix broken scrub on android (not working)

* follow button

* Part out the interstitials for perf, add view more

* Remove prod web route

* Wrap interstitials with BlockDrawerGesture

* Bring video cropping in line with images (#7462)

* Mimic image cropping for videos on web

* Same on native

* Rename variables for clarity

* Fix Android scrubbing

* Add FeedFeedbackProvider

* Remove swipe gesture

* fix light status bar behaviour

* bump

* feedback

* Copy pasta to new location

* Copy pasta part deux

* Filter only videos

* Make whole text clickable to expand

(cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07)

* move scrubber to own file

* end card

* add icon to end card

* add min view time to viewability config

* play haptic on like

* tweak feedback

* tweak feedback again

* Moderation

(cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47)

* remove bad check

* fix feedback for new video grid

* change prop name to items as well

* Simplify logic

* Fix mod footer

* Give scrubber more space on android

* Add subtle track behind scrubber, adjust opacity

* wire in feed context again...

* Add better a11y desc to card

* Fix key issue

* Update a11y copy

* Fix scrubber height

* improve scrubber animation

* Make follow button more obvious

* Make header back button more clear

* Disable interactions with actual video el

* keep content away from the bottom safe area

* fix blur

* fix moderation issue

* improve contrast on mod screen

* Make moderation static per item

* Memoize rows

* Optimizations

* Take video moderation into account

* Only blur titles for list blur

* Change copy

* Bump blur radius

* animate text in both directions

* Rm unused field

* Filter by root early

* Refactor for clarity

* add compose prompt to scrubber

* rm log

* tweak gradient

* Bump SDK, use contentMode to power video feed

* Ensure ProfileFeed view also supports video feed

* improve scrubber on android

* rm border from footer

* Update prod video feed did

* Separate caches

* Add lil hover to View More

* Fix undefined logic, remove header for interstitial

* Ungate

* Fix stuckness

* remove extra useless map

* Fix effect cleanup

* Send seen without cleanup

* Simplify react stuff

* Earlier early return to avoid loading flash

* remove scrubber placeholder

* Remove opacity hack

* Render useEvent conditionally

* Fix Android flash

---------

Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
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>
+  )
+}