import {useCallback, useRef, useState} from 'react' import { type HostInstance, ScrollView, type ScrollViewImperativeMethods, useWindowDimensions, View, } from 'react-native' import {LinearGradient} from 'expo-linear-gradient' import {Plural, useLingui} from '@lingui/react/macro' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {type FeedPostSlice} from '#/state/queries/post-feed' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {atoms as a, useTheme, utils, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, ChevronTop_Stroke2_Corner0_Rounded as ChevronTop, } from '#/components/icons/Chevron' import {GalleryBleed} from '#/components/images/Gallery' import {ITEM_GAP} from '#/components/images/Gallery/const' import {tween} from '#/components/images/Gallery/tween' import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' import {getOffsetForIndex} from '#/components/images/Gallery/utils' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import {PostFeedItem} from './PostFeedItem' const CARD_WIDTH = 320 const CARD_INTERVAL = CARD_WIDTH + ITEM_GAP const SETTLE_DURATION = 700 const MAX_CAROUSEL_HEIGHT = 520 const CAROUSEL_VIEWPORT_RATIO = 0.55 const BOTTOM_FADE_HEIGHT = 96 function RepostCarouselViewport({ children, expanded, }: { children: React.ReactNode expanded: boolean }) { const t = useTheme() const {height} = useWindowDimensions() const maxHeight = Math.min( MAX_CAROUSEL_HEIGHT, height * CAROUSEL_VIEWPORT_RATIO, ) return ( {children} {!expanded && ( )} ) } function RepostCard({ slice, itemRef, }: { slice: FeedPostSlice itemRef?: (node: HostInstance | null) => void }) { const t = useTheme() const item = slice.items[0] /* * GalleryBleed sizes nested image carousels to this card instead of the * viewport. It also applies overflow: hidden for border-radius clipping. */ return ( ) } function RepostCarouselHeader({ count, expanded, onToggleExpanded, onScrollLeft, onScrollRight, }: { count: number expanded: boolean onToggleExpanded: () => void onScrollLeft: () => void onScrollRight: () => void }) { const t = useTheme() const {t: l} = useLingui() const enableSquareButtons = useEnableSquareButtons() return ( {count} ) } function RepostCarouselNative({ items, expanded, onToggleExpanded, }: { items: FeedPostSlice[] expanded: boolean onToggleExpanded: () => void }) { const scrollRef = useRef<(HostInstance & ScrollViewImperativeMethods) | null>( null, ) const currentIndexRef = useRef(0) const scrollToIndex = useCallback((index: number) => { currentIndexRef.current = index scrollRef.current?.scrollTo({ x: index * CARD_INTERVAL, y: 0, animated: true, }) }, []) const scrollLeft = useCallback(() => { const current = currentIndexRef.current const next = current > 0 ? current - 1 : items.length - 1 scrollToIndex(next) }, [items.length, scrollToIndex]) const scrollRight = useCallback(() => { const current = currentIndexRef.current const next = current < items.length - 1 ? current + 1 : 0 scrollToIndex(next) }, [items.length, scrollToIndex]) return ( <> { currentIndexRef.current = Math.round( e.nativeEvent.contentOffset.x / CARD_INTERVAL, ) }} contentContainerStyle={[a.px_md, a.pt_sm, a.pb_lg]}> {items.map(slice => ( ))} ) } function RepostCarouselWeb({ items, expanded, onToggleExpanded, }: { items: FeedPostSlice[] expanded: boolean onToggleExpanded: () => void }) { const {t: l} = useLingui() const scrollRef = useRef<(HostInstance & ScrollViewImperativeMethods) | null>( null, ) const itemWidthsRef = useRef>(new Map()) const itemRefsRef = useRef>(new Map()) const currentIndexRef = useRef(0) const stopTweenRef = useRef<(() => void) | null>(null) const scrollTo = useCallback((offset: number) => { const el = scrollRef.current?.getScrollableNode() as unknown as HTMLElement | null if (el) { el.scrollLeft = offset } }, []) const onSettle = useCallback((index: number) => { currentIndexRef.current = index itemRefsRef.current.forEach((node, i) => { const el = node as unknown as HTMLElement el.tabIndex = i === index ? 0 : -1 }) const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null el?.focus({preventScroll: true}) }, []) const scrollToIndex = useCallback( (index: number) => { const el = scrollRef.current?.getScrollableNode() as unknown as HTMLElement | null if (!el) return if (stopTweenRef.current) { stopTweenRef.current() stopTweenRef.current = null } /* * Update the index immediately (matching native) so rapid arrow presses * advance past an in-flight settle instead of re-targeting the same slide. */ currentIndexRef.current = index const from = el.scrollLeft const to = getOffsetForIndex(itemWidthsRef.current, index) if (from === to) { onSettle(index) return } stopTweenRef.current = tween( from, to, SETTLE_DURATION, )( v => { scrollTo(v) }, () => { stopTweenRef.current = null onSettle(index) }, ) }, [onSettle, scrollTo], ) const scrollLeft = useCallback(() => { const current = currentIndexRef.current const next = current > 0 ? current - 1 : items.length - 1 scrollToIndex(next) }, [items.length, scrollToIndex]) const scrollRight = useCallback(() => { const current = currentIndexRef.current const next = current < items.length - 1 ? current + 1 : 0 scrollToIndex(next) }, [items.length, scrollToIndex]) /* * Use ScrollView (not FlatList) so nested media Galleries can keep their own * horizontal FlatList. Nested VirtualizedLists break inner carousels into a * vertical stack and stretch sibling cards to the tallest item. */ useKeyboardHandlers({ flatListRef: scrollRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount: items.length, }) usePointerHandlers({ flatListRef: scrollRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount: items.length, }) return ( <> {items.map((slice, index) => { itemWidthsRef.current.set(index, CARD_WIDTH) return ( { if (node) { itemRefsRef.current.set(index, node) } else { itemRefsRef.current.delete(index) } }} /> ) })} ) } export function PostFeedItemCarousel({items}: {items: FeedPostSlice[]}) { const t = useTheme() const [expanded, setExpanded] = useState(false) const onToggleExpanded = useCallback(() => { setExpanded(value => !value) }, []) return ( {IS_WEB ? ( ) : ( )} ) }