Files
buzz/mobile/ios/Runner/NativeMessageActionSurface.swift
Tom Brow 4ee9f8b35a fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758)
Long-pressing a message while the keyboard dismissed could leave the iOS
action menu visible but unresponsive. Rebuilds installed a new selection
handler before the previous hook's cleanup removed it. Keep that handler
for the native view's lifetime and dispatch to the latest callback. Also
let taps on decorative action icons reach the row control.

Added regression coverage for callback rebuilds, view
replacement/dismissal, and native icon/label/edge hit targets. The
callback regression fails before the fix and passes after it.

Validation: all 2,257 mobile tests (4 skipped), three unconfigured-push
tests, mobile analysis, 42 focused action/handler tests, and three
native XCTest checks passed. Physical iOS 26.5 taps verified
keyboard-open long press, Edit, Copy (including clipboard contents),
repeated opening, and outside dismissal. Desktop tests/builds, Tauri
checks/tests, and the web build passed. The full local gate encountered
unrelated Rust agent timeout failures; all 105 tests in the affected ACP
test module passed when rerun serially.

GitHub CI passed, including mobile tests, Swift tests, Android build,
and iOS simulator/release builds. Codex code review completed without
findings.

No matching open issue or PR found.

### Before
Synthetic message using the production long-press/menu components: Edit
and Copy taps leave the menu open; tapping outside still dismisses it.


https://github.com/user-attachments/assets/5e062d38-cbca-4be2-98b5-ca9a47b89df3

### After
With the keyboard open, the first Edit icon tap opens the editor.
Reopening and tapping Copy copies the message and dismisses the menu.


https://github.com/user-attachments/assets/49e11bfd-d531-4fa1-b551-5abd4fec83d7

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-19 10:25:18 -07:00

456 lines
14 KiB
Swift

import Flutter
import UIKit
struct NativeMessageActionDefinition {
enum Group: String, CaseIterable {
case primary
case utility
case destructive
}
let id: String
let title: String
let symbol: String
let group: Group
let isDestructive: Bool
init?(arguments: [String: Any]) {
guard
let id = arguments["id"] as? String,
let title = arguments["title"] as? String,
let symbol = arguments["symbol"] as? String,
let groupName = arguments["group"] as? String,
let group = Group(rawValue: groupName)
else {
return nil
}
self.id = id
self.title = title
self.symbol = symbol
self.group = group
isDestructive = arguments["destructive"] as? Bool ?? false
}
}
enum NativeMessageActionSurfaceLayout {
static let minimumRowHeight: CGFloat = 48
static let rowVerticalPadding: CGFloat = 4
static let separatorHeight: CGFloat = 0.5
static let verticalInset: CGFloat = 4
static let horizontalInset: CGFloat = 16
static let iconColumnWidth: CGFloat = 32
static let iconToTextSpacing: CGFloat = 12
static var cornerRadius: CGFloat {
if #available(iOS 26.0, *) {
return 33
}
return 12
}
static func populatedGroups(
actions: [NativeMessageActionDefinition]
) -> [NativeMessageActionDefinition.Group] {
NativeMessageActionDefinition.Group.allCases.filter { group in
actions.contains { $0.group == group }
}
}
static func separatorCount(
actions: [NativeMessageActionDefinition]
) -> Int {
max(0, populatedGroups(actions: actions).count - 1)
}
static func rowHeight(
minimumHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let labelHeight = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
).lineHeight
return max(
minimumHeight,
ceil(labelHeight + (rowVerticalPadding * 2))
)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition],
minimumRowHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let resolvedRowHeight = rowHeight(
minimumHeight: minimumRowHeight,
compatibleWith: traitCollection
)
return (verticalInset * 2)
+ (CGFloat(actions.count) * resolvedRowHeight)
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
}
}
@available(iOS 16.0, *)
enum NativeMessageActionSurfaceAppearance {
// The native list is only one sibling inside the Flutter-owned dialog.
// Keeping it non-modal leaves the reaction tray and dismiss barrier
// reachable to VoiceOver.
static let actionListAccessibilityViewIsModal = false
static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle {
switch value as? String {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
guard !reduceTransparency else { return nil }
if #available(iOS 26.0, *) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
return effect
}
return UIBlurEffect(style: .systemMaterial)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionRowControl: UIControl {
let actionImageView: UIImageView
let actionTitleLabel = UILabel()
init(
definition: NativeMessageActionDefinition,
foregroundColor: UIColor,
destructiveColor: UIColor,
minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil,
onSelected: @escaping () -> Void
) {
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
super.init(frame: .zero)
let color = definition.isDestructive ? destructiveColor : foregroundColor
actionImageView.tintColor = color
actionImageView.contentMode = .center
actionTitleLabel.text = definition.title
actionTitleLabel.textColor = color
actionTitleLabel.font = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
)
actionTitleLabel.adjustsFontForContentSizeCategory = true
actionTitleLabel.numberOfLines = 0
actionTitleLabel.lineBreakMode = .byWordWrapping
actionTitleLabel.setContentCompressionResistancePriority(
.required,
for: .vertical
)
let iconColumn = UIView()
// Decorative content must leave the entire row tappable.
iconColumn.isUserInteractionEnabled = false
iconColumn.translatesAutoresizingMaskIntoConstraints = false
actionImageView.translatesAutoresizingMaskIntoConstraints = false
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
iconColumn.addSubview(actionImageView)
addSubview(iconColumn)
addSubview(actionTitleLabel)
NSLayoutConstraint.activate([
iconColumn.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.horizontalInset
),
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
iconColumn.widthAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
iconColumn.heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
actionTitleLabel.leadingAnchor.constraint(
equalTo: iconColumn.trailingAnchor,
constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
),
actionTitleLabel.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.horizontalInset
),
actionTitleLabel.topAnchor.constraint(
greaterThanOrEqualTo: topAnchor,
constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.bottomAnchor.constraint(
lessThanOrEqualTo: bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
minimumHeight: minimumHeight,
compatibleWith: traitCollection
)
),
])
accessibilityLabel = definition.title
accessibilityTraits = .button
isAccessibilityElement = true
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
override var isHighlighted: Bool {
didSet {
backgroundColor =
isHighlighted
? actionTitleLabel.textColor.withAlphaComponent(0.08)
: .clear
}
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSeparatorView: UIView {
init(color: UIColor) {
super.init(frame: .zero)
backgroundColor = color
heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfaceFactory: NSObject,
FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeMessageActionSurfacePlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfacePlatformView: NSObject,
FlutterPlatformView
{
private let surfaceView: UIView
private let backdropView: UIVisualEffectView
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
let arguments = args as? [String: Any]
let surfaceColor = Self.color(
from: arguments?["surfaceColor"],
fallback: .systemBackground
)
let foregroundColor = Self.color(
from: arguments?["foregroundColor"],
fallback: .label
)
let separatorColor = Self.color(
from: arguments?["separatorColor"],
fallback: .separator
)
let destructiveColor = Self.color(
from: arguments?["errorColor"],
fallback: .systemRed
)
let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
from: arguments?["interfaceStyle"]
)
var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
requestedRowHeight.doubleValue.isFinite
{
minimumRowHeight = max(
minimumRowHeight,
CGFloat(requestedRowHeight.doubleValue)
)
}
let actionArguments = arguments?["actions"] as? [[String: Any]]
let actions =
actionArguments?.compactMap(
NativeMessageActionDefinition.init(arguments:)
) ?? []
surfaceView = UIView(frame: frame)
backdropView = UIVisualEffectView(
effect: NativeMessageActionSurfaceAppearance.backdropEffect(
reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
)
)
channel = FlutterMethodChannel(
name: "buzz/native_message_action_surface/\(viewId)",
binaryMessenger: messenger
)
super.init()
surfaceView.backgroundColor = .clear
surfaceView.clipsToBounds = false
surfaceView.accessibilityViewIsModal =
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
surfaceView.overrideUserInterfaceStyle = interfaceStyle
backdropView.translatesAutoresizingMaskIntoConstraints = false
backdropView.overrideUserInterfaceStyle = interfaceStyle
backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
backdropView.layer.cornerCurve = .continuous
backdropView.layer.masksToBounds = true
if backdropView.effect == nil {
backdropView.backgroundColor = surfaceColor
}
surfaceView.addSubview(backdropView)
NSLayoutConstraint.activate([
backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
])
if #unavailable(iOS 26.0) {
surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
surfaceView.layer.shadowRadius = 32
surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
surfaceView.layer.shadowColor = UIColor.black.cgColor
surfaceView.layer.shadowOpacity = 0.2
}
install(
actions: actions,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
separatorColor: separatorColor,
minimumRowHeight: minimumRowHeight
)
}
func view() -> UIView {
surfaceView
}
private func install(
actions: [NativeMessageActionDefinition],
foregroundColor: UIColor,
destructiveColor: UIColor,
separatorColor: UIColor,
minimumRowHeight: CGFloat
) {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
backdropView.contentView.addSubview(scrollView)
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.topAnchor,
constant: NativeMessageActionSurfaceLayout.verticalInset
),
stack.bottomAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.verticalInset
),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
var installedGroup = false
for group in NativeMessageActionDefinition.Group.allCases {
let groupActions = actions.filter { $0.group == group }
guard !groupActions.isEmpty else { continue }
if installedGroup {
stack.addArrangedSubview(
NativeMessageActionSeparatorView(color: separatorColor)
)
}
for definition in groupActions {
stack.addArrangedSubview(
NativeMessageActionRowControl(
definition: definition,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
minimumHeight: minimumRowHeight,
onSelected: { [weak self] in self?.select(definition) }
)
)
}
installedGroup = true
}
}
private func select(_ definition: NativeMessageActionDefinition) {
channel.invokeMethod("selected", arguments: ["id": definition.id])
}
private static func color(from value: Any?, fallback: UIColor) -> UIColor {
guard let number = value as? NSNumber else { return fallback }
let color = number.uint32Value
let alpha = CGFloat((color >> 24) & 0xFF) / 255
let red = CGFloat((color >> 16) & 0xFF) / 255
let green = CGFloat((color >> 8) & 0xFF) / 255
let blue = CGFloat(color & 0xFF) / 255
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}