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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user