@bsky.app/peek-menu 0.2.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.
Files changed (75) hide show
  1. package/.eslintrc.js +5 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +121 -0
  4. package/build/ExpoContextMenuNativeView.android.d.ts +6 -0
  5. package/build/ExpoContextMenuNativeView.android.d.ts.map +1 -0
  6. package/build/ExpoContextMenuNativeView.android.js +9 -0
  7. package/build/ExpoContextMenuNativeView.android.js.map +1 -0
  8. package/build/ExpoContextMenuNativeView.d.ts +5 -0
  9. package/build/ExpoContextMenuNativeView.d.ts.map +1 -0
  10. package/build/ExpoContextMenuNativeView.js +4 -0
  11. package/build/ExpoContextMenuNativeView.js.map +1 -0
  12. package/build/ExpoContextMenuNativeView.web.d.ts +7 -0
  13. package/build/ExpoContextMenuNativeView.web.d.ts.map +1 -0
  14. package/build/ExpoContextMenuNativeView.web.js +10 -0
  15. package/build/ExpoContextMenuNativeView.web.js.map +1 -0
  16. package/build/Menu.d.ts +6 -0
  17. package/build/Menu.d.ts.map +1 -0
  18. package/build/Menu.js +10 -0
  19. package/build/Menu.js.map +1 -0
  20. package/build/MenuItem.d.ts +11 -0
  21. package/build/MenuItem.d.ts.map +1 -0
  22. package/build/MenuItem.js +10 -0
  23. package/build/MenuItem.js.map +1 -0
  24. package/build/MenuItemIcon.d.ts +6 -0
  25. package/build/MenuItemIcon.d.ts.map +1 -0
  26. package/build/MenuItemIcon.js +11 -0
  27. package/build/MenuItemIcon.js.map +1 -0
  28. package/build/MenuItemText.d.ts +5 -0
  29. package/build/MenuItemText.d.ts.map +1 -0
  30. package/build/MenuItemText.js +11 -0
  31. package/build/MenuItemText.js.map +1 -0
  32. package/build/Root.d.ts +8 -0
  33. package/build/Root.d.ts.map +1 -0
  34. package/build/Root.js +81 -0
  35. package/build/Root.js.map +1 -0
  36. package/build/Trigger.d.ts +15 -0
  37. package/build/Trigger.d.ts.map +1 -0
  38. package/build/Trigger.js +10 -0
  39. package/build/Trigger.js.map +1 -0
  40. package/build/index.d.ts +8 -0
  41. package/build/index.d.ts.map +1 -0
  42. package/build/index.js +7 -0
  43. package/build/index.js.map +1 -0
  44. package/build/registry.d.ts +13 -0
  45. package/build/registry.d.ts.map +1 -0
  46. package/build/registry.js +18 -0
  47. package/build/registry.js.map +1 -0
  48. package/build/types.d.ts +70 -0
  49. package/build/types.d.ts.map +1 -0
  50. package/build/types.js +2 -0
  51. package/build/types.js.map +1 -0
  52. package/expo-module.config.json +6 -0
  53. package/ios/ExpoBlueskyPeekMenu.podspec +22 -0
  54. package/ios/ExpoBlueskyPeekMenuModule.swift +24 -0
  55. package/ios/ExpoBlueskyPeekMenuView.swift +111 -0
  56. package/ios/IconRenderer.swift +69 -0
  57. package/ios/ImagePreviewController.swift +133 -0
  58. package/ios/MenuBuilder.swift +51 -0
  59. package/ios/PreviewFactory.swift +27 -0
  60. package/ios/SVGPathParser.swift +320 -0
  61. package/package.json +38 -0
  62. package/src/ExpoContextMenuNativeView.android.tsx +10 -0
  63. package/src/ExpoContextMenuNativeView.tsx +10 -0
  64. package/src/ExpoContextMenuNativeView.web.tsx +11 -0
  65. package/src/Menu.tsx +17 -0
  66. package/src/MenuItem.tsx +22 -0
  67. package/src/MenuItemIcon.tsx +17 -0
  68. package/src/MenuItemText.tsx +16 -0
  69. package/src/Root.tsx +119 -0
  70. package/src/Trigger.tsx +26 -0
  71. package/src/index.ts +12 -0
  72. package/src/registry.ts +32 -0
  73. package/src/types.ts +71 -0
  74. package/tsconfig.json +9 -0
  75. package/vitest.config.ts +7 -0
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["ios"],
3
+ "ios": {
4
+ "modules": ["ExpoBlueskyPeekMenuModule"]
5
+ }
6
+ }
@@ -0,0 +1,22 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'ExpoBlueskyPeekMenu'
3
+ s.version = '1.0.0'
4
+ s.summary = 'Native iOS context menu (peek + long-press) for embeds'
5
+ s.description = 'Wraps UIContextMenuInteraction with a compositional JS API.'
6
+ s.author = ''
7
+ s.homepage = 'https://github.com/bluesky-social/toolbox'
8
+ s.platforms = { :ios => '13.4', :tvos => '13.4' }
9
+ s.source = { git: '' }
10
+ s.static_framework = true
11
+
12
+ s.dependency 'ExpoModulesCore'
13
+ # Must match the version pinned by expo-image so we share SDImageCache.shared.
14
+ s.dependency 'SDWebImage', '~> 5.21.0'
15
+
16
+ s.pod_target_xcconfig = {
17
+ 'DEFINES_MODULE' => 'YES',
18
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
19
+ }
20
+
21
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
22
+ end
@@ -0,0 +1,24 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoBlueskyPeekMenuModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoBlueskyPeekMenu")
6
+
7
+ View(ExpoBlueskyPeekMenuView.self) {
8
+ Events(["onItemPress", "onPreviewPress"])
9
+
10
+ Prop("preview") { (view: ExpoBlueskyPeekMenuView, value: [String: Any]?) in
11
+ view.setPreview(value)
12
+ }
13
+
14
+ Prop("menuItems") { (view: ExpoBlueskyPeekMenuView, value: [[String: Any]]) in
15
+ view.setMenuItems(value)
16
+ }
17
+
18
+ Prop("previewCornerRadius") {
19
+ (view: ExpoBlueskyPeekMenuView, value: Double) in
20
+ view.setPreviewCornerRadius(value)
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,111 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ /// Native view that hosts the children and attaches a
5
+ /// `UIContextMenuInteraction`. JS-shipped props drive behaviour:
6
+ /// - `preview`: discriminated union describing what to show during peek
7
+ /// - `menuItems`: array of menu item specs (see `MenuBuilder`)
8
+ /// - `previewCornerRadius`: used for the targeted preview's visible path so the
9
+ /// lift animation matches the thumbnail's clipping. (Named distinctly from
10
+ /// the RN-owned `borderRadius` style prop on UIView.)
11
+ class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
12
+ private var preview: [String: Any]?
13
+ private var menuItems: [[String: Any]] = []
14
+ private var previewCornerRadius: CGFloat = 0
15
+
16
+ private let onItemPress = EventDispatcher()
17
+ private let onPreviewPress = EventDispatcher()
18
+
19
+ required init(appContext: AppContext? = nil) {
20
+ super.init(appContext: appContext)
21
+ let interaction = UIContextMenuInteraction(delegate: self)
22
+ self.addInteraction(interaction)
23
+ }
24
+
25
+ // RN layout can leave bounds at fractional-pixel values. The targeted preview
26
+ // snapshots this view's bounds for its return animation, and subpixel mismatches
27
+ // cause a visible glitch when the preview shrinks back into the thumbnail.
28
+ // Snapping to whole-pixel values prevents that.
29
+ override var bounds: CGRect {
30
+ get {
31
+ let b = super.bounds
32
+ let s = self.window?.screen.scale ?? UIScreen.main.scale
33
+ return CGRect(
34
+ x: b.origin.x,
35
+ y: b.origin.y,
36
+ width: round(b.width * s) / s,
37
+ height: round(b.height * s) / s
38
+ )
39
+ }
40
+ set { super.bounds = newValue }
41
+ }
42
+
43
+ func setPreview(_ value: [String: Any]?) { self.preview = value }
44
+ func setMenuItems(_ value: [[String: Any]]) { self.menuItems = value }
45
+ func setPreviewCornerRadius(_ value: Double) {
46
+ self.previewCornerRadius = CGFloat(value)
47
+ }
48
+
49
+ // MARK: - UIContextMenuInteractionDelegate
50
+
51
+ func contextMenuInteraction(
52
+ _ interaction: UIContextMenuInteraction,
53
+ configurationForMenuAtLocation location: CGPoint
54
+ ) -> UIContextMenuConfiguration? {
55
+ let previewSpec = self.preview
56
+ let items = self.menuItems
57
+
58
+ return UIContextMenuConfiguration(
59
+ identifier: nil,
60
+ previewProvider: { [weak self] in
61
+ guard self != nil else { return nil }
62
+ return PreviewFactory.makeController(from: previewSpec)
63
+ },
64
+ actionProvider: { [weak self] _ in
65
+ guard let self = self else { return nil }
66
+ return MenuBuilder.build(items: items) { [weak self] id in
67
+ self?.onItemPress(["id": id])
68
+ }
69
+ }
70
+ )
71
+ }
72
+
73
+ func contextMenuInteraction(
74
+ _ interaction: UIContextMenuInteraction,
75
+ previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
76
+ ) -> UITargetedPreview? {
77
+ return makeTargetedPreview()
78
+ }
79
+
80
+ func contextMenuInteraction(
81
+ _ interaction: UIContextMenuInteraction,
82
+ previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
83
+ ) -> UITargetedPreview? {
84
+ return makeTargetedPreview()
85
+ }
86
+
87
+ func contextMenuInteraction(
88
+ _ interaction: UIContextMenuInteraction,
89
+ willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
90
+ animator: UIContextMenuInteractionCommitAnimating
91
+ ) {
92
+ self.onPreviewPress([:])
93
+ }
94
+
95
+ // MARK: - Targeted preview
96
+
97
+ /// The targeted preview uses the view itself as target with a rounded-corner
98
+ /// visible path matching the thumbnail's clipping, so the lift animation
99
+ /// respects the existing corner radius.
100
+ private func makeTargetedPreview() -> UITargetedPreview {
101
+ let parameters = UIPreviewParameters()
102
+ parameters.backgroundColor = .clear
103
+ if previewCornerRadius > 0 {
104
+ parameters.visiblePath = UIBezierPath(
105
+ roundedRect: self.bounds,
106
+ cornerRadius: previewCornerRadius
107
+ )
108
+ }
109
+ return UITargetedPreview(view: self, parameters: parameters)
110
+ }
111
+ }
@@ -0,0 +1,69 @@
1
+ import UIKit
2
+
3
+ /// Renders SVG path data (the `d` attribute) into a `UIImage`. Supports the
4
+ /// subset of SVG path commands used by the Bluesky icon set: M/m, L/l, H/h,
5
+ /// V/v, C/c, S/s, Q/q, T/t, A/a, Z/z. Results are cached by (path, size, tint).
6
+ enum IconRenderer {
7
+ private static let cache = NSCache<NSString, UIImage>()
8
+
9
+ struct Spec: Hashable {
10
+ let paths: [String]
11
+ let viewBox: String
12
+ let strokeWidth: CGFloat
13
+ let pointSize: CGFloat
14
+ }
15
+
16
+ static func image(for spec: Spec) -> UIImage? {
17
+ let key = cacheKey(spec) as NSString
18
+ if let cached = cache.object(forKey: key) { return cached }
19
+
20
+ guard let image = render(spec) else { return nil }
21
+ cache.setObject(image, forKey: key)
22
+ return image
23
+ }
24
+
25
+ private static func cacheKey(_ spec: Spec) -> String {
26
+ return "\(spec.paths.joined(separator: "|"))|\(spec.viewBox)|\(spec.strokeWidth)|\(spec.pointSize)"
27
+ }
28
+
29
+ private static func render(_ spec: Spec) -> UIImage? {
30
+ let viewBox = parseViewBox(spec.viewBox) ?? CGRect(x: 0, y: 0, width: 24, height: 24)
31
+ let size = CGSize(width: spec.pointSize, height: spec.pointSize)
32
+ let scaleX = size.width / viewBox.width
33
+ let scaleY = size.height / viewBox.height
34
+ let scale = min(scaleX, scaleY)
35
+
36
+ let renderer = UIGraphicsImageRenderer(size: size)
37
+ let image = renderer.image { ctx in
38
+ let cg = ctx.cgContext
39
+ cg.translateBy(x: -viewBox.origin.x * scale, y: -viewBox.origin.y * scale)
40
+ cg.scaleBy(x: scale, y: scale)
41
+
42
+ // Render in opaque black; callers use `.alwaysTemplate` so iOS tints
43
+ // the icon with the menu's label color (and red for destructive items).
44
+ UIColor.black.setFill()
45
+ UIColor.black.setStroke()
46
+
47
+ for pathString in spec.paths {
48
+ let bezier = SVGPathParser.parse(pathString)
49
+ if spec.strokeWidth > 0 {
50
+ bezier.lineWidth = spec.strokeWidth
51
+ bezier.lineCapStyle = .round
52
+ bezier.lineJoinStyle = .round
53
+ bezier.stroke()
54
+ } else {
55
+ bezier.usesEvenOddFillRule = true
56
+ bezier.fill()
57
+ }
58
+ }
59
+ }
60
+ return image.withRenderingMode(.alwaysTemplate)
61
+ }
62
+
63
+ private static func parseViewBox(_ s: String) -> CGRect? {
64
+ let parts = s.split(whereSeparator: { $0 == " " || $0 == "," })
65
+ .compactMap { Double($0) }
66
+ guard parts.count == 4 else { return nil }
67
+ return CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3])
68
+ }
69
+ }
@@ -0,0 +1,133 @@
1
+ import SDWebImage
2
+ import UIKit
3
+
4
+ /// Preview view controller shown during a peek. Renders a single image sized
5
+ /// to the provided aspect ratio, capped to the screen bounds.
6
+ ///
7
+ /// The aspect ratio drives `preferredContentSize` so iOS animates directly to
8
+ /// the final size without the mid-flight stretch that happens when a mis-sized
9
+ /// snapshot is scaled up.
10
+ ///
11
+ /// Image loading cooperates with expo-image by sharing
12
+ /// `SDImageCache.shared` and `SDWebImageManager.shared`:
13
+ /// 1. Query the cache synchronously for the fullsize — if it's there
14
+ /// (e.g. prefetched on press-in), paint it immediately.
15
+ /// 2. Else, paint the thumbnail (almost always cached — it's what the feed
16
+ /// renders) as a placeholder.
17
+ /// 3. Asynchronously load the fullsize and swap it in when it arrives.
18
+ /// This eliminates the "black flash" on first peek of an unloaded image.
19
+ final class ImagePreviewController: UIViewController {
20
+ private let imageURL: URL?
21
+ private let thumbURL: URL?
22
+ private let aspectRatio: CGFloat
23
+
24
+ private let imageView = UIImageView()
25
+
26
+ init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) {
27
+ self.imageURL = imageURL
28
+ self.thumbURL = thumbURL
29
+ self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1
30
+ super.init(nibName: nil, bundle: nil)
31
+ self.preferredContentSize = Self.sizeForAspect(self.aspectRatio)
32
+ }
33
+
34
+ required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
35
+
36
+ override func loadView() {
37
+ let root = UIView()
38
+ root.backgroundColor = .black
39
+ root.clipsToBounds = true
40
+
41
+ // Use autoresizing mask rather than AutoLayout so the imageView's frame
42
+ // interpolates cleanly during the dismiss animation — AutoLayout-driven
43
+ // relayout during a CALayer animation can cause a visible snap.
44
+ imageView.frame = root.bounds
45
+ imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
46
+ imageView.contentMode = .scaleAspectFit
47
+ imageView.backgroundColor = .black
48
+ root.addSubview(imageView)
49
+
50
+ self.view = root
51
+ primeImage()
52
+ }
53
+
54
+ // MARK: - Image loading
55
+
56
+ private func primeImage() {
57
+ // 1. Fullsize cache hit? Paint it immediately.
58
+ if let url = imageURL, let cached = cachedImage(for: url) {
59
+ log("fullsize memory hit")
60
+ imageView.image = cached
61
+ return
62
+ }
63
+ log("fullsize memory miss")
64
+ // 2. Thumb placeholder — check memory first, fall back to disk.
65
+ // Thumbs are small so the sync disk read is acceptable here.
66
+ if let thumb = thumbURL {
67
+ let (image, source) = cachedImageWithDisk(for: thumb)
68
+ if let image = image {
69
+ log("thumb hit (\(source))")
70
+ imageView.image = image
71
+ } else {
72
+ log("thumb miss")
73
+ }
74
+ }
75
+ // 3. Kick off the async fullsize load.
76
+ guard let url = imageURL else { return }
77
+ SDWebImageManager.shared.loadImage(
78
+ with: url,
79
+ options: [.retryFailed],
80
+ progress: nil
81
+ ) { [weak self] image, _, _, cacheType, _, _ in
82
+ self?.log("async fullsize loaded, cacheType=\(cacheType.rawValue)")
83
+ guard let self = self, let image = image else { return }
84
+ DispatchQueue.main.async {
85
+ self.imageView.image = image
86
+ }
87
+ }
88
+ }
89
+
90
+ #if DEBUG
91
+ private func log(_ msg: String) {
92
+ print("[PeekMenu] \(msg)")
93
+ }
94
+ #else
95
+ @inline(__always) private func log(_ msg: String) {}
96
+ #endif
97
+
98
+ /// Memory-only cache lookup.
99
+ private func cachedImage(for url: URL) -> UIImage? {
100
+ let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString
101
+ return SDImageCache.shared.imageFromMemoryCache(forKey: key)
102
+ }
103
+
104
+ /// Memory + disk cache lookup. Only used for thumbnails where the file is
105
+ /// small enough that a synchronous disk read won't block the peek animation.
106
+ /// Returns the image and which cache layer it came from.
107
+ private func cachedImageWithDisk(for url: URL) -> (UIImage?, String) {
108
+ let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString
109
+ if let image = SDImageCache.shared.imageFromMemoryCache(forKey: key) {
110
+ return (image, "memory")
111
+ }
112
+ if let image = SDImageCache.shared.imageFromCache(forKey: key) {
113
+ return (image, "disk")
114
+ }
115
+ return (nil, "")
116
+ }
117
+
118
+ /// Caps the preview to a comfortable size within the current key window.
119
+ private static func sizeForAspect(_ aspect: CGFloat) -> CGSize {
120
+ let screenBounds = UIApplication.shared.connectedScenes
121
+ .compactMap { $0 as? UIWindowScene }
122
+ .first?.screen.bounds ?? UIScreen.main.bounds
123
+ let maxW = screenBounds.width - 32
124
+ let maxH = screenBounds.height * 0.7
125
+ var w = maxW
126
+ var h = w / aspect
127
+ if h > maxH {
128
+ h = maxH
129
+ w = h * aspect
130
+ }
131
+ return CGSize(width: w, height: h)
132
+ }
133
+ }
@@ -0,0 +1,51 @@
1
+ import UIKit
2
+
3
+ /// Builds a `UIMenu` from the JS-shipped item specs. Each item may carry an
4
+ /// icon spec (SVG path data) which is rasterized via `IconRenderer`.
5
+ enum MenuBuilder {
6
+ /// Expected item shape from JS:
7
+ /// {
8
+ /// id: String,
9
+ /// label: String,
10
+ /// destructive?: Bool,
11
+ /// disabled?: Bool,
12
+ /// icon?: {
13
+ /// paths: [String],
14
+ /// viewBox: String,
15
+ /// strokeWidth: Double
16
+ /// }
17
+ /// }
18
+ static func build(items: [[String: Any]], onSelect: @escaping (String) -> Void) -> UIMenu {
19
+ let actions: [UIMenuElement] = items.compactMap { spec in
20
+ guard let id = spec["id"] as? String,
21
+ let label = spec["label"] as? String else { return nil }
22
+
23
+ let destructive = (spec["destructive"] as? Bool) ?? false
24
+ let disabled = (spec["disabled"] as? Bool) ?? false
25
+ let image = icon(from: spec["icon"] as? [String: Any])
26
+
27
+ var attributes: UIMenuElement.Attributes = []
28
+ if destructive { attributes.insert(.destructive) }
29
+ if disabled { attributes.insert(.disabled) }
30
+
31
+ return UIAction(title: label, image: image, attributes: attributes) { _ in
32
+ onSelect(id)
33
+ }
34
+ }
35
+ return UIMenu(title: "", children: actions)
36
+ }
37
+
38
+ private static func icon(from spec: [String: Any]?) -> UIImage? {
39
+ guard let spec = spec,
40
+ let paths = spec["paths"] as? [String], !paths.isEmpty else { return nil }
41
+ let viewBox = (spec["viewBox"] as? String) ?? "0 0 24 24"
42
+ let strokeWidth = CGFloat((spec["strokeWidth"] as? Double) ?? 0)
43
+ let renderSpec = IconRenderer.Spec(
44
+ paths: paths,
45
+ viewBox: viewBox,
46
+ strokeWidth: strokeWidth,
47
+ pointSize: 24
48
+ )
49
+ return IconRenderer.image(for: renderSpec)
50
+ }
51
+ }
@@ -0,0 +1,27 @@
1
+ import UIKit
2
+
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.
6
+ enum PreviewFactory {
7
+ static func makeController(from spec: [String: Any]?) -> UIViewController? {
8
+ guard let spec = spec,
9
+ let type = spec["type"] as? String else { return nil }
10
+
11
+ switch type {
12
+ case "image":
13
+ let uri = spec["uri"] as? String
14
+ let thumbUri = spec["thumbUri"] as? String
15
+ let url = uri.flatMap(URL.init(string:))
16
+ let thumbURL = thumbUri.flatMap(URL.init(string:))
17
+ let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1)
18
+ return ImagePreviewController(
19
+ imageURL: url,
20
+ thumbURL: thumbURL,
21
+ aspectRatio: aspect
22
+ )
23
+ default:
24
+ return nil
25
+ }
26
+ }
27
+ }