forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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
231 style={[a.text_sm, a.font_semi_bold, {color: 'white'}]}>
232 {formatCount(i18n, likeCount)}
233 </Text>
234 </View>
235 )}
236 {repostCount > 0 && (
237 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
238 <Repost size="sm" fill="white" />
239 <Text
240 style={[a.text_sm, a.font_semi_bold, {color: 'white'}]}>
241 {formatCount(i18n, repostCount)}
242 </Text>
243 </View>
244 )}
245 </View>
246 </View>
247 </View>
248 </View>
249 {textAndAuthor}
250 </Hider.Content>
251 </Hider.Outer>
252 </Link>
253 )
254}
255
256export function VideoPostCardPlaceholder() {
257 const t = useTheme()
258 const black = getBlackColor(t)
259
260 return (
261 <View style={[a.flex_1]}>
262 <View
263 style={[
264 a.rounded_md,
265 a.overflow_hidden,
266 {
267 backgroundColor: black,
268 aspectRatio: 9 / 16,
269 },
270 ]}>
271 <MediaInsetBorder />
272 </View>
273 <VideoPostCardTextPlaceholder />
274 </View>
275 )
276}
277
278export function VideoPostCardTextPlaceholder({
279 author,
280}: {
281 author?: AppBskyActorDefs.ProfileViewBasic
282}) {
283 const t = useTheme()
284
285 return (
286 <View style={[a.flex_1]}>
287 <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
288 <View
289 style={[
290 a.w_full,
291 a.rounded_xs,
292 t.atoms.bg_contrast_50,
293 {
294 height: 14,
295 },
296 ]}
297 />
298 <View
299 style={[
300 a.w_full,
301 a.rounded_xs,
302 t.atoms.bg_contrast_50,
303 {
304 height: 14,
305 width: '70%',
306 },
307 ]}
308 />
309 {author ? (
310 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
311 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
312 <UserAvatar type="user" size={20} avatar={author.avatar} />
313 <MediaInsetBorder />
314 </View>
315 <Text
316 style={[
317 a.flex_1,
318 a.text_sm,
319 a.leading_tight,
320 t.atoms.text_contrast_medium,
321 ]}
322 numberOfLines={1}>
323 {sanitizeHandle(author.handle, '@')}
324 </Text>
325 </View>
326 ) : (
327 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
328 <View
329 style={[
330 a.rounded_full,
331 t.atoms.bg_contrast_50,
332 {
333 width: 20,
334 height: 20,
335 },
336 ]}
337 />
338 <View
339 style={[
340 a.rounded_xs,
341 t.atoms.bg_contrast_25,
342 {
343 height: 12,
344 width: '75%',
345 },
346 ]}
347 />
348 </View>
349 )}
350 </View>
351 </View>
352 )
353}
354
355export function CompactVideoPostCard({
356 post,
357 sourceContext,
358 moderation,
359 onInteract,
360}: {
361 post: AppBskyFeedDefs.PostView
362 sourceContext: VideoFeedSourceContext
363 moderation: ModerationDecision
364 /**
365 * Callback for metrics etc
366 */
367 onInteract?: () => void
368}) {
369 const t = useTheme()
370 const {_, i18n} = useLingui()
371 const embed = post.embed
372 const {
373 state: pressed,
374 onIn: onPressIn,
375 onOut: onPressOut,
376 } = useInteractionState()
377
378 const mergedModui = useMemo(() => {
379 const modui = moderation.ui('contentList')
380 const mediaModui = moderation.ui('contentMedia')
381 modui.alerts = [...modui.alerts, ...mediaModui.alerts]
382 modui.blurs = [...modui.blurs, ...mediaModui.blurs]
383 modui.filters = [...modui.filters, ...mediaModui.filters]
384 modui.informs = [...modui.informs, ...mediaModui.informs]
385 return modui
386 }, [moderation])
387
388 /**
389 * Filtering should be done at a higher level, such as `PostFeed` or
390 * `PostFeedVideoGridRow`, but we need to protect here as well.
391 */
392 if (!AppBskyEmbedVideo.isView(embed)) return null
393
394 const likeCount = post?.likeCount ?? 0
395 const showLikeCount = false
396 const {thumbnail} = embed
397 const black = getBlackColor(t)
398
399 return (
400 <Link
401 label={_(msg`View video`)}
402 to={{
403 screen: 'VideoFeed',
404 params: {
405 ...sourceContext,
406 initialPostUri: post.uri,
407 },
408 }}
409 onPress={() => {
410 onInteract?.()
411 }}
412 onPressIn={onPressIn}
413 onPressOut={onPressOut}
414 style={[
415 a.flex_col,
416 t.atoms.shadow_sm,
417 {
418 alignItems: undefined,
419 justifyContent: undefined,
420 },
421 ]}>
422 <Hider.Outer modui={mergedModui}>
423 <Hider.Mask>
424 <View
425 style={[
426 a.justify_center,
427 a.rounded_lg,
428 a.overflow_hidden,
429 a.border,
430 t.atoms.border_contrast_low,
431 {
432 backgroundColor: black,
433 aspectRatio: 9 / 16,
434 },
435 ]}>
436 <Image
437 source={{uri: thumbnail}}
438 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
439 accessibilityIgnoresInvertColors
440 blurRadius={100}
441 />
442 <MediaInsetBorder />
443 <View
444 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
445 <View
446 style={[
447 a.absolute,
448 a.inset_0,
449 a.justify_center,
450 a.align_center,
451 a.border,
452 t.atoms.border_contrast_low,
453 {
454 backgroundColor: 'black',
455 opacity: 0.2,
456 },
457 ]}
458 />
459 <View style={[a.align_center, a.gap_xs]}>
460 <Eye size="lg" fill="white" />
461 <Text style={[a.text_sm, {color: 'white'}]}>
462 {_(msg`Hidden`)}
463 </Text>
464 </View>
465 </View>
466 </View>
467 </Hider.Mask>
468 <Hider.Content>
469 <View
470 style={[
471 a.justify_center,
472 a.rounded_lg,
473 a.overflow_hidden,
474 a.border,
475 t.atoms.border_contrast_low,
476 {
477 backgroundColor: black,
478 aspectRatio: 9 / 16,
479 },
480 ]}>
481 <Image
482 source={{uri: thumbnail}}
483 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
484 accessibilityIgnoresInvertColors
485 />
486 <MediaInsetBorder />
487
488 <View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
489 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
490 <View
491 style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
492 <UserAvatar
493 type="user"
494 size={24}
495 avatar={post.author.avatar}
496 />
497 <MediaInsetBorder />
498 </View>
499 </View>
500
501 {showLikeCount && (
502 <View
503 style={[
504 a.absolute,
505 a.inset_0,
506 a.pt_2xl,
507 {
508 top: 'auto',
509 },
510 ]}>
511 <LinearGradient
512 colors={[black, 'rgba(0, 0, 0, 0)']}
513 locations={[0.02, 1]}
514 start={{x: 0, y: 1}}
515 end={{x: 0, y: 0}}
516 style={[a.absolute, a.inset_0, {opacity: 0.9}]}
517 />
518
519 <View
520 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
521 {likeCount > 0 && (
522 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
523 <Heart size="sm" fill="white" />
524 <Text
525 style={[
526 a.text_sm,
527 a.font_semi_bold,
528 {color: 'white'},
529 ]}>
530 {formatCount(i18n, likeCount)}
531 </Text>
532 </View>
533 )}
534 </View>
535 </View>
536 )}
537 </View>
538 </View>
539 </Hider.Content>
540 </Hider.Outer>
541 </Link>
542 )
543}
544
545export function CompactVideoPostCardPlaceholder() {
546 const t = useTheme()
547 const black = getBlackColor(t)
548
549 return (
550 <View style={[a.flex_1, t.atoms.shadow_sm]}>
551 <View
552 style={[
553 a.rounded_lg,
554 a.overflow_hidden,
555 a.border,
556 t.atoms.border_contrast_low,
557 {
558 backgroundColor: black,
559 aspectRatio: 9 / 16,
560 },
561 ]}>
562 <MediaInsetBorder />
563 </View>
564 </View>
565 )
566}