Files
2026-04-21 13:04:02 -07:00

1187 lines
33 KiB
TypeScript

import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
type ListRenderItem,
Pressable,
ScrollView,
View,
type ViewabilityConfig,
type ViewToken,
} from 'react-native'
import {
Gesture,
GestureDetector,
type NativeGesture,
} from 'react-native-gesture-handler'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useEvent, useEventListener} from 'expo'
import {Image, type ImageStyle} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video'
import {
AppBskyEmbedVideo,
type AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {
type RouteProp,
useFocusEffect,
useIsFocused,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_20} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {
FeedFeedbackProvider,
useFeedFeedback,
useFeedFeedbackContext,
} from '#/state/feed-feedback'
import {useFeedInfo} from '#/state/queries/feed'
import {usePostLikeMutationQueue} from '#/state/queries/post'
import {
type FeedPostSliceItem,
usePostFeedQuery,
} from '#/state/queries/post-feed'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
import {List} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
import {Header} from '#/screens/VideoFeed/components/Header'
import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf'
import {KeepAwake} from '#/components/KeepAwake'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import * as Hider from '#/components/moderation/Hider'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID} from '#/env'
import * as bsky from '#/types/bsky'
import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber'
function createThreeVideoPlayers(
sources?: [string, string, string],
): [VideoPlayer, VideoPlayer, VideoPlayer] {
// android is typically slower and can't keep up with a 0.1 interval
const eventInterval = platform({
ios: 0.2,
android: 0.5,
default: 0,
})
const p1 = createVideoPlayer(sources?.[0] ?? '')
p1.loop = true
p1.timeUpdateEventInterval = eventInterval
const p2 = createVideoPlayer(sources?.[1] ?? '')
p2.loop = true
p2.timeUpdateEventInterval = eventInterval
const p3 = createVideoPlayer(sources?.[2] ?? '')
p3.loop = true
p3.timeUpdateEventInterval = eventInterval
return [p1, p2, p3]
}
export function VideoFeed({}: NativeStackScreenProps<
CommonNavigatorParams,
'VideoFeed'
>) {
const {top} = useSafeAreaInsets()
const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
const t = useTheme()
useFocusEffect(
useCallback(() => {
setSystemUITheme('lightbox', t)
return () => {
setSystemUITheme('theme', t)
}
}, [t]),
)
const isFocused = useIsFocused()
useSetLightStatusBar(isFocused)
return (
<ThemeProvider theme="dark">
<Layout.Screen minimalShell noInsetTop style={{backgroundColor: 'black'}}>
<KeepAwake />
<View
style={[
a.absolute,
a.z_50,
{top: 0, left: 0, right: 0, paddingTop: top},
]}>
<Header sourceContext={params} />
</View>
<Feed />
</Layout.Screen>
</ThemeProvider>
)
}
const viewabilityConfig = {
itemVisiblePercentThreshold: 100,
minimumViewTime: 0,
} satisfies ViewabilityConfig
type CurrentSource = {
source: string
} | null
type VideoItem = {
moderation: ModerationDecision
post: AppBskyFeedDefs.PostView
video: AppBskyEmbedVideo.View
feedContext: string | undefined
reqId: string | undefined
}
function Feed() {
const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
const isFocused = useIsFocused()
const {hasSession} = useSession()
const {height} = useSafeAreaFrame()
const feedDesc = useMemo(() => {
switch (params.type) {
case 'feedgen':
return `feedgen|${params.uri}` as const
case 'author':
return `author|${params.did}|${params.filter}` as const
default:
throw new Error(`Invalid video feed params ${JSON.stringify(params)}`)
}
}, [params])
const feedUri = params.type === 'feedgen' ? params.uri : undefined
const {data: feedInfo} = useFeedInfo(feedUri)
const feedFeedback = useFeedFeedback(feedInfo ?? undefined, hasSession)
const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} =
usePostFeedQuery(
feedDesc,
params.type === 'feedgen' && params.sourceInterstitial !== 'none'
? {feedCacheKey: params.sourceInterstitial}
: undefined,
)
const videos = useMemo(() => {
let vids =
data?.pages.flatMap(page => {
const items: {
_reactKey: string
moderation: ModerationDecision
post: AppBskyFeedDefs.PostView
video: AppBskyEmbedVideo.View
feedContext: string | undefined
reqId: string | undefined
}[] = []
for (const slice of page.slices) {
const feedPost = slice.items.find(
item => item.uri === slice.feedPostUri,
)
if (feedPost && AppBskyEmbedVideo.isView(feedPost.post.embed)) {
items.push({
_reactKey: feedPost._reactKey,
moderation: feedPost.moderation,
post: feedPost.post,
video: feedPost.post.embed,
feedContext: slice.feedContext,
reqId: slice.reqId,
})
}
}
return items
}) ?? []
const startingVideoIndex = vids?.findIndex(video => {
return video.post.uri === params.initialPostUri
})
if (vids && startingVideoIndex && startingVideoIndex > -1) {
vids = vids.slice(startingVideoIndex)
}
return vids
}, [data, params.initialPostUri])
const [currentSources, setCurrentSources] = useState<
[CurrentSource, CurrentSource, CurrentSource]
>([null, null, null])
const [players, setPlayers] = useState<
[VideoPlayer, VideoPlayer, VideoPlayer] | null
>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const scrollGesture = useMemo(() => Gesture.Native(), [])
const renderItem: ListRenderItem<VideoItem> = useCallback(
({item, index}) => {
const {post, video} = item
const player = players?.[index % 3]
const currentSource = currentSources[index % 3]
return (
<VideoItem
player={player}
post={post}
embed={video}
active={
isFocused &&
index === currentIndex &&
currentSource?.source === video.playlist
}
adjacent={index === currentIndex - 1 || index === currentIndex + 1}
moderation={item.moderation}
scrollGesture={scrollGesture}
feedContext={item.feedContext}
reqId={item.reqId}
/>
)
},
[players, currentIndex, isFocused, currentSources, scrollGesture],
)
const updateVideoState = useCallback(
(index: number) => {
if (!videos.length) return
const prevSlice = videos.at(index - 1)
const prevPost = prevSlice?.post
const prevEmbed = prevPost?.embed
const prevVideo =
prevEmbed && AppBskyEmbedVideo.isView(prevEmbed)
? prevEmbed.playlist
: null
const currSlice = videos.at(index)
const currPost = currSlice?.post
const currEmbed = currPost?.embed
const currVideo =
currEmbed && AppBskyEmbedVideo.isView(currEmbed)
? currEmbed.playlist
: null
const currVideoModeration = currSlice?.moderation
const nextSlice = videos.at(index + 1)
const nextPost = nextSlice?.post
const nextEmbed = nextPost?.embed
const nextVideo =
nextEmbed && AppBskyEmbedVideo.isView(nextEmbed)
? nextEmbed.playlist
: null
const prevPlayerCurrentSource = currentSources[(index + 2) % 3]
const currPlayerCurrentSource = currentSources[index % 3]
const nextPlayerCurrentSource = currentSources[(index + 1) % 3]
if (!players) {
const args = ['', '', ''] satisfies [string, string, string]
if (prevVideo) args[(index + 2) % 3] = prevVideo
if (currVideo) args[index % 3] = currVideo
if (nextVideo) args[(index + 1) % 3] = nextVideo
const [player1, player2, player3] = createThreeVideoPlayers(args)
setPlayers([player1, player2, player3])
if (currVideo) {
const currPlayer = [player1, player2, player3][index % 3]
currPlayer.play()
}
} else {
const [player1, player2, player3] = players
const prevPlayer = [player1, player2, player3][(index + 2) % 3]
const currPlayer = [player1, player2, player3][index % 3]
const nextPlayer = [player1, player2, player3][(index + 1) % 3]
if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
prevPlayer.replace(prevVideo)
}
prevPlayer.pause()
if (currVideo) {
if (currVideo !== currPlayerCurrentSource?.source) {
currPlayer.replace(currVideo)
}
if (
currVideoModeration &&
(currVideoModeration.ui('contentView').blur ||
currVideoModeration.ui('contentMedia').blur)
) {
currPlayer.pause()
} else {
currPlayer.play()
}
}
if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
nextPlayer.replace(nextVideo)
}
nextPlayer.pause()
}
const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [
...currentSources,
]
if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) {
updatedSources[(index + 2) % 3] = {
source: prevVideo,
}
}
if (currVideo && currVideo !== currPlayerCurrentSource?.source) {
updatedSources[index % 3] = {
source: currVideo,
}
}
if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) {
updatedSources[(index + 1) % 3] = {
source: nextVideo,
}
}
if (
updatedSources[0]?.source !== currentSources[0]?.source ||
updatedSources[1]?.source !== currentSources[1]?.source ||
updatedSources[2]?.source !== currentSources[2]?.source
) {
setCurrentSources(updatedSources)
}
},
[videos, currentSources, players],
)
const updateVideoStateInitially = useNonReactiveCallback(() => {
updateVideoState(currentIndex)
})
useFocusEffect(
useCallback(() => {
if (!players) {
// create players, set sources, start playing
updateVideoStateInitially()
}
return () => {
if (players) {
// manually release players when offscreen
players.forEach(p => p.release())
setPlayers(null)
}
}
}, [players, updateVideoStateInitially]),
)
const onViewableItemsChanged = useCallback(
({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
if (viewableItems[0] && viewableItems[0].index !== null) {
const newIndex = viewableItems[0].index
setCurrentIndex(newIndex)
updateVideoState(newIndex)
}
},
[updateVideoState],
)
const renderEndMessage = useCallback(() => <EndMessage />, [])
return (
<FeedFeedbackProvider value={feedFeedback}>
<GestureDetector gesture={scrollGesture}>
<List
data={videos}
renderItem={renderItem}
keyExtractor={keyExtractor}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={6}
pagingEnabled={true}
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
height={height}
showEndMessage
renderEndMessage={renderEndMessage}
style={[a.justify_center, a.border_0]}
/>
}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage()
}
}}
showsVerticalScrollIndicator={false}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
/>
</GestureDetector>
</FeedFeedbackProvider>
)
}
function keyExtractor(item: FeedPostSliceItem) {
return item._reactKey
}
let VideoItem = ({
player,
post,
embed,
active,
adjacent,
scrollGesture,
moderation,
feedContext,
reqId,
}: {
player?: VideoPlayer
post: AppBskyFeedDefs.PostView
embed: AppBskyEmbedVideo.View
active: boolean
adjacent: boolean
scrollGesture: NativeGesture
moderation?: ModerationDecision
feedContext: string | undefined
reqId: string | undefined
}): React.ReactNode => {
const ax = useAnalytics()
const postShadow = usePostShadow(post)
const {width, height} = useSafeAreaFrame()
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
const hasTrackedView = useRef(false)
useEffect(() => {
if (active) {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext,
reqId,
})
// Track post:view event
if (!hasTrackedView.current) {
hasTrackedView.current = true
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
logContext: 'ImmersiveVideo',
feedDescriptor,
})
}
}
}, [
ax,
active,
post.uri,
post.author.did,
feedContext,
reqId,
sendInteraction,
feedDescriptor,
])
// TODO: high-performance android phones should also
// be capable of rendering 3 video players, but currently
// we can't distinguish between them
const shouldRenderVideo = active || ios(adjacent)
return (
<View style={[a.relative, {height, width}]}>
{postShadow === POST_TOMBSTONE ? (
<View
style={[
a.absolute,
a.inset_0,
a.z_20,
a.align_center,
a.justify_center,
{backgroundColor: 'rgba(0, 0, 0, 0.8)'},
]}>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.leading_tight,
a.mx_xl,
]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
) : (
<>
<VideoItemPlaceholder embed={embed} />
{shouldRenderVideo && player && (
<VideoItemInner player={player} embed={embed} />
)}
{moderation && (
<Overlay
player={player}
post={postShadow}
embed={embed}
active={active}
scrollGesture={scrollGesture}
moderation={moderation}
feedContext={feedContext}
reqId={reqId}
/>
)}
</>
)}
</View>
)
}
VideoItem = memo(VideoItem)
function VideoItemInner({
player,
embed,
}: {
player: VideoPlayer
embed: AppBskyEmbedVideo.View
}) {
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
useEventListener(player, 'timeUpdate', evt => {
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
setIsReady(true)
}
})
return (
<VideoView
accessible={false}
style={[
a.absolute,
{
top: 0,
left: 0,
right: 0,
bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
},
!isReady && {opacity: 0},
]}
player={player}
nativeControls={false}
contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'}
accessibilityIgnoresInvertColors
/>
)
}
function ModerationOverlay({
embed,
onPressShow,
}: {
embed: AppBskyEmbedVideo.View
onPressShow: () => void
}) {
const {t: l} = useLingui()
const hider = Hider.useHider()
const {bottom} = useSafeAreaInsets()
const onShow = useCallback(() => {
hider.setIsContentVisible(true)
onPressShow()
}, [hider, onPressShow])
return (
<View style={[a.absolute, a.inset_0, a.z_20]}>
<VideoItemPlaceholder blur embed={embed} />
<View
style={[
a.absolute,
a.inset_0,
a.z_20,
a.justify_center,
a.align_center,
{backgroundColor: 'rgba(0, 0, 0, 0.8)'},
]}>
<View style={[a.align_center, a.gap_sm]}>
<Eye width={36} fill="white" />
<Text style={[a.text_center, a.leading_snug, a.pb_xs]}>
<Trans>Hidden by your moderation settings.</Trans>
</Text>
<Button
label={l`Show anyway`}
size="small"
variant="solid"
color="secondary_inverted"
onPress={onShow}>
<ButtonText>
<Trans>Show anyway</Trans>
</ButtonText>
</Button>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.px_xl,
a.pt_4xl,
{
top: 'auto',
paddingBottom: bottom,
},
]}>
<LinearGradient
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)']}
style={[a.absolute, a.inset_0]}
/>
<Divider style={{borderColor: 'white'}} />
<View>
<Button
label={l`View details`}
onPress={() => {
hider.showInfoDialog()
}}
style={[
a.w_full,
{
height: 60,
},
]}>
{({pressed}) => (
<Text
style={[
a.text_sm,
a.font_semi_bold,
a.text_center,
{opacity: pressed ? 0.5 : 1},
]}>
<Trans>View details</Trans>
</Text>
)}
</Button>
</View>
</View>
</View>
</View>
)
}
function Overlay({
player,
post,
embed,
active,
scrollGesture,
moderation,
feedContext,
reqId,
}: {
player?: VideoPlayer
post: Shadow<AppBskyFeedDefs.PostView>
embed: AppBskyEmbedVideo.View
active: boolean
scrollGesture: NativeGesture
moderation: ModerationDecision
feedContext: string | undefined
reqId: string | undefined
}) {
const {t: l} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const seekingAnimationSV = useSharedValue(0)
const profile = useProfileShadow(post.author)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ImmersiveVideo',
)
const rkey = new AtUri(post.uri).rkey
const record = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? post.record
: undefined
const richText = new RichTextAPI({
text: record?.text || '',
facets: record?.facets,
})
const handle = sanitizeHandle(post.author.handle, '@')
const animatedStyle = useAnimatedStyle(() => ({
opacity: 1 - seekingAnimationSV.get(),
}))
const onPressShow = useCallback(() => {
player?.play()
}, [player])
const mergedModui = useMemo(() => {
const modui = moderation.ui('contentView')
const mediaModui = moderation.ui('contentMedia')
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
modui.filters = [...modui.filters, ...mediaModui.filters]
modui.informs = [...modui.informs, ...mediaModui.informs]
return modui
}, [moderation])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record?.text || '',
author: post.author,
embed: post.embed,
langs: record?.langs,
},
logContext: 'PostReply',
})
}, [openComposer, post, record])
return (
<Hider.Outer modui={mergedModui}>
<Hider.Mask>
<ModerationOverlay embed={embed} onPressShow={onPressShow} />
</Hider.Mask>
<Hider.Content>
<View style={[a.absolute, a.inset_0, a.z_20]}>
<View style={[a.flex_1]}>
{player && (
<PlayPauseTapArea
player={player}
post={post}
feedContext={feedContext}
reqId={reqId}
/>
)}
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.95)',
'rgba(0,0,0,0.95)',
]}
style={[a.w_full, a.pt_md]}>
<Animated.View style={[a.px_md, animatedStyle]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
<Link
label={l`View ${sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}'s profile`}
to={{
screen: 'Profile',
params: {name: post.author.did},
}}
style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}>
<UserAvatar
type="user"
avatar={post.author.avatar}
size={32}
/>
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold]}
emoji
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}
</Text>
<Text
style={[a.text_sm, t.atoms.text_contrast_high]}
numberOfLines={1}>
{handle}
</Text>
</View>
</Link>
{/* show button based on non-reactive version, so it doesn't hide on press */}
{post.author.did !== currentAccount?.did &&
!post.author.viewer?.following && (
<Button
label={
profile.viewer?.following
? l`Following ${handle}`
: l`Follow ${handle}`
}
accessibilityHint={
profile.viewer?.following ? l`Unfollows the user` : ''
}
size="small"
variant="solid"
color="secondary_inverted"
style={[a.mb_xs]}
onPress={() =>
profile.viewer?.following
? void queueUnfollow()
: void queueFollow()
}>
{!!profile.viewer?.following && (
<ButtonIcon icon={CheckIcon} />
)}
<ButtonText>
{profile.viewer?.following ? (
<Trans>Following</Trans>
) : (
<Trans>Follow</Trans>
)}
</ButtonText>
</Button>
)}
</View>
{record?.text?.trim() && (
<ExpandableRichTextView
value={richText}
authorHandle={post.author.handle}
/>
)}
{record && (
<View style={[{left: -5}]}>
<PostControls
richText={richText}
post={post}
record={record}
feedContext={feedContext}
logContext="FeedItem"
forceGoogleTranslate={true}
onPressReply={() =>
navigation.navigate('PostThread', {
name: post.author.did,
rkey,
})
}
big
/>
</View>
)}
</Animated.View>
<Scrubber
active={active}
player={player}
seekingAnimationSV={seekingAnimationSV}
scrollGesture={scrollGesture}>
<ThreadComposePrompt
onPressCompose={onPressReply}
style={[a.pt_md, a.pb_sm]}
/>
</Scrubber>
</LinearGradient>
</View>
{/*
{IS_ANDROID && status === 'loading' && (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
a.z_10,
]}
pointerEvents="none">
<Loader size="2xl" />
</View>
)}
*/}
</Hider.Content>
</Hider.Outer>
)
}
function ExpandableRichTextView({
value,
authorHandle,
}: {
value: RichTextAPI
authorHandle?: string
}) {
const {height: screenHeight} = useSafeAreaFrame()
const [expanded, setExpanded] = useState(false)
const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
const [constrained, setConstrained] = useState(false)
const [contentHeight, setContentHeight] = useState(0)
const {t: l} = useLingui()
const {screenReaderEnabled} = useA11y()
if (expanded && !hasBeenExpanded) {
setHasBeenExpanded(true)
}
return (
<ScrollView
scrollEnabled={expanded}
onContentSizeChange={(_w, h) => {
if (hasBeenExpanded) {
LayoutAnimation.configureNext({
duration: 500,
update: {type: 'spring', springDamping: 0.6},
})
}
setContentHeight(h)
}}
style={{height: Math.min(contentHeight, screenHeight * 0.5)}}
contentContainerStyle={[
a.py_sm,
a.gap_xs,
expanded ? [a.align_start] : a.flex_row,
]}>
<RichText
value={value}
style={[a.text_sm, a.flex_1, a.leading_relaxed]}
authorHandle={authorHandle}
enableTags
numberOfLines={
expanded || screenReaderEnabled ? undefined : constrained ? 2 : 2
}
onTextLayout={evt => {
if (!constrained && evt.nativeEvent.lines.length > 1) {
setConstrained(true)
}
}}
/>
{constrained && !screenReaderEnabled && (
<Pressable
accessibilityHint={l`Expands or collapses post text`}
accessibilityLabel={expanded ? l`Read less` : l`Read more`}
hitSlop={HITSLOP_20}
onPress={() => setExpanded(prev => !prev)}
style={[a.absolute, a.inset_0]}
/>
)}
</ScrollView>
)
}
function VideoItemPlaceholder({
embed,
style,
blur,
}: {
embed: AppBskyEmbedVideo.View
style?: ImageStyle
blur?: boolean
}) {
const {bottom} = useSafeAreaInsets()
const src = embed.thumbnail
let contentFit = isTallAspectRatio(embed.aspectRatio)
? ('cover' as const)
: ('contain' as const)
if (blur) {
contentFit = 'cover' as const
}
return src ? (
<Image
accessibilityIgnoresInvertColors
source={{uri: src}}
style={[
a.absolute,
blur
? a.inset_0
: {
top: 0,
left: 0,
right: 0,
bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET,
},
style,
]}
contentFit={contentFit}
blurRadius={blur ? 100 : 0}
/>
) : null
}
function PlayPauseTapArea({
player,
post,
feedContext,
reqId,
}: {
player: VideoPlayer
post: Shadow<AppBskyFeedDefs.PostView>
feedContext: string | undefined
reqId: string | undefined
}) {
const {t: l} = useLingui()
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const playHaptic = useHaptics()
// TODO: implement viaRepost -sfn
const [queueLike] = usePostLikeMutationQueue(
post,
undefined,
undefined,
'ImmersiveVideo',
)
const {sendInteraction} = useFeedFeedbackContext()
const {isPlaying} = useEvent(player, 'playingChange', {
isPlaying: player.playing,
})
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
const togglePlayPause = useNonReactiveCallback(() => {
// gets called after a timeout, so guard against being called after unmount -sfn
if (!player || !isMounted.current) return
doubleTapRef.current = null
try {
if (player.playing) {
player.pause()
} else {
player.play()
}
} catch (err) {
logger.error('Could not toggle play/pause', {safeMessage: err})
}
})
const onPress = () => {
if (doubleTapRef.current) {
clearTimeout(doubleTapRef.current)
doubleTapRef.current = null
playHaptic('Light')
void queueLike()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
feedContext,
reqId,
})
} else {
doubleTapRef.current = setTimeout(togglePlayPause, 200)
}
}
return (
<Button
disabled={!player}
aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
label={l`Video from ${sanitizeHandle(
post.author.handle,
'@',
)}. Tap to play or pause the video`}
accessibilityHint={l`Double tap to like`}
onPress={onPress}
style={[a.absolute, a.inset_0, a.z_10]}>
<View />
</Button>
)
}
function EndMessage() {
const navigation = useNavigation<NavigationProp>()
const {t: l} = useLingui()
const t = useTheme()
return (
<View
style={[
a.w_full,
a.gap_3xl,
a.px_lg,
a.mx_auto,
a.align_center,
{maxWidth: 350},
]}>
<View
style={[
{height: 100, width: 100},
a.rounded_full,
t.atoms.bg_contrast_700,
a.align_center,
a.justify_center,
]}>
<LeafIcon width={64} fill="black" />
</View>
<View style={[a.w_full, a.gap_md]}>
<Text style={[a.text_3xl, a.text_center, a.font_bold]}>
<Trans>That's everything!</Trans>
</Text>
<Text
style={[
a.text_lg,
a.text_center,
t.atoms.text_contrast_high,
a.leading_snug,
]}>
<Trans>
You've run out of videos to watch. Maybe it's a good time to take a
break?
</Trans>
</Text>
</View>
<Button
testID="videoFeedGoBackButton"
onPress={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}}
variant="solid"
color="secondary_inverted"
size="small"
label={l`Go back`}
accessibilityHint={l`Returns to previous page`}>
<ButtonIcon icon={ArrowLeftIcon} />
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
)
}
/*
* If the video is taller than 9:16
*/
function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) {
const videoAspectRatio =
(aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1)
return videoAspectRatio <= 9 / 16
}