From fcdd466a9ca7d3a2bdd04c485c2b2c9db1137083 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 20 Apr 2026 16:24:34 +0300 Subject: [PATCH] Create README.md --- modules/expo-bluesky-context-menu/README.md | 119 ++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 modules/expo-bluesky-context-menu/README.md diff --git a/modules/expo-bluesky-context-menu/README.md b/modules/expo-bluesky-context-menu/README.md new file mode 100644 index 0000000000..4f79aefa8d --- /dev/null +++ b/modules/expo-bluesky-context-menu/README.md @@ -0,0 +1,119 @@ +# expo-bluesky-context-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`. + +The app re-exports this module through `#/components/PeekMenu`, which provides a noop on non-iOS platforms. Consumers should use `PeekMenu` rather than importing this module directly. + +## JS API + +Declarative, compound-component API. `Root` collects children tagged as `Trigger` and `Menu`, serializes the menu items, and renders a single native view. + +```tsx +import * as PeekMenu from '#/components/PeekMenu' + + + + {children} + + + + + Save image + + + +``` + +`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. Only `image` is implemented; `video` and `externalCard` are 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 the `onItemPress` event. +- `onSelect: () => void` — called when this item is tapped. +- `destructive?: boolean` — renders the item in red. +- `disabled?: boolean` — greys the item out. + +**`MenuItemIcon`** +- `icon: IconWithSvgMeta` — any component from `#/components/icons` that has `svgPaths`, `svgViewBox`, and `svgStrokeWidth` metadata. Rendered natively via `IconRenderer`. + +### Preview types + +```ts +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 implemented +``` + +## iOS native architecture + +### View hierarchy + +``` +ExpoBlueskyContextMenuView (ExpoView subclass) + └── hosts the RN children directly + └── attaches a UIContextMenuInteraction to itself +``` + +The view is both the interaction's delegate and the target for the `UITargetedPreview`, so iOS animates the lift/dismiss between the actual thumbnail and the preview controller. + +### Files + +| File | Purpose | +|---|---| +| `ExpoBlueskyContextMenuModule.swift` | Expo module definition. Registers the view, props (`preview`, `menuItems`, `previewCornerRadius`), and events (`onItemPress`, `onPreviewPress`). | +| `ExpoBlueskyContextMenuView.swift` | The native view. Hosts `UIContextMenuInteraction`, builds targeted previews, and dispatches events back to JS. | +| `PreviewFactory.swift` | Decodes the `preview` prop dict and constructs the right `UIViewController`. Currently only handles `image` → `ImagePreviewController`. | +| `ImagePreviewController.swift` | Preview VC for images. Sizes via `preferredContentSize` based on aspect ratio. Loads images from SDWebImage's shared cache (see below). | +| `MenuBuilder.swift` | Converts the JS menu item specs into a `UIMenu` with `UIAction`s. Supports icons, destructive styling, and disabled state. | +| `IconRenderer.swift` | Rasterizes SVG path data from the app's icon components into `UIImage`s for menu items. Results are cached by `NSCache`. | +| `SVGPathParser.swift` | Minimal SVG `d`-attribute parser. Handles M/L/H/V/C/S/Q/T/A/Z (the subset used by the Bluesky icon set). | + +### Image loading + +`ImagePreviewController` shares SDWebImage's `SDImageCache.shared` and `SDWebImageManager.shared` with expo-image, so cache hits are free: + +1. **Memory cache hit on fullsize?** Paint it immediately — zero latency. +2. **Memory cache hit on thumbnail?** Paint the thumb as a placeholder, then async-load the fullsize. +3. **No cache hit?** Show nothing initially, async-load the fullsize. + +Disk cache lookups are intentionally skipped in the synchronous path to avoid blocking the main thread during the peek animation. + +### Targeted preview & bounds snapping + +The view overrides `bounds` to snap widths/heights to exact pixel boundaries: + +```swift +override var bounds: CGRect { + get { + let b = super.bounds + let s = self.window?.screen.scale ?? UIScreen.main.scale + return CGRect( + x: b.origin.x, y: b.origin.y, + width: round(b.width * s) / s, + height: round(b.height * s) / s + ) + } + set { super.bounds = newValue } +} +``` + +React Native's Yoga layout engine operates in float32 and can produce bounds like `150.00001525878906`. iOS's context menu dismiss animation interpolates between the preview and the target bounds — a sub-pixel mismatch causes a visible frame-size glitch on the first animation frame. Snapping to device pixels eliminates this. + +### `onPreviewPress` timing + +`onPreviewPress` fires immediately in `willPerformPreviewActionForMenuWith`, not inside `animator.addCompletion`. This lets the JS side open the lightbox while iOS's commit animation is still running, so the two transitions overlap rather than running sequentially. + +## Known limitations + +- **Carousel clipping**: When an image is inside a horizontal `FlatList` (gallery carousel), the `UIScrollView`'s `clipsToBounds` clips the peek lift animation and its shadow. This is a UIKit constraint — the scroll view clips its contents during the snapshot phase, before iOS renders the lift in its own window. +- **Android/web**: No native implementation yet. The module falls through to a plain `View` wrapper. The `PeekMenu` re-export layer noops these platforms entirely. +- **Video and external card previews**: Typed in `PreviewContent` but not implemented on the native side. `PreviewFactory` returns `nil` for unknown types, which makes iOS show its default preview (a snapshot of the source view).