@bsky.app/peek-menu 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @bsky.app/peek-menu
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#49](https://github.com/bluesky-social/toolbox/pull/49) [`a824caa`](https://github.com/bluesky-social/toolbox/commit/a824caa72e03ef7c0bd5b1e40b848a5ac06e8b5e) Thanks [@mozzius](https://github.com/mozzius)! - Add `link` support
8
+
9
+ ## 0.2.4
10
+
11
+ ### Patch Changes
12
+
13
+ - [`585597d`](https://github.com/bluesky-social/toolbox/commit/585597d1d9c14bee8b074e6fd7fed5ed1f277294) Thanks [@mozzius](https://github.com/mozzius)! - Enable provenance via env
14
+
3
15
  ## 0.2.3
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @bsky.app/peek-menu
2
2
 
3
- 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`.
3
+ 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`.
4
4
 
5
5
  ## Installation
6
6
 
@@ -42,9 +42,9 @@ import * as PeekMenu from '@bsky.app/peek-menu'
42
42
 
43
43
  **`Trigger`**
44
44
 
45
- - `preview?: PreviewContent` — what to show during peek. Only `image` is implemented; `video` and `externalCard` are typed but will fall back to no preview.
45
+ - `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)).
46
46
  - `borderRadius?: number` — corner radius of the thumbnail. Used in the native targeted-preview so the lift animation matches the clipping.
47
- - `onPreviewPress?: () => void` — fires when the user taps the expanded preview to commit into it (i.e. open the lightbox).
47
+ - `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.
48
48
 
49
49
  **`MenuItem`**
50
50
 
@@ -77,9 +77,17 @@ Any icon component that carries these three properties (e.g. those created with
77
77
  type PreviewContent =
78
78
  | {type: 'image'; uri: string; thumbUri?: string; aspectRatio: number}
79
79
  | {type: 'video'; uri: string; poster?: string; aspectRatio: number} // not yet implemented
80
- | {type: 'externalCard'; thumbUri?: string; title: string; url: string} // not yet implemented
80
+ | {
81
+ type: 'link'
82
+ url: string
83
+ useInAppBrowser?: boolean
84
+ browserToolbarColor?: string // hex, e.g. '#ffffff'
85
+ browserControlsColor?: string // hex, e.g. '#0085ff'
86
+ }
81
87
  ```
82
88
 
89
+ 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).
90
+
83
91
  ## Platform behavior
84
92
 
85
93
  | Platform | Behavior |
@@ -113,8 +121,19 @@ For best results, prefetch the fullsize image into memory on press-in so it's re
113
121
 
114
122
  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.
115
123
 
124
+ ### Link previews
125
+
126
+ The peek is **always** a live `SFSafariViewController` already loading the page. The `useInAppBrowser` option only changes what tapping the peek does:
127
+
128
+ - **`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.
129
+ - **`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).
130
+
131
+ 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.
132
+
133
+ > **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.
134
+
116
135
  ### Known limitations
117
136
 
118
137
  - **Carousel clipping**: When an image is inside a horizontal `FlatList`, the `UIScrollView`'s `clipsToBounds` clips the peek lift animation and its shadow.
119
138
  - **Android/web**: No native implementation yet. Falls through to a plain `View` wrapper.
120
- - **Video and external card previews**: Typed in `PreviewContent` but not implemented on the native side.
139
+ - **Video previews**: Typed in `PreviewContent` but not implemented on the native side.
@@ -3,7 +3,9 @@ import { type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { type PreviewContent } from './types';
4
4
  export type TriggerProps = {
5
5
  preview?: PreviewContent;
6
- /** Fires when the user taps the expanded preview to "commit" into it. */
6
+ /** Fires when the user taps the expanded preview to "commit" into it.
7
+ * Not called for a `link` preview when `useInAppBrowser` is true — the
8
+ * native morph into the in-app browser handles that case. */
7
9
  onPreviewPress?: () => void;
8
10
  /** Border radius of the thumbnail being wrapped. Used natively to clip the
9
11
  * targeted-preview lift animation. */
@@ -1 +1 @@
1
- {"version":3,"file":"Trigger.d.ts","sourceRoot":"","sources":["../src/Trigger.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAA;AACpC,OAAO,EAAC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAC,MAAM,cAAc,CAAA;AAG3D,OAAO,EAAC,KAAK,cAAc,EAAC,MAAM,SAAS,CAAA;AAE3C,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;2CACuC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,QAAQ,EAAE,SAAS,CAAA;CACpB,CAAA;AAUD,eAAO,MAAM,OAAO,oDAA8B,CAAA"}
1
+ {"version":3,"file":"Trigger.d.ts","sourceRoot":"","sources":["../src/Trigger.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAA;AACpC,OAAO,EAAC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAC,MAAM,cAAc,CAAA;AAG3D,OAAO,EAAC,KAAK,cAAc,EAAC,MAAM,SAAS,CAAA;AAE3C,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB;;kEAE8D;IAC9D,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;2CACuC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,QAAQ,EAAE,SAAS,CAAA;CACpB,CAAA;AAUD,eAAO,MAAM,OAAO,oDAA8B,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"Trigger.js","sourceRoot":"","sources":["../src/Trigger.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAC,GAAG,EAAC,MAAM,YAAY,CAAA;AAc9B;;;GAGG;AACH,SAAS,WAAW,CAAC,CAAe;IAClC,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAC,WAAW,EAAE,SAAS,CAAC,CAAA","sourcesContent":["import {type ReactNode} from 'react'\nimport {type StyleProp, type ViewStyle} from 'react-native'\n\nimport {tag} from './registry'\nimport {type PreviewContent} from './types'\n\nexport type TriggerProps = {\n preview?: PreviewContent\n /** Fires when the user taps the expanded preview to \"commit\" into it. */\n onPreviewPress?: () => void\n /** Border radius of the thumbnail being wrapped. Used natively to clip the\n * targeted-preview lift animation. */\n borderRadius?: number\n style?: StyleProp<ViewStyle>\n children: ReactNode\n}\n\n/**\n * Sentinel: does not render. `Root` reads props + children off this element\n * and hosts `children` inside the native context-menu view.\n */\nfunction TriggerImpl(_: TriggerProps): null {\n return null\n}\n\nexport const Trigger = tag(TriggerImpl, 'trigger')\n"]}
1
+ {"version":3,"file":"Trigger.js","sourceRoot":"","sources":["../src/Trigger.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAC,GAAG,EAAC,MAAM,YAAY,CAAA;AAgB9B;;;GAGG;AACH,SAAS,WAAW,CAAC,CAAe;IAClC,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAC,WAAW,EAAE,SAAS,CAAC,CAAA","sourcesContent":["import {type ReactNode} from 'react'\nimport {type StyleProp, type ViewStyle} from 'react-native'\n\nimport {tag} from './registry'\nimport {type PreviewContent} from './types'\n\nexport type TriggerProps = {\n preview?: PreviewContent\n /** Fires when the user taps the expanded preview to \"commit\" into it.\n * Not called for a `link` preview when `useInAppBrowser` is true — the\n * native morph into the in-app browser handles that case. */\n onPreviewPress?: () => void\n /** Border radius of the thumbnail being wrapped. Used natively to clip the\n * targeted-preview lift animation. */\n borderRadius?: number\n style?: StyleProp<ViewStyle>\n children: ReactNode\n}\n\n/**\n * Sentinel: does not render. `Root` reads props + children off this element\n * and hosts `children` inside the native context-menu view.\n */\nfunction TriggerImpl(_: TriggerProps): null {\n return null\n}\n\nexport const Trigger = tag(TriggerImpl, 'trigger')\n"]}
package/build/types.d.ts CHANGED
@@ -15,8 +15,8 @@ export type SvgIconMeta = {
15
15
  * Content to show during the peek preview. Discriminated by `type`; the native
16
16
  * side dispatches on it to build the right `UIViewController`.
17
17
  *
18
- * Only `image` is implemented on iOS today. `video` and `externalCard` are the
19
- * planned follow-ups; leaving them in the type keeps the JS call-sites honest.
18
+ * `image` and `link` are implemented on iOS. `video` is the remaining
19
+ * follow-up; leaving it in the type keeps the JS call-sites honest.
20
20
  */
21
21
  export type PreviewContent = {
22
22
  type: 'image';
@@ -33,11 +33,19 @@ export type PreviewContent = {
33
33
  poster?: string;
34
34
  aspectRatio: number;
35
35
  } | {
36
- type: 'externalCard';
37
- thumbUri?: string;
38
- title: string;
39
- description?: string;
36
+ type: 'link';
40
37
  url: string;
38
+ /** Controls what tapping the peek does. When true, it morphs into the
39
+ * live in-app browser at full-screen. When false/omitted, the peek
40
+ * dismisses and `onPreviewPress` fires for the host to handle the tap.
41
+ * Either way the peek preview shows the live page. */
42
+ useInAppBrowser?: boolean;
43
+ /** Toolbar tint for the in-app browser. Matches expo-web-browser's
44
+ * `toolbarColor`. Hex string, e.g. `#ffffff`. */
45
+ browserToolbarColor?: string;
46
+ /** Controls tint for the in-app browser. Matches expo-web-browser's
47
+ * `controlsColor`. Hex string, e.g. `#0085ff`. */
48
+ browserControlsColor?: string;
41
49
  };
42
50
  export type MenuItemSpec = {
43
51
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAA;AACpC,OAAO,EAAC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAC,MAAM,cAAc,CAAA;AAE3D;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,cAAc,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GACtB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX;;iEAE6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IACE,IAAI,EAAE,cAAc,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAEL,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE;QACL,KAAK,EAAE,MAAM,EAAE,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,WAAW,EAAE,MAAM,CAAA;KACpB,CAAA;CACF,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,WAAW,CAAA;AAE5C,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE,YAAY,EAAE,CAAA;IACzB,2EAA2E;IAC3E,mBAAmB,EAAE,MAAM,CAAA;IAC3B,WAAW,EAAE,CAAC,CAAC,EAAE;QAAC,WAAW,EAAE;YAAC,EAAE,EAAE,MAAM,CAAA;SAAC,CAAA;KAAC,KAAK,IAAI,CAAA;IACrD,cAAc,EAAE,CAAC,CAAC,EAAE;QAAC,WAAW,EAAE,EAAE,CAAA;KAAC,KAAK,IAAI,CAAA;IAC9C,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAA;AACpC,OAAO,EAAC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAC,MAAM,cAAc,CAAA;AAE3D;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,cAAc,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GACtB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX;;iEAE6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IAIE,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX;;;2DAGuD;IACvD,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB;sDACkD;IAClD,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;uDACmD;IACnD,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B,CAAA;AAEL,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE;QACL,KAAK,EAAE,MAAM,EAAE,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,WAAW,EAAE,MAAM,CAAA;KACpB,CAAA;CACF,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,WAAW,CAAA;AAE5C,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE,YAAY,EAAE,CAAA;IACzB,2EAA2E;IAC3E,mBAAmB,EAAE,MAAM,CAAA;IAC3B,WAAW,EAAE,CAAC,CAAC,EAAE;QAAC,WAAW,EAAE;YAAC,EAAE,EAAE,MAAM,CAAA;SAAC,CAAA;KAAC,KAAK,IAAI,CAAA;IACrD,cAAc,EAAE,CAAC,CAAC,EAAE;QAAC,WAAW,EAAE,EAAE,CAAA;KAAC,KAAK,IAAI,CAAA;IAC9C,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import {type ReactNode} from 'react'\nimport {type StyleProp, type ViewStyle} from 'react-native'\n\n/**\n * The subset of SVG metadata needed by the native menu icon renderer.\n * Any icon component that carries these three properties can be passed\n * to `MenuItemIcon`. In practice this is satisfied by every icon\n * created with `createSinglePathSVG` / `createMultiPathSVG`.\n */\nexport type SvgIconMeta = {\n svgPaths: string[]\n svgViewBox: string\n svgStrokeWidth: number\n}\n\n/**\n * Content to show during the peek preview. Discriminated by `type`; the native\n * side dispatches on it to build the right `UIViewController`.\n *\n * Only `image` is implemented on iOS today. `video` and `externalCard` are the\n * planned follow-ups; leaving them in the type keeps the JS call-sites honest.\n */\nexport type PreviewContent =\n | {\n type: 'image'\n uri: string\n /** Thumb URL. When present, the native side paints it in as an instant\n * placeholder (reading from the shared SDWebImage cache) while the\n * fullsize loads — avoids the black flash on first peek. */\n thumbUri?: string\n /** Aspect ratio as width / height. */\n aspectRatio: number\n }\n | {\n type: 'video'\n uri: string\n poster?: string\n aspectRatio: number\n }\n | {\n type: 'externalCard'\n thumbUri?: string\n title: string\n description?: string\n url: string\n }\n\nexport type MenuItemSpec = {\n id: string\n label: string\n destructive?: boolean\n disabled?: boolean\n icon?: {\n paths: string[]\n viewBox: string\n strokeWidth: number\n }\n}\n\nexport type MenuItemIconSource = SvgIconMeta\n\nexport type NativeViewProps = {\n preview?: PreviewContent\n menuItems: MenuItemSpec[]\n /** Named distinctly from `borderRadius`, which RN owns as a style prop. */\n previewCornerRadius: number\n onItemPress: (e: {nativeEvent: {id: string}}) => void\n onPreviewPress: (e: {nativeEvent: {}}) => void\n style?: StyleProp<ViewStyle>\n children?: ReactNode\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import {type ReactNode} from 'react'\nimport {type StyleProp, type ViewStyle} from 'react-native'\n\n/**\n * The subset of SVG metadata needed by the native menu icon renderer.\n * Any icon component that carries these three properties can be passed\n * to `MenuItemIcon`. In practice this is satisfied by every icon\n * created with `createSinglePathSVG` / `createMultiPathSVG`.\n */\nexport type SvgIconMeta = {\n svgPaths: string[]\n svgViewBox: string\n svgStrokeWidth: number\n}\n\n/**\n * Content to show during the peek preview. Discriminated by `type`; the native\n * side dispatches on it to build the right `UIViewController`.\n *\n * `image` and `link` are implemented on iOS. `video` is the remaining\n * follow-up; leaving it in the type keeps the JS call-sites honest.\n */\nexport type PreviewContent =\n | {\n type: 'image'\n uri: string\n /** Thumb URL. When present, the native side paints it in as an instant\n * placeholder (reading from the shared SDWebImage cache) while the\n * fullsize loads — avoids the black flash on first peek. */\n thumbUri?: string\n /** Aspect ratio as width / height. */\n aspectRatio: number\n }\n | {\n type: 'video'\n uri: string\n poster?: string\n aspectRatio: number\n }\n | {\n // The peek is always a live in-app browser rendering `url`. No content is\n // sent to native beyond the `url` and browser options — the page itself\n // is what's previewed.\n type: 'link'\n url: string\n /** Controls what tapping the peek does. When true, it morphs into the\n * live in-app browser at full-screen. When false/omitted, the peek\n * dismisses and `onPreviewPress` fires for the host to handle the tap.\n * Either way the peek preview shows the live page. */\n useInAppBrowser?: boolean\n /** Toolbar tint for the in-app browser. Matches expo-web-browser's\n * `toolbarColor`. Hex string, e.g. `#ffffff`. */\n browserToolbarColor?: string\n /** Controls tint for the in-app browser. Matches expo-web-browser's\n * `controlsColor`. Hex string, e.g. `#0085ff`. */\n browserControlsColor?: string\n }\n\nexport type MenuItemSpec = {\n id: string\n label: string\n destructive?: boolean\n disabled?: boolean\n icon?: {\n paths: string[]\n viewBox: string\n strokeWidth: number\n }\n}\n\nexport type MenuItemIconSource = SvgIconMeta\n\nexport type NativeViewProps = {\n preview?: PreviewContent\n menuItems: MenuItemSpec[]\n /** Named distinctly from `borderRadius`, which RN owns as a style prop. */\n previewCornerRadius: number\n onItemPress: (e: {nativeEvent: {id: string}}) => void\n onPreviewPress: (e: {nativeEvent: {}}) => void\n style?: StyleProp<ViewStyle>\n children?: ReactNode\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import ExpoModulesCore
2
+ import SafariServices
2
3
  import UIKit
3
4
 
4
5
  /// Native view that hosts the children and attaches a
@@ -8,11 +9,26 @@ import UIKit
8
9
  /// - `previewCornerRadius`: used for the targeted preview's visible path so the
9
10
  /// lift animation matches the thumbnail's clipping. (Named distinctly from
10
11
  /// the RN-owned `borderRadius` style prop on UIView.)
11
- class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
12
+ /// A `link` preview with `useInAppBrowser` peeks a live SFSafariViewController
13
+ /// that morphs to full-screen on commit; `browserToolbarColor`/`browserControlsColor`
14
+ /// (read from the preview spec) tint it (see `previewProvider`).
15
+ class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate,
16
+ SFSafariViewControllerDelegate
17
+ {
12
18
  private var preview: [String: Any]?
13
19
  private var menuItems: [[String: Any]] = []
14
20
  private var previewCornerRadius: CGFloat = 0
15
21
 
22
+ // The Safari VC returned from `previewProvider` for a `link` peek. UIKit owns
23
+ // its lifetime (preview container → presented on commit → released on
24
+ // cancel), so this is weak. Built for any valid `link` so the live page
25
+ // always peeks; whether we morph into it on commit is `shouldMorphToBrowser`.
26
+ private weak var liveSafariVC: SFSafariViewController?
27
+ // True when the `link` peek should morph into the in-app browser on commit.
28
+ // When false, the same Safari preview shows, but the tap just dismisses and
29
+ // forwards to JS via `onPreviewPress` (host's default open behavior).
30
+ private var shouldMorphToBrowser = false
31
+
16
32
  private let onItemPress = EventDispatcher()
17
33
  private let onPreviewPress = EventDispatcher()
18
34
 
@@ -58,7 +74,18 @@ class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
58
74
  return UIContextMenuConfiguration(
59
75
  identifier: nil,
60
76
  previewProvider: { [weak self] in
61
- guard self != nil else { return nil }
77
+ guard let self = self else { return nil }
78
+ // A `link` always peeks the live page in an SFSafariViewController. On
79
+ // commit it either morphs into the browser or just dismisses + forwards
80
+ // to JS, per `shouldMorphToBrowser` (see `willPerformPreviewAction`).
81
+ if let safari = self.makeLiveSafariController(from: previewSpec) {
82
+ self.liveSafariVC = safari
83
+ self.shouldMorphToBrowser =
84
+ (previewSpec?["useInAppBrowser"] as? Bool) == true
85
+ return safari
86
+ }
87
+ // Otherwise: image controller, or nil → UIKit lifts the source view.
88
+ // The commit is forwarded to JS via `onPreviewPress`.
62
89
  return PreviewFactory.makeController(from: previewSpec)
63
90
  },
64
91
  actionProvider: { [weak self] _ in
@@ -70,6 +97,57 @@ class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
70
97
  )
71
98
  }
72
99
 
100
+ /// Builds the live Safari preview for a `link` with a web (http/https) URL.
101
+ /// Returns nil for every other case so the caller falls through to
102
+ /// `PreviewFactory`. Whether the commit morphs into this browser is decided
103
+ /// separately by `shouldMorphToBrowser`.
104
+ private func makeLiveSafariController(
105
+ from spec: [String: Any]?
106
+ ) -> SFSafariViewController? {
107
+ guard let spec = spec,
108
+ spec["type"] as? String == "link",
109
+ let urlString = spec["url"] as? String,
110
+ let url = URL(string: urlString),
111
+ let scheme = url.scheme?.lowercased(),
112
+ scheme == "http" || scheme == "https"
113
+ else { return nil }
114
+
115
+ let config = SFSafariViewController.Configuration()
116
+ let safari = SFSafariViewController(url: url, configuration: config)
117
+ safari.delegate = self
118
+ // SFSafariViewController's own view is transparent until the page paints,
119
+ // so the peek bubble flashes through to whatever is behind it. A dark-mode
120
+ // aware background fills that gap.
121
+ safari.view.backgroundColor = .systemBackground
122
+ // Tints must be set before presentation; ignored once presented.
123
+ if let toolbar = color(from: spec["browserToolbarColor"]) {
124
+ safari.preferredBarTintColor = toolbar
125
+ }
126
+ if let controls = color(from: spec["browserControlsColor"]) {
127
+ safari.preferredControlTintColor = controls
128
+ }
129
+ return safari
130
+ }
131
+
132
+ /// Parses a `#rgb`/`#rrggbb`/`#rrggbbaa` hex string into a UIColor. Colors
133
+ /// arrive as raw strings here (they're nested in the `preview` dict rather
134
+ /// than a top-level `Prop`, so ExpoModulesCore doesn't coerce them).
135
+ private func color(from value: Any?) -> UIColor? {
136
+ guard var hex = value as? String else { return nil }
137
+ if hex.hasPrefix("#") { hex.removeFirst() }
138
+ if hex.count == 3 { // expand shorthand #rgb → #rrggbb
139
+ hex = hex.map { "\($0)\($0)" }.joined()
140
+ }
141
+ guard hex.count == 6 || hex.count == 8,
142
+ let int = UInt64(hex, radix: 16) else { return nil }
143
+ let hasAlpha = hex.count == 8
144
+ let r = CGFloat((int >> (hasAlpha ? 24 : 16)) & 0xff) / 255
145
+ let g = CGFloat((int >> (hasAlpha ? 16 : 8)) & 0xff) / 255
146
+ let b = CGFloat((int >> (hasAlpha ? 8 : 0)) & 0xff) / 255
147
+ let a = hasAlpha ? CGFloat(int & 0xff) / 255 : 1
148
+ return UIColor(red: r, green: g, blue: b, alpha: a)
149
+ }
150
+
73
151
  func contextMenuInteraction(
74
152
  _ interaction: UIContextMenuInteraction,
75
153
  previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
@@ -89,9 +167,59 @@ class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
89
167
  willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
90
168
  animator: UIContextMenuInteractionCommitAnimating
91
169
  ) {
170
+ // A live Safari peek with the in-app browser enabled morphs into the
171
+ // browser: `.pop` plays the morph animation, but UIKit does NOT present the
172
+ // preview VC for us — it tears the preview container down at completion. So
173
+ // we present the same (now-detached) Safari instance in the completion. The
174
+ // morph has already filled the screen, so present without animation to snap
175
+ // it in seamlessly rather than slide twice.
176
+ if let safari = liveSafariVC, shouldMorphToBrowser {
177
+ animator.preferredCommitStyle = .pop
178
+ animator.addCompletion { [weak self] in
179
+ self?.topmostViewController()?.present(safari, animated: false)
180
+ }
181
+ return
182
+ }
183
+ // Otherwise (image preview, lifted source view, or a `link` peek with the
184
+ // in-app browser disabled): just dismiss and let JS handle the tap.
92
185
  self.onPreviewPress([:])
93
186
  }
94
187
 
188
+ /// Walks from the key window's root to the topmost presented controller —
189
+ /// the one we can safely present Safari from. Mirrors expo-web-browser.
190
+ private func topmostViewController() -> UIViewController? {
191
+ let scene = UIApplication.shared.connectedScenes
192
+ .compactMap { $0 as? UIWindowScene }
193
+ .first { $0.activationState == .foregroundActive }
194
+ ?? UIApplication.shared.connectedScenes
195
+ .compactMap { $0 as? UIWindowScene }.first
196
+ let keyWindow = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
197
+ var top = keyWindow?.rootViewController
198
+ while let presented = top?.presentedViewController {
199
+ top = presented
200
+ }
201
+ return top
202
+ }
203
+
204
+ func contextMenuInteraction(
205
+ _ interaction: UIContextMenuInteraction,
206
+ willEndFor configuration: UIContextMenuConfiguration,
207
+ animator: UIContextMenuInteractionAnimating?
208
+ ) {
209
+ // The menu is ending (dismissed or committed). Drop our weak reference; on
210
+ // commit the `addCompletion` closure retains the Safari instance until it's
211
+ // presented, after which UIKit owns it.
212
+ self.liveSafariVC = nil
213
+ self.shouldMorphToBrowser = false
214
+ }
215
+
216
+ // MARK: - SFSafariViewControllerDelegate
217
+
218
+ func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
219
+ // Safari dismisses itself; nothing to forward to JS.
220
+ self.liveSafariVC = nil
221
+ }
222
+
95
223
  // MARK: - Targeted preview
96
224
 
97
225
  /// The targeted preview uses the view itself as target with a rounded-corner
@@ -1,8 +1,10 @@
1
1
  import UIKit
2
2
 
3
3
  /// Decodes the `preview` prop shipped from JS and constructs the right
4
- /// `UIViewController` for the peek. Day-one only handles `image`. Add cases
5
- /// here for `video` and `externalCard` follow-ups.
4
+ /// `UIViewController` for the peek. Only `image` builds a custom controller (it
5
+ /// swaps the thumbnail for the cached fullsize). For `link`, returning nil lets
6
+ /// UIKit lift the RN-rendered view as-is; the live-Safari `link` morph is built
7
+ /// in the view itself, before this is reached. `video` is still a follow-up.
6
8
  enum PreviewFactory {
7
9
  static func makeController(from spec: [String: Any]?) -> UIViewController? {
8
10
  guard let spec = spec,
@@ -21,6 +23,7 @@ enum PreviewFactory {
21
23
  aspectRatio: aspect
22
24
  )
23
25
  default:
26
+ // nil → UIKit uses the source view itself as the peek preview.
24
27
  return nil
25
28
  }
26
29
  }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@bsky.app/peek-menu",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Native iOS context menu with peek preview for images.",
6
- "repository": "https://github.com/bluesky-social/toolbox",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/bluesky-social/toolbox"
9
+ },
7
10
  "author": "Bluesky Social PBC <hello@blueskyweb.xyz>",
8
11
  "homepage": "https://github.com/bluesky-social/toolbox/tree/main/packages/peek-menu",
9
12
  "main": "build/index.js",
package/src/Trigger.tsx CHANGED
@@ -6,7 +6,9 @@ import {type PreviewContent} from './types'
6
6
 
7
7
  export type TriggerProps = {
8
8
  preview?: PreviewContent
9
- /** Fires when the user taps the expanded preview to "commit" into it. */
9
+ /** Fires when the user taps the expanded preview to "commit" into it.
10
+ * Not called for a `link` preview when `useInAppBrowser` is true — the
11
+ * native morph into the in-app browser handles that case. */
10
12
  onPreviewPress?: () => void
11
13
  /** Border radius of the thumbnail being wrapped. Used natively to clip the
12
14
  * targeted-preview lift animation. */
package/src/types.ts CHANGED
@@ -17,8 +17,8 @@ export type SvgIconMeta = {
17
17
  * Content to show during the peek preview. Discriminated by `type`; the native
18
18
  * side dispatches on it to build the right `UIViewController`.
19
19
  *
20
- * Only `image` is implemented on iOS today. `video` and `externalCard` are the
21
- * planned follow-ups; leaving them in the type keeps the JS call-sites honest.
20
+ * `image` and `link` are implemented on iOS. `video` is the remaining
21
+ * follow-up; leaving it in the type keeps the JS call-sites honest.
22
22
  */
23
23
  export type PreviewContent =
24
24
  | {
@@ -38,11 +38,22 @@ export type PreviewContent =
38
38
  aspectRatio: number
39
39
  }
40
40
  | {
41
- type: 'externalCard'
42
- thumbUri?: string
43
- title: string
44
- description?: string
41
+ // The peek is always a live in-app browser rendering `url`. No content is
42
+ // sent to native beyond the `url` and browser options — the page itself
43
+ // is what's previewed.
44
+ type: 'link'
45
45
  url: string
46
+ /** Controls what tapping the peek does. When true, it morphs into the
47
+ * live in-app browser at full-screen. When false/omitted, the peek
48
+ * dismisses and `onPreviewPress` fires for the host to handle the tap.
49
+ * Either way the peek preview shows the live page. */
50
+ useInAppBrowser?: boolean
51
+ /** Toolbar tint for the in-app browser. Matches expo-web-browser's
52
+ * `toolbarColor`. Hex string, e.g. `#ffffff`. */
53
+ browserToolbarColor?: string
54
+ /** Controls tint for the in-app browser. Matches expo-web-browser's
55
+ * `controlsColor`. Hex string, e.g. `#0085ff`. */
56
+ browserControlsColor?: string
46
57
  }
47
58
 
48
59
  export type MenuItemSpec = {