From 711334b34e6a41d7e38d41f7067986cb3c4c8883 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 3 Jun 2026 05:03:55 +0300 Subject: [PATCH] Add iOS peek long-press context menu for image embeds (#10300) Co-authored-by: Claude --- eslint-suppressions.json | 4 +- .../modules/bottomsheet/BottomSheetView.kt | 33 ++- package.json | 1 + pnpm-lock.yaml | 16 ++ src/components/MediaPreview.tsx | 70 ++++- src/components/PeekMenu.tsx | 1 + .../Post/Embed/ImageContextMenu.tsx | 79 +++++ src/components/Post/Embed/ImageEmbed.tsx | 64 +++-- src/components/icons/TEMPLATE.tsx | 30 +- src/components/images/AutoSizedImage.tsx | 17 +- src/components/images/Gallery/index.tsx | 272 +++++++++--------- src/components/images/ImageLayoutGridItem.tsx | 94 +++--- .../notifications/NotificationFeedItem.tsx | 1 + 13 files changed, 464 insertions(+), 218 deletions(-) create mode 100644 src/components/PeekMenu.tsx create mode 100644 src/components/Post/Embed/ImageContextMenu.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 40b918e771..5b7eef4c94 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -76,7 +76,7 @@ }, "src/components/Post/Embed/ImageEmbed.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 1 + "count": 2 } }, "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": { @@ -169,7 +169,7 @@ }, "src/components/images/AutoSizedImage.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 1 + "count": 2 } }, "src/components/images/Gallery/index.tsx": { diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt index b7e81a61f6..1b9224c6e7 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -41,11 +41,14 @@ class BottomSheetView( private val screenHeight: Float = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { // API 35+: edge-to-edge is mandatory, heightPixels is the full display - context.resources.displayMetrics.heightPixels.toFloat() + context.resources.displayMetrics.heightPixels + .toFloat() } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { // API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager - wm.currentWindowMetrics.bounds.height().toFloat() + wm.currentWindowMetrics.bounds + .height() + .toFloat() } else { // API < 30: currentWindowMetrics not available, use getRealSize // which includes system bars (heightPixels may exclude them) @@ -166,6 +169,7 @@ class BottomSheetView( when { // Full height sheets contentHeight >= screenHeight -> 0.99f + else -> this.clampRatio(this.getTargetHeight() / screenHeight) } @@ -271,8 +275,9 @@ class BottomSheetView( } // Apply deferred layout update after gesture completes if (newState != BottomSheetBehavior.STATE_DRAGGING && - newState != BottomSheetBehavior.STATE_SETTLING && - pendingLayoutUpdate) { + newState != BottomSheetBehavior.STATE_SETTLING && + pendingLayoutUpdate + ) { pendingLayoutUpdate = false updateLayout() } @@ -292,7 +297,6 @@ class BottomSheetView( if (!fullHeight) { this.startObservingContentHeight() } - } fun updateLayout() { @@ -365,17 +369,18 @@ class BottomSheetView( val innerViewGroup = this.innerView as? ViewGroup ?: return - val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> - val newHeight = bottom - top - val oldHeight = oldBottom - oldTop - if (newHeight != oldHeight) { - val contentHeight = getContentHeight() - if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) { - lastObservedContentHeight = contentHeight - updateLayout() + val listener = + OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> + val newHeight = bottom - top + val oldHeight = oldBottom - oldTop + if (newHeight != oldHeight) { + val contentHeight = getContentHeight() + if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) { + lastObservedContentHeight = contentHeight + updateLayout() + } } } - } val children = mutableListOf() for (i in 0 until innerViewGroup.childCount) { diff --git a/package.json b/package.json index 0716bd085b..b8bd26e241 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-translate-text": "^0.2.9", + "@bsky.app/peek-menu": "^0.2.4", "@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/sift": "^0.3.8", "@bsky.app/tapper": "^0.5.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d9210cc2d..b3d0003107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@bsky.app/expo-translate-text': specifier: ^0.2.9 version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@bsky.app/peek-menu': + specifier: ^0.2.4 + version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bsky.app/react-native-mmkv': specifier: 2.12.5 version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -1649,6 +1652,13 @@ packages: react: '*' react-native: '*' + '@bsky.app/peek-menu@0.2.4': + resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + '@bsky.app/react-native-mmkv@2.12.5': resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==} peerDependencies: @@ -10461,6 +10471,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + '@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + '@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: react: 19.1.0 diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 530c85ae66..9aad6da864 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,11 +1,16 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {type AppBskyFeedDefs} from '@atproto/api' -import {Trans} from '@lingui/react/macro' +import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' +import {shareImageModal} from '#/lib/media/manip' +import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' import {isGifEmbed} from '#/lib/strings/embed-player' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' +import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import * as PeekMenu from '#/components/PeekMenu' import {Text} from '#/components/Typography' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import * as bsky from '#/types/bsky' @@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky' export function Embed({ embed, style, + peekable = false, }: { embed: AppBskyFeedDefs.PostView['embed'] style?: StyleProp + peekable?: boolean }) { const e = bsky.post.parseEmbed(embed) @@ -27,13 +34,17 @@ export function Embed({ if (e.type === 'images') { return ( - {e.view.images.map(image => ( - - ))} + {e.view.images.map(image => + peekable ? ( + + ) : ( + + ), + )} ) } else if (e.type === 'link') { @@ -156,6 +167,45 @@ export function VideoItem({ ) } +function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) { + const {t: l} = useLingui() + const saveImage = useSaveImageToMediaLibrary() + + const aspect = + image.aspectRatio && image.aspectRatio.height > 0 + ? image.aspectRatio.width / image.aspectRatio.height + : undefined + + return ( + + 0 ? aspect : 1, + }} + borderRadius={tokens.borderRadius.xs}> + + + + void saveImage(image.fullsize)}> + + {l`Save image`} + + void shareImageModal({uri: image.fullsize})}> + + {l`Share`} + + + + ) +} + const styles = StyleSheet.create({ altContainer: { backgroundColor: 'rgba(0, 0, 0, 0.75)', diff --git a/src/components/PeekMenu.tsx b/src/components/PeekMenu.tsx new file mode 100644 index 0000000000..53732ec3e8 --- /dev/null +++ b/src/components/PeekMenu.tsx @@ -0,0 +1 @@ +export * from '@bsky.app/peek-menu' diff --git a/src/components/Post/Embed/ImageContextMenu.tsx b/src/components/Post/Embed/ImageContextMenu.tsx new file mode 100644 index 0000000000..3aadb56901 --- /dev/null +++ b/src/components/Post/Embed/ImageContextMenu.tsx @@ -0,0 +1,79 @@ +import {type ReactNode} from 'react' +import {type StyleProp, type ViewStyle} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {shareImageModal} from '#/lib/media/manip' +import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' +import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' +import * as PeekMenu from '#/components/PeekMenu' +import {IS_IOS} from '#/env' + +/** + * Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS + * platforms this renders children unchanged. + * + * The aspect ratio is consumed by the native side to size the preview + * viewController correctly — which is what makes the lift animation clean + * for portrait/panorama images. + */ +export function ImageContextMenu({ + fullsizeUri, + thumbUri, + aspectRatio, + borderRadius, + onPreviewPress, + style, + children, +}: { + fullsizeUri: string + /** Thumbnail URL. Used as an instant placeholder in the native preview + * while the fullsize loads, so there's no black flash on first peek. */ + thumbUri?: string + /** width / height; defaults to 1 if missing. */ + aspectRatio: number | undefined + borderRadius?: number + onPreviewPress?: () => void + style?: StyleProp + children: ReactNode +}) { + const {t: l} = useLingui() + const saveImage = useSaveImageToMediaLibrary() + + if (!IS_IOS) { + return children + } + + const handleSave = () => { + void saveImage(fullsizeUri) + } + const handleShare = () => { + void shareImageModal({uri: fullsizeUri}) + } + + return ( + + 0 ? aspectRatio : 1, + }} + borderRadius={borderRadius} + onPreviewPress={onPreviewPress}> + {children} + + + + + {l`Save image`} + + + + {l`Share`} + + + + ) +} diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index d779d04876..ccd1ceb270 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -1,3 +1,4 @@ +import {useRef} from 'react' import {InteractionManager, View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' @@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {useLightboxControls} from '#/components/Lightbox/state' import {type Dimensions} from '#/components/Lightbox/types' +import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' @@ -24,6 +26,11 @@ export function ImageEmbed({ const {images} = embed.view 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 | null>(null) + const singleDimsRef = useRef(null) + if (images.length > 0) { const items = images.map(img => ({ uri: img.fullsize, @@ -59,24 +66,49 @@ export function ImageEmbed({ if (images.length === 1) { const image = images[0] + const aspect = + image.aspectRatio && image.aspectRatio.height > 0 + ? image.aspectRatio.width / image.aspectRatio.height + : undefined + const openFromSingle = () => { + if (singleContainerRef.current) { + onPress(0, [singleContainerRef.current], [singleDimsRef.current]) + } + } return ( - onPress(0, [containerRef], [dims])} - onPressIn={() => onPressIn(0)} - hideBadge={ - rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> + + { + singleContainerRef.current = ref + }} + onDimsChange={dims => { + singleDimsRef.current = dims + }} + onPress={(containerRef, dims) => + onPress(0, [containerRef], [dims]) + } + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === + PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ) } diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index 4feaaef359..a7ef371cd1 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -1,8 +1,20 @@ -import {forwardRef} from 'react' +import { + forwardRef, + type ForwardRefExoticComponent, + type RefAttributes, +} from 'react' import Svg, {Path} from 'react-native-svg' import {type Props, useCommonSVGProps} from '#/components/icons/common' +export type IconWithSvgMeta = ForwardRefExoticComponent< + Props & RefAttributes +> & { + svgPaths: string[] + svgViewBox: string + svgStrokeWidth: number +} + export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef( function LogoImpl(props: Props, ref) { const {fill, size, style, ...rest} = useCommonSVGProps(props) @@ -41,7 +53,7 @@ export function createSinglePathSVG({ strokeLinecap?: 'butt' | 'round' | 'square' strokeLinejoin?: 'miter' | 'round' | 'bevel' }) { - return forwardRef(function LogoImpl(props, ref) { + const Icon = forwardRef(function LogoImpl(props, ref) { const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const hasStroke = strokeWidth > 0 @@ -68,7 +80,11 @@ export function createSinglePathSVG({ /> ) - }) + }) as IconWithSvgMeta + Icon.svgPaths = [path] + Icon.svgViewBox = viewBox || '0 0 24 24' + Icon.svgStrokeWidth = strokeWidth + return Icon } export function createMultiPathSVG({ @@ -78,7 +94,7 @@ export function createMultiPathSVG({ paths: string[] viewBox?: string }) { - return forwardRef(function LogoImpl(props, ref) { + const Icon = forwardRef(function LogoImpl(props, ref) { const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( @@ -102,5 +118,9 @@ export function createMultiPathSVG({ ))} ) - }) + }) as IconWithSvgMeta + Icon.svgPaths = paths + Icon.svgViewBox = viewBox || '0 0 24 24' + Icon.svgStrokeWidth = 0 + return Icon } diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index 2df8d056d6..79b41311cb 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef} from 'react' +import {useEffect, useMemo, useRef} from 'react' import {type DimensionValue, Pressable, View} from 'react-native' import Animated, { type AnimatedRef, @@ -69,6 +69,8 @@ export function AutoSizedImage({ onPress, onLongPress, onPressIn, + onContainerRef, + onDimsChange, }: { image: AppBskyEmbedImages.ViewImage crop?: 'none' | 'square' | 'constrained' @@ -79,6 +81,11 @@ export function AutoSizedImage({ ) => void onLongPress?: () => 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) => void + /** Fires when the underlying image reports its natural dimensions. */ + onDimsChange?: (dims: Dimensions) => void }) { const t = useTheme() const {_} = useLingui() @@ -86,6 +93,10 @@ export function AutoSizedImage({ const containerRef = useAnimatedRef() const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) + useEffect(() => { + onContainerRef?.(containerRef) + }, [containerRef, onContainerRef]) + let aspectRatio: number | undefined const dims = image.aspectRatio if (dims) { @@ -122,10 +133,12 @@ export function AutoSizedImage({ accessibilityHint="" onLoad={e => { if (!isContain) { - fetchedDimsRef.current = { + const dims = { width: e.source.width, height: e.source.height, } + fetchedDimsRef.current = dims + onDimsChange?.(dims) } }} loading="lazy" diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 1566f09736..f4ffcd7159 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -24,7 +24,7 @@ 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, 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 {AutoSizedImage} from '#/components/images/AutoSizedImage' import { @@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle 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' @@ -264,6 +265,21 @@ export function Gallery({ 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[] = [] + 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 ( { thumbDimsRef.current.set(i, dims) }} - onPress={ - onPress - ? () => { - ax.metric('post:gallery:openLightbox', { - fromImage: index + 1, // convert to 1-based index for easier analysis - totalImages: images.length, - }) - const refs: AnimatedRef[] = [] - 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 - } + onPress={openLightboxAtIndex} onPressIn={onPressIn ? () => onPressIn(index) : undefined} + onPreviewPress={openLightboxAtIndex} /> ) }} @@ -378,6 +379,7 @@ function GalleryImage({ onThumbDims, onPress, onPressIn, + onPreviewPress, }: { contentHeight: number image: AppBskyEmbedImages.ViewImage @@ -391,6 +393,7 @@ function GalleryImage({ onThumbDims: (index: number, dims: Dimensions) => void onPress?: () => void onPressIn?: () => void + onPreviewPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() @@ -416,124 +419,131 @@ function GalleryImage({ collapsable={false} aria-roledescription={l`slide`} aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> - 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}]}, - ]), - ]}> - { - const ar = getAspectRatio(e.source) - if (ar && ar !== aspectRatio) { - setAspectRatio(ar) - } - onThumbDims(index, { - width: e.source.width, - height: e.source.height, - }) + + 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, }} - /> - - {(hasAlt || isCropped) && !hideBadges ? ( - [ + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + web([ { - bottom: a.p_xs.padding, - right: a.p_xs.padding, - gap: 3, + cursor: 'inherit', + outline: 0, + border: 0, }, - largeAltBadge && { - gap: 4, - }, - ]}> - {isCropped && ( - - - - )} - {hasAlt && ( - - - ALT - - - )} - - ) : null} + a.transition_transform, + {transitionDuration: '200ms'}, + pressed && {transform: [{scale: 0.99}]}, + ]), + ]}> + { + const ar = getAspectRatio(e.source) + if (ar && ar !== aspectRatio) { + setAspectRatio(ar) + } + onThumbDims(index, { + width: e.source.width, + height: e.source.height, + }) + }} + /> - + {isCropped && ( + + + + )} + {hasAlt && ( + + + ALT + + + )} + + ) : null} + + - + /> + + ) } diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index b809a783fa..0c349b6612 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -9,8 +9,9 @@ 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 {atoms as a, tokens, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' @@ -52,46 +53,63 @@ export function GalleryItem({ const hasAlt = !!image.alt const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + + const aspect = + image.aspectRatio && image.aspectRatio.height > 0 + ? image.aspectRatio.width / image.aspectRatio.height + : 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 ( - 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, - } + + onPressIn(index) : undefined} + onLongPress={onLongPress ? () => onLongPress(index) : undefined} + android_ripple={{ + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), + foreground: true, }} - loading="lazy" - /> - - + 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 ? ( )