@bsky.app/peek-menu
v0.2.0
Published
Native iOS context menu with peek preview for images.
Readme
@bsky.app/peek-menu
Native iOS context menu with peek preview for images. Long-pressing a wrapped view shows a UIContextMenuInteraction with a full-size image preview and action menu. Android and web fall through to a passthrough View.
Installation
npm install @bsky.app/peek-menuNote: The iOS native side shares SDImageCache.shared with expo-image via SDWebImage ~> 5.21.0. Your app must have a compatible SDWebImage version installed (expo-image provides this).
Usage
Declarative, compound-component API. Root collects children tagged as Trigger and Menu, serializes the menu items, and renders a single native view.
import * as PeekMenu from '@bsky.app/peek-menu'
;<PeekMenu.Root>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUrl,
thumbUri: thumbUrl,
aspectRatio: 1.5,
}}
borderRadius={12}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
<PeekMenu.MenuItemIcon icon={SaveIcon} />
<PeekMenu.MenuItemText>Save image</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>Trigger, Menu, MenuItem, MenuItemIcon, and MenuItemText are sentinel components — they render nothing. Root walks the children tree at render time, extracts their props, and passes serialized data to the native view.
Props
Trigger
preview?: PreviewContent— what to show during peek. Onlyimageis implemented;videoandexternalCardare typed but will fall back to no preview.borderRadius?: number— corner radius of the thumbnail. Used in the native targeted-preview so the lift animation matches the clipping.onPreviewPress?: () => void— fires when the user taps the expanded preview to commit into it (i.e. open the lightbox).
MenuItem
id: string— stable identifier, sent back in theonItemPressevent.onSelect: () => void— called when this item is tapped.destructive?: boolean— renders the item in red.disabled?: boolean— greys the item out.
MenuItemIcon
icon: SvgIconMeta— any object withsvgPaths: string[],svgViewBox: string, andsvgStrokeWidth: number. Rendered natively viaIconRenderer.
Icon compatibility
The SvgIconMeta type is intentionally minimal:
type SvgIconMeta = {
svgPaths: string[]
svgViewBox: string
svgStrokeWidth: number
}Any icon component that carries these three properties (e.g. those created with createSinglePathSVG) can be passed directly — TypeScript's structural typing handles the rest.
Preview types
type PreviewContent =
| {type: 'image'; uri: string; thumbUri?: string; aspectRatio: number}
| {type: 'video'; uri: string; poster?: string; aspectRatio: number} // not yet implemented
| {type: 'externalCard'; thumbUri?: string; title: string; url: string} // not yet implementedPlatform behavior
| Platform | Behavior |
| -------- | ------------------------------------------------------------------- |
| iOS | Native UIContextMenuInteraction with peek preview and action menu |
| Android | Passthrough View wrapper (no context menu) |
| Web | Passthrough View wrapper (no context menu) |
iOS native architecture
Image loading
ImagePreviewController shares SDWebImage's SDImageCache.shared and SDWebImageManager.shared with expo-image, so cache hits are free:
- Memory cache hit on fullsize? Paint it immediately — zero latency.
- Memory or disk cache hit on thumbnail? Paint the thumb as a placeholder, then async-load the fullsize. Thumbs are small enough that a sync disk read is acceptable.
- No cache hit? Show nothing initially, async-load the fullsize.
For best results, prefetch the fullsize image into memory on press-in so it's ready by the time the peek animation starts:
<Pressable
onPressIn={() => {
InteractionManager.runAfterInteractions(() => {
Image.prefetch(fullsizeUri, 'memory')
})
}}>
{children}
</Pressable>This is the pattern used by social-app — Image.prefetch(url, 'memory') from expo-image writes into SDImageCache.shared, which the native preview controller reads from synchronously.
Known limitations
- Carousel clipping: When an image is inside a horizontal
FlatList, theUIScrollView'sclipsToBoundsclips the peek lift animation and its shadow. - Android/web: No native implementation yet. Falls through to a plain
Viewwrapper. - Video and external card previews: Typed in
PreviewContentbut not implemented on the native side.
