diff options
Diffstat (limited to 'src/components/VideoPostCard.tsx')
-rw-r--r-- | src/components/VideoPostCard.tsx | 540 |
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> + ) +} |