forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {
3 LayoutAnimation,
4 type ListRenderItem,
5 Pressable,
6 ScrollView,
7 View,
8 type ViewabilityConfig,
9 type ViewToken,
10} from 'react-native'
11import {
12 Gesture,
13 GestureDetector,
14 type NativeGesture,
15} from 'react-native-gesture-handler'
16import Animated, {
17 useAnimatedStyle,
18 useSharedValue,
19} from 'react-native-reanimated'
20import {
21 useSafeAreaFrame,
22 useSafeAreaInsets,
23} from 'react-native-safe-area-context'
24import {useEvent, useEventListener} from 'expo'
25import {Image, type ImageStyle} from 'expo-image'
26import {LinearGradient} from 'expo-linear-gradient'
27import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video'
28import {
29 AppBskyEmbedVideo,
30 type AppBskyFeedDefs,
31 AppBskyFeedPost,
32 AtUri,
33 type ModerationDecision,
34 RichText as RichTextAPI,
35} from '@atproto/api'
36import {msg, Trans} from '@lingui/macro'
37import {useLingui} from '@lingui/react'
38import {
39 type RouteProp,
40 useFocusEffect,
41 useIsFocused,
42 useNavigation,
43 useRoute,
44} from '@react-navigation/native'
45import {type NativeStackScreenProps} from '@react-navigation/native-stack'
46
47import {HITSLOP_20} from '#/lib/constants'
48import {useHaptics} from '#/lib/haptics'
49import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
50import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
51import {
52 type CommonNavigatorParams,
53 type NavigationProp,
54} from '#/lib/routes/types'
55import {sanitizeDisplayName} from '#/lib/strings/display-names'
56import {cleanError} from '#/lib/strings/errors'
57import {sanitizeHandle} from '#/lib/strings/handles'
58import {logger} from '#/logger'
59import {useA11y} from '#/state/a11y'
60import {
61 POST_TOMBSTONE,
62 type Shadow,
63 usePostShadow,
64} from '#/state/cache/post-shadow'
65import {useProfileShadow} from '#/state/cache/profile-shadow'
66import {
67 FeedFeedbackProvider,
68 useFeedFeedback,
69 useFeedFeedbackContext,
70} from '#/state/feed-feedback'
71import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
72import {useFeedInfo} from '#/state/queries/feed'
73import {usePostLikeMutationQueue} from '#/state/queries/post'
74import {
75 type FeedPostSliceItem,
76 usePostFeedQuery,
77} from '#/state/queries/post-feed'
78import {useProfileFollowMutationQueue} from '#/state/queries/profile'
79import {useSession} from '#/state/session'
80import {useSetMinimalShellMode} from '#/state/shell'
81import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
82import {List} from '#/view/com/util/List'
83import {UserAvatar} from '#/view/com/util/UserAvatar'
84import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
85import {Header} from '#/screens/VideoFeed/components/Header'
86import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
87import {setSystemUITheme} from '#/alf/util/systemUI'
88import {Button, ButtonIcon, ButtonText} from '#/components/Button'
89import {Divider} from '#/components/Divider'
90import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
91import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
92import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
93import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf'
94import {KeepAwake} from '#/components/KeepAwake'
95import * as Layout from '#/components/Layout'
96import {Link} from '#/components/Link'
97import {ListFooter} from '#/components/Lists'
98import * as Hider from '#/components/moderation/Hider'
99import {PostControls} from '#/components/PostControls'
100import {RichText} from '#/components/RichText'
101import {Text} from '#/components/Typography'
102import {useAnalytics} from '#/analytics'
103import {IS_ANDROID} from '#/env'
104import * as bsky from '#/types/bsky'
105import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber'
106
107function createThreeVideoPlayers(
108 sources?: [string, string, string],
109): [VideoPlayer, VideoPlayer, VideoPlayer] {
110 // android is typically slower and can't keep up with a 0.1 interval
111 const eventInterval = platform({
112 ios: 0.2,
113 android: 0.5,
114 default: 0,
115 })
116 const p1 = createVideoPlayer(sources?.[0] ?? '')
117 p1.loop = true
118 p1.timeUpdateEventInterval = eventInterval
119 const p2 = createVideoPlayer(sources?.[1] ?? '')
120 p2.loop = true
121 p2.timeUpdateEventInterval = eventInterval
122 const p3 = createVideoPlayer(sources?.[2] ?? '')
123 p3.loop = true
124 p3.timeUpdateEventInterval = eventInterval
125 return [p1, p2, p3]
126}
127
128export function VideoFeed({}: NativeStackScreenProps<
129 CommonNavigatorParams,
130 'VideoFeed'
131>) {
132 const {top} = useSafeAreaInsets()
133 const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
134
135 const t = useTheme()
136 const setMinShellMode = useSetMinimalShellMode()
137 useFocusEffect(
138 useCallback(() => {
139 setMinShellMode(true)
140 setSystemUITheme('lightbox', t)
141 return () => {
142 setMinShellMode(false)
143 setSystemUITheme('theme', t)
144 }
145 }, [setMinShellMode, t]),
146 )
147
148 const isFocused = useIsFocused()
149 useSetLightStatusBar(isFocused)
150
151 return (
152 <ThemeProvider theme="dark">
153 <Layout.Screen noInsetTop style={{backgroundColor: 'black'}}>
154 <KeepAwake />
155 <View
156 style={[
157 a.absolute,
158 a.z_50,
159 {top: 0, left: 0, right: 0, paddingTop: top},
160 ]}>
161 <Header sourceContext={params} />
162 </View>
163 <Feed />
164 </Layout.Screen>
165 </ThemeProvider>
166 )
167}
168
169const viewabilityConfig = {
170 itemVisiblePercentThreshold: 100,
171 minimumViewTime: 0,
172} satisfies ViewabilityConfig
173
174type CurrentSource = {
175 source: string
176} | null
177
178type VideoItem = {
179 moderation: ModerationDecision
180 post: AppBskyFeedDefs.PostView
181 video: AppBskyEmbedVideo.View
182 feedContext: string | undefined
183 reqId: string | undefined
184}
185
186function Feed() {
187 const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
188 const isFocused = useIsFocused()
189 const {hasSession} = useSession()
190 const {height} = useSafeAreaFrame()
191
192 const feedDesc = useMemo(() => {
193 switch (params.type) {
194 case 'feedgen':
195 return `feedgen|${params.uri}` as const
196 case 'author':
197 return `author|${params.did}|${params.filter}` as const
198 default:
199 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`)
200 }
201 }, [params])
202 const feedUri = params.type === 'feedgen' ? params.uri : undefined
203 const {data: feedInfo} = useFeedInfo(feedUri)
204 const feedFeedback = useFeedFeedback(feedInfo ?? undefined, hasSession)
205 const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} =
206 usePostFeedQuery(
207 feedDesc,
208 params.type === 'feedgen' && params.sourceInterstitial !== 'none'
209 ? {feedCacheKey: params.sourceInterstitial}
210 : undefined,
211 )
212
213 const videos = useMemo(() => {
214 let vids =
215 data?.pages.flatMap(page => {
216 const items: {
217 _reactKey: string
218 moderation: ModerationDecision
219 post: AppBskyFeedDefs.PostView
220 video: AppBskyEmbedVideo.View
221 feedContext: string | undefined
222 reqId: string | undefined
223 }[] = []
224 for (const slice of page.slices) {
225 const feedPost = slice.items.find(
226 item => item.uri === slice.feedPostUri,
227 )
228 if (feedPost && AppBskyEmbedVideo.isView(feedPost.post.embed)) {
229 items.push({
230 _reactKey: feedPost._reactKey,
231 moderation: feedPost.moderation,
232 post: feedPost.post,
233 video: feedPost.post.embed,
234 feedContext: slice.feedContext,
235 reqId: slice.reqId,
236 })
237 }
238 }
239 return items
240 }) ?? []
241 const startingVideoIndex = vids?.findIndex(video => {
242 return video.post.uri === params.initialPostUri
243 })
244 if (vids && startingVideoIndex && startingVideoIndex > -1) {
245 vids = vids.slice(startingVideoIndex)
246 }
247 return vids
248 }, [data, params.initialPostUri])
249
250 const [currentSources, setCurrentSources] = useState<
251 [CurrentSource, CurrentSource, CurrentSource]
252 >([null, null, null])
253
254 const [players, setPlayers] = useState<
255 [VideoPlayer, VideoPlayer, VideoPlayer] | null
256 >(null)
257
258 const [currentIndex, setCurrentIndex] = useState(0)
259
260 const scrollGesture = useMemo(() => Gesture.Native(), [])
261
262 const renderItem: ListRenderItem<VideoItem> = useCallback(
263 ({item, index}) => {
264 const {post, video} = item
265 const player = players?.[index % 3]
266 const currentSource = currentSources[index % 3]
267
268 return (
269 <VideoItem
270 player={player}
271 post={post}
272 embed={video}
273 active={
274 isFocused &&
275 index === currentIndex &&
276 currentSource?.source === video.playlist
277 }
278 adjacent={index === currentIndex - 1 || index === currentIndex + 1}
279 moderation={item.moderation}
280 scrollGesture={scrollGesture}
281 feedContext={item.feedContext}
282 reqId={item.reqId}
283 />
284 )
285 },
286 [players, currentIndex, isFocused, currentSources, scrollGesture],
287 )
288
289 const updateVideoState = useCallback(
290 (index: number) => {
291 if (!videos.length) return
292
293 const prevSlice = videos.at(index - 1)
294 const prevPost = prevSlice?.post
295 const prevEmbed = prevPost?.embed
296 const prevVideo =
297 prevEmbed && AppBskyEmbedVideo.isView(prevEmbed)
298 ? prevEmbed.playlist
299 : null
300 const currSlice = videos.at(index)
301 const currPost = currSlice?.post
302 const currEmbed = currPost?.embed
303 const currVideo =
304 currEmbed && AppBskyEmbedVideo.isView(currEmbed)
305 ? currEmbed.playlist
306 : null
307 const currVideoModeration = currSlice?.moderation
308 const nextSlice = videos.at(index + 1)
309 const nextPost = nextSlice?.post
310 const nextEmbed = nextPost?.embed
311 const nextVideo =
312 nextEmbed && AppBskyEmbedVideo.isView(nextEmbed)
313 ? nextEmbed.playlist
314 : null
315
316 const prevPlayerCurrentSource = currentSources[(index + 2) % 3]
317 const currPlayerCurrentSource = currentSources[index % 3]
318 const nextPlayerCurrentSource = currentSources[(index + 1) % 3]
319
320 if (!players) {
321 const args = ['', '', ''] satisfies [string, string, string]
322 if (prevVideo) args[(index + 2) % 3] = prevVideo
323 if (currVideo) args[index % 3] = currVideo
324 if (nextVideo) args[(index + 1) % 3] = nextVideo
325 const [player1, player2, player3] = createThreeVideoPlayers(args)
326
327 setPlayers([player1, player2, player3])
328
329 if (currVideo) {
330 const currPlayer = [player1, player2, player3][index % 3]
331 currPlayer.play()
332 }
333 } else {
334 const [player1, player2, player3] = players
335
336 const prevPlayer = [player1, player2, player3][(index + 2) % 3]
337 const currPlayer = [player1, player2, player3][index % 3]
338 const nextPlayer = [player1, player2, player3][(index + 1) % 3]
339
340 if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
341 prevPlayer.replace(prevVideo)
342 }
343 prevPlayer.pause()
344
345 if (currVideo) {
346 if (currVideo !== currPlayerCurrentSource?.source) {
347 currPlayer.replace(currVideo)
348 }
349 if (
350 currVideoModeration &&
351 (currVideoModeration.ui('contentView').blur ||
352 currVideoModeration.ui('contentMedia').blur)
353 ) {
354 currPlayer.pause()
355 } else {
356 currPlayer.play()
357 }
358 }
359
360 if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
361 nextPlayer.replace(nextVideo)
362 }
363 nextPlayer.pause()
364 }
365
366 const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [
367 ...currentSources,
368 ]
369 if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
370 updatedSources[(index + 2) % 3] = {
371 source: prevVideo,
372 }
373 }
374 if (currVideo && currVideo !== currPlayerCurrentSource?.source) {
375 updatedSources[index % 3] = {
376 source: currVideo,
377 }
378 }
379 if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
380 updatedSources[(index + 1) % 3] = {
381 source: nextVideo,
382 }
383 }
384
385 if (
386 updatedSources[0]?.source !== currentSources[0]?.source ||
387 updatedSources[1]?.source !== currentSources[1]?.source ||
388 updatedSources[2]?.source !== currentSources[2]?.source
389 ) {
390 setCurrentSources(updatedSources)
391 }
392 },
393 [videos, currentSources, players],
394 )
395
396 const updateVideoStateInitially = useNonReactiveCallback(() => {
397 updateVideoState(currentIndex)
398 })
399
400 useFocusEffect(
401 useCallback(() => {
402 if (!players) {
403 // create players, set sources, start playing
404 updateVideoStateInitially()
405 }
406 return () => {
407 if (players) {
408 // manually release players when offscreen
409 players.forEach(p => p.release())
410 setPlayers(null)
411 }
412 }
413 }, [players, updateVideoStateInitially]),
414 )
415
416 const onViewableItemsChanged = useCallback(
417 ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
418 if (viewableItems[0] && viewableItems[0].index !== null) {
419 const newIndex = viewableItems[0].index
420 setCurrentIndex(newIndex)
421 updateVideoState(newIndex)
422 }
423 },
424 [updateVideoState],
425 )
426
427 const renderEndMessage = useCallback(() => <EndMessage />, [])
428
429 return (
430 <FeedFeedbackProvider value={feedFeedback}>
431 <GestureDetector gesture={scrollGesture}>
432 <List
433 data={videos}
434 renderItem={renderItem}
435 keyExtractor={keyExtractor}
436 initialNumToRender={3}
437 maxToRenderPerBatch={3}
438 windowSize={6}
439 pagingEnabled={true}
440 ListFooterComponent={
441 <ListFooter
442 hasNextPage={hasNextPage}
443 isFetchingNextPage={isFetchingNextPage}
444 error={cleanError(error)}
445 onRetry={fetchNextPage}
446 height={height}
447 showEndMessage
448 renderEndMessage={renderEndMessage}
449 style={[a.justify_center, a.border_0]}
450 />
451 }
452 onEndReached={() => {
453 if (hasNextPage && !isFetchingNextPage) {
454 fetchNextPage()
455 }
456 }}
457 showsVerticalScrollIndicator={false}
458 onViewableItemsChanged={onViewableItemsChanged}
459 viewabilityConfig={viewabilityConfig}
460 />
461 </GestureDetector>
462 </FeedFeedbackProvider>
463 )
464}
465
466function keyExtractor(item: FeedPostSliceItem) {
467 return item._reactKey
468}
469
470let VideoItem = ({
471 player,
472 post,
473 embed,
474 active,
475 adjacent,
476 scrollGesture,
477 moderation,
478 feedContext,
479 reqId,
480}: {
481 player?: VideoPlayer
482 post: AppBskyFeedDefs.PostView
483 embed: AppBskyEmbedVideo.View
484 active: boolean
485 adjacent: boolean
486 scrollGesture: NativeGesture
487 moderation?: ModerationDecision
488 feedContext: string | undefined
489 reqId: string | undefined
490}): React.ReactNode => {
491 const ax = useAnalytics()
492 const postShadow = usePostShadow(post)
493 const {width, height} = useSafeAreaFrame()
494 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
495 const hasTrackedView = useRef(false)
496
497 useEffect(() => {
498 if (active) {
499 sendInteraction({
500 item: post.uri,
501 event: 'app.bsky.feed.defs#interactionSeen',
502 feedContext,
503 reqId,
504 })
505
506 // Track post:view event
507 if (!hasTrackedView.current) {
508 hasTrackedView.current = true
509 ax.metric('post:view', {
510 uri: post.uri,
511 authorDid: post.author.did,
512 logContext: 'ImmersiveVideo',
513 feedDescriptor,
514 })
515 }
516 }
517 }, [
518 active,
519 post.uri,
520 post.author.did,
521 feedContext,
522 reqId,
523 sendInteraction,
524 feedDescriptor,
525 ])
526
527 // TODO: high-performance android phones should also
528 // be capable of rendering 3 video players, but currently
529 // we can't distinguish between them
530 const shouldRenderVideo = active || ios(adjacent)
531
532 return (
533 <View style={[a.relative, {height, width}]}>
534 {postShadow === POST_TOMBSTONE ? (
535 <View
536 style={[
537 a.absolute,
538 a.inset_0,
539 a.z_20,
540 a.align_center,
541 a.justify_center,
542 {backgroundColor: 'rgba(0, 0, 0, 0.8)'},
543 ]}>
544 <Text
545 style={[
546 a.text_2xl,
547 a.font_bold,
548 a.text_center,
549 a.leading_tight,
550 a.mx_xl,
551 ]}>
552 <Trans>Post has been deleted</Trans>
553 </Text>
554 </View>
555 ) : (
556 <>
557 <VideoItemPlaceholder embed={embed} />
558 {shouldRenderVideo && player && (
559 <VideoItemInner player={player} embed={embed} />
560 )}
561 {moderation && (
562 <Overlay
563 player={player}
564 post={postShadow}
565 embed={embed}
566 active={active}
567 scrollGesture={scrollGesture}
568 moderation={moderation}
569 feedContext={feedContext}
570 reqId={reqId}
571 />
572 )}
573 </>
574 )}
575 </View>
576 )
577}
578VideoItem = memo(VideoItem)
579
580function VideoItemInner({
581 player,
582 embed,
583}: {
584 player: VideoPlayer
585 embed: AppBskyEmbedVideo.View
586}) {
587 const {bottom} = useSafeAreaInsets()
588 const [isReady, setIsReady] = useState(!IS_ANDROID)
589
590 useEventListener(player, 'timeUpdate', evt => {
591 if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
592 setIsReady(true)
593 }
594 })
595
596 return (
597 <VideoView
598 accessible={false}
599 style={[
600 a.absolute,
601 {
602 top: 0,
603 left: 0,
604 right: 0,
605 bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
606 },
607 !isReady && {opacity: 0},
608 ]}
609 player={player}
610 nativeControls={false}
611 contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'}
612 accessibilityIgnoresInvertColors
613 />
614 )
615}
616
617function ModerationOverlay({
618 embed,
619 onPressShow,
620}: {
621 embed: AppBskyEmbedVideo.View
622 onPressShow: () => void
623}) {
624 const {_} = useLingui()
625 const hider = Hider.useHider()
626 const {bottom} = useSafeAreaInsets()
627
628 const onShow = useCallback(() => {
629 hider.setIsContentVisible(true)
630 onPressShow()
631 }, [hider, onPressShow])
632
633 return (
634 <View style={[a.absolute, a.inset_0, a.z_20]}>
635 <VideoItemPlaceholder blur embed={embed} />
636 <View
637 style={[
638 a.absolute,
639 a.inset_0,
640 a.z_20,
641 a.justify_center,
642 a.align_center,
643 {backgroundColor: 'rgba(0, 0, 0, 0.8)'},
644 ]}>
645 <View style={[a.align_center, a.gap_sm]}>
646 <Eye width={36} fill="white" />
647 <Text style={[a.text_center, a.leading_snug, a.pb_xs]}>
648 <Trans>Hidden by your moderation settings.</Trans>
649 </Text>
650 <Button
651 label={_(msg`Show anyway`)}
652 size="small"
653 variant="solid"
654 color="secondary_inverted"
655 onPress={onShow}>
656 <ButtonText>
657 <Trans>Show anyway</Trans>
658 </ButtonText>
659 </Button>
660 </View>
661 <View
662 style={[
663 a.absolute,
664 a.inset_0,
665 a.px_xl,
666 a.pt_4xl,
667 {
668 top: 'auto',
669 paddingBottom: bottom,
670 },
671 ]}>
672 <LinearGradient
673 colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)']}
674 style={[a.absolute, a.inset_0]}
675 />
676 <Divider style={{borderColor: 'white'}} />
677 <View>
678 <Button
679 label={_(msg`View details`)}
680 onPress={() => {
681 hider.showInfoDialog()
682 }}
683 style={[
684 a.w_full,
685 {
686 height: 60,
687 },
688 ]}>
689 {({pressed}) => (
690 <Text
691 style={[
692 a.text_sm,
693 a.font_semi_bold,
694 a.text_center,
695 {opacity: pressed ? 0.5 : 1},
696 ]}>
697 <Trans>View details</Trans>
698 </Text>
699 )}
700 </Button>
701 </View>
702 </View>
703 </View>
704 </View>
705 )
706}
707
708function Overlay({
709 player,
710 post,
711 embed,
712 active,
713 scrollGesture,
714 moderation,
715 feedContext,
716 reqId,
717}: {
718 player?: VideoPlayer
719 post: Shadow<AppBskyFeedDefs.PostView>
720 embed: AppBskyEmbedVideo.View
721 active: boolean
722 scrollGesture: NativeGesture
723 moderation: ModerationDecision
724 feedContext: string | undefined
725 reqId: string | undefined
726}) {
727 const {_} = useLingui()
728 const t = useTheme()
729 const {openComposer} = useOpenComposer()
730 const {currentAccount} = useSession()
731 const navigation = useNavigation<NavigationProp>()
732 const seekingAnimationSV = useSharedValue(0)
733
734 const profile = useProfileShadow(post.author)
735 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
736 profile,
737 'ImmersiveVideo',
738 )
739
740 const rkey = new AtUri(post.uri).rkey
741 const record = bsky.dangerousIsType<AppBskyFeedPost.Record>(
742 post.record,
743 AppBskyFeedPost.isRecord,
744 )
745 ? post.record
746 : undefined
747 const richText = new RichTextAPI({
748 text: record?.text || '',
749 facets: record?.facets,
750 })
751 const handle = sanitizeHandle(post.author.handle, '@')
752
753 const animatedStyle = useAnimatedStyle(() => ({
754 opacity: 1 - seekingAnimationSV.get(),
755 }))
756
757 const onPressShow = useCallback(() => {
758 player?.play()
759 }, [player])
760
761 const mergedModui = useMemo(() => {
762 const modui = moderation.ui('contentView')
763 const mediaModui = moderation.ui('contentMedia')
764 modui.alerts = [...modui.alerts, ...mediaModui.alerts]
765 modui.blurs = [...modui.blurs, ...mediaModui.blurs]
766 modui.filters = [...modui.filters, ...mediaModui.filters]
767 modui.informs = [...modui.informs, ...mediaModui.informs]
768 return modui
769 }, [moderation])
770
771 const onPressReply = useCallback(() => {
772 openComposer({
773 replyTo: {
774 uri: post.uri,
775 cid: post.cid,
776 text: record?.text || '',
777 author: post.author,
778 embed: post.embed,
779 langs: record?.langs,
780 },
781 })
782 }, [openComposer, post, record])
783
784 return (
785 <Hider.Outer modui={mergedModui}>
786 <Hider.Mask>
787 <ModerationOverlay embed={embed} onPressShow={onPressShow} />
788 </Hider.Mask>
789 <Hider.Content>
790 <View style={[a.absolute, a.inset_0, a.z_20]}>
791 <View style={[a.flex_1]}>
792 {player && (
793 <PlayPauseTapArea
794 player={player}
795 post={post}
796 feedContext={feedContext}
797 reqId={reqId}
798 />
799 )}
800 </View>
801
802 <LinearGradient
803 colors={[
804 'rgba(0,0,0,0)',
805 'rgba(0,0,0,0.7)',
806 'rgba(0,0,0,0.95)',
807 'rgba(0,0,0,0.95)',
808 ]}
809 style={[a.w_full, a.pt_md]}>
810 <Animated.View style={[a.px_md, animatedStyle]}>
811 <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
812 <Link
813 label={_(
814 msg`View ${sanitizeDisplayName(
815 post.author.displayName || post.author.handle,
816 )}'s profile`,
817 )}
818 to={{
819 screen: 'Profile',
820 params: {name: post.author.did},
821 }}
822 style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}>
823 <UserAvatar
824 type="user"
825 avatar={post.author.avatar}
826 size={32}
827 />
828 <View style={[a.flex_1]}>
829 <Text
830 style={[a.text_md, a.font_bold]}
831 emoji
832 numberOfLines={1}>
833 {sanitizeDisplayName(
834 post.author.displayName || post.author.handle,
835 )}
836 </Text>
837 <Text
838 style={[a.text_sm, t.atoms.text_contrast_high]}
839 numberOfLines={1}>
840 {handle}
841 </Text>
842 </View>
843 </Link>
844 {/* show button based on non-reactive version, so it doesn't hide on press */}
845 {post.author.did !== currentAccount?.did &&
846 !post.author.viewer?.following && (
847 <Button
848 label={
849 profile.viewer?.following
850 ? _(msg`Following ${handle}`)
851 : _(msg`Follow ${handle}`)
852 }
853 accessibilityHint={
854 profile.viewer?.following
855 ? _(msg`Unfollows the user`)
856 : ''
857 }
858 size="small"
859 variant="solid"
860 color="secondary_inverted"
861 style={[a.mb_xs]}
862 onPress={() =>
863 profile.viewer?.following
864 ? queueUnfollow()
865 : queueFollow()
866 }>
867 {!!profile.viewer?.following && (
868 <ButtonIcon icon={CheckIcon} />
869 )}
870 <ButtonText>
871 {profile.viewer?.following ? (
872 <Trans>Following</Trans>
873 ) : (
874 <Trans>Follow</Trans>
875 )}
876 </ButtonText>
877 </Button>
878 )}
879 </View>
880 {record?.text?.trim() && (
881 <ExpandableRichTextView
882 value={richText}
883 authorHandle={post.author.handle}
884 />
885 )}
886 {record && (
887 <View style={[{left: -5}]}>
888 <PostControls
889 richText={richText}
890 post={post}
891 record={record}
892 feedContext={feedContext}
893 logContext="FeedItem"
894 onPressReply={() =>
895 navigation.navigate('PostThread', {
896 name: post.author.did,
897 rkey,
898 })
899 }
900 big
901 />
902 </View>
903 )}
904 </Animated.View>
905 <Scrubber
906 active={active}
907 player={player}
908 seekingAnimationSV={seekingAnimationSV}
909 scrollGesture={scrollGesture}>
910 <ThreadComposePrompt
911 onPressCompose={onPressReply}
912 style={[a.pt_md, a.pb_sm]}
913 />
914 </Scrubber>
915 </LinearGradient>
916 </View>
917 {/*
918 {IS_ANDROID && status === 'loading' && (
919 <View
920 style={[
921 a.absolute,
922 a.inset_0,
923 a.align_center,
924 a.justify_center,
925 a.z_10,
926 ]}
927 pointerEvents="none">
928 <Loader size="2xl" />
929 </View>
930 )}
931 */}
932 </Hider.Content>
933 </Hider.Outer>
934 )
935}
936
937function ExpandableRichTextView({
938 value,
939 authorHandle,
940}: {
941 value: RichTextAPI
942 authorHandle?: string
943}) {
944 const {height: screenHeight} = useSafeAreaFrame()
945 const [expanded, setExpanded] = useState(false)
946 const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
947 const [constrained, setConstrained] = useState(false)
948 const [contentHeight, setContentHeight] = useState(0)
949 const {_} = useLingui()
950 const {screenReaderEnabled} = useA11y()
951
952 if (expanded && !hasBeenExpanded) {
953 setHasBeenExpanded(true)
954 }
955
956 return (
957 <ScrollView
958 scrollEnabled={expanded}
959 onContentSizeChange={(_w, h) => {
960 if (hasBeenExpanded) {
961 LayoutAnimation.configureNext({
962 duration: 500,
963 update: {type: 'spring', springDamping: 0.6},
964 })
965 }
966 setContentHeight(h)
967 }}
968 style={{height: Math.min(contentHeight, screenHeight * 0.5)}}
969 contentContainerStyle={[
970 a.py_sm,
971 a.gap_xs,
972 expanded ? [a.align_start] : a.flex_row,
973 ]}>
974 <RichText
975 value={value}
976 style={[a.text_sm, a.flex_1, a.leading_relaxed]}
977 authorHandle={authorHandle}
978 enableTags
979 numberOfLines={
980 expanded || screenReaderEnabled ? undefined : constrained ? 2 : 2
981 }
982 onTextLayout={evt => {
983 if (!constrained && evt.nativeEvent.lines.length > 1) {
984 setConstrained(true)
985 }
986 }}
987 />
988 {constrained && !screenReaderEnabled && (
989 <Pressable
990 accessibilityHint={_(msg`Expands or collapses post text`)}
991 accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)}
992 hitSlop={HITSLOP_20}
993 onPress={() => setExpanded(prev => !prev)}
994 style={[a.absolute, a.inset_0]}
995 />
996 )}
997 </ScrollView>
998 )
999}
1000
1001function VideoItemPlaceholder({
1002 embed,
1003 style,
1004 blur,
1005}: {
1006 embed: AppBskyEmbedVideo.View
1007 style?: ImageStyle
1008 blur?: boolean
1009}) {
1010 const {bottom} = useSafeAreaInsets()
1011 const src = embed.thumbnail
1012 let contentFit = isTallAspectRatio(embed.aspectRatio)
1013 ? ('cover' as const)
1014 : ('contain' as const)
1015 if (blur) {
1016 contentFit = 'cover' as const
1017 }
1018 return src ? (
1019 <Image
1020 accessibilityIgnoresInvertColors
1021 source={{uri: src}}
1022 style={[
1023 a.absolute,
1024 blur
1025 ? a.inset_0
1026 : {
1027 top: 0,
1028 left: 0,
1029 right: 0,
1030 bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
1031 },
1032 style,
1033 ]}
1034 contentFit={contentFit}
1035 blurRadius={blur ? 100 : 0}
1036 />
1037 ) : null
1038}
1039
1040function PlayPauseTapArea({
1041 player,
1042 post,
1043 feedContext,
1044 reqId,
1045}: {
1046 player: VideoPlayer
1047 post: Shadow<AppBskyFeedDefs.PostView>
1048 feedContext: string | undefined
1049 reqId: string | undefined
1050}) {
1051 const {_} = useLingui()
1052 const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1053 const playHaptic = useHaptics()
1054 // TODO: implement viaRepost -sfn
1055 const [queueLike] = usePostLikeMutationQueue(
1056 post,
1057 undefined,
1058 undefined,
1059 'ImmersiveVideo',
1060 )
1061 const {sendInteraction} = useFeedFeedbackContext()
1062 const {isPlaying} = useEvent(player, 'playingChange', {
1063 isPlaying: player.playing,
1064 })
1065 const isMounted = useRef(false)
1066
1067 useEffect(() => {
1068 isMounted.current = true
1069 return () => {
1070 isMounted.current = false
1071 }
1072 }, [])
1073
1074 const togglePlayPause = useNonReactiveCallback(() => {
1075 // gets called after a timeout, so guard against being called after unmount -sfn
1076 if (!player || !isMounted.current) return
1077 doubleTapRef.current = null
1078 try {
1079 if (player.playing) {
1080 player.pause()
1081 } else {
1082 player.play()
1083 }
1084 } catch (err) {
1085 logger.error('Could not toggle play/pause', {safeMessage: err})
1086 }
1087 })
1088
1089 const onPress = () => {
1090 if (doubleTapRef.current) {
1091 clearTimeout(doubleTapRef.current)
1092 doubleTapRef.current = null
1093 playHaptic('Light')
1094 queueLike()
1095 sendInteraction({
1096 item: post.uri,
1097 event: 'app.bsky.feed.defs#interactionLike',
1098 feedContext,
1099 reqId,
1100 })
1101 } else {
1102 doubleTapRef.current = setTimeout(togglePlayPause, 200)
1103 }
1104 }
1105
1106 return (
1107 <Button
1108 disabled={!player}
1109 aria-valuetext={
1110 isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
1111 }
1112 label={_(
1113 `Video from ${sanitizeHandle(
1114 post.author.handle,
1115 '@',
1116 )}. Tap to play or pause the video`,
1117 )}
1118 accessibilityHint={_(msg`Double tap to like`)}
1119 onPress={onPress}
1120 style={[a.absolute, a.inset_0, a.z_10]}>
1121 <View />
1122 </Button>
1123 )
1124}
1125
1126function EndMessage() {
1127 const navigation = useNavigation<NavigationProp>()
1128 const {_} = useLingui()
1129 const t = useTheme()
1130 const enableSquareButtons = useEnableSquareButtons()
1131 return (
1132 <View
1133 style={[
1134 a.w_full,
1135 a.gap_3xl,
1136 a.px_lg,
1137 a.mx_auto,
1138 a.align_center,
1139 {maxWidth: 350},
1140 ]}>
1141 <View
1142 style={[
1143 {height: 100, width: 100},
1144 enableSquareButtons ? a.rounded_sm : a.rounded_full,
1145 t.atoms.bg_contrast_700,
1146 a.align_center,
1147 a.justify_center,
1148 ]}>
1149 <LeafIcon width={64} fill="black" />
1150 </View>
1151 <View style={[a.w_full, a.gap_md]}>
1152 <Text style={[a.text_3xl, a.text_center, a.font_bold]}>
1153 <Trans>That's everything!</Trans>
1154 </Text>
1155 <Text
1156 style={[
1157 a.text_lg,
1158 a.text_center,
1159 t.atoms.text_contrast_high,
1160 a.leading_snug,
1161 ]}>
1162 <Trans>
1163 You've run out of videos to watch. Maybe it's a good time to take a
1164 break?
1165 </Trans>
1166 </Text>
1167 </View>
1168 <Button
1169 testID="videoFeedGoBackButton"
1170 onPress={() => {
1171 if (navigation.canGoBack()) {
1172 navigation.goBack()
1173 } else {
1174 navigation.navigate('Home')
1175 }
1176 }}
1177 variant="solid"
1178 color="secondary_inverted"
1179 size="small"
1180 label={_(msg`Go back`)}
1181 accessibilityHint={_(msg`Returns to previous page`)}>
1182 <ButtonIcon icon={ArrowLeftIcon} />
1183 <ButtonText>
1184 <Trans>Go back</Trans>
1185 </ButtonText>
1186 </Button>
1187 </View>
1188 )
1189}
1190
1191/*
1192 * If the video is taller than 9:16
1193 */
1194function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) {
1195 const videoAspectRatio =
1196 (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1)
1197 return videoAspectRatio <= 9 / 16
1198}