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>
)
}