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
This commit is contained in:
Claude
2026-04-19 08:38:13 +00:00
committed by Samuel Newman
parent 1856c36221
commit 723b20bfce
5 changed files with 59 additions and 52 deletions
+18 -15
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react'
import {InteractionManager, View} from 'react-native' import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated' import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
@@ -25,6 +26,11 @@ export function ImageEmbed({
const {images} = embed.view const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) { if (images.length > 0) {
const items = images.map(img => ({ const items = images.map(img => ({
uri: img.fullsize, uri: img.fullsize,
@@ -57,18 +63,6 @@ export function ImageEmbed({
) )
}) })
} }
const onPreviewPress = (index: number) =>
openLightbox({
images: items.map(item => ({
...item,
thumbRect: null,
thumbRef: null,
thumbDimensions: null,
thumbBorderRadius: tokens.borderRadius.md,
type: 'image',
})),
index,
})
if (images.length === 1) { if (images.length === 1) {
const image = images[0] const image = images[0]
@@ -76,6 +70,11 @@ export function ImageEmbed({
image.aspectRatio && image.aspectRatio.height > 0 image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height ? image.aspectRatio.width / image.aspectRatio.height
: undefined : undefined
const openFromSingle = () => {
if (singleContainerRef.current) {
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<ImageContextMenu <ImageContextMenu
@@ -83,7 +82,7 @@ export function ImageEmbed({
thumbUri={image.thumb} thumbUri={image.thumb}
aspectRatio={aspect} aspectRatio={aspect}
borderRadius={tokens.borderRadius.md} borderRadius={tokens.borderRadius.md}
onPreviewPress={() => onPreviewPress(0)}> onPreviewPress={openFromSingle}>
<AutoSizedImage <AutoSizedImage
crop={ crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted rest.viewContext === PostEmbedViewContext.ThreadHighlighted
@@ -94,6 +93,12 @@ export function ImageEmbed({
: 'constrained' : 'constrained'
} }
image={image} image={image}
onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) => onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims]) onPress(0, [containerRef], [dims])
} }
@@ -115,7 +120,6 @@ export function ImageEmbed({
images={images} images={images}
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
onPreviewPress={onPreviewPress}
viewContext={rest.viewContext} viewContext={rest.viewContext}
/> />
</View> </View>
@@ -128,7 +132,6 @@ export function ImageEmbed({
images={images} images={images}
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
onPreviewPress={onPreviewPress}
viewContext={rest.viewContext} viewContext={rest.viewContext}
/> />
</View> </View>
+15 -2
View File
@@ -1,4 +1,4 @@
import {useMemo, useRef} from 'react' import {useEffect, useMemo, useRef} from 'react'
import {type DimensionValue, Pressable, View} from 'react-native' import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, { import Animated, {
type AnimatedRef, type AnimatedRef,
@@ -69,6 +69,8 @@ export function AutoSizedImage({
onPress, onPress,
onLongPress, onLongPress,
onPressIn, onPressIn,
onContainerRef,
onDimsChange,
}: { }: {
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained' crop?: 'none' | 'square' | 'constrained'
@@ -79,6 +81,11 @@ export function AutoSizedImage({
) => void ) => void
onLongPress?: () => void onLongPress?: () => void
onPressIn?: () => void onPressIn?: () => void
/** Fires once with the internal container ref so a parent can drive its
* own lightbox-return animation without waiting for an `onPress`. */
onContainerRef?: (ref: AnimatedRef<any>) => void
/** Fires when the underlying image reports its natural dimensions. */
onDimsChange?: (dims: Dimensions) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -86,6 +93,10 @@ export function AutoSizedImage({
const containerRef = useAnimatedRef() const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
useEffect(() => {
onContainerRef?.(containerRef)
}, [containerRef, onContainerRef])
let aspectRatio: number | undefined let aspectRatio: number | undefined
const dims = image.aspectRatio const dims = image.aspectRatio
if (dims) { if (dims) {
@@ -122,10 +133,12 @@ export function AutoSizedImage({
accessibilityHint="" accessibilityHint=""
onLoad={e => { onLoad={e => {
if (!isContain) { if (!isContain) {
fetchedDimsRef.current = { const dims = {
width: e.source.width, width: e.source.width,
height: e.source.height, height: e.source.height,
} }
fetchedDimsRef.current = dims
onDimsChange?.(dims)
} }
}} }}
loading="lazy" loading="lazy"
+17 -22
View File
@@ -53,7 +53,6 @@ interface GalleryProps {
fetchedDims: (Dimensions | null)[], fetchedDims: (Dimensions | null)[],
) => void ) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
onPreviewPress?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
} }
@@ -97,7 +96,6 @@ export function Gallery({
images, images,
onPress, onPress,
onPressIn, onPressIn,
onPreviewPress,
viewContext, viewContext,
}: GalleryProps) { }: GalleryProps) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -267,6 +265,21 @@ export function Gallery({
data={images} data={images}
keyExtractor={(item, index) => item.thumb + index} keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, 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 ( return (
<GalleryImage <GalleryImage
hideBadges={hideBadges} hideBadges={hideBadges}
@@ -291,27 +304,9 @@ export function Gallery({
onThumbDims={(i, dims) => { onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims) thumbDimsRef.current.set(i, dims)
}} }}
onPress={ onPress={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
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onPreviewPress={ onPreviewPress={openLightboxAtIndex}
onPreviewPress ? () => onPreviewPress(index) : undefined
}
/> />
) )
}} }}
@@ -17,7 +17,6 @@ interface ImageLayoutGridProps {
) => void ) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
onPreviewPress?: (index: number) => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
} }
@@ -49,7 +48,6 @@ interface ImageLayoutGridInnerProps {
) => void ) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
onPreviewPress?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
gap: {gap: number} gap: {gap: number}
} }
+9 -11
View File
@@ -27,8 +27,6 @@ interface Props {
) => void ) => void
onLongPress?: EventFunction onLongPress?: EventFunction
onPressIn?: EventFunction onPressIn?: EventFunction
/** Fired from the native iOS peek preview tap. */
onPreviewPress?: EventFunction
imageStyle?: StyleProp<ImageStyle> imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle> insetBorderStyle?: StyleProp<ViewStyle>
@@ -43,7 +41,6 @@ export function GalleryItem({
onPress, onPress,
onPressIn, onPressIn,
onLongPress, onLongPress,
onPreviewPress,
viewContext, viewContext,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
@@ -62,22 +59,23 @@ export function GalleryItem({
? image.aspectRatio.width / image.aspectRatio.height ? image.aspectRatio.width / image.aspectRatio.height
: undefined : undefined
// The tap handler and the peek-commit handler do the same thing: open the
// lightbox with this cell's ref + dims so the lightbox's return animation
// can target the original thumbnail.
const openLightboxAtIndex = onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
return ( return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<ImageContextMenu <ImageContextMenu
fullsizeUri={image.fullsize} fullsizeUri={image.fullsize}
thumbUri={image.thumb} thumbUri={image.thumb}
aspectRatio={aspect} aspectRatio={aspect}
onPreviewPress={ onPreviewPress={openLightboxAtIndex}
onPreviewPress ? () => onPreviewPress(index) : undefined
}
style={a.flex_1}> style={a.flex_1}>
<Pressable <Pressable
onPress={ onPress={openLightboxAtIndex}
onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{ android_ripple={{