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
This commit is contained in:
Claude
2026-04-19 06:58:26 +00:00
committed by Samuel Newman
parent ebed23657f
commit d4fd15dc20
24 changed files with 1157 additions and 21 deletions
@@ -0,0 +1,6 @@
{
"platforms": ["ios", "android", "web"],
"ios": {
"modules": ["ExpoBlueskyContextMenuModule"]
}
}
@@ -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'
@@ -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
@@ -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)
}
}
}
}
@@ -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)
}
}
@@ -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<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])
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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..<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)
}
}
@@ -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 <View style={style}>{children}</View>
}
@@ -0,0 +1,9 @@
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
@@ -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 <View style={style}>{children}</View>
}
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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<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}
borderRadius={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,
}
}
@@ -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<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')
@@ -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<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
}
@@ -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<ViewStyle>
children?: ReactNode
}
@@ -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 (
<ContextMenu.Root>
<ContextMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}}
borderRadius={borderRadius}
onPreviewPress={onPreviewPress}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Menu>
<ContextMenu.MenuItem id="save" onSelect={handleSave}>
<ContextMenu.MenuItemIcon icon={DownloadIcon} />
<ContextMenu.MenuItemText>
{_(msg`Save image`)}
</ContextMenu.MenuItemText>
</ContextMenu.MenuItem>
<ContextMenu.MenuItem id="share" onSelect={handleShare}>
<ContextMenu.MenuItemIcon icon={ShareIcon} />
<ContextMenu.MenuItemText>{_(msg`Share`)}</ContextMenu.MenuItemText>
</ContextMenu.MenuItem>
</ContextMenu.Menu>
</ContextMenu.Root>
)
}
+42 -16
View File
@@ -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 (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
<ImageContextMenu
fullsizeUri={image.fullsize}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={() =>
openLightbox({
images: items.map(item => ({
...item,
thumbRect: null,
thumbRef: null,
thumbDimensions: null,
thumbBorderRadius: tokens.borderRadius.md,
type: 'image',
})),
index: 0,
})
}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</ImageContextMenu>
</View>
)
}
+22 -5
View File
@@ -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<Svg>
> & {
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<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0
@@ -68,7 +76,11 @@ export function createSinglePathSVG({
/>
</Svg>
)
})
}) 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<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
return (
@@ -102,5 +114,10 @@ export function createMultiPathSVG({
))}
</Svg>
)
})
}) as IconWithSvgMeta
Icon.svgPaths = paths
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = 0
return Icon
}