# @bsky.app/peek-menu

Native iOS context menu with peek preview. Long-pressing a wrapped view shows a `UIContextMenuInteraction` with a peek preview (a full-size image, or an external link card that can morph into an in-app browser) and an action menu. Android and web fall through to a passthrough `View`.

## Installation

```
npm install @bsky.app/peek-menu
```

**Note:** 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.

```tsx
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. `image` and `link` are implemented; `video` is typed but will fall back to no preview. The `link` variant carries its own `useInAppBrowser` / `browserToolbarColor` / `browserControlsColor` options (see [Preview types](#preview-types) and [Link previews](#link-previews)).
- `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). Not called for a `link` preview when its `useInAppBrowser` is `true` — the native browser morph handles that tap.

**`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: SvgIconMeta` — any object with `svgPaths: string[]`, `svgViewBox: string`, and `svgStrokeWidth: number`. Rendered natively via `IconRenderer`.

### Icon compatibility

The `SvgIconMeta` type is intentionally minimal:

```ts
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

```ts
type PreviewContent =
  | {type: 'image'; uri: string; thumbUri?: string; aspectRatio: number}
  | {type: 'video'; uri: string; poster?: string; aspectRatio: number} // not yet implemented
  | {
      type: 'link'
      url: string
      useInAppBrowser?: boolean
      browserToolbarColor?: string // hex, e.g. '#ffffff'
      browserControlsColor?: string // hex, e.g. '#0085ff'
    }
```

A `link` peek always previews the **live page** in an `SFSafariViewController` — the `url` is loaded natively, along with the optional browser tints (matching expo-web-browser's `toolbarColor` / `controlsColor`). `useInAppBrowser` only changes what tapping the peek does (see below).

## Platform 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:

1. **Memory cache hit on fullsize?** Paint it immediately — zero latency.
2. **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.
3. **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:

```tsx
<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.

### Link previews

The peek is **always** a live `SFSafariViewController` already loading the page. The `useInAppBrowser` option only changes what tapping the peek does:

- **`useInAppBrowser: true`** — the tap commits via `UIContextMenuInteractionCommitStyle.pop`, then presents that same Safari instance in the commit completion so it morphs seamlessly to full-screen — Apple's documented [peek-and-pop pattern](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) for links. (`.pop` plays the animation but doesn't present the VC itself, so we present it, unanimated, once the morph has filled the screen.) Because the morph happens entirely natively, `onPreviewPress` is **not** fired in this mode.
- **`useInAppBrowser: false` (or omitted)** — the tap just dismisses the peek and fires `onPreviewPress`, leaving navigation to the host app (e.g. `Linking.openURL`, or a consent dialog).

This split lets the host honor a user "open links in app" preference: pass it straight through as `useInAppBrowser`, and let `onPreviewPress` cover the off/unset cases. Pre-proxy the `url` on the JS side if your app rewrites outbound links — the native side opens it verbatim.

> **Note:** because the peek loads the page on long-press regardless of the tap behavior, it does fetch the URL even when `useInAppBrowser` is off. That's the same as a Safari/Messages link peek.

### Known limitations

- **Carousel clipping**: When an image is inside a horizontal `FlatList`, the `UIScrollView`'s `clipsToBounds` clips the peek lift animation and its shadow.
- **Android/web**: No native implementation yet. Falls through to a plain `View` wrapper.
- **Video previews**: Typed in `PreviewContent` but not implemented on the native side.
