diff --git a/src/components/images/Gallery/index.web.tsx b/src/components/images/Gallery/index.web.tsx index e02e8cbf2d..cbc531388d 100644 --- a/src/components/images/Gallery/index.web.tsx +++ b/src/components/images/Gallery/index.web.tsx @@ -1,5 +1,5 @@ import {useRef, useState} from 'react' -import {Pressable, ScrollView, View} from 'react-native' +import {Pressable, ScrollView, useWindowDimensions, View} from 'react-native' import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {type AppBskyEmbedImages} from '@atproto/api' @@ -16,8 +16,7 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' const CONTAINER_ASPECT_RATIO = 4 / 3 -const PEEK_WIDTH = 40 -const ITEM_GAP = 6 +const ITEM_GAP = 8 // tokens.space.sm interface GalleryProps { images: AppBskyEmbedImages.ViewImage[] @@ -40,9 +39,9 @@ export function Gallery({ const {_} = useLingui() const ax = useAnalytics() const largeAltBadge = useLargeAltBadgeEnabled() - const [currentPage, setCurrentPage] = useState(0) const currentPageRef = useRef(0) const scrollRef = useRef(null) + const {width: windowWidth} = useWindowDimensions() const [containerWidth, setContainerWidth] = useState(0) const containerRefs = useRef[]>([]).current @@ -57,33 +56,63 @@ export function Gallery({ containerRefs[i] = refs[i] } - const hideBadges = + const isWithinQuote = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + const hideBadges = isWithinQuote - const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0 - const snapInterval = itemWidth + ITEM_GAP + const containerHeight = + containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0 + const QUOTE_PADDING = 12 + const bleed = !isWithinQuote + const insetLeft = bleed + ? Math.max(windowWidth - containerWidth, 0) + : QUOTE_PADDING + const insetRight = bleed + ? Math.max(windowWidth - insetLeft - containerWidth, 0) + : QUOTE_PADDING - const goToPage = (index: number) => { - scrollRef.current?.scrollTo({ - x: index * snapInterval, - animated: true, - }) + const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => { + const ar = image.aspectRatio + if (ar && ar.width > 0 && ar.height > 0) { + const ratio = ar.width / ar.height + const w = containerHeight * ratio + return Math.max(containerWidth * 0.4, Math.min(w, containerWidth)) + } + return containerWidth } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowLeft' && currentPage > 0) { + const scrollEl = scrollRef.current as unknown as { + scrollTo: (opts: {x: number; animated: boolean}) => void + } + if (!scrollEl) return + + if (e.key === 'ArrowLeft') { e.preventDefault() - goToPage(currentPage - 1) - } else if (e.key === 'ArrowRight' && currentPage < images.length - 1) { + scrollEl.scrollTo({ + x: Math.max(0, currentScrollX.current - 200), + animated: true, + }) + } else if (e.key === 'ArrowRight') { e.preventDefault() - goToPage(currentPage + 1) + scrollEl.scrollTo({x: currentScrollX.current + 200, animated: true}) } } + const currentScrollX = useRef(0) return ( setContainerWidth(e.nativeEvent.layout.width)} + style={ + containerWidth > 0 + ? {height: containerHeight, overflow: 'visible'} + : {aspectRatio: CONTAINER_ASPECT_RATIO} + } + onLayout={e => { + const w = e.nativeEvent.layout.width + if (w > 0) { + setContainerWidth(w) + } + }} // @ts-expect-error web-only prop onKeyDown={handleKeyDown} tabIndex={0} @@ -97,26 +126,42 @@ export function Gallery({ showsHorizontalScrollIndicator={false} scrollEventThrottle={16} style={[ - {aspectRatio: CONTAINER_ASPECT_RATIO}, + { + width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2, + height: containerHeight, + marginLeft: -insetLeft, + }, web({ - scrollSnapType: 'x mandatory', + scrollBehavior: 'smooth', WebkitOverflowScrolling: 'touch', }), ]} - contentContainerStyle={{gap: ITEM_GAP}} + contentContainerStyle={{ + gap: ITEM_GAP, + paddingLeft: insetLeft, + paddingRight: insetRight, + }} onScroll={e => { 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) + currentScrollX.current = offsetX + let accumulated = insetLeft + let page = 0 + for (let i = 0; i < images.length; i++) { + const w = getItemWidth(images[i]) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + page = i + break } + accumulated += w + page = i + } + if (page !== currentPageRef.current) { + ax.metric('post:gallery:swipe', { + fromIndex: currentPageRef.current, + toIndex: page, + totalImages: images.length, + }) + currentPageRef.current = page } }}> {images.map((image, index) => ( @@ -126,10 +171,9 @@ export function Gallery({ collapsable={false} style={[ { - width: itemWidth, - aspectRatio: CONTAINER_ASPECT_RATIO, + width: getItemWidth(image), + height: containerHeight, }, - web({scrollSnapAlign: 'start'}), ]} aria-roledescription="slide" aria-label={ @@ -216,96 +260,6 @@ export function Gallery({ ))} )} - {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'}), - ]}> - - {'>'} - - - )} - - )} ) }