Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 999e52ed2d5a2c8b2f7b8747dfcfd0e2017e5eb0 1198 lines 35 kB view raw
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}