move to toolbox
This commit is contained in:
@@ -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'
|
|
||||||
|
|
||||||
<PeekMenu.Root>
|
|
||||||
<PeekMenu.Trigger
|
|
||||||
preview={{type: 'image', uri: fullsizeUrl, thumbUri: thumbUrl, aspectRatio: 1.5}}
|
|
||||||
borderRadius={12}>
|
|
||||||
{children}
|
|
||||||
</PeekMenu.Trigger>
|
|
||||||
<PeekMenu.Menu>
|
|
||||||
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
|
|
||||||
<PeekMenu.MenuItemIcon icon={SaveIcon} />
|
|
||||||
<PeekMenu.MenuItemText>Save image</PeekMenu.MenuItemText>
|
|
||||||
</PeekMenu.MenuItem>
|
|
||||||
</PeekMenu.Menu>
|
|
||||||
</PeekMenu.Root>
|
|
||||||
```
|
|
||||||
|
|
||||||
`Trigger`, `Menu`, `MenuItem`, `MenuItemIcon`, and `MenuItemText` are sentinel components — they render nothing. `Root` walks the children tree at render time, extracts their props, and passes serialized data to the native view.
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
**`Trigger`**
|
|
||||||
- `preview?: PreviewContent` — what to show during peek. 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).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["ExpoBlueskyContextMenuModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<NSString, UIImage>()
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..<index])
|
|
||||||
return CGFloat(Double(slice) ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func readPoint() -> 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..<segments {
|
|
||||||
let cosT = cos(theta)
|
|
||||||
let sinT = sin(theta)
|
|
||||||
let cosT2 = cos(theta + delta)
|
|
||||||
let sinT2 = sin(theta + delta)
|
|
||||||
|
|
||||||
let p1 = CGPoint(x: cosT - t * sinT, y: sinT + t * cosT)
|
|
||||||
let p2 = CGPoint(x: cosT2 + t * sinT2, y: sinT2 - t * cosT2)
|
|
||||||
let p3 = CGPoint(x: cosT2, y: sinT2)
|
|
||||||
|
|
||||||
let c1 = transformEllipsePoint(p1, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
||||||
let c2 = transformEllipsePoint(p2, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
||||||
let c3 = transformEllipsePoint(p3, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
||||||
|
|
||||||
path.addCurve(to: c3, controlPoint1: c1, controlPoint2: c2)
|
|
||||||
theta += delta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func transformEllipsePoint(_ p: CGPoint, rx: CGFloat, ry: CGFloat, phi: CGFloat, cx: CGFloat, cy: CGFloat) -> 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <View style={style}>{children}</View>
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import {type ComponentType} from 'react'
|
|
||||||
import {requireNativeViewManager} from 'expo-modules-core'
|
|
||||||
|
|
||||||
import {type NativeViewProps} from './types'
|
|
||||||
|
|
||||||
const NativeView: ComponentType<NativeViewProps> = requireNativeViewManager(
|
|
||||||
'ExpoBlueskyContextMenu',
|
|
||||||
)
|
|
||||||
|
|
||||||
export default NativeView
|
|
||||||
@@ -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 <View style={style}>{children}</View>
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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<ViewStyle>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Root({children, style}: RootProps) {
|
|
||||||
const {trigger, menu} = collectTriggerAndMenu(children)
|
|
||||||
|
|
||||||
const {menuItems, selectById} = useMemo(() => {
|
|
||||||
const items: MenuItemSpec[] = []
|
|
||||||
const map: Record<string, () => 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<MenuItemProps>)
|
|
||||||
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 (
|
|
||||||
<NativeView
|
|
||||||
preview={trigger.props.preview}
|
|
||||||
menuItems={menuItems}
|
|
||||||
previewCornerRadius={trigger.props.borderRadius ?? 0}
|
|
||||||
onItemPress={handleItemPress}
|
|
||||||
onPreviewPress={handlePreviewPress}
|
|
||||||
style={[style, trigger.props.style]}>
|
|
||||||
{trigger.props.children}
|
|
||||||
</NativeView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Collected = {
|
|
||||||
trigger?: ReactElement<TriggerProps>
|
|
||||||
menu?: ReactElement<MenuProps>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<TriggerProps>
|
|
||||||
else if (kind === 'menu') result.menu = child as ReactElement<MenuProps>
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function specFromItem(
|
|
||||||
element: ReactElement<MenuItemProps>,
|
|
||||||
): {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<MenuItemTextProps>).props.children
|
|
||||||
if (typeof text === 'string') label = text
|
|
||||||
} else if (kind === 'item-icon') {
|
|
||||||
const iconSource = (child as ReactElement<MenuItemIconProps>).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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ViewStyle>
|
|
||||||
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')
|
|
||||||
@@ -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<P> = React.FunctionComponent<P> & {
|
|
||||||
[CONTEXT_MENU_KIND]: ContextMenuKind
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tag<P>(
|
|
||||||
component: React.FunctionComponent<P>,
|
|
||||||
kind: ContextMenuKind,
|
|
||||||
): TaggedComponent<P> {
|
|
||||||
;(component as TaggedComponent<P>)[CONTEXT_MENU_KIND] = kind
|
|
||||||
return component as TaggedComponent<P>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function kindOf(type: unknown): ContextMenuKind | undefined {
|
|
||||||
if (type && typeof type === 'function') {
|
|
||||||
return (type as TaggedComponent<unknown>)[CONTEXT_MENU_KIND]
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
@@ -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<ViewStyle>
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
@@ -101,6 +101,7 @@
|
|||||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@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/react-native-mmkv": "2.12.5",
|
||||||
"@bsky.app/sift": "^0.3.4",
|
"@bsky.app/sift": "^0.3.4",
|
||||||
"@bsky.app/tapper": "^0.5.3",
|
"@bsky.app/tapper": "^0.5.3",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from '@bsky.app/peek-menu'
|
||||||
@@ -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'
|
|
||||||
@@ -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<ViewStyle>
|
|
||||||
}) {
|
|
||||||
return <View style={style}>{children}</View>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Trigger({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
preview?: PreviewContent
|
|
||||||
onPreviewPress?: () => void
|
|
||||||
borderRadius?: number
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user