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
+ ) : 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 ? (
>
)