From fda38b1b982dc84ed7524bfb72e228e35b3f03af Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 21 Apr 2026 16:12:38 +0300 Subject: [PATCH] move to toolbox --- modules/expo-bluesky-context-menu/README.md | 119 ------- .../expo-module.config.json | 6 - modules/expo-bluesky-context-menu/index.ts | 7 - .../ios/ExpoBlueskyContextMenu.podspec | 22 -- .../ios/ExpoBlueskyContextMenuModule.swift | 24 -- .../ios/ExpoBlueskyContextMenuView.swift | 111 ------ .../ios/IconRenderer.swift | 69 ---- .../ios/ImagePreviewController.swift | 102 ------ .../ios/MenuBuilder.swift | 51 --- .../ios/PreviewFactory.swift | 27 -- .../ios/SVGPathParser.swift | 320 ------------------ .../src/ExpoContextMenuNativeView.android.tsx | 11 - .../src/ExpoContextMenuNativeView.tsx | 10 - .../src/ExpoContextMenuNativeView.web.tsx | 11 - .../expo-bluesky-context-menu/src/Menu.tsx | 17 - .../src/MenuItem.tsx | 22 -- .../src/MenuItemIcon.tsx | 17 - .../src/MenuItemText.tsx | 16 - .../expo-bluesky-context-menu/src/Root.tsx | 119 ------- .../expo-bluesky-context-menu/src/Trigger.tsx | 26 -- .../expo-bluesky-context-menu/src/registry.ts | 32 -- .../expo-bluesky-context-menu/src/types.ts | 61 ---- package.json | 1 + src/components/PeekMenu.tsx | 1 + src/components/PeekMenu/index.ios.tsx | 12 - src/components/PeekMenu/index.tsx | 54 --- 26 files changed, 2 insertions(+), 1266 deletions(-) delete mode 100644 modules/expo-bluesky-context-menu/README.md delete mode 100644 modules/expo-bluesky-context-menu/expo-module.config.json delete mode 100644 modules/expo-bluesky-context-menu/index.ts delete mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec delete mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/IconRenderer.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/MenuBuilder.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/PreviewFactory.swift delete mode 100644 modules/expo-bluesky-context-menu/ios/SVGPathParser.swift delete mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/Menu.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/MenuItem.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/MenuItemText.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/Root.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/Trigger.tsx delete mode 100644 modules/expo-bluesky-context-menu/src/registry.ts delete mode 100644 modules/expo-bluesky-context-menu/src/types.ts create mode 100644 src/components/PeekMenu.tsx delete mode 100644 src/components/PeekMenu/index.ios.tsx delete mode 100644 src/components/PeekMenu/index.tsx diff --git a/modules/expo-bluesky-context-menu/README.md b/modules/expo-bluesky-context-menu/README.md deleted file mode 100644 index 4f79aefa8d..0000000000 --- a/modules/expo-bluesky-context-menu/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# 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). diff --git a/modules/expo-bluesky-context-menu/expo-module.config.json b/modules/expo-bluesky-context-menu/expo-module.config.json deleted file mode 100644 index f2622af783..0000000000 --- a/modules/expo-bluesky-context-menu/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["ios"], - "ios": { - "modules": ["ExpoBlueskyContextMenuModule"] - } -} diff --git a/modules/expo-bluesky-context-menu/index.ts b/modules/expo-bluesky-context-menu/index.ts deleted file mode 100644 index 75ea3fd7b2..0000000000 --- a/modules/expo-bluesky-context-menu/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {Menu} from './src/Menu' -export {MenuItem} from './src/MenuItem' -export {MenuItemIcon} from './src/MenuItemIcon' -export {MenuItemText} from './src/MenuItemText' -export {Root} from './src/Root' -export {Trigger} from './src/Trigger' -export type {MenuItemSpec, PreviewContent} from './src/types' diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec deleted file mode 100644 index 5503528db2..0000000000 --- a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec +++ /dev/null @@ -1,22 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'ExpoBlueskyContextMenu' - s.version = '1.0.0' - s.summary = 'Native iOS context menu (peek + long-press) for embeds' - s.description = 'Wraps UIContextMenuInteraction with a compositional JS API.' - s.author = '' - s.homepage = 'https://github.com/bluesky-social/social-app' - s.platforms = { :ios => '13.4', :tvos => '13.4' } - s.source = { git: '' } - s.static_framework = true - - s.dependency 'ExpoModulesCore' - # Must match the version pinned by expo-image so we share SDImageCache.shared. - s.dependency 'SDWebImage', '~> 5.21.0' - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule' - } - - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" -end diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift deleted file mode 100644 index 655880b0b0..0000000000 --- a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift +++ /dev/null @@ -1,24 +0,0 @@ -import ExpoModulesCore - -public class ExpoBlueskyContextMenuModule: Module { - public func definition() -> ModuleDefinition { - Name("ExpoBlueskyContextMenu") - - View(ExpoBlueskyContextMenuView.self) { - Events(["onItemPress", "onPreviewPress"]) - - Prop("preview") { (view: ExpoBlueskyContextMenuView, value: [String: Any]?) in - view.setPreview(value) - } - - Prop("menuItems") { (view: ExpoBlueskyContextMenuView, value: [[String: Any]]) in - view.setMenuItems(value) - } - - Prop("previewCornerRadius") { - (view: ExpoBlueskyContextMenuView, value: Double) in - view.setPreviewCornerRadius(value) - } - } - } -} diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift deleted file mode 100644 index 19acc10ede..0000000000 --- a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift +++ /dev/null @@ -1,111 +0,0 @@ -import ExpoModulesCore -import UIKit - -/// Native view that hosts the children and attaches a -/// `UIContextMenuInteraction`. JS-shipped props drive behaviour: -/// - `preview`: discriminated union describing what to show during peek -/// - `menuItems`: array of menu item specs (see `MenuBuilder`) -/// - `previewCornerRadius`: used for the targeted preview's visible path so the -/// lift animation matches the thumbnail's clipping. (Named distinctly from -/// the RN-owned `borderRadius` style prop on UIView.) -class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate { - private var preview: [String: Any]? - private var menuItems: [[String: Any]] = [] - private var previewCornerRadius: CGFloat = 0 - - private let onItemPress = EventDispatcher() - private let onPreviewPress = EventDispatcher() - - required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - let interaction = UIContextMenuInteraction(delegate: self) - self.addInteraction(interaction) - } - - // RN layout can leave bounds at fractional-pixel values. The targeted preview - // snapshots this view's bounds for its return animation, and subpixel mismatches - // cause a visible glitch when the preview shrinks back into the thumbnail. - // Snapping to whole-pixel values prevents that. - 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 } - } - - func setPreview(_ value: [String: Any]?) { self.preview = value } - func setMenuItems(_ value: [[String: Any]]) { self.menuItems = value } - func setPreviewCornerRadius(_ value: Double) { - self.previewCornerRadius = CGFloat(value) - } - - // MARK: - UIContextMenuInteractionDelegate - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - let previewSpec = self.preview - let items = self.menuItems - - return UIContextMenuConfiguration( - identifier: nil, - previewProvider: { [weak self] in - guard self != nil else { return nil } - return PreviewFactory.makeController(from: previewSpec) - }, - actionProvider: { [weak self] _ in - guard let self = self else { return nil } - return MenuBuilder.build(items: items) { [weak self] id in - self?.onItemPress(["id": id]) - } - } - ) - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - return makeTargetedPreview() - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - return makeTargetedPreview() - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, - animator: UIContextMenuInteractionCommitAnimating - ) { - self.onPreviewPress([:]) - } - - // MARK: - Targeted preview - - /// The targeted preview uses the view itself as target with a rounded-corner - /// visible path matching the thumbnail's clipping, so the lift animation - /// respects the existing corner radius. - private func makeTargetedPreview() -> UITargetedPreview { - let parameters = UIPreviewParameters() - parameters.backgroundColor = .clear - if previewCornerRadius > 0 { - parameters.visiblePath = UIBezierPath( - roundedRect: self.bounds, - cornerRadius: previewCornerRadius - ) - } - return UITargetedPreview(view: self, parameters: parameters) - } -} diff --git a/modules/expo-bluesky-context-menu/ios/IconRenderer.swift b/modules/expo-bluesky-context-menu/ios/IconRenderer.swift deleted file mode 100644 index 8200aca1a0..0000000000 --- a/modules/expo-bluesky-context-menu/ios/IconRenderer.swift +++ /dev/null @@ -1,69 +0,0 @@ -import UIKit - -/// Renders SVG path data (the `d` attribute) into a `UIImage`. Supports the -/// subset of SVG path commands used by the Bluesky icon set: M/m, L/l, H/h, -/// V/v, C/c, S/s, Q/q, T/t, A/a, Z/z. Results are cached by (path, size, tint). -enum IconRenderer { - private static let cache = NSCache() - - struct Spec: Hashable { - let paths: [String] - let viewBox: String - let strokeWidth: CGFloat - let pointSize: CGFloat - } - - static func image(for spec: Spec) -> UIImage? { - let key = cacheKey(spec) as NSString - if let cached = cache.object(forKey: key) { return cached } - - guard let image = render(spec) else { return nil } - cache.setObject(image, forKey: key) - return image - } - - private static func cacheKey(_ spec: Spec) -> String { - return "\(spec.paths.joined(separator: "|"))|\(spec.viewBox)|\(spec.strokeWidth)|\(spec.pointSize)" - } - - private static func render(_ spec: Spec) -> UIImage? { - let viewBox = parseViewBox(spec.viewBox) ?? CGRect(x: 0, y: 0, width: 24, height: 24) - let size = CGSize(width: spec.pointSize, height: spec.pointSize) - let scaleX = size.width / viewBox.width - let scaleY = size.height / viewBox.height - let scale = min(scaleX, scaleY) - - let renderer = UIGraphicsImageRenderer(size: size) - let image = renderer.image { ctx in - let cg = ctx.cgContext - cg.translateBy(x: -viewBox.origin.x * scale, y: -viewBox.origin.y * scale) - cg.scaleBy(x: scale, y: scale) - - // Render in opaque black; callers use `.alwaysTemplate` so iOS tints - // the icon with the menu's label color (and red for destructive items). - UIColor.black.setFill() - UIColor.black.setStroke() - - for pathString in spec.paths { - let bezier = SVGPathParser.parse(pathString) - if spec.strokeWidth > 0 { - bezier.lineWidth = spec.strokeWidth - bezier.lineCapStyle = .round - bezier.lineJoinStyle = .round - bezier.stroke() - } else { - bezier.usesEvenOddFillRule = false - bezier.fill() - } - } - } - return image.withRenderingMode(.alwaysTemplate) - } - - private static func parseViewBox(_ s: String) -> CGRect? { - let parts = s.split(whereSeparator: { $0 == " " || $0 == "," }) - .compactMap { Double($0) } - guard parts.count == 4 else { return nil } - return CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3]) - } -} diff --git a/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift b/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift deleted file mode 100644 index 7800952c53..0000000000 --- a/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift +++ /dev/null @@ -1,102 +0,0 @@ -import SDWebImage -import UIKit - -/// Preview view controller shown during a peek. Renders a single image sized -/// to the provided aspect ratio, capped to the screen bounds. -/// -/// The aspect ratio drives `preferredContentSize` so iOS animates directly to -/// the final size without the mid-flight stretch that happens when a mis-sized -/// snapshot is scaled up. -/// -/// Image loading cooperates with expo-image by sharing -/// `SDImageCache.shared` and `SDWebImageManager.shared`: -/// 1. Query the cache synchronously for the fullsize — if it's there -/// (e.g. prefetched on press-in), paint it immediately. -/// 2. Else, paint the thumbnail (almost always cached — it's what the feed -/// renders) as a placeholder. -/// 3. Asynchronously load the fullsize and swap it in when it arrives. -/// This eliminates the "black flash" on first peek of an unloaded image. -final class ImagePreviewController: UIViewController { - private let imageURL: URL? - private let thumbURL: URL? - private let aspectRatio: CGFloat - - private let imageView = UIImageView() - - init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) { - self.imageURL = imageURL - self.thumbURL = thumbURL - self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1 - super.init(nibName: nil, bundle: nil) - self.preferredContentSize = Self.sizeForAspect(self.aspectRatio) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } - - override func loadView() { - let root = UIView() - root.backgroundColor = .black - root.clipsToBounds = true - - // Use autoresizing mask rather than AutoLayout so the imageView's frame - // interpolates cleanly during the dismiss animation — AutoLayout-driven - // relayout during a CALayer animation can cause a visible snap. - imageView.frame = root.bounds - imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - imageView.contentMode = .scaleAspectFit - imageView.backgroundColor = .black - root.addSubview(imageView) - - self.view = root - primeImage() - } - - // MARK: - Image loading - - private func primeImage() { - // 1. Fullsize cache hit? Paint it immediately. - if let url = imageURL, let cached = cachedImage(for: url) { - imageView.image = cached - return - } - // 2. Thumb placeholder (almost always cached by the feed). - if let thumb = thumbURL, let cached = cachedImage(for: thumb) { - imageView.image = cached - } - // 3. Kick off the async fullsize load. - guard let url = imageURL else { return } - SDWebImageManager.shared.loadImage( - with: url, - options: [.retryFailed], - progress: nil - ) { [weak self] image, _, _, _, _, _ in - guard let self = self, let image = image else { return } - DispatchQueue.main.async { - self.imageView.image = image - } - } - } - - /// Memory-only cache lookup. Disk reads are left to the async SDWebImage - /// load to avoid blocking the main thread during the peek animation. - private func cachedImage(for url: URL) -> UIImage? { - let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString - return SDImageCache.shared.imageFromMemoryCache(forKey: key) - } - - /// Caps the preview to a comfortable size within the current key window. - private static func sizeForAspect(_ aspect: CGFloat) -> CGSize { - let screenBounds = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first?.screen.bounds ?? UIScreen.main.bounds - let maxW = screenBounds.width - 32 - let maxH = screenBounds.height * 0.7 - var w = maxW - var h = w / aspect - if h > maxH { - h = maxH - w = h * aspect - } - return CGSize(width: w, height: h) - } -} diff --git a/modules/expo-bluesky-context-menu/ios/MenuBuilder.swift b/modules/expo-bluesky-context-menu/ios/MenuBuilder.swift deleted file mode 100644 index 3ab4b3923a..0000000000 --- a/modules/expo-bluesky-context-menu/ios/MenuBuilder.swift +++ /dev/null @@ -1,51 +0,0 @@ -import UIKit - -/// Builds a `UIMenu` from the JS-shipped item specs. Each item may carry an -/// icon spec (SVG path data) which is rasterized via `IconRenderer`. -enum MenuBuilder { - /// Expected item shape from JS: - /// { - /// id: String, - /// label: String, - /// destructive?: Bool, - /// disabled?: Bool, - /// icon?: { - /// paths: [String], - /// viewBox: String, - /// strokeWidth: Double - /// } - /// } - static func build(items: [[String: Any]], onSelect: @escaping (String) -> Void) -> UIMenu { - let actions: [UIMenuElement] = items.compactMap { spec in - guard let id = spec["id"] as? String, - let label = spec["label"] as? String else { return nil } - - let destructive = (spec["destructive"] as? Bool) ?? false - let disabled = (spec["disabled"] as? Bool) ?? false - let image = icon(from: spec["icon"] as? [String: Any]) - - var attributes: UIMenuElement.Attributes = [] - if destructive { attributes.insert(.destructive) } - if disabled { attributes.insert(.disabled) } - - return UIAction(title: label, image: image, attributes: attributes) { _ in - onSelect(id) - } - } - return UIMenu(title: "", children: actions) - } - - private static func icon(from spec: [String: Any]?) -> UIImage? { - guard let spec = spec, - let paths = spec["paths"] as? [String], !paths.isEmpty else { return nil } - let viewBox = (spec["viewBox"] as? String) ?? "0 0 24 24" - let strokeWidth = CGFloat((spec["strokeWidth"] as? Double) ?? 0) - let renderSpec = IconRenderer.Spec( - paths: paths, - viewBox: viewBox, - strokeWidth: strokeWidth, - pointSize: 24 - ) - return IconRenderer.image(for: renderSpec) - } -} diff --git a/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift b/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift deleted file mode 100644 index a0acf62887..0000000000 --- a/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift +++ /dev/null @@ -1,27 +0,0 @@ -import UIKit - -/// Decodes the `preview` prop shipped from JS and constructs the right -/// `UIViewController` for the peek. Day-one only handles `image`. Add cases -/// here for `video` and `externalCard` follow-ups. -enum PreviewFactory { - static func makeController(from spec: [String: Any]?) -> UIViewController? { - guard let spec = spec, - let type = spec["type"] as? String else { return nil } - - switch type { - case "image": - let uri = spec["uri"] as? String - let thumbUri = spec["thumbUri"] as? String - let url = uri.flatMap(URL.init(string:)) - let thumbURL = thumbUri.flatMap(URL.init(string:)) - let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1) - return ImagePreviewController( - imageURL: url, - thumbURL: thumbURL, - aspectRatio: aspect - ) - default: - return nil - } - } -} diff --git a/modules/expo-bluesky-context-menu/ios/SVGPathParser.swift b/modules/expo-bluesky-context-menu/ios/SVGPathParser.swift deleted file mode 100644 index 70dad07725..0000000000 --- a/modules/expo-bluesky-context-menu/ios/SVGPathParser.swift +++ /dev/null @@ -1,320 +0,0 @@ -import UIKit - -/// Minimal SVG path `d` parser. Handles the subset used by Bluesky's icon set: -/// M m L l H h V v C c S s Q q T t A a Z z. -/// Assumes well-formed input from the app's own compiled-in icon paths. -enum SVGPathParser { - static func parse(_ d: String) -> UIBezierPath { - let path = UIBezierPath() - var tokens = Tokenizer(d) - var currentPoint = CGPoint.zero - var subpathStart = CGPoint.zero - var lastControl: CGPoint? - var lastQuadControl: CGPoint? - var command: Character = "M" - - while let next = tokens.peek() { - if next.isLetter { - command = next - tokens.consume() - } - - switch command { - case "M", "m": - let p = tokens.readPoint() - let abs = command == "M" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - path.move(to: abs) - currentPoint = abs - subpathStart = abs - lastControl = nil - lastQuadControl = nil - // Subsequent coordinate pairs after M/m are implicit L/l - command = command == "M" ? "L" : "l" - - case "L", "l": - let p = tokens.readPoint() - let abs = command == "L" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - path.addLine(to: abs) - currentPoint = abs - lastControl = nil - lastQuadControl = nil - - case "H", "h": - let x = tokens.readNumber() - let abs = command == "H" ? CGPoint(x: x, y: currentPoint.y) : CGPoint(x: currentPoint.x + x, y: currentPoint.y) - path.addLine(to: abs) - currentPoint = abs - lastControl = nil - lastQuadControl = nil - - case "V", "v": - let y = tokens.readNumber() - let abs = command == "V" ? CGPoint(x: currentPoint.x, y: y) : CGPoint(x: currentPoint.x, y: currentPoint.y + y) - path.addLine(to: abs) - currentPoint = abs - lastControl = nil - lastQuadControl = nil - - case "C", "c": - let c1 = tokens.readPoint() - let c2 = tokens.readPoint() - let p = tokens.readPoint() - let (ac1, ac2, ap): (CGPoint, CGPoint, CGPoint) - if command == "C" { - ac1 = c1; ac2 = c2; ap = p - } else { - ac1 = CGPoint(x: currentPoint.x + c1.x, y: currentPoint.y + c1.y) - ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y) - ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - } - path.addCurve(to: ap, controlPoint1: ac1, controlPoint2: ac2) - currentPoint = ap - lastControl = ac2 - lastQuadControl = nil - - case "S", "s": - let c2 = tokens.readPoint() - let p = tokens.readPoint() - let reflected = lastControl.map { - CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y) - } ?? currentPoint - let (ac2, ap): (CGPoint, CGPoint) - if command == "S" { - ac2 = c2; ap = p - } else { - ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y) - ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - } - path.addCurve(to: ap, controlPoint1: reflected, controlPoint2: ac2) - currentPoint = ap - lastControl = ac2 - lastQuadControl = nil - - case "Q", "q": - let c = tokens.readPoint() - let p = tokens.readPoint() - let (ac, ap): (CGPoint, CGPoint) - if command == "Q" { - ac = c; ap = p - } else { - ac = CGPoint(x: currentPoint.x + c.x, y: currentPoint.y + c.y) - ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - } - path.addQuadCurve(to: ap, controlPoint: ac) - currentPoint = ap - lastControl = nil - lastQuadControl = ac - - case "T", "t": - let p = tokens.readPoint() - let reflected = lastQuadControl.map { - CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y) - } ?? currentPoint - let ap = command == "T" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y) - path.addQuadCurve(to: ap, controlPoint: reflected) - currentPoint = ap - lastControl = nil - lastQuadControl = reflected - - case "A", "a": - let rx = tokens.readNumber() - let ry = tokens.readNumber() - let xAxisRotation = tokens.readNumber() * .pi / 180 - let largeArc = tokens.readNumber() != 0 - let sweep = tokens.readNumber() != 0 - let end = tokens.readPoint() - let absEnd = command == "A" ? end : CGPoint(x: currentPoint.x + end.x, y: currentPoint.y + end.y) - ArcBuilder.addArc( - to: path, - from: currentPoint, - to: absEnd, - rx: rx, - ry: ry, - xAxisRotation: xAxisRotation, - largeArc: largeArc, - sweep: sweep - ) - currentPoint = absEnd - lastControl = nil - lastQuadControl = nil - - case "Z", "z": - path.close() - currentPoint = subpathStart - lastControl = nil - lastQuadControl = nil - - default: - tokens.consume() - } - } - - return path - } -} - -private struct Tokenizer { - private let chars: [Character] - private var index = 0 - - init(_ s: String) { self.chars = Array(s) } - - mutating func peek() -> Character? { - skipSeparators() - return index < chars.count ? chars[index] : nil - } - - mutating func consume() { - if index < chars.count { index += 1 } - } - - mutating func readNumber() -> CGFloat { - skipSeparators() - let start = index - var sawDot = false - var sawE = false - while index < chars.count { - let c = chars[index] - if index == start && (c == "+" || c == "-") { - index += 1 - continue - } - if c == "." { - if sawDot || sawE { break } - sawDot = true - index += 1 - continue - } - if c == "e" || c == "E" { - if sawE { break } - sawE = true - index += 1 - if index < chars.count && (chars[index] == "+" || chars[index] == "-") { - index += 1 - } - continue - } - if c.isNumber { - index += 1 - continue - } - break - } - let slice = String(chars[start.. CGPoint { - let x = readNumber() - let y = readNumber() - return CGPoint(x: x, y: y) - } - - private mutating func skipSeparators() { - while index < chars.count { - let c = chars[index] - if c == " " || c == "," || c == "\t" || c == "\n" || c == "\r" { - index += 1 - } else { - break - } - } - } -} - -private enum ArcBuilder { - /// Converts an SVG elliptical arc to a series of cubic Bezier segments and - /// appends them to the given path. Based on the W3C "Elliptical Arc - /// Implementation Notes" conversion. - static func addArc( - to path: UIBezierPath, - from start: CGPoint, - to end: CGPoint, - rx rxIn: CGFloat, - ry ryIn: CGFloat, - xAxisRotation phi: CGFloat, - largeArc: Bool, - sweep: Bool - ) { - if start == end { return } - if rxIn == 0 || ryIn == 0 { - path.addLine(to: end) - return - } - - var rx = abs(rxIn) - var ry = abs(ryIn) - let cosPhi = cos(phi) - let sinPhi = sin(phi) - - let dx = (start.x - end.x) / 2 - let dy = (start.y - end.y) / 2 - let x1p = cosPhi * dx + sinPhi * dy - let y1p = -sinPhi * dx + cosPhi * dy - - let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) - if lambda > 1 { - let s = sqrt(lambda) - rx *= s - ry *= s - } - - let sign: CGFloat = (largeArc == sweep) ? -1 : 1 - let numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p - let denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p - let factor = sign * sqrt(max(0, numerator / denominator)) - let cxp = factor * (rx * y1p / ry) - let cyp = factor * (-ry * x1p / rx) - - let cx = cosPhi * cxp - sinPhi * cyp + (start.x + end.x) / 2 - let cy = sinPhi * cxp + cosPhi * cyp + (start.y + end.y) / 2 - - let startVec = CGPoint(x: (x1p - cxp) / rx, y: (y1p - cyp) / ry) - let endVec = CGPoint(x: (-x1p - cxp) / rx, y: (-y1p - cyp) / ry) - let theta1 = angle(from: CGPoint(x: 1, y: 0), to: startVec) - var deltaTheta = angle(from: startVec, to: endVec) - if !sweep && deltaTheta > 0 { - deltaTheta -= 2 * .pi - } else if sweep && deltaTheta < 0 { - deltaTheta += 2 * .pi - } - - // Split into up to 4 cubic beziers (each covering <= 90°). - let segments = max(1, Int(ceil(abs(deltaTheta) / (.pi / 2)))) - let delta = deltaTheta / CGFloat(segments) - let t = (4.0 / 3.0) * tan(delta / 4) - - var theta = theta1 - for _ in 0.. CGPoint { - let x = rx * p.x - let y = ry * p.y - let rx_ = cos(phi) * x - sin(phi) * y + cx - let ry_ = sin(phi) * x + cos(phi) * y + cy - return CGPoint(x: rx_, y: ry_) - } - - private static func angle(from u: CGPoint, to v: CGPoint) -> CGFloat { - let dot = u.x * v.x + u.y * v.y - let det = u.x * v.y - u.y * v.x - return atan2(det, dot) - } -} diff --git a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx deleted file mode 100644 index 4f87f07d03..0000000000 --- a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {View} from 'react-native' - -import {type NativeViewProps} from './types' - -/** - * Android fallback: passthrough for now. Follow-up: wire the existing - * `#/components/Menu` on long-press. - */ -export default function NativeView({children, style}: NativeViewProps) { - return {children} -} diff --git a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx deleted file mode 100644 index 614f752330..0000000000 --- a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import {type ComponentType} from 'react' -import {requireNativeViewManager} from 'expo-modules-core' - -import {type NativeViewProps} from './types' - -const NativeView: ComponentType = requireNativeViewManager( - 'ExpoBlueskyContextMenu', -) - -export default NativeView diff --git a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx deleted file mode 100644 index 6e9f9f70f0..0000000000 --- a/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {View} from 'react-native' - -import {type NativeViewProps} from './types' - -/** - * Web fallback: passthrough. Long-press is a no-op; tap handling is delegated - * to children. - */ -export default function NativeView({children, style}: NativeViewProps) { - return {children} -} diff --git a/modules/expo-bluesky-context-menu/src/Menu.tsx b/modules/expo-bluesky-context-menu/src/Menu.tsx deleted file mode 100644 index b6dd67ef87..0000000000 --- a/modules/expo-bluesky-context-menu/src/Menu.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {type ReactNode} from 'react' - -import {tag} from './registry' - -export type MenuProps = { - children: ReactNode -} - -/** - * Sentinel: does not render. `Root` reads this element's children to collect - * menu items. - */ -function MenuImpl(_: MenuProps): null { - return null -} - -export const Menu = tag(MenuImpl, 'menu') diff --git a/modules/expo-bluesky-context-menu/src/MenuItem.tsx b/modules/expo-bluesky-context-menu/src/MenuItem.tsx deleted file mode 100644 index 0029d51595..0000000000 --- a/modules/expo-bluesky-context-menu/src/MenuItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {type ReactNode} from 'react' - -import {tag} from './registry' - -export type MenuItemProps = { - id: string - destructive?: boolean - disabled?: boolean - onSelect: () => void - /** Children must include a `MenuItemIcon` and a `MenuItemText`. */ - children: ReactNode -} - -/** - * Sentinel: does not render. `Root` walks the children tree to extract icon + - * label, then ships a plain menu item spec to native. - */ -function MenuItemImpl(_: MenuItemProps): null { - return null -} - -export const MenuItem = tag(MenuItemImpl, 'item') diff --git a/modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx b/modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx deleted file mode 100644 index 00ea381328..0000000000 --- a/modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {tag} from './registry' -import {type MenuItemIconSource} from './types' - -export type MenuItemIconProps = { - icon: MenuItemIconSource -} - -/** - * Sentinel: does not render any React output. `Root` introspects this element - * during its collection pass to pull the SVG path data off the icon component, - * then ships the data to native. - */ -function MenuItemIconImpl(_: MenuItemIconProps): null { - return null -} - -export const MenuItemIcon = tag(MenuItemIconImpl, 'item-icon') diff --git a/modules/expo-bluesky-context-menu/src/MenuItemText.tsx b/modules/expo-bluesky-context-menu/src/MenuItemText.tsx deleted file mode 100644 index bdff5cc4f0..0000000000 --- a/modules/expo-bluesky-context-menu/src/MenuItemText.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {tag} from './registry' - -export type MenuItemTextProps = { - children: string -} - -/** - * Sentinel: does not render. `Root` reads `children` as the menu item label. - * Keeping this a sentinel (vs. a real Text) mirrors how `Menu.ItemText` is - * used elsewhere while letting iOS draw the menu chrome natively. - */ -function MenuItemTextImpl(_: MenuItemTextProps): null { - return null -} - -export const MenuItemText = tag(MenuItemTextImpl, 'item-text') diff --git a/modules/expo-bluesky-context-menu/src/Root.tsx b/modules/expo-bluesky-context-menu/src/Root.tsx deleted file mode 100644 index 32d7cbb03a..0000000000 --- a/modules/expo-bluesky-context-menu/src/Root.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { - Children, - isValidElement, - type ReactElement, - type ReactNode, - useCallback, - useMemo, -} from 'react' -import {type StyleProp, type ViewStyle} from 'react-native' - -import NativeView from './ExpoContextMenuNativeView' -import {type MenuProps} from './Menu' -import {type MenuItemProps} from './MenuItem' -import {type MenuItemIconProps} from './MenuItemIcon' -import {type MenuItemTextProps} from './MenuItemText' -import {kindOf} from './registry' -import {type TriggerProps} from './Trigger' -import {type MenuItemSpec} from './types' - -export type RootProps = { - children: ReactNode - style?: StyleProp -} - -export function Root({children, style}: RootProps) { - const {trigger, menu} = collectTriggerAndMenu(children) - - const {menuItems, selectById} = useMemo(() => { - const items: MenuItemSpec[] = [] - const map: Record void> = {} - if (menu) { - Children.forEach(menu.props.children, child => { - if (!isValidElement(child)) return - if (kindOf(child.type) !== 'item') return - const spec = specFromItem(child as ReactElement) - if (!spec) return - items.push(spec.item) - map[spec.item.id] = spec.onSelect - }) - } - return {menuItems: items, selectById: map} - }, [menu]) - - const handleItemPress = useCallback( - (e: {nativeEvent: {id: string}}) => { - selectById[e.nativeEvent.id]?.() - }, - [selectById], - ) - - const onPreviewPress = trigger?.props.onPreviewPress - const handlePreviewPress = useCallback(() => { - onPreviewPress?.() - }, [onPreviewPress]) - - if (!trigger) { - return <>{children} - } - - return ( - - {trigger.props.children} - - ) -} - -// ----------------------------------------------------------------------------- - -type Collected = { - trigger?: ReactElement - menu?: ReactElement -} - -function collectTriggerAndMenu(children: ReactNode): Collected { - const result: Collected = {} - Children.forEach(children, child => { - if (!isValidElement(child)) return - const kind = kindOf(child.type) - if (kind === 'trigger') result.trigger = child as ReactElement - else if (kind === 'menu') result.menu = child as ReactElement - }) - return result -} - -function specFromItem( - element: ReactElement, -): {item: MenuItemSpec; onSelect: () => void} | null { - const {id, destructive, disabled, onSelect, children} = element.props - let label = '' - let icon: MenuItemSpec['icon'] - Children.forEach(children, child => { - if (!isValidElement(child)) return - const kind = kindOf(child.type) - if (kind === 'item-text') { - const text = (child as ReactElement).props.children - if (typeof text === 'string') label = text - } else if (kind === 'item-icon') { - const iconSource = (child as ReactElement).props.icon - if (iconSource?.svgPaths?.length) { - icon = { - paths: iconSource.svgPaths, - viewBox: iconSource.svgViewBox, - strokeWidth: iconSource.svgStrokeWidth, - } - } - } - }) - if (!label) return null - return { - item: {id, label, destructive, disabled, icon}, - onSelect, - } -} diff --git a/modules/expo-bluesky-context-menu/src/Trigger.tsx b/modules/expo-bluesky-context-menu/src/Trigger.tsx deleted file mode 100644 index b36fdf2ea4..0000000000 --- a/modules/expo-bluesky-context-menu/src/Trigger.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {type ReactNode} from 'react' -import {type StyleProp, type ViewStyle} from 'react-native' - -import {tag} from './registry' -import {type PreviewContent} from './types' - -export type TriggerProps = { - preview?: PreviewContent - /** Fires when the user taps the expanded preview to "commit" into it. */ - onPreviewPress?: () => void - /** Border radius of the thumbnail being wrapped. Used natively to clip the - * targeted-preview lift animation. */ - borderRadius?: number - style?: StyleProp - children: ReactNode -} - -/** - * Sentinel: does not render. `Root` reads props + children off this element - * and hosts `children` inside the native context-menu view. - */ -function TriggerImpl(_: TriggerProps): null { - return null -} - -export const Trigger = tag(TriggerImpl, 'trigger') diff --git a/modules/expo-bluesky-context-menu/src/registry.ts b/modules/expo-bluesky-context-menu/src/registry.ts deleted file mode 100644 index a3a6b2c269..0000000000 --- a/modules/expo-bluesky-context-menu/src/registry.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Marker keys and type tags shared between `Root`, `Trigger`, `Menu`, and - * `MenuItem*`. `Root` walks its children looking for these tags so the - * composition API doesn't rely on string component names or display names. - */ -export const CONTEXT_MENU_KIND = '__ExpoBlueskyContextMenuKind__' - -export type ContextMenuKind = - | 'trigger' - | 'menu' - | 'item' - | 'item-icon' - | 'item-text' - -export type TaggedComponent

= React.FunctionComponent

& { - [CONTEXT_MENU_KIND]: ContextMenuKind -} - -export function tag

( - component: React.FunctionComponent

, - kind: ContextMenuKind, -): TaggedComponent

{ - ;(component as TaggedComponent

)[CONTEXT_MENU_KIND] = kind - return component as TaggedComponent

-} - -export function kindOf(type: unknown): ContextMenuKind | undefined { - if (type && typeof type === 'function') { - return (type as TaggedComponent)[CONTEXT_MENU_KIND] - } - return undefined -} diff --git a/modules/expo-bluesky-context-menu/src/types.ts b/modules/expo-bluesky-context-menu/src/types.ts deleted file mode 100644 index d3eb10027c..0000000000 --- a/modules/expo-bluesky-context-menu/src/types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {type ReactNode} from 'react' -import {type StyleProp, type ViewStyle} from 'react-native' - -import {type IconWithSvgMeta} from '#/components/icons/TEMPLATE' - -/** - * Content to show during the peek preview. Discriminated by `type`; the native - * side dispatches on it to build the right `UIViewController`. - * - * Only `image` is implemented on iOS today. `video` and `externalCard` are the - * planned follow-ups; leaving them in the type keeps the JS call-sites honest. - */ -export type PreviewContent = - | { - type: 'image' - uri: string - /** Thumb URL. When present, the native side paints it in as an instant - * placeholder (reading from the shared SDWebImage cache) while the - * fullsize loads — avoids the black flash on first peek. */ - thumbUri?: string - /** Aspect ratio as width / height. */ - aspectRatio: number - } - | { - type: 'video' - uri: string - poster?: string - aspectRatio: number - } - | { - type: 'externalCard' - thumbUri?: string - title: string - description?: string - url: string - } - -export type MenuItemSpec = { - id: string - label: string - destructive?: boolean - disabled?: boolean - icon?: { - paths: string[] - viewBox: string - strokeWidth: number - } -} - -export type MenuItemIconSource = IconWithSvgMeta - -export type NativeViewProps = { - preview?: PreviewContent - menuItems: MenuItemSpec[] - /** Named distinctly from `borderRadius`, which RN owns as a style prop. */ - previewCornerRadius: number - onItemPress: (e: {nativeEvent: {id: string}}) => void - onPreviewPress: (e: {nativeEvent: {}}) => void - style?: StyleProp - children?: ReactNode -} diff --git a/package.json b/package.json index 1787e76315..39ee8ebb28 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-translate-text": "^0.2.9", + "@bsky.app/peek-menu": "^0.2.0", "@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/sift": "^0.3.4", "@bsky.app/tapper": "^0.5.3", diff --git a/src/components/PeekMenu.tsx b/src/components/PeekMenu.tsx new file mode 100644 index 0000000000..53732ec3e8 --- /dev/null +++ b/src/components/PeekMenu.tsx @@ -0,0 +1 @@ +export * from '@bsky.app/peek-menu' diff --git a/src/components/PeekMenu/index.ios.tsx b/src/components/PeekMenu/index.ios.tsx deleted file mode 100644 index 3e2f025542..0000000000 --- a/src/components/PeekMenu/index.ios.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export type { - MenuItemSpec, - PreviewContent, -} from '../../../modules/expo-bluesky-context-menu' -export { - Menu, - MenuItem, - MenuItemIcon, - MenuItemText, - Root, - Trigger, -} from '../../../modules/expo-bluesky-context-menu' diff --git a/src/components/PeekMenu/index.tsx b/src/components/PeekMenu/index.tsx deleted file mode 100644 index 695347edc2..0000000000 --- a/src/components/PeekMenu/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import {type ReactNode} from 'react' -import {type StyleProp, View, type ViewStyle} from 'react-native' - -import {type IconWithSvgMeta} from '#/components/icons/TEMPLATE' -import {type PreviewContent} from '../../../modules/expo-bluesky-context-menu' - -export type { - MenuItemSpec, - PreviewContent, -} from '../../../modules/expo-bluesky-context-menu' - -export function Root({ - children, - style, -}: { - children: ReactNode - style?: StyleProp -}) { - return {children} -} - -export function Trigger({ - children, -}: { - preview?: PreviewContent - onPreviewPress?: () => void - borderRadius?: number - style?: StyleProp - children: ReactNode -}) { - return <>{children} -} - -export function Menu(_: {children: ReactNode}): null { - return null -} - -export function MenuItem(_: { - id: string - destructive?: boolean - disabled?: boolean - onSelect: () => void - children: ReactNode -}): null { - return null -} - -export function MenuItemIcon(_: {icon: IconWithSvgMeta}): null { - return null -} - -export function MenuItemText(_: {children: string}): null { - return null -}