Wire image context menu into grid and gallery embeds

Thread onPreviewPress(index) through ImageLayoutGrid / GalleryItem and the
Gallery scroll item so multi-image embeds get the same iOS peek behaviour as
single-image embeds. Each grid cell wraps its Pressable in ImageContextMenu
with the image's true aspect ratio, so the preview lifts to the full
uncropped image even when the cell is cropped to square.

https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT
This commit is contained in:
Claude
2026-04-19 07:05:11 +00:00
committed by Samuel Newman
parent d4fd15dc20
commit ce686fae89
5 changed files with 203 additions and 164 deletions
@@ -1,4 +1,5 @@
import {type ReactNode} from 'react' import {type ReactNode} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -22,6 +23,7 @@ export function ImageContextMenu({
aspectRatio, aspectRatio,
borderRadius, borderRadius,
onPreviewPress, onPreviewPress,
style,
children, children,
}: { }: {
fullsizeUri: string fullsizeUri: string
@@ -29,6 +31,7 @@ export function ImageContextMenu({
aspectRatio: number | undefined aspectRatio: number | undefined
borderRadius?: number borderRadius?: number
onPreviewPress?: () => void onPreviewPress?: () => void
style?: StyleProp<ViewStyle>
children: ReactNode children: ReactNode
}) { }) {
const {_} = useLingui() const {_} = useLingui()
@@ -46,7 +49,7 @@ export function ImageContextMenu({
} }
return ( return (
<ContextMenu.Root> <ContextMenu.Root style={style}>
<ContextMenu.Trigger <ContextMenu.Trigger
preview={{ preview={{
type: 'image', type: 'image',
+15 -13
View File
@@ -57,6 +57,18 @@ 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]
@@ -70,19 +82,7 @@ export function ImageEmbed({
fullsizeUri={image.fullsize} fullsizeUri={image.fullsize}
aspectRatio={aspect} aspectRatio={aspect}
borderRadius={tokens.borderRadius.md} borderRadius={tokens.borderRadius.md}
onPreviewPress={() => onPreviewPress={() => onPreviewPress(0)}>
openLightbox({
images: items.map(item => ({
...item,
thumbRect: null,
thumbRef: null,
thumbDimensions: null,
thumbBorderRadius: tokens.borderRadius.md,
type: 'image',
})),
index: 0,
})
}>
<AutoSizedImage <AutoSizedImage
crop={ crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted rest.viewContext === PostEmbedViewContext.ThreadHighlighted
@@ -114,6 +114,7 @@ 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>
@@ -126,6 +127,7 @@ 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 -114
View File
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import { import {
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils' import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -52,6 +53,7 @@ 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
} }
@@ -95,6 +97,7 @@ export function Gallery({
images, images,
onPress, onPress,
onPressIn, onPressIn,
onPreviewPress,
viewContext, viewContext,
}: GalleryProps) { }: GalleryProps) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -306,6 +309,9 @@ export function Gallery({
: undefined : undefined
} }
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onPreviewPress={
onPreviewPress ? () => onPreviewPress(index) : undefined
}
/> />
) )
}} }}
@@ -378,6 +384,7 @@ function GalleryImage({
onThumbDims, onThumbDims,
onPress, onPress,
onPressIn, onPressIn,
onPreviewPress,
}: { }: {
contentHeight: number contentHeight: number
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
@@ -391,6 +398,7 @@ function GalleryImage({
onThumbDims: (index: number, dims: Dimensions) => void onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void onPress?: () => void
onPressIn?: () => void onPressIn?: () => void
onPreviewPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -416,124 +424,130 @@ function GalleryImage({
collapsable={false} collapsable={false}
aria-roledescription={l`slide`} aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable <ImageContextMenu
ref={itemRef} fullsizeUri={image.fullsize}
tabIndex={index === 0 ? 0 : -1} aspectRatio={aspectRatio}
onPress={onPress} borderRadius={tokens.borderRadius.md}
onPressIn={onPressIn} onPreviewPress={onPreviewPress}>
onFocus={() => setFocused(true)} <Pressable
onBlur={() => setFocused(false)} ref={itemRef}
accessibilityRole="button" tabIndex={index === 0 ? 0 : -1}
accessibilityLabel={image.alt || l`Image ${index + 1}`} onPress={onPress}
accessibilityHint={l`Opens full image`} onPressIn={onPressIn}
android_ripple={{ onFocus={() => setFocused(true)}
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), onBlur={() => setFocused(false)}
foreground: true, accessibilityRole="button"
}} accessibilityLabel={image.alt || l`Image ${index + 1}`}
style={({pressed}) => [ accessibilityHint={l`Opens full image`}
a.rounded_md, android_ripple={{
a.overflow_hidden, color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
t.atoms.bg_contrast_25, foreground: true,
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,
})
}} }}
/> style={({pressed}) => [
a.rounded_md,
{(hasAlt || isCropped) && !hideBadges ? ( a.overflow_hidden,
<View t.atoms.bg_contrast_25,
accessible={false} web([
style={[
a.absolute,
a.flex_row,
{ {
bottom: a.p_xs.padding, cursor: 'inherit',
right: a.p_xs.padding, outline: 0,
gap: 3, border: 0,
}, },
largeAltBadge && { a.transition_transform,
gap: 4, {transitionDuration: '200ms'},
}, pressed && {transform: [{scale: 0.99}]},
]}> ]),
{isCropped && ( ]}>
<View <Image
style={[ source={{uri: image.thumb}}
a.rounded_sm, contentFit="cover"
a.p_xs, accessible={true}
t.atoms.bg_contrast_25, accessibilityLabel={image.alt}
{ accessibilityHint=""
opacity: 0.8, accessibilityIgnoresInvertColors
}, loading={index === 0 ? 'eager' : 'lazy'}
largeAltBadge && { style={[dims]}
padding: 6, onLoad={e => {
}, const ar = getAspectRatio(e.source)
]}> if (ar && ar !== aspectRatio) {
<Fullscreen setAspectRatio(ar)
fill={t.atoms.text_contrast_high.color} }
width={largeAltBadge ? 18 : 12} onThumbDims(index, {
/> width: e.source.width,
</View> height: e.source.height,
)} })
{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 {(hasAlt || isCropped) && !hideBadges ? (
style={ <View
focused && { accessible={false}
borderWidth: 2, 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>
</Pressable> </ImageContextMenu>
</Animated.View> </Animated.View>
) )
} }
@@ -17,6 +17,7 @@ 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
} }
@@ -48,6 +49,7 @@ 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}
} }
+54 -36
View File
@@ -11,6 +11,7 @@ import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -26,6 +27,8 @@ 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>
@@ -40,6 +43,7 @@ export function GalleryItem({
onPress, onPress,
onPressIn, onPressIn,
onLongPress, onLongPress,
onPreviewPress,
viewContext, viewContext,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
@@ -52,46 +56,60 @@ export function GalleryItem({
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
return ( return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable <ImageContextMenu
onPress={ fullsizeUri={image.fullsize}
onPress aspectRatio={aspect}
? () => onPress(index, containerRefs, thumbDimsRef.current.slice()) onPreviewPress={
: undefined onPreviewPress ? () => onPreviewPress(index) : undefined
} }
onPressIn={onPressIn ? () => onPressIn(index) : undefined} style={a.flex_1}>
onLongPress={onLongPress ? () => onLongPress(index) : undefined} <Pressable
android_ripple={{ onPress={
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), onPress
foreground: true, ? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
}} : undefined
style={[ }
a.flex_1, onPressIn={onPressIn ? () => onPressIn(index) : undefined}
a.overflow_hidden, onLongPress={onLongPress ? () => onLongPress(index) : undefined}
t.atoms.bg_contrast_25, android_ripple={{
imageStyle, color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
]} foreground: true,
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}} }}
loading="lazy" style={[
/> a.flex_1,
<MediaInsetBorder style={insetBorderStyle} /> a.overflow_hidden,
</Pressable> t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? ( {hasAlt && !hideBadges ? (
<View <View
accessible={false} accessible={false}