@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 +12 -0
- package/README.md +24 -5
- package/build/Trigger.d.ts +3 -1
- package/build/Trigger.d.ts.map +1 -1
- package/build/Trigger.js.map +1 -1
- package/build/types.d.ts +14 -6
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/ExpoBlueskyPeekMenuView.swift +130 -2
- package/ios/PreviewFactory.swift +5 -2
- package/package.json +5 -2
- package/src/Trigger.tsx +3 -1
- package/src/types.ts +17 -6
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
|
|
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.
|
|
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
|
-
| {
|
|
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
|
|
139
|
+
- **Video previews**: Typed in `PreviewContent` but not implemented on the native side.
|
package/build/Trigger.d.ts
CHANGED
|
@@ -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. */
|
package/build/Trigger.d.ts.map
CHANGED
|
@@ -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
|
|
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"}
|
package/build/Trigger.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Trigger.js","sourceRoot":"","sources":["../src/Trigger.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAC,GAAG,EAAC,MAAM,YAAY,CAAA;
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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: '
|
|
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;
|
package/build/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/types.js.map
CHANGED
|
@@ -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 *
|
|
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
|
-
|
|
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
|
|
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
|
package/ios/PreviewFactory.swift
CHANGED
|
@@ -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.
|
|
5
|
-
///
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Native iOS context menu with peek preview for images.",
|
|
6
|
-
"repository":
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 = {
|