An ATproto social media client -- with an independent Appview.
1import {useMemo} from 'react'
2import {View} from 'react-native'
3import {Image} from 'expo-image'
4import {LinearGradient} from 'expo-linear-gradient'
5import {
6 type AppBskyActorDefs,
7 AppBskyEmbedVideo,
8 type AppBskyFeedDefs,
9 AppBskyFeedPost,
10 type ModerationDecision,
11} from '@atproto/api'
12import {msg} from '@lingui/macro'
13import {useLingui} from '@lingui/react'
14
15import {sanitizeHandle} from '#/lib/strings/handles'
16import {formatCount} from '#/view/com/util/numeric/format'
17import {UserAvatar} from '#/view/com/util/UserAvatar'
18import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
19import {atoms as a, useTheme} from '#/alf'
20import {BLUE_HUE} from '#/alf/util/colorGeneration'
21import {select} from '#/alf/util/themeSelector'
22import {useInteractionState} from '#/components/hooks/useInteractionState'
23import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
24import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
25import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
26import {Link} from '#/components/Link'
27import {MediaInsetBorder} from '#/components/MediaInsetBorder'
28import * as Hider from '#/components/moderation/Hider'
29import {Text} from '#/components/Typography'
30import * as bsky from '#/types/bsky'
31
32function getBlackColor(t: ReturnType<typeof useTheme>) {
33 return select(t.name, {
34 light: t.palette.black,
35 dark: t.atoms.bg_contrast_25.backgroundColor,
36 dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
37 })
38}
39
40export function VideoPostCard({
41 post,
42 sourceContext,
43 moderation,
44 onInteract,
45}: {
46 post: AppBskyFeedDefs.PostView
47 sourceContext: VideoFeedSourceContext
48 moderation: ModerationDecision
49 /**
50 * Callback for metrics etc
51 */
52 onInteract?: () => void
53}) {
54 const t = useTheme()
55 const {_, i18n} = useLingui()
56 const embed = post.embed
57 const {
58 state: pressed,
59 onIn: onPressIn,
60 onOut: onPressOut,
61 } = useInteractionState()
62
63 const listModUi = moderation.ui('contentList')
64
65 const mergedModui = useMemo(() => {
66 const modui = moderation.ui('contentList')
67 const mediaModui = moderation.ui('contentMedia')
68 modui.alerts = [...modui.alerts, ...mediaModui.alerts]
69 modui.blurs = [...modui.blurs, ...mediaModui.blurs]
70 modui.filters = [...modui.filters, ...mediaModui.filters]
71 modui.informs = [...modui.informs, ...mediaModui.informs]
72 return modui
73 }, [moderation])
74
75 /**
76 * Filtering should be done at a higher level, such as `PostFeed` or
77 * `PostFeedVideoGridRow`, but we need to protect here as well.
78 */
79 if (!AppBskyEmbedVideo.isView(embed)) return null
80
81 const author = post.author
82 const text = bsky.dangerousIsType<AppBskyFeedPost.Record>(
83 post.record,
84 AppBskyFeedPost.isRecord,
85 )
86 ? post.record?.text
87 : ''
88 const likeCount = post?.likeCount ?? 0
89 const repostCount = post?.repostCount ?? 0
90 const {thumbnail} = embed
91 const black = getBlackColor(t)
92
93 const textAndAuthor = (
94 <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
95 {text && (
96 <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
97 {text}
98 </Text>
99 )}
100 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
101 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
102 <UserAvatar type="user" size={20} avatar={post.author.avatar} />
103 <MediaInsetBorder />
104 </View>
105 <Text
106 style={[
107 a.flex_1,
108 a.text_sm,
109 a.leading_tight,
110 t.atoms.text_contrast_medium,
111 ]}
112 numberOfLines={1}>
113 {sanitizeHandle(post.author.handle, '@')}
114 </Text>
115 </View>
116 </View>
117 )
118
119 return (
120 <Link
121 accessibilityHint={_(msg`Views video in immersive mode`)}
122 label={_(msg`Video from ${author.handle}: ${text}`)}
123 to={{
124 screen: 'VideoFeed',
125 params: {
126 ...sourceContext,
127 initialPostUri: post.uri,
128 },
129 }}
130 onPress={() => {
131 onInteract?.()
132 }}
133 onPressIn={onPressIn}
134 onPressOut={onPressOut}
135 style={[
136 a.flex_col,
137 {
138 alignItems: undefined,
139 justifyContent: undefined,
140 },
141 ]}>
142 <Hider.Outer modui={mergedModui}>
143 <Hider.Mask>
144 <View
145 style={[
146 a.justify_center,
147 a.rounded_md,
148 a.overflow_hidden,
149 {
150 backgroundColor: black,
151 aspectRatio: 9 / 16,
152 },
153 ]}>
154 <Image
155 source={{uri: thumbnail}}
156 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
157 accessibilityIgnoresInvertColors
158 blurRadius={100}
159 />
160 <MediaInsetBorder />
161 <View
162 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
163 <View
164 style={[
165 a.absolute,
166 a.inset_0,
167 a.justify_center,
168 a.align_center,
169 {
170 backgroundColor: 'black',
171 opacity: 0.2,
172 },
173 ]}
174 />
175 <View style={[a.align_center, a.gap_xs]}>
176 <Eye size="lg" fill="white" />
177 <Text style={[a.text_sm, {color: 'white'}]}>
178 {_(msg`Hidden`)}
179 </Text>
180 </View>
181 </View>
182 </View>
183 {listModUi.blur ? (
184 <VideoPostCardTextPlaceholder author={post.author} />
185 ) : (
186 textAndAuthor
187 )}
188 </Hider.Mask>
189 <Hider.Content>
190 <View
191 style={[
192 a.justify_center,
193 a.rounded_md,
194 a.overflow_hidden,
195 {
196 backgroundColor: black,
197 aspectRatio: 9 / 16,
198 },
199 ]}>
200 <Image
201 source={{uri: thumbnail}}
202 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
203 accessibilityIgnoresInvertColors
204 />
205 <MediaInsetBorder />
206
207 <View style={[a.absolute, a.inset_0]}>
208 <View
209 style={[
210 a.absolute,
211 a.inset_0,
212 a.pt_2xl,
213 {
214 top: 'auto',
215 },
216 ]}>
217 <LinearGradient
218 colors={[black, 'rgba(0, 0, 0, 0)']}
219 locations={[0.02, 1]}
220 start={{x: 0, y: 1}}
221 end={{x: 0, y: 0}}
222 style={[a.absolute, a.inset_0, {opacity: 0.9}]}
223 />
224
225 <View
226 style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
227 {likeCount > 0 && (
228 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
229 <Heart size="sm" fill="white" />
230 <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
231 {formatCount(i18n, likeCount)}
232 </Text>
233 </View>
234 )}
235 {repostCount > 0 && (
236 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
237 <Repost size="sm" fill="white" />
238 <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
239 {formatCount(i18n, repostCount)}
240 </Text>
241 </View>
242 )}
243 </View>
244 </View>
245 </View>
246 </View>
247 {textAndAuthor}
248 </Hider.Content>
249 </Hider.Outer>
250 </Link>
251 )
252}
253
254export function VideoPostCardPlaceholder() {
255 const t = useTheme()
256 const black = getBlackColor(t)
257
258 return (
259 <View style={[a.flex_1]}>
260 <View
261 style={[
262 a.rounded_md,
263 a.overflow_hidden,
264 {
265 backgroundColor: black,
266 aspectRatio: 9 / 16,
267 },
268 ]}>
269 <MediaInsetBorder />
270 </View>
271 <VideoPostCardTextPlaceholder />
272 </View>
273 )
274}
275
276export function VideoPostCardTextPlaceholder({
277 author,
278}: {
279 author?: AppBskyActorDefs.ProfileViewBasic
280}) {
281 const t = useTheme()
282
283 return (
284 <View style={[a.flex_1]}>
285 <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
286 <View
287 style={[
288 a.w_full,
289 a.rounded_xs,
290 t.atoms.bg_contrast_50,
291 {
292 height: 14,
293 },
294 ]}
295 />
296 <View
297 style={[
298 a.w_full,
299 a.rounded_xs,
300 t.atoms.bg_contrast_50,
301 {
302 height: 14,
303 width: '70%',
304 },
305 ]}
306 />
307 {author ? (
308 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
309 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
310 <UserAvatar type="user" size={20} avatar={author.avatar} />
311 <MediaInsetBorder />
312 </View>
313 <Text
314 style={[
315 a.flex_1,
316 a.text_sm,
317 a.leading_tight,
318 t.atoms.text_contrast_medium,
319 ]}
320 numberOfLines={1}>
321 {sanitizeHandle(author.handle, '@')}
322 </Text>
323 </View>
324 ) : (
325 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
326 <View
327 style={[
328 a.rounded_full,
329 t.atoms.bg_contrast_50,
330 {
331 width: 20,
332 height: 20,
333 },
334 ]}
335 />
336 <View
337 style={[
338 a.rounded_xs,
339 t.atoms.bg_contrast_25,
340 {
341 height: 12,
342 width: '75%',
343 },
344 ]}
345 />
346 </View>
347 )}
348 </View>
349 </View>
350 )
351}
352
353export function CompactVideoPostCard({
354 post,
355 sourceContext,
356 moderation,
357 onInteract,
358}: {
359 post: AppBskyFeedDefs.PostView
360 sourceContext: VideoFeedSourceContext
361 moderation: ModerationDecision
362 /**
363 * Callback for metrics etc
364 */
365 onInteract?: () => void
366}) {
367 const t = useTheme()
368 const {_, i18n} = useLingui()
369 const embed = post.embed
370 const {
371 state: pressed,
372 onIn: onPressIn,
373 onOut: onPressOut,
374 } = useInteractionState()
375
376 const mergedModui = useMemo(() => {
377 const modui = moderation.ui('contentList')
378 const mediaModui = moderation.ui('contentMedia')
379 modui.alerts = [...modui.alerts, ...mediaModui.alerts]
380 modui.blurs = [...modui.blurs, ...mediaModui.blurs]
381 modui.filters = [...modui.filters, ...mediaModui.filters]
382 modui.informs = [...modui.informs, ...mediaModui.informs]
383 return modui
384 }, [moderation])
385
386 /**
387 * Filtering should be done at a higher level, such as `PostFeed` or
388 * `PostFeedVideoGridRow`, but we need to protect here as well.
389 */
390 if (!AppBskyEmbedVideo.isView(embed)) return null
391
392 const likeCount = post?.likeCount ?? 0
393 const showLikeCount = false
394 const {thumbnail} = embed
395 const black = getBlackColor(t)
396
397 return (
398 <Link
399 label={_(msg`View video`)}
400 to={{
401 screen: 'VideoFeed',
402 params: {
403 ...sourceContext,
404 initialPostUri: post.uri,
405 },
406 }}
407 onPress={() => {
408 onInteract?.()
409 }}
410 onPressIn={onPressIn}
411 onPressOut={onPressOut}
412 style={[
413 a.flex_col,
414 t.atoms.shadow_sm,
415 {
416 alignItems: undefined,
417 justifyContent: undefined,
418 },
419 ]}>
420 <Hider.Outer modui={mergedModui}>
421 <Hider.Mask>
422 <View
423 style={[
424 a.justify_center,
425 a.rounded_lg,
426 a.overflow_hidden,
427 a.border,
428 t.atoms.border_contrast_low,
429 {
430 backgroundColor: black,
431 aspectRatio: 9 / 16,
432 },
433 ]}>
434 <Image
435 source={{uri: thumbnail}}
436 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
437 accessibilityIgnoresInvertColors
438 blurRadius={100}
439 />
440 <MediaInsetBorder />
441 <View
442 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
443 <View
444 style={[
445 a.absolute,
446 a.inset_0,
447 a.justify_center,
448 a.align_center,
449 a.border,
450 t.atoms.border_contrast_low,
451 {
452 backgroundColor: 'black',
453 opacity: 0.2,
454 },
455 ]}
456 />
457 <View style={[a.align_center, a.gap_xs]}>
458 <Eye size="lg" fill="white" />
459 <Text style={[a.text_sm, {color: 'white'}]}>
460 {_(msg`Hidden`)}
461 </Text>
462 </View>
463 </View>
464 </View>
465 </Hider.Mask>
466 <Hider.Content>
467 <View
468 style={[
469 a.justify_center,
470 a.rounded_lg,
471 a.overflow_hidden,
472 a.border,
473 t.atoms.border_contrast_low,
474 {
475 backgroundColor: black,
476 aspectRatio: 9 / 16,
477 },
478 ]}>
479 <Image
480 source={{uri: thumbnail}}
481 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
482 accessibilityIgnoresInvertColors
483 />
484 <MediaInsetBorder />
485
486 <View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
487 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
488 <View
489 style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
490 <UserAvatar
491 type="user"
492 size={24}
493 avatar={post.author.avatar}
494 />
495 <MediaInsetBorder />
496 </View>
497 </View>
498
499 {showLikeCount && (
500 <View
501 style={[
502 a.absolute,
503 a.inset_0,
504 a.pt_2xl,
505 {
506 top: 'auto',
507 },
508 ]}>
509 <LinearGradient
510 colors={[black, 'rgba(0, 0, 0, 0)']}
511 locations={[0.02, 1]}
512 start={{x: 0, y: 1}}
513 end={{x: 0, y: 0}}
514 style={[a.absolute, a.inset_0, {opacity: 0.9}]}
515 />
516
517 <View
518 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
519 {likeCount > 0 && (
520 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
521 <Heart size="sm" fill="white" />
522 <Text
523 style={[a.text_sm, a.font_bold, {color: 'white'}]}>
524 {formatCount(i18n, likeCount)}
525 </Text>
526 </View>
527 )}
528 </View>
529 </View>
530 )}
531 </View>
532 </View>
533 </Hider.Content>
534 </Hider.Outer>
535 </Link>
536 )
537}
538
539export function CompactVideoPostCardPlaceholder() {
540 const t = useTheme()
541 const black = getBlackColor(t)
542
543 return (
544 <View style={[a.flex_1, t.atoms.shadow_sm]}>
545 <View
546 style={[
547 a.rounded_lg,
548 a.overflow_hidden,
549 a.border,
550 t.atoms.border_contrast_low,
551 {
552 backgroundColor: black,
553 aspectRatio: 9 / 16,
554 },
555 ]}>
556 <MediaInsetBorder />
557 </View>
558 </View>
559 )
560}