mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useMemo} from 'react'
2import {View} from 'react-native'
3import {Image} from 'expo-image'
4import {LinearGradient} from 'expo-linear-gradient'
5import {
6 AppBskyActorDefs,
7 AppBskyEmbedVideo,
8 AppBskyFeedDefs,
9 AppBskyFeedPost,
10 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 {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 {thumbnail} = embed
394 const black = getBlackColor(t)
395
396 return (
397 <Link
398 label={_(msg`View video`)}
399 to={{
400 screen: 'VideoFeed',
401 params: {
402 ...sourceContext,
403 initialPostUri: post.uri,
404 },
405 }}
406 onPress={() => {
407 onInteract?.()
408 }}
409 onPressIn={onPressIn}
410 onPressOut={onPressOut}
411 style={[
412 a.flex_col,
413 {
414 alignItems: undefined,
415 justifyContent: undefined,
416 },
417 ]}>
418 <Hider.Outer modui={mergedModui}>
419 <Hider.Mask>
420 <View
421 style={[
422 a.justify_center,
423 a.rounded_md,
424 a.overflow_hidden,
425 {
426 backgroundColor: black,
427 aspectRatio: 9 / 16,
428 },
429 ]}>
430 <Image
431 source={{uri: thumbnail}}
432 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
433 accessibilityIgnoresInvertColors
434 blurRadius={100}
435 />
436 <MediaInsetBorder />
437 <View
438 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
439 <View
440 style={[
441 a.absolute,
442 a.inset_0,
443 a.justify_center,
444 a.align_center,
445 {
446 backgroundColor: 'black',
447 opacity: 0.2,
448 },
449 ]}
450 />
451 <View style={[a.align_center, a.gap_xs]}>
452 <Eye size="lg" fill="white" />
453 <Text style={[a.text_sm, {color: 'white'}]}>
454 {_(msg`Hidden`)}
455 </Text>
456 </View>
457 </View>
458 </View>
459 </Hider.Mask>
460 <Hider.Content>
461 <View
462 style={[
463 a.justify_center,
464 a.rounded_md,
465 a.overflow_hidden,
466 {
467 backgroundColor: black,
468 aspectRatio: 9 / 16,
469 },
470 ]}>
471 <Image
472 source={{uri: thumbnail}}
473 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
474 accessibilityIgnoresInvertColors
475 />
476 <MediaInsetBorder />
477
478 <View style={[a.absolute, a.inset_0]}>
479 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
480 <View
481 style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
482 <UserAvatar
483 type="user"
484 size={20}
485 avatar={post.author.avatar}
486 />
487 <MediaInsetBorder />
488 </View>
489 </View>
490 <View
491 style={[
492 a.absolute,
493 a.inset_0,
494 a.pt_2xl,
495 {
496 top: 'auto',
497 },
498 ]}>
499 <LinearGradient
500 colors={[black, 'rgba(0, 0, 0, 0)']}
501 locations={[0.02, 1]}
502 start={{x: 0, y: 1}}
503 end={{x: 0, y: 0}}
504 style={[a.absolute, a.inset_0, {opacity: 0.9}]}
505 />
506
507 <View
508 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
509 {likeCount > 0 && (
510 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
511 <Heart size="sm" fill="white" />
512 <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
513 {formatCount(i18n, likeCount)}
514 </Text>
515 </View>
516 )}
517 </View>
518 </View>
519 </View>
520 </View>
521 </Hider.Content>
522 </Hider.Outer>
523 </Link>
524 )
525}
526
527export function CompactVideoPostCardPlaceholder() {
528 const t = useTheme()
529 const black = getBlackColor(t)
530
531 return (
532 <View style={[a.flex_1]}>
533 <View
534 style={[
535 a.rounded_md,
536 a.overflow_hidden,
537 {
538 backgroundColor: black,
539 aspectRatio: 9 / 16,
540 },
541 ]}>
542 <MediaInsetBorder />
543 </View>
544 </View>
545 )
546}