diff --git a/src/components/images/Gallery/GalleryItem.tsx b/src/components/images/Gallery/GalleryItem.tsx new file mode 100644 index 0000000000..b809a783fa --- /dev/null +++ b/src/components/images/Gallery/GalleryItem.tsx @@ -0,0 +1,126 @@ +import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' +import {type AnimatedRef} from 'react-native-reanimated' +import {Image, type ImageStyle} from 'expo-image' +import {type AppBskyEmbedImages} from '@atproto/api' +import {utils} from '@bsky.app/alf' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {type Dimensions} from '#/lib/media/types' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme} from '#/alf' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {Text} from '#/components/Typography' + +type EventFunction = (index: number) => void + +interface Props { + images: AppBskyEmbedImages.ViewImage[] + index: number + onPress?: ( + index: number, + containerRefs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => void + onLongPress?: EventFunction + onPressIn?: EventFunction + imageStyle?: StyleProp + viewContext?: PostEmbedViewContext + insetBorderStyle?: StyleProp + containerRefs: AnimatedRef[] + thumbDimsRef: React.RefObject<(Dimensions | null)[]> +} + +export function GalleryItem({ + images, + index, + imageStyle, + onPress, + onPressIn, + onLongPress, + viewContext, + insetBorderStyle, + containerRefs, + thumbDimsRef, +}: Props) { + const t = useTheme() + const {_} = useLingui() + const largeAltBadge = useLargeAltBadgeEnabled() + const image = images[index] + const hasAlt = !!image.alt + const hideBadges = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + return ( + + onPress(index, containerRefs, thumbDimsRef.current.slice()) + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + onLongPress={onLongPress ? () => onLongPress(index) : undefined} + android_ripple={{ + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), + foreground: true, + }} + style={[ + a.flex_1, + a.overflow_hidden, + t.atoms.bg_contrast_25, + imageStyle, + ]} + accessibilityRole="button" + accessibilityLabel={image.alt || _(msg`Image`)} + accessibilityHint=""> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading="lazy" + /> + + + {hasAlt && !hideBadges ? ( + + + ALT + + + ) : null} + + ) +} diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx new file mode 100644 index 0000000000..fffa1fe9c1 --- /dev/null +++ b/src/components/images/Gallery/index.tsx @@ -0,0 +1,293 @@ +import {useContext, useMemo, useRef, useState} from 'react' +import {FlatList, Pressable, View} from 'react-native' +import {DrawerGestureContext} from 'react-native-drawer-layout' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import {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 {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {type Dimensions} from '#/lib/media/types' +import {useA11y} from '#/state/a11y' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme} from '#/alf' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' + +const CONTAINER_ASPECT_RATIO = 4 / 3 +const PEEK_WIDTH = 40 +const ITEM_GAP = 6 + +interface GalleryProps { + images: AppBskyEmbedImages.ViewImage[] + onPress?: ( + index: number, + containerRefs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +export function Gallery({ + images, + onPress, + onPressIn, + viewContext, +}: GalleryProps) { + const t = useTheme() + const {_} = useLingui() + const ax = useAnalytics() + const {screenReaderEnabled} = useA11y() + const largeAltBadge = useLargeAltBadgeEnabled() + const [currentPage, setCurrentPage] = useState(0) + const currentPageRef = useRef(0) + const [containerWidth, setContainerWidth] = useState(0) + + const containerRefs = useRef[]>([]).current + const thumbDimsRef = useRef<(Dimensions | null)[]>([]) + + const ref0 = useAnimatedRef() + const ref1 = useAnimatedRef() + const ref2 = useAnimatedRef() + const ref3 = useAnimatedRef() + const refs = [ref0, ref1, ref2, ref3] + for (let i = 0; i < images.length; i++) { + containerRefs[i] = refs[i] + } + + const hideBadges = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + + const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0 + const snapInterval = itemWidth + ITEM_GAP + + if (screenReaderEnabled) { + return ( + + {images.map((image, index) => ( + + + onPress( + index, + containerRefs.slice(0, images.length), + thumbDimsRef.current.slice(), + ) + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + accessibilityRole="button" + accessibilityLabel={ + image.alt || _(msg`Image ${index + 1} of ${images.length}`) + } + accessibilityHint={_(msg`Opens full image`)} + style={[a.flex_1]}> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading={index === 0 ? 'eager' : 'lazy'} + /> + + + + ))} + + ) + } + + return ( + setContainerWidth(e.nativeEvent.layout.width)}> + {containerWidth > 0 && ( + + { + const offsetX = e.nativeEvent.contentOffset.x + if (snapInterval > 0) { + const page = Math.round(offsetX / snapInterval) + if (page !== currentPageRef.current) { + ax.metric('post:gallery:swipe', { + fromIndex: currentPageRef.current, + toIndex: page, + totalImages: images.length, + }) + currentPageRef.current = page + setCurrentPage(page) + } + } + }} + scrollEventThrottle={16} + keyExtractor={(_, index) => String(index)} + renderItem={({item: image, index}) => ( + + { + ax.metric('post:gallery:openLightbox', { + imageIndex: index, + totalImages: images.length, + }) + onPress( + index, + containerRefs.slice(0, images.length), + thumbDimsRef.current.slice(), + ) + } + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + android_ripple={{ + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), + foreground: true, + }} + accessibilityRole="button" + accessibilityLabel={ + image.alt || _(msg`Image ${index + 1} of ${images.length}`) + } + accessibilityHint={_(msg`Opens full image`)} + style={[ + a.flex_1, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading={index === 0 ? 'eager' : 'lazy'} + /> + + + {image.alt && !hideBadges ? ( + + + ALT + + + ) : null} + + )} + /> + + )} + {images.length > 1 && ( + + + {currentPage + 1}/{images.length} + + + )} + + ) +} + +function DrawerGestureBlocker({children}: {children: React.ReactNode}) { + const drawerGesture = useContext(DrawerGestureContext) + + const nativeGesture = useMemo(() => { + const gesture = Gesture.Native() + if (drawerGesture) { + gesture.blocksExternalGesture(drawerGesture) + } + return gesture + }, [drawerGesture]) + + return {children} +} diff --git a/src/components/images/Gallery/index.web.tsx b/src/components/images/Gallery/index.web.tsx new file mode 100644 index 0000000000..e02e8cbf2d --- /dev/null +++ b/src/components/images/Gallery/index.web.tsx @@ -0,0 +1,311 @@ +import {useRef, useState} from 'react' +import {Pressable, ScrollView, View} from 'react-native' +import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' +import {Image} from 'expo-image' +import {type AppBskyEmbedImages} from '@atproto/api' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {type Dimensions} from '#/lib/media/types' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme, web} from '#/alf' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' + +const CONTAINER_ASPECT_RATIO = 4 / 3 +const PEEK_WIDTH = 40 +const ITEM_GAP = 6 + +interface GalleryProps { + images: AppBskyEmbedImages.ViewImage[] + onPress?: ( + index: number, + containerRefs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +export function Gallery({ + images, + onPress, + onPressIn, + viewContext, +}: GalleryProps) { + const t = useTheme() + const {_} = useLingui() + const ax = useAnalytics() + const largeAltBadge = useLargeAltBadgeEnabled() + const [currentPage, setCurrentPage] = useState(0) + const currentPageRef = useRef(0) + const scrollRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + + const containerRefs = useRef[]>([]).current + const thumbDimsRef = useRef<(Dimensions | null)[]>([]) + + const ref0 = useAnimatedRef() + const ref1 = useAnimatedRef() + const ref2 = useAnimatedRef() + const ref3 = useAnimatedRef() + const refs = [ref0, ref1, ref2, ref3] + for (let i = 0; i < images.length; i++) { + containerRefs[i] = refs[i] + } + + const hideBadges = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + + const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0 + const snapInterval = itemWidth + ITEM_GAP + + const goToPage = (index: number) => { + scrollRef.current?.scrollTo({ + x: index * snapInterval, + animated: true, + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft' && currentPage > 0) { + e.preventDefault() + goToPage(currentPage - 1) + } else if (e.key === 'ArrowRight' && currentPage < images.length - 1) { + e.preventDefault() + goToPage(currentPage + 1) + } + } + + return ( + setContainerWidth(e.nativeEvent.layout.width)} + // @ts-expect-error web-only prop + onKeyDown={handleKeyDown} + tabIndex={0} + role="group" + aria-roledescription="carousel" + aria-label={_(msg`Image gallery, ${images.length} images`)}> + {containerWidth > 0 && ( + { + const offsetX = e.nativeEvent.contentOffset.x + if (snapInterval > 0) { + const page = Math.round(offsetX / snapInterval) + if (page !== currentPageRef.current) { + ax.metric('post:gallery:swipe', { + fromIndex: currentPageRef.current, + toIndex: page, + totalImages: images.length, + }) + currentPageRef.current = page + setCurrentPage(page) + } + } + }}> + {images.map((image, index) => ( + + { + ax.metric('post:gallery:openLightbox', { + imageIndex: index, + totalImages: images.length, + }) + onPress( + index, + containerRefs.slice(0, images.length), + thumbDimsRef.current.slice(), + ) + } + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + accessibilityRole="button" + accessibilityLabel={ + image.alt || _(msg`Image ${index + 1} of ${images.length}`) + } + accessibilityHint={_(msg`Opens full image`)} + style={[ + a.flex_1, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + web({cursor: 'pointer'}), + ]}> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading={index === 0 ? 'eager' : 'lazy'} + /> + + + {image.alt && !hideBadges ? ( + + + ALT + + + ) : null} + + ))} + + )} + {images.length > 1 && ( + <> + + + {currentPage + 1}/{images.length} + + + {currentPage > 0 && ( + goToPage(currentPage - 1)} + accessibilityRole="button" + accessibilityLabel={_(msg`Previous image`)} + accessibilityHint="" + style={[ + a.absolute, + a.align_center, + a.justify_center, + a.rounded_full, + t.atoms.bg_contrast_975, + { + left: a.p_xs.padding, + top: '50%', + marginTop: -16, + width: 32, + height: 32, + opacity: 0.75, + }, + web({cursor: 'pointer'}), + ]}> + + {'<'} + + + )} + {currentPage < images.length - 1 && ( + goToPage(currentPage + 1)} + accessibilityRole="button" + accessibilityLabel={_(msg`Next image`)} + accessibilityHint="" + style={[ + a.absolute, + a.align_center, + a.justify_center, + a.rounded_full, + t.atoms.bg_contrast_975, + { + right: a.p_xs.padding, + top: '50%', + marginTop: -16, + width: 32, + height: 32, + opacity: 0.75, + }, + web({cursor: 'pointer'}), + ]}> + + {'>'} + + + )} + + )} + + ) +}