From d4fd15dc2044249b67a68b57115166e103fef45e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 06:58:26 +0000 Subject: [PATCH] Add iOS peek long-press context menu for image embeds Introduces expo-bluesky-context-menu, a compositional Root/Trigger/Menu/MenuItem wrapper around UIContextMenuInteraction. The preview ViewController is sized to the image's true aspect ratio so tall/panorama previews don't stretch mid-lift, and menu icons are rasterized from the app's SVG icon set on the native side. The preview prop is a discriminated union (image today; video and externalCard reserved) so the same module can back those embeds in follow-ups. https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT --- .../expo-module.config.json | 6 + modules/expo-bluesky-context-menu/index.ts | 7 + .../ios/ExpoBlueskyContextMenu.podspec | 20 ++ .../ios/ExpoBlueskyContextMenuModule.swift | 23 ++ .../ios/ExpoBlueskyContextMenuView.swift | 94 ++++++ .../ios/IconRenderer.swift | 70 ++++ .../ios/ImagePreviewController.swift | 72 ++++ .../ios/MenuBuilder.swift | 51 +++ .../ios/PreviewFactory.swift | 21 ++ .../ios/SVGPathParser.swift | 319 ++++++++++++++++++ .../src/ExpoContextMenuNativeView.android.tsx | 11 + .../src/ExpoContextMenuNativeView.tsx | 9 + .../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 | 56 +++ .../Post/Embed/ImageContextMenu.tsx | 74 ++++ src/components/Post/Embed/ImageEmbed.tsx | 58 +++- src/components/icons/TEMPLATE.tsx | 27 +- 24 files changed, 1157 insertions(+), 21 deletions(-) create mode 100644 modules/expo-bluesky-context-menu/expo-module.config.json create mode 100644 modules/expo-bluesky-context-menu/index.ts create mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec create mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift create mode 100644 modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift create mode 100644 modules/expo-bluesky-context-menu/ios/IconRenderer.swift create mode 100644 modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift create mode 100644 modules/expo-bluesky-context-menu/ios/MenuBuilder.swift create mode 100644 modules/expo-bluesky-context-menu/ios/PreviewFactory.swift create mode 100644 modules/expo-bluesky-context-menu/ios/SVGPathParser.swift create mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx create mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx create mode 100644 modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx create mode 100644 modules/expo-bluesky-context-menu/src/Menu.tsx create mode 100644 modules/expo-bluesky-context-menu/src/MenuItem.tsx create mode 100644 modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx create mode 100644 modules/expo-bluesky-context-menu/src/MenuItemText.tsx create mode 100644 modules/expo-bluesky-context-menu/src/Root.tsx create mode 100644 modules/expo-bluesky-context-menu/src/Trigger.tsx create mode 100644 modules/expo-bluesky-context-menu/src/registry.ts create mode 100644 modules/expo-bluesky-context-menu/src/types.ts create mode 100644 src/components/Post/Embed/ImageContextMenu.tsx diff --git a/modules/expo-bluesky-context-menu/expo-module.config.json b/modules/expo-bluesky-context-menu/expo-module.config.json new file mode 100644 index 0000000000..c88c620b09 --- /dev/null +++ b/modules/expo-bluesky-context-menu/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios", "android", "web"], + "ios": { + "modules": ["ExpoBlueskyContextMenuModule"] + } +} diff --git a/modules/expo-bluesky-context-menu/index.ts b/modules/expo-bluesky-context-menu/index.ts new file mode 100644 index 0000000000..afa611a482 --- /dev/null +++ b/modules/expo-bluesky-context-menu/index.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..7bcaf22aaa --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenu.podspec @@ -0,0 +1,20 @@ +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' + + 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 new file mode 100644 index 0000000000..c203996983 --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuModule.swift @@ -0,0 +1,23 @@ +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("borderRadius") { (view: ExpoBlueskyContextMenuView, value: Double) in + view.setBorderRadius(value) + } + } + } +} diff --git a/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift new file mode 100644 index 0000000000..09e11b4769 --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift @@ -0,0 +1,94 @@ +import ExpoModulesCore +import UIKit + +/// Native view that hosts the children and attaches a +/// `UIContextMenuInteraction`. Two JS-shipped props drive behaviour: +/// - `preview`: discriminated union describing what to show during peek +/// - `menuItems`: array of menu item specs (see `MenuBuilder`) +/// - `borderRadius`: used for the targeted preview's visible path so the lift +/// animation matches the thumbnail's clipping +class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate { + private var preview: [String: Any]? + private var menuItems: [[String: Any]] = [] + private var borderRadius: CGFloat = 0 + + private let onItemPress = EventDispatcher() + private let onPreviewPress = EventDispatcher() + + private var pendingCommitId: String? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + let interaction = UIContextMenuInteraction(delegate: self) + self.addInteraction(interaction) + } + + func setPreview(_ value: [String: Any]?) { self.preview = value } + func setMenuItems(_ value: [[String: Any]]) { self.menuItems = value } + func setBorderRadius(_ value: Double) { self.borderRadius = 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 + ) { + animator.addCompletion { [weak self] in + 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 borderRadius > 0 { + parameters.visiblePath = UIBezierPath( + roundedRect: self.bounds, + cornerRadius: borderRadius + ) + } + 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 new file mode 100644 index 0000000000..94a0da893a --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/IconRenderer.swift @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000000..12b72aa662 --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/ImagePreviewController.swift @@ -0,0 +1,72 @@ +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. +final class ImagePreviewController: UIViewController { + private let imageURL: URL? + private let aspectRatio: CGFloat + + init(imageURL: URL?, aspectRatio: CGFloat) { + self.imageURL = imageURL + 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 + + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.backgroundColor = .black + root.addSubview(imageView) + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: root.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: root.trailingAnchor), + imageView.topAnchor.constraint(equalTo: root.topAnchor), + imageView.bottomAnchor.constraint(equalTo: root.bottomAnchor), + ]) + + self.view = root + load(into: imageView) + } + + private func load(into imageView: UIImageView) { + guard let url = imageURL else { return } + // Use URLSession + URLCache so we cooperate with Expo Image's HTTP cache. + let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 10) + if let cached = URLCache.shared.cachedResponse(for: request), + let image = UIImage(data: cached.data) { + imageView.image = image + return + } + URLSession.shared.dataTask(with: request) { [weak imageView] data, _, _ in + guard let data = data, let image = UIImage(data: data) else { return } + DispatchQueue.main.async { imageView?.image = image } + }.resume() + } + + /// Caps the preview to a comfortable size within the current key window. + private static func sizeForAspect(_ aspect: CGFloat) -> CGSize { + let screen = UIScreen.main.bounds + let maxW = screen.width - 32 + let maxH = screen.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 new file mode 100644 index 0000000000..3ab4b3923a --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/MenuBuilder.swift @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000000..36c3ffa0ef --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/PreviewFactory.swift @@ -0,0 +1,21 @@ +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 url = uri.flatMap(URL.init(string:)) + let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1) + return ImagePreviewController(imageURL: url, 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 new file mode 100644 index 0000000000..83a2ec7698 --- /dev/null +++ b/modules/expo-bluesky-context-menu/ios/SVGPathParser.swift @@ -0,0 +1,319 @@ +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. +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? = nil + var lastQuadControl: CGPoint? = nil + 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() + var 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 new file mode 100644 index 0000000000..4f87f07d03 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.android.tsx @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000..c227f053f9 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.tsx @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..6e9f9f70f0 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/ExpoContextMenuNativeView.web.tsx @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000..b6dd67ef87 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/Menu.tsx @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000000..0029d51595 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/MenuItem.tsx @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000000..00ea381328 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/MenuItemIcon.tsx @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000000..bdff5cc4f0 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/MenuItemText.tsx @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..df4635089e --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/Root.tsx @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000000..b36fdf2ea4 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/Trigger.tsx @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000000..a3a6b2c269 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/registry.ts @@ -0,0 +1,32 @@ +/** + * 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 new file mode 100644 index 0000000000..188c142429 --- /dev/null +++ b/modules/expo-bluesky-context-menu/src/types.ts @@ -0,0 +1,56 @@ +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 + /** 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[] + borderRadius: number + onItemPress: (e: {nativeEvent: {id: string}}) => void + onPreviewPress: (e: {nativeEvent: {}}) => void + style?: StyleProp + children?: ReactNode +} diff --git a/src/components/Post/Embed/ImageContextMenu.tsx b/src/components/Post/Embed/ImageContextMenu.tsx new file mode 100644 index 0000000000..3867fe9acf --- /dev/null +++ b/src/components/Post/Embed/ImageContextMenu.tsx @@ -0,0 +1,74 @@ +import {type ReactNode} from 'react' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {shareImageModal} from '#/lib/media/manip' +import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' +import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' +import {IS_IOS} from '#/env' +import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu' + +/** + * Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS + * platforms this renders children unchanged. + * + * The aspect ratio is consumed by the native side to size the preview + * viewController correctly — which is what makes the lift animation clean + * for portrait/panorama images. + */ +export function ImageContextMenu({ + fullsizeUri, + aspectRatio, + borderRadius, + onPreviewPress, + children, +}: { + fullsizeUri: string + /** width / height; defaults to 1 if missing. */ + aspectRatio: number | undefined + borderRadius?: number + onPreviewPress?: () => void + children: ReactNode +}) { + const {_} = useLingui() + const saveImage = useSaveImageToMediaLibrary() + + if (!IS_IOS) { + return children + } + + const handleSave = () => { + void saveImage(fullsizeUri) + } + const handleShare = () => { + void shareImageModal({uri: fullsizeUri}) + } + + return ( + + 0 ? aspectRatio : 1, + }} + borderRadius={borderRadius} + onPreviewPress={onPreviewPress}> + {children} + + + + + + {_(msg`Save image`)} + + + + + {_(msg`Share`)} + + + + ) +} diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index d779d04876..dc4c9ef368 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -8,6 +8,7 @@ import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {useLightboxControls} from '#/components/Lightbox/state' import {type Dimensions} from '#/components/Lightbox/types' +import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' @@ -59,24 +60,49 @@ export function ImageEmbed({ if (images.length === 1) { const image = images[0] + const aspect = + image.aspectRatio && image.aspectRatio.height > 0 + ? image.aspectRatio.width / image.aspectRatio.height + : undefined return ( - onPress(0, [containerRef], [dims])} - onPressIn={() => onPressIn(0)} - hideBadge={ - rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> + + openLightbox({ + images: items.map(item => ({ + ...item, + thumbRect: null, + thumbRef: null, + thumbDimensions: null, + thumbBorderRadius: tokens.borderRadius.md, + type: 'image', + })), + index: 0, + }) + }> + + onPress(0, [containerRef], [dims]) + } + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === + PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ) } diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index 4feaaef359..18eb60145a 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -1,8 +1,16 @@ -import {forwardRef} from 'react' +import {forwardRef, type ForwardRefExoticComponent, type RefAttributes} from 'react' import Svg, {Path} from 'react-native-svg' import {type Props, useCommonSVGProps} from '#/components/icons/common' +export type IconWithSvgMeta = ForwardRefExoticComponent< + Props & RefAttributes +> & { + svgPaths: string[] + svgViewBox: string + svgStrokeWidth: number +} + export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef( function LogoImpl(props: Props, ref) { const {fill, size, style, ...rest} = useCommonSVGProps(props) @@ -41,7 +49,7 @@ export function createSinglePathSVG({ strokeLinecap?: 'butt' | 'round' | 'square' strokeLinejoin?: 'miter' | 'round' | 'bevel' }) { - return forwardRef(function LogoImpl(props, ref) { + const Icon = forwardRef(function LogoImpl(props, ref) { const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) const hasStroke = strokeWidth > 0 @@ -68,7 +76,11 @@ export function createSinglePathSVG({ /> ) - }) + }) as IconWithSvgMeta + Icon.svgPaths = [path] + Icon.svgViewBox = viewBox || '0 0 24 24' + Icon.svgStrokeWidth = strokeWidth + return Icon } export function createMultiPathSVG({ @@ -78,7 +90,7 @@ export function createMultiPathSVG({ paths: string[] viewBox?: string }) { - return forwardRef(function LogoImpl(props, ref) { + const Icon = forwardRef(function LogoImpl(props, ref) { const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( @@ -102,5 +114,10 @@ export function createMultiPathSVG({ ))} ) - }) + }) as IconWithSvgMeta + Icon.svgPaths = paths + Icon.svgViewBox = viewBox || '0 0 24 24' + Icon.svgStrokeWidth = 0 + return Icon } +