Add iOS peek long-press context menu for image embeds (#10300)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-03 05:03:55 +03:00
committed by GitHub
parent c88218ab87
commit 711334b34e
13 changed files with 464 additions and 218 deletions
+2 -2
View File
@@ -76,7 +76,7 @@
}, },
"src/components/Post/Embed/ImageEmbed.tsx": { "src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 2
} }
}, },
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": { "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
@@ -169,7 +169,7 @@
}, },
"src/components/images/AutoSizedImage.tsx": { "src/components/images/AutoSizedImage.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 2
} }
}, },
"src/components/images/Gallery/index.tsx": { "src/components/images/Gallery/index.tsx": {
@@ -41,11 +41,14 @@ class BottomSheetView(
private val screenHeight: Float = private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { 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 // 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) { } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics // API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat() wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else { } else {
// API < 30: currentWindowMetrics not available, use getRealSize // API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them) // which includes system bars (heightPixels may exclude them)
@@ -166,6 +169,7 @@ class BottomSheetView(
when { when {
// Full height sheets // Full height sheets
contentHeight >= screenHeight -> 0.99f contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight) else -> this.clampRatio(this.getTargetHeight() / screenHeight)
} }
@@ -272,7 +276,8 @@ class BottomSheetView(
// Apply deferred layout update after gesture completes // Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING && if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING && newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) { pendingLayoutUpdate
) {
pendingLayoutUpdate = false pendingLayoutUpdate = false
updateLayout() updateLayout()
} }
@@ -292,7 +297,6 @@ class BottomSheetView(
if (!fullHeight) { if (!fullHeight) {
this.startObservingContentHeight() this.startObservingContentHeight()
} }
} }
fun updateLayout() { fun updateLayout() {
@@ -365,7 +369,8 @@ class BottomSheetView(
val innerViewGroup = this.innerView as? ViewGroup ?: return val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> val listener =
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top val newHeight = bottom - top
val oldHeight = oldBottom - oldTop val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) { if (newHeight != oldHeight) {
+1
View File
@@ -102,6 +102,7 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9", "@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/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8", "@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7", "@bsky.app/tapper": "^0.5.7",
+16
View File
@@ -268,6 +268,9 @@ importers:
'@bsky.app/expo-translate-text': '@bsky.app/expo-translate-text':
specifier: ^0.2.9 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) 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': '@bsky.app/react-native-mmkv':
specifier: 2.12.5 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) 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: '*'
react-native: '*' 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': '@bsky.app/react-native-mmkv@2.12.5':
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==} resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
peerDependencies: peerDependencies:
@@ -10461,6 +10471,12 @@ snapshots:
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-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)': '@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: dependencies:
react: 19.1.0 react: 19.1.0
+55 -5
View File
@@ -1,11 +1,16 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro' 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 {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 {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as PeekMenu from '#/components/PeekMenu'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
export function Embed({ export function Embed({
embed, embed,
style, style,
peekable = false,
}: { }: {
embed: AppBskyFeedDefs.PostView['embed'] embed: AppBskyFeedDefs.PostView['embed']
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
peekable?: boolean
}) { }) {
const e = bsky.post.parseEmbed(embed) const e = bsky.post.parseEmbed(embed)
@@ -27,13 +34,17 @@ export function Embed({
if (e.type === 'images') { if (e.type === 'images') {
return ( return (
<Outer style={style}> <Outer style={style}>
{e.view.images.map(image => ( {e.view.images.map(image =>
peekable ? (
<PeekableImageItem key={image.thumb} image={image} />
) : (
<ImageItem <ImageItem
key={image.thumb} key={image.thumb}
thumbnail={image.thumb} thumbnail={image.thumb}
alt={image.alt} alt={image.alt}
/> />
))} ),
)}
</Outer> </Outer>
) )
} else if (e.type === 'link') { } 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 (
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: image.fullsize,
thumbUri: image.thumb,
aspectRatio: aspect && aspect > 0 ? aspect : 1,
}}
borderRadius={tokens.borderRadius.xs}>
<ImageItem thumbnail={image.thumb} alt={image.alt} />
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem
id="save"
onSelect={() => void saveImage(image.fullsize)}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem
id="share"
onSelect={() => void shareImageModal({uri: image.fullsize})}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
altContainer: { altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
+1
View File
@@ -0,0 +1 @@
export * from '@bsky.app/peek-menu'
@@ -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<ViewStyle>
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 (
<PeekMenu.Root style={style}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUri,
thumbUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}}
borderRadius={borderRadius}
onPreviewPress={onPreviewPress}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
+34 -2
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'
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {useLightboxControls} from '#/components/Lightbox/state' import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types' import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
@@ -24,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,
@@ -59,8 +66,23 @@ export function ImageEmbed({
if (images.length === 1) { if (images.length === 1) {
const image = images[0] 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 ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openFromSingle}>
<AutoSizedImage <AutoSizedImage
crop={ crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted rest.viewContext === PostEmbedViewContext.ThreadHighlighted
@@ -71,12 +93,22 @@ export function ImageEmbed({
: 'constrained' : 'constrained'
} }
image={image} image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])} onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
hideBadge={ hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
} }
/> />
</ImageContextMenu>
</View> </View>
) )
} }
+25 -5
View File
@@ -1,8 +1,20 @@
import {forwardRef} from 'react' import {
forwardRef,
type ForwardRefExoticComponent,
type RefAttributes,
} from 'react'
import Svg, {Path} from 'react-native-svg' import Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common' import {type Props, useCommonSVGProps} from '#/components/icons/common'
export type IconWithSvgMeta = ForwardRefExoticComponent<
Props & RefAttributes<Svg>
> & {
svgPaths: string[]
svgViewBox: string
svgStrokeWidth: number
}
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef( export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
function LogoImpl(props: Props, ref) { function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props) const {fill, size, style, ...rest} = useCommonSVGProps(props)
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
strokeLinecap?: 'butt' | 'round' | 'square' strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel' strokeLinejoin?: 'miter' | 'round' | 'bevel'
}) { }) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) { const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0 const hasStroke = strokeWidth > 0
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
/> />
</Svg> </Svg>
) )
}) }) as IconWithSvgMeta
Icon.svgPaths = [path]
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = strokeWidth
return Icon
} }
export function createMultiPathSVG({ export function createMultiPathSVG({
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
paths: string[] paths: string[]
viewBox?: string viewBox?: string
}) { }) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) { const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
return ( return (
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
))} ))}
</Svg> </Svg>
) )
}) }) as IconWithSvgMeta
Icon.svgPaths = paths
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = 0
return Icon
} }
+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"
+28 -18
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'
@@ -264,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}
@@ -288,24 +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={openLightboxAtIndex}
/> />
) )
}} }}
@@ -378,6 +379,7 @@ function GalleryImage({
onThumbDims, onThumbDims,
onPress, onPress,
onPressIn, onPressIn,
onPreviewPress,
}: { }: {
contentHeight: number contentHeight: number
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
@@ -391,6 +393,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,6 +419,12 @@ 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}`}>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspectRatio}
borderRadius={tokens.borderRadius.md}
onPreviewPress={onPreviewPress}>
<Pressable <Pressable
ref={itemRef} ref={itemRef}
tabIndex={index === 0 ? 0 : -1} tabIndex={index === 0 ? 0 : -1}
@@ -534,6 +543,7 @@ function GalleryImage({
} }
/> />
</Pressable> </Pressable>
</ImageContextMenu>
</Animated.View> </Animated.View>
) )
} }
+25 -7
View File
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types' 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, tokens, 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'
@@ -52,14 +53,30 @@ export function GalleryItem({
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> const aspect =
<Pressable image.aspectRatio && image.aspectRatio.height > 0
onPress={ ? image.aspectRatio.width / image.aspectRatio.height
onPress : 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()) ? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined : undefined
}
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openLightboxAtIndex}
style={a.flex_1}>
<Pressable
onPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{ android_ripple={{
@@ -92,6 +109,7 @@ export function GalleryItem({
/> />
<MediaInsetBorder style={insetBorderStyle} /> <MediaInsetBorder style={insetBorderStyle} />
</Pressable> </Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? ( {hasAlt && !hideBadges ? (
<View <View
accessible={false} accessible={false}
@@ -1093,6 +1093,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
<MediaPreview.Embed <MediaPreview.Embed
embed={post.embed} embed={post.embed}
style={styles.additionalPostImages} style={styles.additionalPostImages}
peekable
/> />
</> </>
) )