Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 {Trans, useLingui} from '@lingui/react/macro'
37import {
38 type RouteProp,
39 useFocusEffect,
40 useIsFocused,
41 useNavigation,
42 useRoute,
43} from '@react-navigation/native'
44import {type NativeStackScreenProps} from '@react-navigation/native-stack'
45
46import {HITSLOP_20} from '#/lib/constants'
47import {useHaptics} from '#/lib/haptics'
48import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
49import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
50import {
51 type CommonNavigatorParams,
52 type NavigationProp,
53} from '#/lib/routes/types'
54import {sanitizeDisplayName} from '#/lib/strings/display-names'
55import {cleanError} from '#/lib/strings/errors'
56import {sanitizeHandle} from '#/lib/strings/handles'
57import {logger} from '#/logger'
58import {useA11y} from '#/state/a11y'
59import {
60 POST_TOMBSTONE,
61 type Shadow,
62 usePostShadow,
63} from '#/state/cache/post-shadow'
64import {useProfileShadow} from '#/state/cache/profile-shadow'
65import {
66 FeedFeedbackProvider,
67 useFeedFeedback,
68 useFeedFeedbackContext,
69} from '#/state/feed-feedback'
70import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
71import {useFeedInfo} from '#/state/queries/feed'
72import {usePostLikeMutationQueue} from '#/state/queries/post'
73import {
74 type FeedPostSliceItem,
75 usePostFeedQuery,
76} from '#/state/queries/post-feed'
77import {useProfileFollowMutationQueue} from '#/state/queries/profile'
78import {useSession} from '#/state/session'
79import {useSetMinimalShellMode} from '#/state/shell'
80import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
81import {List} from '#/view/com/util/List'
82import {UserAvatar} from '#/view/com/util/UserAvatar'
83import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
84import {Header} from '#/screens/VideoFeed/components/Header'
85import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
86import {setSystemUITheme} from '#/alf/util/systemUI'
87import {Button, ButtonIcon, ButtonText} from '#/components/Button'
88import {Divider} from '#/components/Divider'
89import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
90import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
91import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
92import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf'
93import {KeepAwake} from '#/components/KeepAwake'
94import * as Layout from '#/components/Layout'
95import {Link} from '#/components/Link'
96import {ListFooter} from '#/components/Lists'
97import * as Hider from '#/components/moderation/Hider'
98import {PostControls} from '#/components/PostControls'
99import {RichText} from '#/components/RichText'
100import {Text} from '#/components/Typography'
101import {useAnalytics} from '#/analytics'
102import {IS_ANDROID} from '#/env'
103import * as bsky from '#/types/bsky'
104import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber'
105
106function createThreeVideoPlayers(
107 sources?: [string, string, string],
108): [VideoPlayer, VideoPlayer, VideoPlayer] {
109 // android is typically slower and can't keep up with a 0.1 interval
110 const eventInterval = platform({
111 ios: 0.2,
112 android: 0.5,
113 default: 0,
114 })
115 const p1 = createVideoPlayer(sources?.[0] ?? '')
116 p1.loop = true
117 p1.timeUpdateEventInterval = eventInterval
118 const p2 = createVideoPlayer(sources?.[1] ?? '')
119 p2.loop = true
120 p2.timeUpdateEventInterval = eventInterval
121 const p3 = createVideoPlayer(sources?.[2] ?? '')
122 p3.loop = true
123 p3.timeUpdateEventInterval = eventInterval
124 return [p1, p2, p3]
125}
126
127export function VideoFeed({}: NativeStackScreenProps<
128 CommonNavigatorParams,
129 'VideoFeed'
130>) {
131 const {top} = useSafeAreaInsets()
132 const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
133
134 const t = useTheme()
135 const setMinShellMode = useSetMinimalShellMode()
136 useFocusEffect(
137 useCallback(() => {
138 setMinShellMode(true)
139 setSystemUITheme('lightbox', t)
140 return () => {
141 setMinShellMode(false)
142 setSystemUITheme('theme', t)
143 }
144 }, [setMinShellMode, t]),
145 )
146
147 const isFocused = useIsFocused()
148 useSetLightStatusBar(isFocused)
149
150 return (
151 <ThemeProvider theme="dark">
152 <Layout.Screen noInsetTop style={{backgroundColor: 'black'}}>
153 <KeepAwake />
154 <View
155 style={[
156 a.absolute,
157 a.z_50,
158 {top: 0, left: 0, right: 0, paddingTop: top},
159 ]}>
160 <Header sourceContext={params} />
161 </View>
162 <Feed />
163 </Layout.Screen>
164 </ThemeProvider>
165 )
166}
167
168const viewabilityConfig = {
169 itemVisiblePercentThreshold: 100,
170 minimumViewTime: 0,
171} satisfies ViewabilityConfig
172
173type CurrentSource = {
174 source: string
175} | null
176
177type VideoItem = {
178 moderation: ModerationDecision
179 post: AppBskyFeedDefs.PostView
180 video: AppBskyEmbedVideo.View
181 feedContext: string | undefined
182 reqId: string | undefined
183}
184
185function Feed() {
186 const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
187 const isFocused = useIsFocused()
188 const {hasSession} = useSession()
189 const {height} = useSafeAreaFrame()
190
191 const feedDesc = useMemo(() => {
192 switch (params.type) {
193 case 'feedgen':
194 return `feedgen|${params.uri}` as const
195 case 'author':
196 return `author|${params.did}|${params.filter}` as const
197 default:
198 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`)
199 }
200 }, [params])
201 const feedUri = params.type === 'feedgen' ? params.uri : undefined
202 const {data: feedInfo} = useFeedInfo(feedUri)
203 const feedFeedback = useFeedFeedback(feedInfo ?? undefined, hasSession)
204 const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} =
205 usePostFeedQuery(
206 feedDesc,
207 params.type === 'feedgen' && params.sourceInterstitial !== 'none'
208 ? {feedCacheKey: params.sourceInterstitial}
209 : undefined,
210 )
211
212 const videos = useMemo(() => {
213 let vids =
214 data?.pages.flatMap(page => {
215 const items: {
216 _reactKey: string
217 moderation: ModerationDecision
218 post: AppBskyFeedDefs.PostView
219 video: AppBskyEmbedVideo.View
220 feedContext: string | undefined
221 reqId: string | undefined
222 }[] = []
223 for (const slice of page.slices) {
224 const feedPost = slice.items.find(
225 item => item.uri === slice.feedPostUri,
226 )
227 if (feedPost && AppBskyEmbedVideo.isView(feedPost.post.embed)) {
228 items.push({
229 _reactKey: feedPost._reactKey,
230 moderation: feedPost.moderation,
231 post: feedPost.post,
232 video: feedPost.post.embed,
233 feedContext: slice.feedContext,
234 reqId: slice.reqId,
235 })
236 }
237 }
238 return items
239 }) ?? []
240 const startingVideoIndex = vids?.findIndex(video => {
241 return video.post.uri === params.initialPostUri
242 })
243 if (vids && startingVideoIndex && startingVideoIndex > -1) {
244 vids = vids.slice(startingVideoIndex)
245 }
246 return vids
247 }, [data, params.initialPostUri])
248
249 const [currentSources, setCurrentSources] = useState<
250 [CurrentSource, CurrentSource, CurrentSource]
251 >([null, null, null])
252
253 const [players, setPlayers] = useState<
254 [VideoPlayer, VideoPlayer, VideoPlayer] | null
255 >(null)
256
257 const [currentIndex, setCurrentIndex] = useState(0)
258
259 const scrollGesture = useMemo(() => Gesture.Native(), [])
260
261 const renderItem: ListRenderItem<VideoItem> = useCallback(
262 ({item, index}) => {
263 const {post, video} = item
264 const player = players?.[index % 3]
265 const currentSource = currentSources[index % 3]
266
267 return (
268 <VideoItem
269 player={player}
270 post={post}
271 embed={video}
272 active={
273 isFocused &&
274 index === currentIndex &&
275 currentSource?.source === video.playlist
276 }
277 adjacent={index === currentIndex - 1 || index === currentIndex + 1}
278 moderation={item.moderation}
279 scrollGesture={scrollGesture}
280 feedContext={item.feedContext}
281 reqId={item.reqId}
282 />
283 )
284 },
285 [players, currentIndex, isFocused, currentSources, scrollGesture],
286 )
287
288 const updateVideoState = useCallback(
289 (index: number) => {
290 if (!videos.length) return
291
292 const prevSlice = videos.at(index - 1)
293 const prevPost = prevSlice?.post
294 const prevEmbed = prevPost?.embed
295 const prevVideo =
296 prevEmbed && AppBskyEmbedVideo.isView(prevEmbed)
297 ? prevEmbed.playlist
298 : null
299 const currSlice = videos.at(index)
300 const currPost = currSlice?.post
301 const currEmbed = currPost?.embed
302 const currVideo =
303 currEmbed && AppBskyEmbedVideo.isView(currEmbed)
304 ? currEmbed.playlist
305 : null
306 const currVideoModeration = currSlice?.moderation
307 const nextSlice = videos.at(index + 1)
308 const nextPost = nextSlice?.post
309 const nextEmbed = nextPost?.embed
310 const nextVideo =
311 nextEmbed && AppBskyEmbedVideo.isView(nextEmbed)
312 ? nextEmbed.playlist
313 : null
314
315 const prevPlayerCurrentSource = currentSources[(index + 2) % 3]
316 const currPlayerCurrentSource = currentSources[index % 3]
317 const nextPlayerCurrentSource = currentSources[(index + 1) % 3]
318
319 if (!players) {
320 const args = ['', '', ''] satisfies [string, string, string]
321 if (prevVideo) args[(index + 2) % 3] = prevVideo
322 if (currVideo) args[index % 3] = currVideo
323 if (nextVideo) args[(index + 1) % 3] = nextVideo
324 const [player1, player2, player3] = createThreeVideoPlayers(args)
325
326 setPlayers([player1, player2, player3])
327
328 if (currVideo) {
329 const currPlayer = [player1, player2, player3][index % 3]
330 currPlayer.play()
331 }
332 } else {
333 const [player1, player2, player3] = players
334
335 const prevPlayer = [player1, player2, player3][(index + 2) % 3]
336 const currPlayer = [player1, player2, player3][index % 3]
337 const nextPlayer = [player1, player2, player3][(index + 1) % 3]
338
339 if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
340 prevPlayer.replace(prevVideo)
341 }
342 prevPlayer.pause()
343
344 if (currVideo) {
345 if (currVideo !== currPlayerCurrentSource?.source) {
346 currPlayer.replace(currVideo)
347 }
348 if (
349 currVideoModeration &&
350 (currVideoModeration.ui('contentView').blur ||
351 currVideoModeration.ui('contentMedia').blur)
352 ) {
353 currPlayer.pause()
354 } else {
355 currPlayer.play()
356 }
357 }
358
359 if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
360 nextPlayer.replace(nextVideo)
361 }
362 nextPlayer.pause()
363 }
364
365 const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [
366 ...currentSources,
367 ]
368 if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
369 updatedSources[(index + 2) % 3] = {
370 source: prevVideo,
371 }
372 }
373 if (currVideo && currVideo !== currPlayerCurrentSource?.source) {
374 updatedSources[index % 3] = {
375 source: currVideo,
376 }
377 }
378 if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
379 updatedSources[(index + 1) % 3] = {
380 source: nextVideo,
381 }
382 }
383
384 if (
385 updatedSources[0]?.source !== currentSources[0]?.source ||
386 updatedSources[1]?.source !== currentSources[1]?.source ||
387 updatedSources[2]?.source !== currentSources[2]?.source
388 ) {
389 setCurrentSources(updatedSources)
390 }
391 },
392 [videos, currentSources, players],
393 )
394
395 const updateVideoStateInitially = useNonReactiveCallback(() => {
396 updateVideoState(currentIndex)
397 })
398
399 useFocusEffect(
400 useCallback(() => {
401 if (!players) {
402 // create players, set sources, start playing
403 updateVideoStateInitially()
404 }
405 return () => {
406 if (players) {
407 // manually release players when offscreen
408 players.forEach(p => p.release())
409 setPlayers(null)
410 }
411 }
412 }, [players, updateVideoStateInitially]),
413 )
414
415 const onViewableItemsChanged = useCallback(
416 ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
417 if (viewableItems[0] && viewableItems[0].index !== null) {
418 const newIndex = viewableItems[0].index
419 setCurrentIndex(newIndex)
420 updateVideoState(newIndex)
421 }
422 },
423 [updateVideoState],
424 )
425
426 const renderEndMessage = useCallback(() => <EndMessage />, [])
427
428 return (
429 <FeedFeedbackProvider value={feedFeedback}>
430 <GestureDetector gesture={scrollGesture}>
431 <List
432 data={videos}
433 renderItem={renderItem}
434 keyExtractor={keyExtractor}
435 initialNumToRender={3}
436 maxToRenderPerBatch={3}
437 windowSize={6}
438 pagingEnabled={true}
439 ListFooterComponent={
440 <ListFooter
441 hasNextPage={hasNextPage}
442 isFetchingNextPage={isFetchingNextPage}
443 error={cleanError(error)}
444 onRetry={fetchNextPage}
445 height={height}
446 showEndMessage
447 renderEndMessage={renderEndMessage}
448 style={[a.justify_center, a.border_0]}
449 />
450 }
451 onEndReached={() => {
452 if (hasNextPage && !isFetchingNextPage) {
453 void fetchNextPage()
454 }
455 }}
456 showsVerticalScrollIndicator={false}
457 onViewableItemsChanged={onViewableItemsChanged}
458 viewabilityConfig={viewabilityConfig}
459 />
460 </GestureDetector>
461 </FeedFeedbackProvider>
462 )
463}
464
465function keyExtractor(item: FeedPostSliceItem) {
466 return item._reactKey
467}
468
469let VideoItem = ({
470 player,
471 post,
472 embed,
473 active,
474 adjacent,
475 scrollGesture,
476 moderation,
477 feedContext,
478 reqId,
479}: {
480 player?: VideoPlayer
481 post: AppBskyFeedDefs.PostView
482 embed: AppBskyEmbedVideo.View
483 active: boolean
484 adjacent: boolean
485 scrollGesture: NativeGesture
486 moderation?: ModerationDecision
487 feedContext: string | undefined
488 reqId: string | undefined
489}): React.ReactNode => {
490 const ax = useAnalytics()
491 const postShadow = usePostShadow(post)
492 const {width, height} = useSafeAreaFrame()
493 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
494 const hasTrackedView = useRef(false)
495
496 useEffect(() => {
497 if (active) {
498 sendInteraction({
499 item: post.uri,
500 event: 'app.bsky.feed.defs#interactionSeen',
501 feedContext,
502 reqId,
503 })
504
505 // Track post:view event
506 if (!hasTrackedView.current) {
507 hasTrackedView.current = true
508 ax.metric('post:view', {
509 uri: post.uri,
510 authorDid: post.author.did,
511 logContext: 'ImmersiveVideo',
512 feedDescriptor,
513 })
514 }
515 }
516 }, [
517 ax,
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 {t: l} = 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={l`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={l`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 {t: l} = 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 logContext: 'PostReply',
782 })
783 }, [openComposer, post, record])
784
785 return (
786 <Hider.Outer modui={mergedModui}>
787 <Hider.Mask>
788 <ModerationOverlay embed={embed} onPressShow={onPressShow} />
789 </Hider.Mask>
790 <Hider.Content>
791 <View style={[a.absolute, a.inset_0, a.z_20]}>
792 <View style={[a.flex_1]}>
793 {player && (
794 <PlayPauseTapArea
795 player={player}
796 post={post}
797 feedContext={feedContext}
798 reqId={reqId}
799 />
800 )}
801 </View>
802
803 <LinearGradient
804 colors={[
805 'rgba(0,0,0,0)',
806 'rgba(0,0,0,0.7)',
807 'rgba(0,0,0,0.95)',
808 'rgba(0,0,0,0.95)',
809 ]}
810 style={[a.w_full, a.pt_md]}>
811 <Animated.View style={[a.px_md, animatedStyle]}>
812 <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
813 <Link
814 label={l`View ${sanitizeDisplayName(
815 post.author.displayName || post.author.handle,
816 )}'s profile`}
817 to={{
818 screen: 'Profile',
819 params: {name: post.author.did},
820 }}
821 style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}>
822 <UserAvatar
823 type="user"
824 avatar={post.author.avatar}
825 size={32}
826 />
827 <View style={[a.flex_1]}>
828 <Text
829 style={[a.text_md, a.font_bold]}
830 emoji
831 numberOfLines={1}>
832 {sanitizeDisplayName(
833 post.author.displayName || post.author.handle,
834 )}
835 </Text>
836 <Text
837 style={[a.text_sm, t.atoms.text_contrast_high]}
838 numberOfLines={1}>
839 {handle}
840 </Text>
841 </View>
842 </Link>
843 {/* show button based on non-reactive version, so it doesn't hide on press */}
844 {post.author.did !== currentAccount?.did &&
845 !post.author.viewer?.following && (
846 <Button
847 label={
848 profile.viewer?.following
849 ? l`Following ${handle}`
850 : l`Follow ${handle}`
851 }
852 accessibilityHint={
853 profile.viewer?.following ? l`Unfollows the user` : ''
854 }
855 size="small"
856 variant="solid"
857 color="secondary_inverted"
858 style={[a.mb_xs]}
859 onPress={() =>
860 profile.viewer?.following
861 ? void queueUnfollow()
862 : void queueFollow()
863 }>
864 {!!profile.viewer?.following && (
865 <ButtonIcon icon={CheckIcon} />
866 )}
867 <ButtonText>
868 {profile.viewer?.following ? (
869 <Trans>Following</Trans>
870 ) : (
871 <Trans>Follow</Trans>
872 )}
873 </ButtonText>
874 </Button>
875 )}
876 </View>
877 {record?.text?.trim() && (
878 <ExpandableRichTextView
879 value={richText}
880 authorHandle={post.author.handle}
881 />
882 )}
883 {record && (
884 <View style={[{left: -5}]}>
885 <PostControls
886 richText={richText}
887 post={post}
888 record={record}
889 feedContext={feedContext}
890 logContext="FeedItem"
891 forceGoogleTranslate={true}
892 onPressReply={() =>
893 navigation.navigate('PostThread', {
894 name: post.author.did,
895 rkey,
896 })
897 }
898 big
899 />
900 </View>
901 )}
902 </Animated.View>
903 <Scrubber
904 active={active}
905 player={player}
906 seekingAnimationSV={seekingAnimationSV}
907 scrollGesture={scrollGesture}>
908 <ThreadComposePrompt
909 onPressCompose={onPressReply}
910 style={[a.pt_md, a.pb_sm]}
911 />
912 </Scrubber>
913 </LinearGradient>
914 </View>
915 {/*
916 {IS_ANDROID && status === 'loading' && (
917 <View
918 style={[
919 a.absolute,
920 a.inset_0,
921 a.align_center,
922 a.justify_center,
923 a.z_10,
924 ]}
925 pointerEvents="none">
926 <Loader size="2xl" />
927 </View>
928 )}
929 */}
930 </Hider.Content>
931 </Hider.Outer>
932 )
933}
934
935function ExpandableRichTextView({
936 value,
937 authorHandle,
938}: {
939 value: RichTextAPI
940 authorHandle?: string
941}) {
942 const {height: screenHeight} = useSafeAreaFrame()
943 const [expanded, setExpanded] = useState(false)
944 const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
945 const [constrained, setConstrained] = useState(false)
946 const [contentHeight, setContentHeight] = useState(0)
947 const {t: l} = useLingui()
948 const {screenReaderEnabled} = useA11y()
949
950 if (expanded && !hasBeenExpanded) {
951 setHasBeenExpanded(true)
952 }
953
954 return (
955 <ScrollView
956 scrollEnabled={expanded}
957 onContentSizeChange={(_w, h) => {
958 if (hasBeenExpanded) {
959 LayoutAnimation.configureNext({
960 duration: 500,
961 update: {type: 'spring', springDamping: 0.6},
962 })
963 }
964 setContentHeight(h)
965 }}
966 style={{height: Math.min(contentHeight, screenHeight * 0.5)}}
967 contentContainerStyle={[
968 a.py_sm,
969 a.gap_xs,
970 expanded ? [a.align_start] : a.flex_row,
971 ]}>
972 <RichText
973 value={value}
974 style={[a.text_sm, a.flex_1, a.leading_relaxed]}
975 authorHandle={authorHandle}
976 enableTags
977 numberOfLines={
978 expanded || screenReaderEnabled ? undefined : constrained ? 2 : 2
979 }
980 onTextLayout={evt => {
981 if (!constrained && evt.nativeEvent.lines.length > 1) {
982 setConstrained(true)
983 }
984 }}
985 />
986 {constrained && !screenReaderEnabled && (
987 <Pressable
988 accessibilityHint={l`Expands or collapses post text`}
989 accessibilityLabel={expanded ? l`Read less` : l`Read more`}
990 hitSlop={HITSLOP_20}
991 onPress={() => setExpanded(prev => !prev)}
992 style={[a.absolute, a.inset_0]}
993 />
994 )}
995 </ScrollView>
996 )
997}
998
999function VideoItemPlaceholder({
1000 embed,
1001 style,
1002 blur,
1003}: {
1004 embed: AppBskyEmbedVideo.View
1005 style?: ImageStyle
1006 blur?: boolean
1007}) {
1008 const {bottom} = useSafeAreaInsets()
1009 const src = embed.thumbnail
1010 let contentFit = isTallAspectRatio(embed.aspectRatio)
1011 ? ('cover' as const)
1012 : ('contain' as const)
1013 if (blur) {
1014 contentFit = 'cover' as const
1015 }
1016 return src ? (
1017 <Image
1018 accessibilityIgnoresInvertColors
1019 source={{uri: src}}
1020 style={[
1021 a.absolute,
1022 blur
1023 ? a.inset_0
1024 : {
1025 top: 0,
1026 left: 0,
1027 right: 0,
1028 bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
1029 },
1030 style,
1031 ]}
1032 contentFit={contentFit}
1033 blurRadius={blur ? 100 : 0}
1034 />
1035 ) : null
1036}
1037
1038function PlayPauseTapArea({
1039 player,
1040 post,
1041 feedContext,
1042 reqId,
1043}: {
1044 player: VideoPlayer
1045 post: Shadow<AppBskyFeedDefs.PostView>
1046 feedContext: string | undefined
1047 reqId: string | undefined
1048}) {
1049 const {t: l} = useLingui()
1050 const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1051 const playHaptic = useHaptics()
1052 // TODO: implement viaRepost -sfn
1053 const [queueLike] = usePostLikeMutationQueue(
1054 post,
1055 undefined,
1056 undefined,
1057 'ImmersiveVideo',
1058 )
1059 const {sendInteraction} = useFeedFeedbackContext()
1060 const {isPlaying} = useEvent(player, 'playingChange', {
1061 isPlaying: player.playing,
1062 })
1063 const isMounted = useRef(false)
1064
1065 useEffect(() => {
1066 isMounted.current = true
1067 return () => {
1068 isMounted.current = false
1069 }
1070 }, [])
1071
1072 const togglePlayPause = useNonReactiveCallback(() => {
1073 // gets called after a timeout, so guard against being called after unmount -sfn
1074 if (!player || !isMounted.current) return
1075 doubleTapRef.current = null
1076 try {
1077 if (player.playing) {
1078 player.pause()
1079 } else {
1080 player.play()
1081 }
1082 } catch (err) {
1083 logger.error('Could not toggle play/pause', {safeMessage: err})
1084 }
1085 })
1086
1087 const onPress = () => {
1088 if (doubleTapRef.current) {
1089 clearTimeout(doubleTapRef.current)
1090 doubleTapRef.current = null
1091 playHaptic('Light')
1092 void queueLike()
1093 sendInteraction({
1094 item: post.uri,
1095 event: 'app.bsky.feed.defs#interactionLike',
1096 feedContext,
1097 reqId,
1098 })
1099 } else {
1100 doubleTapRef.current = setTimeout(togglePlayPause, 200)
1101 }
1102 }
1103
1104 return (
1105 <Button
1106 disabled={!player}
1107 aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
1108 label={l`Video from ${sanitizeHandle(
1109 post.author.handle,
1110 '@',
1111 )}. Tap to play or pause the video`}
1112 accessibilityHint={l`Double tap to like`}
1113 onPress={onPress}
1114 style={[a.absolute, a.inset_0, a.z_10]}>
1115 <View />
1116 </Button>
1117 )
1118}
1119
1120function EndMessage() {
1121 const navigation = useNavigation<NavigationProp>()
1122 const {t: l} = useLingui()
1123 const t = useTheme()
1124 const enableSquareButtons = useEnableSquareButtons()
1125 return (
1126 <View
1127 style={[
1128 a.w_full,
1129 a.gap_3xl,
1130 a.px_lg,
1131 a.mx_auto,
1132 a.align_center,
1133 {maxWidth: 350},
1134 ]}>
1135 <View
1136 style={[
1137 {height: 100, width: 100},
1138 enableSquareButtons ? a.rounded_sm : a.rounded_full,
1139 t.atoms.bg_contrast_700,
1140 a.align_center,
1141 a.justify_center,
1142 ]}>
1143 <LeafIcon width={64} fill="black" />
1144 </View>
1145 <View style={[a.w_full, a.gap_md]}>
1146 <Text style={[a.text_3xl, a.text_center, a.font_bold]}>
1147 <Trans>That's everything!</Trans>
1148 </Text>
1149 <Text
1150 style={[
1151 a.text_lg,
1152 a.text_center,
1153 t.atoms.text_contrast_high,
1154 a.leading_snug,
1155 ]}>
1156 <Trans>
1157 You've run out of videos to watch. Maybe it's a good time to take a
1158 break?
1159 </Trans>
1160 </Text>
1161 </View>
1162 <Button
1163 testID="videoFeedGoBackButton"
1164 onPress={() => {
1165 if (navigation.canGoBack()) {
1166 navigation.goBack()
1167 } else {
1168 navigation.navigate('Home')
1169 }
1170 }}
1171 variant="solid"
1172 color="secondary_inverted"
1173 size="small"
1174 label={l`Go back`}
1175 accessibilityHint={l`Returns to previous page`}>
1176 <ButtonIcon icon={ArrowLeftIcon} />
1177 <ButtonText>
1178 <Trans>Go back</Trans>
1179 </ButtonText>
1180 </Button>
1181 </View>
1182 )
1183}
1184
1185/*
1186 * If the video is taller than 9:16
1187 */
1188function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) {
1189 const videoAspectRatio =
1190 (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1)
1191 return videoAspectRatio <= 9 / 16
1192}