Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 1192 lines 34 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 {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}