Files
bsky-social-app/src/components/images/Gallery/index.tsx
T
Claude 723b20bfce Pass thumbnail ref through peek commit so lightbox return animates
The lightbox's close animation needs the original thumbnail ref/dims to know
where to animate back to. The peek-commit path was passing null, so the
lightbox dropped to a fade instead of the usual return-to-thumb animation.

Rework each embed path so the tap handler and the peek-commit handler
invoke the same openLightbox call with the same ref + dims:

- Grid and scrollable Gallery: derive `openLightboxAtIndex` once per cell
  and hand it to both Pressable.onPress and ContextMenu.onPreviewPress.
- Single image: add onContainerRef / onDimsChange callbacks to
  AutoSizedImage, capture them in ImageEmbed, and use them for the peek
  commit. Drops the now-redundant onPreviewPress prop chain.

https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT
2026-05-25 14:35:14 +03:00

550 lines
16 KiB
TypeScript

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<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
const Context = createContext<{
bleedRef: React.RefObject<View | null>
bleedWidth: number
}>({
bleedRef: {current: null},
bleedWidth: 0,
})
export function GalleryBleed({children}: {children: React.ReactNode}) {
const ref = useRef<View>(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<any>
return (
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
{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],
})}
</Context.Provider>
)
}
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<View>(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<FlatList>(null)
const itemWidthsRef = useRef<Map<number, number>>(new Map())
const itemRefsRef = useRef<Map<number, View>>(new Map())
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(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 (
<View style={[a.relative, a.gap_sm]}>
{images.map((image, index) => (
<AutoSizedImage
key={image.thumb + index}
crop={
viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
))}
</View>
)
}
return (
<View
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
ref={flatListRef}
role="group"
aria-roledescription={l`carousel`}
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item, index) => 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<any>[] = []
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 (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
index={index}
imageCount={images.length}
onWidthChange={(i, w) => {
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,
}}
/>
</BlockDrawerGesture>
</View>
)
}
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<any>) => 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 (
<Animated.View
ref={containerRef}
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspectRatio}
borderRadius={tokens.borderRadius.md}
onPreviewPress={onPreviewPress}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => 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}]},
]),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
/>
</Pressable>
</ImageContextMenu>
</Animated.View>
)
}