Add iOS peek long-press context menu for image embeds

Introduces expo-bluesky-context-menu, a compositional Root/Trigger/Menu/MenuItem
wrapper around UIContextMenuInteraction. The preview ViewController is sized to
the image's true aspect ratio so tall/panorama previews don't stretch mid-lift,
and menu icons are rasterized from the app's SVG icon set on the native side.

The preview prop is a discriminated union (image today; video and externalCard
reserved) so the same module can back those embeds in follow-ups.

https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT
This commit is contained in:
Claude
2026-04-19 06:58:26 +00:00
committed by Samuel Newman
parent ebed23657f
commit d4fd15dc20
24 changed files with 1157 additions and 21 deletions
@@ -0,0 +1,74 @@
import {type ReactNode} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
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 {IS_IOS} from '#/env'
import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu'
/**
* 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,
aspectRatio,
borderRadius,
onPreviewPress,
children,
}: {
fullsizeUri: string
/** width / height; defaults to 1 if missing. */
aspectRatio: number | undefined
borderRadius?: number
onPreviewPress?: () => void
children: ReactNode
}) {
const {_} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
if (!IS_IOS) {
return children
}
const handleSave = () => {
void saveImage(fullsizeUri)
}
const handleShare = () => {
void shareImageModal({uri: fullsizeUri})
}
return (
<ContextMenu.Root>
<ContextMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}}
borderRadius={borderRadius}
onPreviewPress={onPreviewPress}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Menu>
<ContextMenu.MenuItem id="save" onSelect={handleSave}>
<ContextMenu.MenuItemIcon icon={DownloadIcon} />
<ContextMenu.MenuItemText>
{_(msg`Save image`)}
</ContextMenu.MenuItemText>
</ContextMenu.MenuItem>
<ContextMenu.MenuItem id="share" onSelect={handleShare}>
<ContextMenu.MenuItemIcon icon={ShareIcon} />
<ContextMenu.MenuItemText>{_(msg`Share`)}</ContextMenu.MenuItemText>
</ContextMenu.MenuItem>
</ContextMenu.Menu>
</ContextMenu.Root>
)
}
+42 -16
View File
@@ -8,6 +8,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'
@@ -59,24 +60,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
return (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
<ImageContextMenu
fullsizeUri={image.fullsize}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={() =>
openLightbox({
images: items.map(item => ({
...item,
thumbRect: null,
thumbRef: null,
thumbDimensions: null,
thumbBorderRadius: tokens.borderRadius.md,
type: 'image',
})),
index: 0,
})
}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</ImageContextMenu>
</View>
)
}