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,11 @@
import {View} from 'react-native'
import {type NativeViewProps} from './types'
/**
* Android fallback: passthrough for now. Follow-up: wire the existing
* `#/components/Menu` on long-press.
*/
export default function NativeView({children, style}: NativeViewProps) {
return <View style={style}>{children}</View>
}
@@ -0,0 +1,9 @@
import {type ComponentType} from 'react'
import {requireNativeViewManager} from 'expo-modules-core'
import {type NativeViewProps} from './types'
const NativeView: ComponentType<NativeViewProps> =
requireNativeViewManager('ExpoBlueskyContextMenu')
export default NativeView
@@ -0,0 +1,11 @@
import {View} from 'react-native'
import {type NativeViewProps} from './types'
/**
* Web fallback: passthrough. Long-press is a no-op; tap handling is delegated
* to children.
*/
export default function NativeView({children, style}: NativeViewProps) {
return <View style={style}>{children}</View>
}
@@ -0,0 +1,17 @@
import {type ReactNode} from 'react'
import {tag} from './registry'
export type MenuProps = {
children: ReactNode
}
/**
* Sentinel: does not render. `Root` reads this element's children to collect
* menu items.
*/
function MenuImpl(_: MenuProps): null {
return null
}
export const Menu = tag(MenuImpl, 'menu')
@@ -0,0 +1,22 @@
import {type ReactNode} from 'react'
import {tag} from './registry'
export type MenuItemProps = {
id: string
destructive?: boolean
disabled?: boolean
onSelect: () => void
/** Children must include a `MenuItemIcon` and a `MenuItemText`. */
children: ReactNode
}
/**
* Sentinel: does not render. `Root` walks the children tree to extract icon +
* label, then ships a plain menu item spec to native.
*/
function MenuItemImpl(_: MenuItemProps): null {
return null
}
export const MenuItem = tag(MenuItemImpl, 'item')
@@ -0,0 +1,17 @@
import {tag} from './registry'
import {type MenuItemIconSource} from './types'
export type MenuItemIconProps = {
icon: MenuItemIconSource
}
/**
* Sentinel: does not render any React output. `Root` introspects this element
* during its collection pass to pull the SVG path data off the icon component,
* then ships the data to native.
*/
function MenuItemIconImpl(_: MenuItemIconProps): null {
return null
}
export const MenuItemIcon = tag(MenuItemIconImpl, 'item-icon')
@@ -0,0 +1,16 @@
import {tag} from './registry'
export type MenuItemTextProps = {
children: string
}
/**
* Sentinel: does not render. `Root` reads `children` as the menu item label.
* Keeping this a sentinel (vs. a real Text) mirrors how `Menu.ItemText` is
* used elsewhere while letting iOS draw the menu chrome natively.
*/
function MenuItemTextImpl(_: MenuItemTextProps): null {
return null
}
export const MenuItemText = tag(MenuItemTextImpl, 'item-text')
@@ -0,0 +1,119 @@
import {
Children,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useMemo,
} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import NativeView from './ExpoContextMenuNativeView'
import {type MenuProps} from './Menu'
import {type MenuItemProps} from './MenuItem'
import {type MenuItemIconProps} from './MenuItemIcon'
import {type MenuItemTextProps} from './MenuItemText'
import {kindOf} from './registry'
import {type TriggerProps} from './Trigger'
import {type MenuItemSpec} from './types'
export type RootProps = {
children: ReactNode
style?: StyleProp<ViewStyle>
}
export function Root({children, style}: RootProps) {
const {trigger, menu} = collectTriggerAndMenu(children)
const {menuItems, selectById} = useMemo(() => {
const items: MenuItemSpec[] = []
const map: Record<string, () => void> = {}
if (menu) {
Children.forEach(menu.props.children, child => {
if (!isValidElement(child)) return
if (kindOf(child.type) !== 'item') return
const spec = specFromItem(child as ReactElement<MenuItemProps>)
if (!spec) return
items.push(spec.item)
map[spec.item.id] = spec.onSelect
})
}
return {menuItems: items, selectById: map}
}, [menu])
const handleItemPress = useCallback(
(e: {nativeEvent: {id: string}}) => {
selectById[e.nativeEvent.id]?.()
},
[selectById],
)
const onPreviewPress = trigger?.props.onPreviewPress
const handlePreviewPress = useCallback(() => {
onPreviewPress?.()
}, [onPreviewPress])
if (!trigger) {
return <>{children}</>
}
return (
<NativeView
preview={trigger.props.preview}
menuItems={menuItems}
borderRadius={trigger.props.borderRadius ?? 0}
onItemPress={handleItemPress}
onPreviewPress={handlePreviewPress}
style={[style, trigger.props.style]}>
{trigger.props.children}
</NativeView>
)
}
// -----------------------------------------------------------------------------
type Collected = {
trigger?: ReactElement<TriggerProps>
menu?: ReactElement<MenuProps>
}
function collectTriggerAndMenu(children: ReactNode): Collected {
const result: Collected = {}
Children.forEach(children, child => {
if (!isValidElement(child)) return
const kind = kindOf(child.type)
if (kind === 'trigger') result.trigger = child as ReactElement<TriggerProps>
else if (kind === 'menu') result.menu = child as ReactElement<MenuProps>
})
return result
}
function specFromItem(
element: ReactElement<MenuItemProps>,
): {item: MenuItemSpec; onSelect: () => void} | null {
const {id, destructive, disabled, onSelect, children} = element.props
let label = ''
let icon: MenuItemSpec['icon']
Children.forEach(children, child => {
if (!isValidElement(child)) return
const kind = kindOf(child.type)
if (kind === 'item-text') {
const text = (child as ReactElement<MenuItemTextProps>).props.children
if (typeof text === 'string') label = text
} else if (kind === 'item-icon') {
const iconSource = (child as ReactElement<MenuItemIconProps>).props.icon
if (iconSource?.svgPaths?.length) {
icon = {
paths: iconSource.svgPaths,
viewBox: iconSource.svgViewBox,
strokeWidth: iconSource.svgStrokeWidth,
}
}
}
})
if (!label) return null
return {
item: {id, label, destructive, disabled, icon},
onSelect,
}
}
@@ -0,0 +1,26 @@
import {type ReactNode} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {tag} from './registry'
import {type PreviewContent} from './types'
export type TriggerProps = {
preview?: PreviewContent
/** Fires when the user taps the expanded preview to "commit" into it. */
onPreviewPress?: () => void
/** Border radius of the thumbnail being wrapped. Used natively to clip the
* targeted-preview lift animation. */
borderRadius?: number
style?: StyleProp<ViewStyle>
children: ReactNode
}
/**
* Sentinel: does not render. `Root` reads props + children off this element
* and hosts `children` inside the native context-menu view.
*/
function TriggerImpl(_: TriggerProps): null {
return null
}
export const Trigger = tag(TriggerImpl, 'trigger')
@@ -0,0 +1,32 @@
/**
* Marker keys and type tags shared between `Root`, `Trigger`, `Menu`, and
* `MenuItem*`. `Root` walks its children looking for these tags so the
* composition API doesn't rely on string component names or display names.
*/
export const CONTEXT_MENU_KIND = '__ExpoBlueskyContextMenuKind__'
export type ContextMenuKind =
| 'trigger'
| 'menu'
| 'item'
| 'item-icon'
| 'item-text'
export type TaggedComponent<P> = React.FunctionComponent<P> & {
[CONTEXT_MENU_KIND]: ContextMenuKind
}
export function tag<P>(
component: React.FunctionComponent<P>,
kind: ContextMenuKind,
): TaggedComponent<P> {
;(component as TaggedComponent<P>)[CONTEXT_MENU_KIND] = kind
return component as TaggedComponent<P>
}
export function kindOf(type: unknown): ContextMenuKind | undefined {
if (type && typeof type === 'function') {
return (type as TaggedComponent<unknown>)[CONTEXT_MENU_KIND]
}
return undefined
}
@@ -0,0 +1,56 @@
import {type ReactNode} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {type IconWithSvgMeta} from '#/components/icons/TEMPLATE'
/**
* Content to show during the peek preview. Discriminated by `type`; the native
* side dispatches on it to build the right `UIViewController`.
*
* Only `image` is implemented on iOS today. `video` and `externalCard` are the
* planned follow-ups; leaving them in the type keeps the JS call-sites honest.
*/
export type PreviewContent =
| {
type: 'image'
uri: string
/** Aspect ratio as width / height. */
aspectRatio: number
}
| {
type: 'video'
uri: string
poster?: string
aspectRatio: number
}
| {
type: 'externalCard'
thumbUri?: string
title: string
description?: string
url: string
}
export type MenuItemSpec = {
id: string
label: string
destructive?: boolean
disabled?: boolean
icon?: {
paths: string[]
viewBox: string
strokeWidth: number
}
}
export type MenuItemIconSource = IconWithSvgMeta
export type NativeViewProps = {
preview?: PreviewContent
menuItems: MenuItemSpec[]
borderRadius: number
onItemPress: (e: {nativeEvent: {id: string}}) => void
onPreviewPress: (e: {nativeEvent: {}}) => void
style?: StyleProp<ViewStyle>
children?: ReactNode
}