import { cloneElement, createContext, isValidElement, useContext, useEffect, useMemo, useRef, useState, } from 'react' import {FlatList, Pressable, useWindowDimensions, View} from 'react-native' import Animated, { type AnimatedRef, useAnimatedRef, } from 'react-native-reanimated' import {Image} from 'expo-image' import {type AppBskyEmbedImages} from '@atproto/api' import {utils} from '@bsky.app/alf' import {Trans, useLingui} from '@lingui/react/macro' import debounce from 'lodash.debounce' import {type Dimensions} from '#/lib/media/types' import {mergeRefs} from '#/lib/merge-refs' import {useA11y} from '#/state/a11y' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {AutoSizedImage} from '#/components/images/AutoSizedImage' import { ITEM_GAP, MAX_ASPECT_RATIO, MIN_ASPECT_RATIO, } from '#/components/images/Gallery/const' import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' import {getAspectRatio} from '#/components/images/Gallery/utils' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' export * from './const' export * from './maybeApplyGalleryOffsetStyles' interface GalleryProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, containerRefs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => void onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext } const Context = createContext<{ bleedRef: React.RefObject bleedWidth: number }>({ bleedRef: {current: null}, bleedWidth: 0, }) export function GalleryBleed({children}: {children: React.ReactNode}) { const ref = useRef(null) const [bleedWidth, setBleedWidth] = useState(0) if (!isValidElement(children)) { throw new Error('GalleryBleed children must be a single React element') } const node = children as React.ReactElement return ( {cloneElement(node, { ref: mergeRefs([ref, node?.props?.ref]), onLayout: (e: {nativeEvent: {layout: {width: number}}}) => { setBleedWidth(e.nativeEvent.layout.width) node.props.onLayout?.(e) }, style: [node.props.style, a.overflow_hidden], })} ) } export function useGalleryBleed() { return useContext(Context) } export function Gallery({ images, onPress, onPressIn, viewContext, }: GalleryProps) { const {t: l} = useLingui() const ax = useAnalytics() const {screenReaderEnabled} = useA11y() const largeAltBadge = useLargeAltBadgeEnabled() const bps = useBreakpoints() const window = useWindowDimensions() const isWithinQuote = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage const hideBadges = isWithinQuote const contentHeight = useMemo(() => { if (isWithinChat) { return 120 } if (bps.gtMobile) { return 300 } else if (bps.gtPhone) { return 260 } else { return 200 } }, [bps, isWithinChat]) /* * Container overflow styles * * Uses measureLayout to get the Gallery's offset relative to the GalleryBleed * ancestor. This is a layout-relative measurement that doesn't depend on * scroll position, so it works correctly for off-screen FlatList items. */ const {bleedRef, bleedWidth} = useGalleryBleed() const contentRef = useRef(null) const [contentDims, setContentDims] = useState<{x: number; width: number}>() const measure = () => { if (contentRef.current && bleedRef.current) { contentRef.current.measureLayout( bleedRef.current, (x, _y, w) => { setContentDims({x, width: w}) }, () => {}, ) } } const width = bleedWidth || Math.min(600, window.width) const insetLeft = contentDims?.x ?? 0 const insetRight = bleedWidth > 0 ? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0) : 0 /* End container overflow styles */ const flatListRef = useRef(null) const itemWidthsRef = useRef>(new Map()) const itemRefsRef = useRef>(new Map()) const containerRefsRef = useRef>>(new Map()) const thumbDimsRef = useRef>(new Map()) const currentIndexRef = useRef(0) const emitSwipeMetric = useMemo( () => debounce((fromIndex: number, toIndex: number) => { ax.metric('post:gallery:swipe', { fromImage: fromIndex + 1, // convert to 1-based index for easier analysis toImage: toIndex + 1, // convert to 1-based index for easier analysis totalImages: images.length, }) }, 200), [ax, images.length], ) const setCurrentIndex = (index: number) => { const prev = currentIndexRef.current if (prev !== index) { currentIndexRef.current = index emitSwipeMetric(prev, index) } } const scrollTo = (offset: number) => { flatListRef.current?.scrollToOffset({offset, animated: false}) } const onSettle = (index: number) => { setCurrentIndex(index) if (!IS_WEB) return // Update tabIndex: only the active image is tab-focusable 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}) } useKeyboardHandlers({ flatListRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount: images.length, }) usePointerHandlers({ flatListRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount: images.length, }) if (screenReaderEnabled) { return ( {images.map((image, index) => ( onPress?.(index, [containerRef], [dims]) } onPressIn={() => onPressIn?.(index)} hideBadge={ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia } /> ))} ) } return ( item.thumb + index} renderItem={({item, index}) => { const openLightboxAtIndex = onPress ? () => { ax.metric('post:gallery:openLightbox', { fromImage: index + 1, // convert to 1-based index for easier analysis totalImages: images.length, }) const refs: AnimatedRef[] = [] const dims: (Dimensions | null)[] = [] for (let i = 0; i < images.length; i++) { refs.push(containerRefsRef.current.get(i)!) dims.push(thumbDimsRef.current.get(i) ?? null) } onPress(index, refs, dims) } : undefined return ( { itemWidthsRef.current.set(i, w) }} itemRef={node => { if (node) { itemRefsRef.current.set(index, node) } else { itemRefsRef.current.delete(index) } }} onContainerRef={(i, ref) => { containerRefsRef.current.set(i, ref) }} onThumbDims={(i, dims) => { thumbDimsRef.current.set(i, dims) }} onPress={openLightboxAtIndex} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPreviewPress={openLightboxAtIndex} /> ) }} onScroll={e => { // web handles via onSettle in the web hooks if (IS_WEB) return const offsetX = e.nativeEvent.contentOffset.x let accumulated = 0 for (let i = 0; i < images.length; i++) { const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP if (offsetX < accumulated + w / 2) { setCurrentIndex(i) break } accumulated += w if (i === images.length - 1) { setCurrentIndex(i) } } }} style={[ { height: contentHeight, marginLeft: -insetLeft, width, }, ]} contentContainerStyle={{ gap: ITEM_GAP, paddingLeft: insetLeft, paddingRight: insetRight, }} /> ) } function computeDims({ height, aspectRatio, }: { height: number aspectRatio?: number }) { /* * Old images, or images from other clients can sometimes not have * aspectRatio populated. In these cases, default to square and we'll * resize once the image loads. * * Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO * (landscape) so items stay a reasonable size in the carousel. */ const raw = aspectRatio ?? 1 const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO)) const width = Math.floor(height * clamped) return {width, height, aspectRatio: clamped, isCropped: raw !== clamped} } function GalleryImage({ contentHeight: height, image, index, imageCount, onWidthChange, itemRef, hideBadges, largeAltBadge, onContainerRef, onThumbDims, onPress, onPressIn, onPreviewPress, }: { contentHeight: number image: AppBskyEmbedImages.ViewImage index: number imageCount: number onWidthChange: (index: number, width: number) => void itemRef: (node: View | null) => void hideBadges?: boolean largeAltBadge?: boolean onContainerRef: (index: number, ref: AnimatedRef) => void onThumbDims: (index: number, dims: Dimensions) => void onPress?: () => void onPressIn?: () => void onPreviewPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() const [focused, setFocused] = useState(false) const containerRef = useAnimatedRef() const [aspectRatio, setAspectRatio] = useState(() => getAspectRatio(image.aspectRatio), ) const {isCropped, ...dims} = computeDims({height, aspectRatio}) const hasAlt = !!image.alt useEffect(() => { onWidthChange(index, dims.width) }, [index, dims.width, onWidthChange]) useEffect(() => { onContainerRef(index, containerRef) }, [index, containerRef, onContainerRef]) return ( setFocused(true)} onBlur={() => setFocused(false)} accessibilityRole="button" accessibilityLabel={image.alt || l`Image ${index + 1}`} accessibilityHint={l`Opens full image`} android_ripple={{ color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), foreground: true, }} style={({pressed}) => [ a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25, web([ { cursor: 'inherit', outline: 0, border: 0, }, a.transition_transform, {transitionDuration: '200ms'}, pressed && {transform: [{scale: 0.99}]}, ]), ]}> { const ar = getAspectRatio(e.source) if (ar && ar !== aspectRatio) { setAspectRatio(ar) } onThumbDims(index, { width: e.source.width, height: e.source.height, }) }} /> {(hasAlt || isCropped) && !hideBadges ? ( {isCropped && ( )} {hasAlt && ( ALT )} ) : null} ) }