From 30c01bae815aecdb538e7af833bfce6bae1811d4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 9 Aug 2026 13:01:52 +0100 Subject: [PATCH] checkpoint: preserve iOS message action experiment Signed-off-by: kenny lopez --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 21 + .../NativeMessageActionsCoordinator.swift | 432 ++++++++++++ mobile/ios/RunnerTests/RunnerTests.swift | 283 ++++++++ .../channels/channel_detail_page.dart | 5 +- .../channel_detail_page/message_bubble.dart | 377 +++++----- .../channel_detail_page/message_list.dart | 2 +- .../channel_detail_page/system_rows.dart | 172 +++-- .../features/channels/message_actions.dart | 270 +++---- .../ios_message_actions_popover.dart | 590 ++++++++++++++++ .../ios_native_message_actions.dart | 233 ++++++ .../message_actions_sheet.dart | 117 +++ .../message_actions/reaction_popover.dart | 171 ++++- .../channels/message_long_press_region.dart | 59 +- .../features/channels/thread_detail_page.dart | 472 ++++++------- .../nested_thread_summary_row.dart | 111 +++ .../channels/channel_detail_page_test.dart | 367 +++++++++- .../channels/message_actions_test.dart | 665 +++++++++++++++++- 18 files changed, 3640 insertions(+), 711 deletions(-) create mode 100644 mobile/ios/Runner/NativeMessageActionsCoordinator.swift create mode 100644 mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart create mode 100644 mobile/lib/features/channels/message_actions/ios_native_message_actions.dart create mode 100644 mobile/lib/features/channels/message_actions/message_actions_sheet.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 7ccf13bae4..48569ea3fb 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; }; 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; }; 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; + 4A71C0092F40500100A17E01 /* NativeMessageActionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -59,6 +60,7 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = ""; }; 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; }; 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; + 4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionsCoordinator.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -182,6 +184,7 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */, 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, + 4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -417,6 +420,7 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */, 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, + 4A71C0092F40500100A17E01 /* NativeMessageActionsCoordinator.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 6ab55c359c..d95bd0c431 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -10,6 +10,7 @@ import UserNotifications private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? + private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -95,6 +96,26 @@ import UserNotifications messenger: messenger, parentViewController: nativeAttachmentRegistrar?.viewController ) + if #available(iOS 16.0, *), + let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeMessageActionSurface" + ) { + nativeMessageActionsRegistrar.register( + NativeMessageActionSurfaceFactory(messenger: messenger), + withId: "buzz/native_message_action_surface" + ) + nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel( + name: "buzz/native_message_action_surface", + binaryMessenger: messenger + ) + nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in + guard call.method == "isSupported" else { + result(FlutterMethodNotImplemented) + return + } + result(true) + } + } } private static func handleQrScannerMethodCall( diff --git a/mobile/ios/Runner/NativeMessageActionsCoordinator.swift b/mobile/ios/Runner/NativeMessageActionsCoordinator.swift new file mode 100644 index 0000000000..a35e0f399c --- /dev/null +++ b/mobile/ios/Runner/NativeMessageActionsCoordinator.swift @@ -0,0 +1,432 @@ +import Flutter +import UIKit + +struct NativeMessageActionDefinition: Equatable { + enum Group: String, CaseIterable { + case promoted + case triage + case export + case manage + } + + 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 surfaceWidth: CGFloat = 288 + static let promotedHeight: CGFloat = 72 + static let rowHeight: CGFloat = 50 + static let separatorHeight: CGFloat = 0.5 + static let verticalInset: CGFloat = 4 + static let rowHorizontalInset: CGFloat = 18 + static let separatorHorizontalInset: CGFloat = rowHorizontalInset + static let promotedHorizontalInset: CGFloat = separatorHorizontalInset + static let rowIconColumnWidth: CGFloat = 24 + static let rowIconToTextSpacing: CGFloat = 14 + static let rowVerticalInset: CGFloat = 8 + + 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 preferredHeight( + actions: [NativeMessageActionDefinition] + ) -> CGFloat { + let promoted = actions.contains { $0.group == .promoted } + let rowCount = actions.filter { $0.group != .promoted }.count + return (verticalInset * 2) + + (promoted ? promotedHeight : 0) + + (CGFloat(rowCount) * rowHeight) + + (CGFloat(separatorCount(actions: actions)) * separatorHeight) + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionSeparatorView: UIView { + let lineView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + + heightAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight + ).isActive = true + lineView.backgroundColor = .separator + lineView.translatesAutoresizingMaskIntoConstraints = false + addSubview(lineView) + NSLayoutConstraint.activate([ + lineView.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: NativeMessageActionSurfaceLayout.separatorHorizontalInset + ), + lineView.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -NativeMessageActionSurfaceLayout.separatorHorizontalInset + ), + lineView.topAnchor.constraint(equalTo: topAnchor), + lineView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionRowControl: UIControl { + let actionImageView: UIImageView + let actionTitleLabel: UILabel + + init( + definition: NativeMessageActionDefinition, + onSelected: @escaping () -> Void + ) { + actionImageView = UIImageView(image: UIImage(systemName: definition.symbol)) + actionTitleLabel = UILabel() + super.init(frame: .zero) + + let foregroundColor: UIColor = definition.isDestructive ? .systemRed : .label + actionImageView.tintColor = foregroundColor + actionImageView.contentMode = .center + actionImageView.preferredSymbolConfiguration = + NativeMessageActionSurfaceStyle.defaultSymbolConfiguration + + actionTitleLabel.text = definition.title + actionTitleLabel.textColor = foregroundColor + actionTitleLabel.font = NativeMessageActionSurfaceStyle.rowFont + actionTitleLabel.adjustsFontForContentSizeCategory = true + actionTitleLabel.numberOfLines = 0 + + let iconColumn = UIView() + iconColumn.translatesAutoresizingMaskIntoConstraints = false + actionImageView.translatesAutoresizingMaskIntoConstraints = false + iconColumn.addSubview(actionImageView) + NSLayoutConstraint.activate([ + iconColumn.widthAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.rowIconColumnWidth + ), + iconColumn.heightAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.rowIconColumnWidth + ), + 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.translatesAutoresizingMaskIntoConstraints = false + addSubview(iconColumn) + addSubview(actionTitleLabel) + NSLayoutConstraint.activate([ + iconColumn.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: NativeMessageActionSurfaceLayout.rowHorizontalInset + ), + iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor), + actionTitleLabel.leadingAnchor.constraint( + equalTo: iconColumn.trailingAnchor, + constant: NativeMessageActionSurfaceLayout.rowIconToTextSpacing + ), + actionTitleLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -NativeMessageActionSurfaceLayout.rowHorizontalInset + ), + actionTitleLabel.topAnchor.constraint( + equalTo: topAnchor, + constant: NativeMessageActionSurfaceLayout.rowVerticalInset + ), + actionTitleLabel.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -NativeMessageActionSurfaceLayout.rowVerticalInset + ), + heightAnchor.constraint( + greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight + ), + ]) + + 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 + ? UIColor.label.withAlphaComponent(0.08) + : .clear + } + } +} + +enum NativeMessageActionSurfaceStyle { + static let promotedTextStyle = UIFont.TextStyle.footnote + static let rowTextStyle = UIFont.TextStyle.body + + static var promotedFont: UIFont { + UIFont.preferredFont(forTextStyle: promotedTextStyle) + } + + static var rowFont: UIFont { + UIFont.preferredFont(forTextStyle: rowTextStyle) + } + + static var defaultSymbolConfiguration: UIImage.SymbolConfiguration? { + nil + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionPromotedRow: UIView { + let buttons: [UIButton] + + init( + actions: [NativeMessageActionDefinition], + onSelected: @escaping (NativeMessageActionDefinition) -> Void + ) { + buttons = actions.map { definition in + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: definition.symbol) + configuration.imagePlacement = .top + configuration.imagePadding = 5 + configuration.baseForegroundColor = .label + configuration.contentInsets = NSDirectionalEdgeInsets( + top: NativeMessageActionSurfaceLayout.rowVerticalInset, + leading: 4, + bottom: NativeMessageActionSurfaceLayout.rowVerticalInset, + trailing: 4 + ) + configuration.title = definition.title + configuration.preferredSymbolConfigurationForImage = + NativeMessageActionSurfaceStyle.defaultSymbolConfiguration + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = NativeMessageActionSurfaceStyle.promotedFont + return outgoing + } + + let button = UIButton(configuration: configuration) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.accessibilityLabel = definition.title + button.addAction( + UIAction { _ in onSelected(definition) }, + for: .touchUpInside + ) + return button + } + super.init(frame: .zero) + + let stack = UIStackView(arrangedSubviews: buttons) + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: NativeMessageActionSurfaceLayout.promotedHorizontalInset + ), + stack.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -NativeMessageActionSurfaceLayout.promotedHorizontalInset + ), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + heightAnchor.constraint( + greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.promotedHeight + ), + ]) + } + + @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: UIVisualEffectView + private let channel: FlutterMethodChannel + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger, + arguments args: Any? + ) { + surfaceView = UIVisualEffectView( + effect: UIBlurEffect(style: .systemMaterial) + ) + channel = FlutterMethodChannel( + name: "buzz/native_message_action_surface/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + surfaceView.frame = frame + surfaceView.clipsToBounds = true + surfaceView.layer.cornerRadius = 26 + surfaceView.layer.cornerCurve = .continuous + surfaceView.layer.borderWidth = 0.5 + surfaceView.layer.borderColor = UIColor.separator.withAlphaComponent(0.45).cgColor + surfaceView.accessibilityViewIsModal = true + + let actionArguments = (args as? [String: Any])?["actions"] as? [[String: Any]] + let actions = + actionArguments?.compactMap( + NativeMessageActionDefinition.init(arguments:) + ) ?? [] + install(actions: actions) + } + + func view() -> UIView { + surfaceView + } + + private func install(actions: [NativeMessageActionDefinition]) { + 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 + + surfaceView.contentView.addSubview(scrollView) + scrollView.addSubview(stack) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: surfaceView.contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: surfaceView.contentView.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: surfaceView.contentView.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: surfaceView.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(makeSeparator()) + } + if group == .promoted { + stack.addArrangedSubview(makePromotedRow(actions: groupActions)) + } else { + for definition in groupActions { + stack.addArrangedSubview(makeActionRow(definition: definition)) + } + } + installedGroup = true + } + } + + private func makePromotedRow( + actions: [NativeMessageActionDefinition] + ) -> UIView { + NativeMessageActionPromotedRow(actions: actions) { [weak self] definition in + self?.select(definition) + } + } + + private func makeActionRow( + definition: NativeMessageActionDefinition + ) -> UIView { + NativeMessageActionRowControl(definition: definition) { [weak self] in + self?.select(definition) + } + } + + private func makeSeparator() -> UIView { + NativeMessageActionSeparatorView() + } + + private func select(_ definition: NativeMessageActionDefinition) { + channel.invokeMethod("selected", arguments: ["id": definition.id]) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e1c2ce00f6..88532fb019 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -7,6 +7,289 @@ import XCTest class RunnerTests: XCTestCase { + @available(iOS 16.0, *) + @MainActor + func testNativeMessageActionsUseTheComposedNativeSurfaceLayout() throws { + let definitions = try [ + makeNativeMessageAction( + id: "reply", + title: "Reply", + symbol: "arrowshape.turn.up.left", + group: "promoted" + ), + makeNativeMessageAction( + id: "copyLink", + title: "Copy link", + symbol: "link", + group: "promoted" + ), + makeNativeMessageAction( + id: "remindMe", + title: "Remind me", + symbol: "bell", + group: "promoted" + ), + makeNativeMessageAction( + id: "markUnread", + title: "Mark unread", + symbol: "envelope.badge", + group: "triage" + ), + makeNativeMessageAction( + id: "copyText", + title: "Copy text", + symbol: "doc.on.doc", + group: "export" + ), + makeNativeMessageAction( + id: "edit", + title: "Edit message", + symbol: "pencil", + group: "manage" + ), + makeNativeMessageAction( + id: "delete", + title: "Delete message", + symbol: "trash", + group: "manage", + destructive: true + ), + ] + + XCTAssertEqual( + NativeMessageActionSurfaceLayout.preferredHeight(actions: definitions), + 281.5 + ) + XCTAssertEqual(NativeMessageActionSurfaceLayout.surfaceWidth, 288) + XCTAssertEqual( + NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions), + [.promoted, .triage, .export, .manage] + ) + XCTAssertEqual( + NativeMessageActionSurfaceLayout.separatorCount(actions: definitions), + 3 + ) + XCTAssertEqual(definitions.first?.group, .promoted) + XCTAssertTrue(definitions.last?.isDestructive == true) + } + + func testNativeMessageActionSurfaceUsesDynamicSystemTypographyAndSymbols() { + XCTAssertEqual( + NativeMessageActionSurfaceStyle.promotedTextStyle, + .footnote + ) + XCTAssertEqual(NativeMessageActionSurfaceStyle.rowTextStyle, .body) + XCTAssertEqual( + NativeMessageActionSurfaceStyle.promotedFont.fontDescriptor.object( + forKey: .textStyle + ) as? String, + UIFont.TextStyle.footnote.rawValue + ) + XCTAssertEqual( + NativeMessageActionSurfaceStyle.rowFont.fontDescriptor.object( + forKey: .textStyle + ) as? String, + UIFont.TextStyle.body.rawValue + ) + XCTAssertNil(NativeMessageActionSurfaceStyle.defaultSymbolConfiguration) + } + + @available(iOS 16.0, *) + @MainActor + func testNativeMessageActionRowsShareIconAndTextColumns() throws { + let definitions = try [ + makeNativeMessageAction( + id: "markUnread", + title: "Mark unread", + symbol: "envelope.badge", + group: "triage" + ), + makeNativeMessageAction( + id: "followThread", + title: "Follow thread", + symbol: "bell", + group: "triage" + ), + makeNativeMessageAction( + id: "copyText", + title: "Copy text", + symbol: "doc.on.doc", + group: "export" + ), + ] + let rows = definitions.map { + NativeMessageActionRowControl(definition: $0, onSelected: {}) + } + + for row in rows { + row.frame = CGRect( + x: 0, + y: 0, + width: NativeMessageActionSurfaceLayout.surfaceWidth, + height: NativeMessageActionSurfaceLayout.rowHeight + ) + row.layoutIfNeeded() + } + + let iconAlignmentCenters = rows.map { + let frame = $0.actionImageView.convert($0.actionImageView.bounds, to: $0) + return frame.inset(by: $0.actionImageView.alignmentRectInsets).midX + } + let textLeadingEdges = rows.map { + $0.actionTitleLabel.convert($0.actionTitleLabel.bounds, to: $0).minX + } + let expectedIconCenter = + NativeMessageActionSurfaceLayout.rowHorizontalInset + + (NativeMessageActionSurfaceLayout.rowIconColumnWidth / 2) + let expectedTextLeading = + NativeMessageActionSurfaceLayout.rowHorizontalInset + + NativeMessageActionSurfaceLayout.rowIconColumnWidth + + NativeMessageActionSurfaceLayout.rowIconToTextSpacing + + for iconCenter in iconAlignmentCenters { + XCTAssertEqual(iconCenter, expectedIconCenter, accuracy: 0.01) + } + for textLeadingEdge in textLeadingEdges { + XCTAssertEqual(textLeadingEdge, expectedTextLeading, accuracy: 0.01) + } + XCTAssertTrue( + rows.allSatisfy { + $0.actionTitleLabel.adjustsFontForContentSizeCategory + }) + } + + @available(iOS 16.0, *) + @MainActor + func testNativeMessageActionSeparatorsShareGeometry() { + let separators = [ + NativeMessageActionSeparatorView(), + NativeMessageActionSeparatorView(), + NativeMessageActionSeparatorView(), + ] + + for separator in separators { + separator.frame = CGRect( + x: 0, + y: 0, + width: NativeMessageActionSurfaceLayout.surfaceWidth, + height: NativeMessageActionSurfaceLayout.separatorHeight + ) + separator.layoutIfNeeded() + } + + let lineFrames = separators.map { $0.lineView.frame } + XCTAssertTrue(lineFrames.dropFirst().allSatisfy { $0 == lineFrames.first }) + XCTAssertEqual( + lineFrames.first?.minX, + NativeMessageActionSurfaceLayout.separatorHorizontalInset + ) + XCTAssertEqual( + lineFrames.first?.width, + NativeMessageActionSurfaceLayout.surfaceWidth + - (NativeMessageActionSurfaceLayout.separatorHorizontalInset * 2) + ) + XCTAssertEqual( + lineFrames.first?.height, + NativeMessageActionSurfaceLayout.separatorHeight + ) + } + + @available(iOS 16.0, *) + @MainActor + func testPromotedActionsStayInsideTheDividerBoundsAndShareWidth() throws { + let definitions = try [ + makeNativeMessageAction( + id: "reply", + title: "Reply", + symbol: "arrowshape.turn.up.left", + group: "promoted" + ), + makeNativeMessageAction( + id: "copyLink", + title: "Copy link", + symbol: "link", + group: "promoted" + ), + makeNativeMessageAction( + id: "remindMe", + title: "Remind me", + symbol: "bell", + group: "promoted" + ), + ] + let row = NativeMessageActionPromotedRow( + actions: definitions, + onSelected: { _ in } + ) + row.frame = CGRect( + x: 0, + y: 0, + width: NativeMessageActionSurfaceLayout.surfaceWidth, + height: NativeMessageActionSurfaceLayout.promotedHeight + ) + row.layoutIfNeeded() + + let buttonFrames = row.buttons.map { button in + button.convert(button.bounds, to: row) + } + let firstFrame = try XCTUnwrap(buttonFrames.first) + let lastFrame = try XCTUnwrap(buttonFrames.last) + let expectedWidth = + (NativeMessageActionSurfaceLayout.surfaceWidth + - (NativeMessageActionSurfaceLayout.promotedHorizontalInset * 2)) + / CGFloat(definitions.count) + + XCTAssertEqual( + firstFrame.minX, + NativeMessageActionSurfaceLayout.separatorHorizontalInset, + accuracy: 0.01 + ) + XCTAssertEqual( + lastFrame.maxX, + NativeMessageActionSurfaceLayout.surfaceWidth + - NativeMessageActionSurfaceLayout.separatorHorizontalInset, + accuracy: 0.01 + ) + for frame in buttonFrames { + XCTAssertEqual(frame.width, expectedWidth, accuracy: 0.01) + } + let remindMeLabel = try XCTUnwrap(row.buttons.last?.titleLabel) + let remindMeInsets = try XCTUnwrap(row.buttons.last?.configuration).contentInsets + XCTAssertLessThanOrEqual( + remindMeLabel.intrinsicContentSize.width + + remindMeInsets.leading + + remindMeInsets.trailing, + lastFrame.width + ) + for index in 0..<(buttonFrames.count - 1) { + XCTAssertEqual( + buttonFrames[index].maxX, + buttonFrames[index + 1].minX, + accuracy: 0.01 + ) + } + } + + private func makeNativeMessageAction( + id: String, + title: String, + symbol: String, + group: String, + destructive: Bool = false + ) throws -> NativeMessageActionDefinition { + try XCTUnwrap( + NativeMessageActionDefinition( + arguments: [ + "id": id, + "title": title, + "symbol": symbol, + "group": group, + "destructive": destructive, + ] + ) + ) + } + func testRelativeTrackInsertionTimesPreserveAudioDelay() { let times = AppDelegate.relativeTrackInsertionTimes( videoStart: CMTime(seconds: 1, preferredTimescale: 600), diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c0b64f3e40..f9a6d2976c 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'dart:math' show min; -import 'dart:ui'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/rendering.dart' + show RenderRepaintBoundary, ScrollDirection; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index ac14f1d0a1..9adde92a7c 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -1,6 +1,6 @@ part of '../channel_detail_page.dart'; -class _MessageBubble extends ConsumerWidget { +class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; final Map channelNames; @@ -23,6 +23,27 @@ class _MessageBubble extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final actionSnapshotKey = useMemoized(GlobalKey.new, const []); + final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context); + final nativeMenuPresented = useState(false); + + Future captureActionSnapshot() async { + await WidgetsBinding.instance.endOfFrame; + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + throw StateError('Message snapshot is unavailable'); + } + return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio); + } + + Rect? actionSnapshotRect() { + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + // Watch only this user's profile to avoid rebuilding on unrelated cache changes. final pk = message.pubkey.toLowerCase(); final profile = @@ -33,7 +54,6 @@ class _MessageBubble extends ConsumerWidget { currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); - // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); final knownAgentPubkeys = agentPubkeysWithProfileOwners( @@ -64,7 +84,9 @@ class _MessageBubble extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); - void openMessageActions(Rect anchorRect) { + void openMessageActions(Rect _) { + final anchorRect = actionSnapshotRect(); + if (anchorRect == null) return; showMessageActions( context: context, ref: ref, @@ -76,6 +98,9 @@ class _MessageBubble extends ConsumerWidget { isMember: isMember, isArchived: isArchived, anchorRect: anchorRect, + captureAnchorSnapshot: captureActionSnapshot, + onPopoverPresented: () => nativeMenuPresented.value = true, + onPopoverDismissed: () => nativeMenuPresented.value = false, ); } @@ -88,170 +113,206 @@ class _MessageBubble extends ConsumerWidget { // gutter. InkWell still clips its ink to [borderRadius], while leaving // overflowing message content visible. clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('message-row-${message.id}'), - onLongPress: openMessageActions, - borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - // Tap opens the thread; long-press still opens the action sheet. - // MessageContent handles mention, channel-link, and media taps. - onTap: allMessages == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + child: Opacity( + key: ValueKey('message-native-source-${message.id}'), + opacity: nativeMenuPresented.value ? 0 : 1, + child: MessageLongPressInkWell( + key: ValueKey('message-row-${message.id}'), + onLongPress: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + // Tap opens the thread; long-press still opens the action sheet. + // MessageContent handles mention, channel-link, and media taps. + onTap: allMessages == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), ), - ), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _UserAvatar( - profile: profile, - pubkey: message.pubkey, - ), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + KeyedSubtree( + key: ValueKey('message-action-snapshot-${message.id}'), + child: RepaintBoundary( + key: actionSnapshotKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _UserAvatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], ), ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: + currentChannelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), + ), + ], ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, ), ), - ], + ], + ), ), ), - ), - ], + if (message.reactions.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + ), + child: ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b05126f314..78ed1ee3ab 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -574,7 +574,7 @@ class _JumpToLatestButton extends StatelessWidget { child: ClipRRect( borderRadius: borderRadius, child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( key: const ValueKey('channel-jump-to-latest-surface'), decoration: BoxDecoration( diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index dc60bcd1b1..ca2e34677a 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -21,10 +21,29 @@ class _SystemMessageRow extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final spotlightKey = useMemoized(() => GlobalKey()); + final actionSnapshotKey = useMemoized(GlobalKey.new, const []); + final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context); + final popoverPresented = useState(false); final systemEvent = message.systemEvent; if (systemEvent == null) return const SizedBox.shrink(); + Future captureActionSnapshot() async { + await WidgetsBinding.instance.endOfFrame; + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + throw StateError('System message snapshot is unavailable'); + } + return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio); + } + + Rect? actionSnapshotRect() { + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + final userCache = ref.watch(userCacheProvider); final sourceMessages = groupedMessages ?? [message]; final groupedMembership = _membershipDisplayEvent(sourceMessages); @@ -79,13 +98,7 @@ class _SystemMessageRow extends HookConsumerWidget { } void openReactionPopover(Rect anchorRect) { - final spotlightRenderObject = spotlightKey.currentContext - ?.findRenderObject(); - final spotlightRect = - spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize - ? spotlightRenderObject.localToGlobal(Offset.zero) & - spotlightRenderObject.size - : anchorRect; + final spotlightRect = actionSnapshotRect() ?? anchorRect; showMessageActions( context: context, ref: ref, @@ -97,12 +110,15 @@ class _SystemMessageRow extends HookConsumerWidget { isMember: isMember, isArchived: isArchived, anchorRect: spotlightRect, + captureAnchorSnapshot: captureActionSnapshot, popoverSpotlightPadding: EdgeInsets.fromLTRB( Grid.xxs, Grid.xxs, Grid.xxs, reactions.isEmpty ? Grid.xxs : Grid.quarter, ), + onPopoverPresented: () => popoverPresented.value = true, + onPopoverDismissed: () => popoverPresented.value = false, ); } @@ -110,78 +126,82 @@ class _SystemMessageRow extends HookConsumerWidget { color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), clipBehavior: Clip.antiAlias, - child: MessageLongPressInkWell( - key: ValueKey('system-message-row-${message.id}'), - onLongPress: openReactionPopover, - borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - child: Padding( - padding: EdgeInsets.only( - top: usesMessageStyleLayout ? Grid.xs : Grid.xxs, - bottom: usesMessageStyleLayout ? 0 : Grid.xxs, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - KeyedSubtree( - key: spotlightKey, - child: groupedMembership != null - ? _MembershipSystemMessageContent( - event: groupedMembership, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - ) - : messageStyleActor != null && - messageStyleActor.isNotEmpty && - messageStyleAction != null - ? _MessageStyleSystemMessageContent( - displayPubkey: messageStyleActor, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - actionSpans: [TextSpan(text: messageStyleAction)], - ) - : Row( - children: [ - _systemEventAvatar(context, systemEvent, userCache), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - systemEvent.describe(resolveLabel), - style: systemMessageBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, + child: Opacity( + key: ValueKey('system-message-native-source-${message.id}'), + opacity: popoverPresented.value ? 0 : 1, + child: MessageLongPressInkWell( + key: ValueKey('system-message-row-${message.id}'), + onLongPress: openReactionPopover, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + child: Padding( + padding: EdgeInsets.only( + top: usesMessageStyleLayout ? Grid.xs : Grid.xxs, + bottom: usesMessageStyleLayout ? 0 : Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RepaintBoundary( + key: actionSnapshotKey, + child: groupedMembership != null + ? _MembershipSystemMessageContent( + event: groupedMembership, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + : messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null + ? _MessageStyleSystemMessageContent( + displayPubkey: messageStyleActor, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: [TextSpan(text: messageStyleAction)], + ) + : Row( + children: [ + _systemEventAvatar(context, systemEvent, userCache), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + systemEvent.describe(resolveLabel), + style: systemMessageBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), - ), - _messageTimestamp( - context, - message.createdAt, - key: ValueKey( - 'system-message-timestamp-${message.id}', + _messageTimestamp( + context, + message.createdAt, + key: ValueKey( + 'system-message-timestamp-${message.id}', + ), ), - ), - ], - ), - ), - if (reactions.isNotEmpty) - Padding( - padding: EdgeInsets.only( - left: - (usesMessageStyleLayout ? messageAvatarSize : 36) + - (usesMessageStyleLayout - ? messageAvatarContentGap - : Grid.xxs), - ), - child: ReactionRow( - messageId: message.id, - reactions: reactions, - onToggle: groupedMessages == null - ? (emoji) => toggleReaction(ref, message, emoji) - : toggleGroupedReaction, - ), + ], + ), ), - ], + if (reactions.isNotEmpty) + Padding( + padding: EdgeInsets.only( + left: + (usesMessageStyleLayout ? messageAvatarSize : 36) + + (usesMessageStyleLayout + ? messageAvatarContentGap + : Grid.xxs), + ), + child: ReactionRow( + messageId: message.id, + reactions: reactions, + onToggle: groupedMessages == null + ? (emoji) => toggleReaction(ref, message, emoji) + : toggleGroupedReaction, + ), + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index e09f9478a7..3bea3a6c7f 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; -import 'dart:ui'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path_provider/path_provider.dart'; @@ -37,6 +38,9 @@ import 'thread_follows/thread_follows_provider.dart'; import 'timeline_message.dart'; part 'message_actions/reaction_popover.dart'; +part 'message_actions/ios_native_message_actions.dart'; +part 'message_actions/ios_message_actions_popover.dart'; +part 'message_actions/message_actions_sheet.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. @@ -53,10 +57,38 @@ void showMessageActions({ bool isMember = false, bool isArchived = false, Rect? anchorRect, + Future Function()? captureAnchorSnapshot, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), + VoidCallback? onPopoverPresented, + VoidCallback? onPopoverDismissed, }) { final hasReactionOnlyActions = message.isSystem && !canManageMessage; if (anchorRect != null && hasReactionOnlyActions) { + if (Theme.of(context).platform == TargetPlatform.iOS && + captureAnchorSnapshot != null) { + unawaited( + _showIosReactionOnlyPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: popoverSpotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ).then((shown) { + if (shown || !context.mounted) return; + _showMessageReactionPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + spotlightPadding: popoverSpotlightPadding, + ); + }), + ); + return; + } _showMessageReactionPopover( context: context, ref: ref, @@ -67,109 +99,53 @@ void showMessageActions({ return; } - showBuzzModalBottomSheet( + if (anchorRect != null && + Theme.of(context).platform == TargetPlatform.iOS && + captureAnchorSnapshot != null) { + unawaited( + _showIosMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: popoverSpotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ).then((shown) { + if (shown || !context.mounted) return; + _showMessageActionsSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + }), + ); + return; + } + + _showMessageActionsSheet( context: context, - isScrollControlled: true, - showDragHandle: true, - showCloseButton: false, - builder: (sheetContext) => SafeArea( - child: IconTheme.merge( - data: const IconThemeData(size: 22), - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _QuickReactionRow( - message: message, - sheetContext: sheetContext, - pageContext: context, - pageRef: ref, - ), - const SizedBox(height: Grid.xs), - if (!message.isSystem) ...[ - // Fast actions: respond now, hand off context, defer. - _FastActionsRow( - message: message, - channelId: channelId, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - pageContext: context, - ), - const SizedBox(height: Grid.xs), - // Triage: come back to this message later. - _MarkReadUnreadTile(message: message, channelId: channelId), - _FollowThreadTile(message: message), - const SheetDivider(), - // Export: take the content out of the conversation. - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - // Copy to clipboard - final data = ClipboardData(text: message.content); - Clipboard.setData(data); - }, - ), - ], - if (canManageMessage) ...[ - if (!message.isSystem) const SheetDivider(), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(LucideIcons.pencil), - title: const Text('Edit message'), - onTap: () { - Navigator.of(sheetContext).pop(); - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }, - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, - ), - title: Text( - 'Delete message', - style: TextStyle(color: sheetContext.colors.error), - ), - onTap: () { - Navigator.of(sheetContext).pop(); - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - ); - }, - ), - ], - ], - ), - ), - ), - ), - ), - ), + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ); } @@ -644,6 +620,11 @@ class _FastActionTile extends StatelessWidget { /// The emoji shown are the user's own frequently-used set (desktop's /// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so /// the row is full on a fresh install. +const _quickReactionEmojiLimit = 5; +const _quickReactionDesiredTargetSize = 52.0; +const _quickReactionMinimumTargetSize = 44.0; +const _quickReactionGlyphSize = 28.0; + class _QuickReactionRow extends ConsumerWidget { final TimelineMessage message; @@ -672,22 +653,26 @@ class _QuickReactionRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final usesPopoverTreatment = presentationAnimation != null; final customEmoji = ref.watch(customEmojiListProvider); final emoji = quickReactionEmoji( ref.watch(recentEmojiProvider), customShortcodes: { for (final entry in customEmoji) entry.shortcode.toLowerCase(), }, + limit: _quickReactionEmojiLimit, ); final customByShortcode = { for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry, }; void react(String value) { - // The generic picker is also used for composing and statuses. Record - // recency here, at the reaction call site, so only reactions drive the - // quick-reaction row. - pageRef.read(recentEmojiProvider.notifier).record(value); + // Keep the composed popover's tray stable across repeated opens. The + // existing sheet continues to update the frequently-used ranking exactly + // as it did before the iOS presentation was introduced. + if (!usesPopoverTreatment) { + pageRef.read(recentEmojiProvider.notifier).record(value); + } // The sheet is on its way out, so the burst can't come from this tile — // hand it to the pill that's about to appear in the timeline. armReactionBurst(pageRef, message, value); @@ -696,17 +681,19 @@ class _QuickReactionRow extends ConsumerWidget { return LayoutBuilder( builder: (context, constraints) { - const desiredCircleSize = 52.0; - const minimumCircleSize = 44.0; + final maximumGap = usesPopoverTreatment ? 0.0 : Grid.twelve; final itemCount = emoji.length + 1; final gapCount = itemCount - 1; final circleSize = - ((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount) - .clamp(minimumCircleSize, desiredCircleSize) + ((constraints.maxWidth - (maximumGap * gapCount)) / itemCount) + .clamp( + _quickReactionMinimumTargetSize, + _quickReactionDesiredTargetSize, + ) .toDouble(); final gap = ((constraints.maxWidth - (circleSize * itemCount)) / gapCount) - .clamp(0.0, Grid.twelve) + .clamp(0.0, maximumGap) .toDouble(); final circles = [ for (var index = 0; index < emoji.length; index++) @@ -716,6 +703,8 @@ class _QuickReactionRow extends ConsumerWidget { index: index, child: _QuickReactionCircle( size: circleSize, + semanticLabel: emoji[index], + showIdleBackground: !usesPopoverTreatment, onTap: () { Navigator.of(sheetContext).pop(); react(emoji[index]); @@ -732,12 +721,14 @@ class _QuickReactionRow extends ConsumerWidget { index: emoji.length, child: _QuickReactionCircle( size: circleSize, + semanticLabel: 'Add reaction', + showIdleBackground: !usesPopoverTreatment, onTap: () { Navigator.of(sheetContext).pop(); showEmojiPicker(context: pageContext, onSelect: react); }, child: Icon( - LucideIcons.plus, + LucideIcons.smilePlus, size: 24, color: context.colors.onSurfaceVariant, ), @@ -817,47 +808,60 @@ class _QuickReactionGlyph extends StatelessWidget { return CustomEmojiImage( shortcode: custom.shortcode, url: custom.url, - size: 24, + size: _quickReactionGlyphSize, ); } } - return NativeEmojiGlyph(emoji: value, size: 24); + return NativeEmojiGlyph(emoji: value, size: _quickReactionGlyphSize); } } -/// Circular filled tap target shared by the quick-reaction emojis and the -/// "+" emoji-picker tile — one treatment, one place to change it. +/// Circular tap target shared by the quick-reaction emojis and the "+" +/// emoji-picker tile. Popovers keep the idle surface transparent while sheets +/// retain their filled treatment; both preserve the same hit and press areas. class _QuickReactionCircle extends StatelessWidget { final VoidCallback onTap; final Widget child; final double size; + final String semanticLabel; + final bool showIdleBackground; const _QuickReactionCircle({ required this.onTap, required this.child, required this.size, + required this.semanticLabel, + required this.showIdleBackground, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Color.lerp( - context.colors.surface, - context.colors.primaryContainer, - 0.78, + final filledColor = Color.lerp( + context.colors.surface, + context.colors.primaryContainer, + 0.78, + ); + return Semantics( + button: true, + label: semanticLabel, + excludeSemantics: true, + child: SizedBox.square( + dimension: size, + child: Material( + color: showIdleBackground ? filledColor : Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + highlightColor: context.colors.primary.withValues(alpha: 0.12), + splashColor: context.colors.primary.withValues(alpha: 0.14), + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + child: Center(child: child), ), - shape: BoxShape.circle, ), - child: child, ), ); } diff --git a/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart b/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart new file mode 100644 index 0000000000..56784ba867 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart @@ -0,0 +1,590 @@ +part of '../message_actions.dart'; + +const _iosActionPromotedHeight = 72.0; +const _iosActionRowHeight = 50.0; +const _iosActionSeparatorHeight = 0.5; +const _iosActionVerticalInset = 4.0; +const _iosContextGap = Grid.twelve; +const _iosContextMenuMaxWidth = 288.0; +const _iosContextPreviewMaxWidth = 358.0; +const _iosContextPreviewInset = Grid.xxs; +const _iosContextTransitionDuration = Duration(milliseconds: 240); +bool _iosMessageActionsPresentationInFlight = false; + +Future _showIosReactionOnlyPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required Rect anchorRect, + required Future Function() captureAnchorSnapshot, + required EdgeInsets spotlightPadding, + required VoidCallback? onPopoverPresented, + required VoidCallback? onPopoverDismissed, +}) async { + if (_iosMessageActionsPresentationInFlight) return true; + _iosMessageActionsPresentationInFlight = true; + try { + final ui.Image snapshot; + try { + snapshot = await captureAnchorSnapshot(); + } catch (_) { + return false; + } + if (!context.mounted) { + snapshot.dispose(); + return false; + } + + unawaited(HapticFeedback.mediumImpact()); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + onPopoverPresented?.call(); + try { + await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Dismiss reaction picker', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : _iosContextTransitionDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) => + _IosMessageActionsPopover( + anchorRect: anchorRect, + anchorSnapshot: snapshot, + spotlightPadding: spotlightPadding, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + actions: const [], + ), + ); + } finally { + snapshot.dispose(); + if (context.mounted) onPopoverDismissed?.call(); + } + return true; + } finally { + _iosMessageActionsPresentationInFlight = false; + } +} + +Future _showIosMessageActionsPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect anchorRect, + required Future Function() captureAnchorSnapshot, + required EdgeInsets spotlightPadding, + required VoidCallback? onPopoverPresented, + required VoidCallback? onPopoverDismissed, +}) async { + if (_iosMessageActionsPresentationInFlight) return true; + _iosMessageActionsPresentationInFlight = true; + try { + return await _presentIosMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: spotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ); + } finally { + _iosMessageActionsPresentationInFlight = false; + } +} + +Future _presentIosMessageActionsPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect anchorRect, + required Future Function() captureAnchorSnapshot, + required EdgeInsets spotlightPadding, + required VoidCallback? onPopoverPresented, + required VoidCallback? onPopoverDismissed, +}) async { + if (!await _supportsIosNativeMessageActionSurface() || !context.mounted) { + return false; + } + + final actions = _buildIosNativeMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + if (actions.isEmpty) return false; + + final ui.Image snapshot; + try { + snapshot = await captureAnchorSnapshot(); + } catch (_) { + return false; + } + if (!context.mounted) { + snapshot.dispose(); + return false; + } + + unawaited(HapticFeedback.mediumImpact()); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + onPopoverPresented?.call(); + String? selectedActionId; + try { + selectedActionId = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Dismiss message actions', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : _iosContextTransitionDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) => + _IosMessageActionsPopover( + anchorRect: anchorRect, + anchorSnapshot: snapshot, + spotlightPadding: spotlightPadding, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + actions: actions, + ), + ); + } finally { + snapshot.dispose(); + if (context.mounted) onPopoverDismissed?.call(); + } + + final selectedAction = actions + .where((action) => action.id == selectedActionId) + .firstOrNull; + if (selectedAction != null) await selectedAction.onSelected(); + return true; +} + +class _IosMessageActionsPopover extends StatelessWidget { + final Rect anchorRect; + final ui.Image anchorSnapshot; + final EdgeInsets spotlightPadding; + final Animation animation; + final TimelineMessage message; + final BuildContext pageContext; + final WidgetRef pageRef; + final List<_IosNativeMessageAction> actions; + + const _IosMessageActionsPopover({ + required this.anchorRect, + required this.anchorSnapshot, + required this.spotlightPadding, + required this.animation, + required this.message, + required this.pageContext, + required this.pageRef, + required this.actions, + }); + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final safeLeft = mediaQuery.padding.left + Grid.xxs; + final safeRight = + constraints.maxWidth - mediaQuery.padding.right - Grid.xxs; + final safeTop = mediaQuery.padding.top + Grid.xxs; + final safeBottom = + constraints.maxHeight - mediaQuery.padding.bottom - Grid.xxs; + final availableWidth = safeRight - safeLeft; + final availableHeight = safeBottom - safeTop; + final hasActionMenu = actions.isNotEmpty; + final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); + final menuWidth = hasActionMenu + ? math.min(_iosContextMenuMaxWidth, availableWidth) + : 0.0; + final menuHeight = hasActionMenu + ? math.min( + _iosActionSurfacePreferredHeight(actions), + math.max( + _iosActionRowHeight * 3, + availableHeight - + _reactionTrayMaxHeight - + (_iosContextGap * 2) - + 56, + ), + ) + : 0.0; + final contextGapCount = hasActionMenu ? 2 : 1; + final previewMaximumHeight = math.max( + 56, + availableHeight - + _reactionTrayMaxHeight - + menuHeight - + (_iosContextGap * contextGapCount), + ); + final previewInsetExtent = _iosContextPreviewInset * 2; + final previewScale = math.min( + 1, + math.min( + (math.min(_iosContextPreviewMaxWidth, availableWidth) - + previewInsetExtent) / + anchorRect.width, + (previewMaximumHeight - previewInsetExtent) / anchorRect.height, + ), + ); + final previewContentSize = Size( + anchorRect.width * previewScale, + anchorRect.height * previewScale, + ); + final previewSize = Size( + previewContentSize.width + previewInsetExtent, + previewContentSize.height + previewInsetExtent, + ); + final trayLeft = safeLeft + ((availableWidth - trayWidth) / 2); + final previewLeft = + safeLeft + ((availableWidth - previewSize.width) / 2); + final totalHeight = + _reactionTrayMaxHeight + + _iosContextGap + + previewSize.height + + (hasActionMenu ? _iosContextGap + menuHeight : 0); + final contentTop = safeTop + ((availableHeight - totalHeight) / 2); + final targetPreviewRect = Rect.fromLTWH( + previewLeft, + contentTop + _reactionTrayMaxHeight + _iosContextGap, + previewSize.width, + previewSize.height, + ); + final menuRect = hasActionMenu + ? Rect.fromLTWH( + safeLeft + ((availableWidth - menuWidth) / 2), + targetPreviewRect.bottom + _iosContextGap, + menuWidth, + menuHeight, + ) + : null; + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final progress = Curves.easeOutCubic.transform( + animation.value, + ); + return BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 14 * progress, + sigmaY: 14 * progress, + ), + child: ColoredBox( + color: context.colors.inverseSurface.withValues( + alpha: 0.12 * progress, + ), + ), + ); + }, + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + ), + ), + AnimatedBuilder( + animation: animation, + builder: (context, child) { + final movement = Curves.easeInOutCubic.transform( + animation.value, + ); + final displayRect = Rect.lerp( + anchorRect.inflate(_iosContextPreviewInset), + targetPreviewRect, + movement, + )!; + return Positioned.fromRect(rect: displayRect, child: child!); + }, + child: DecoratedBox( + key: const ValueKey('message-action-preview-container'), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.7), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(_iosContextPreviewInset), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.xs), + child: RawImage( + image: anchorSnapshot, + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ), + Positioned( + left: trayLeft, + top: contentTop, + width: trayWidth, + height: _reactionTrayMaxHeight, + child: _IosReactionTray( + animation: animation, + trayWidth: trayWidth, + message: message, + pageContext: pageContext, + pageRef: pageRef, + ), + ), + if (menuRect != null) + Positioned.fromRect( + rect: menuRect, + child: AnimatedBuilder( + animation: animation, + child: _IosNativeMessageActionSurface( + actions: actions, + onSelected: (actionId) => + Navigator.of(context).pop(actionId), + ), + builder: (context, child) { + final appearance = const Interval( + 0.12, + 0.72, + curve: Curves.easeOutCubic, + ).transform(animation.value); + return Opacity( + opacity: appearance, + child: Transform.scale( + alignment: Alignment.topLeft, + scale: ui.lerpDouble(0.96, 1, appearance)!, + child: child, + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _IosReactionTray extends StatelessWidget { + final Animation animation; + final double trayWidth; + final TimelineMessage message; + final BuildContext pageContext; + final WidgetRef pageRef; + + const _IosReactionTray({ + required this.animation, + required this.trayWidth, + required this.message, + required this.pageContext, + required this.pageRef, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: _QuickReactionRow( + message: message, + sheetContext: context, + pageContext: pageContext, + pageRef: pageRef, + presentationAnimation: animation, + ), + ), + builder: (context, child) { + final appearance = const Interval( + 0.02, + 0.34, + curve: Curves.easeOutCubic, + ).transform(animation.value); + final expansion = _reactionSpringCurve.transform( + const Interval(0.08, 0.92).transform(animation.value), + ); + return Opacity( + opacity: appearance, + child: Transform.scale( + alignment: Alignment.centerLeft, + scale: ui.lerpDouble(0.95, 1, appearance)!, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + key: const ValueKey('reaction-popover-tray'), + width: ui.lerpDouble( + _reactionTrayMaxHeight, + trayWidth, + expansion, + ), + height: _reactionTrayMaxHeight, + child: Material( + color: context.colors.surface, + surfaceTintColor: Colors.transparent, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.2), + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: trayWidth, + maxWidth: trayWidth, + minHeight: _reactionTrayMaxHeight, + maxHeight: _reactionTrayMaxHeight, + child: child, + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _IosNativeMessageActionSurface extends HookWidget { + final List<_IosNativeMessageAction> actions; + final ValueChanged onSelected; + + const _IosNativeMessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + if (!Platform.isIOS) { + return _TestIosNativeMessageActionSurface( + actions: actions, + onSelected: onSelected, + ); + } + + final viewId = useState(null); + useEffect(() { + final id = viewId.value; + if (id == null) return null; + final channel = MethodChannel('buzz/native_message_action_surface/$id'); + channel.setMethodCallHandler((call) async { + if (call.method != 'selected' || call.arguments is! Map) return; + final actionId = (call.arguments as Map)['id']; + if (actionId is String) onSelected(actionId); + }); + return () => channel.setMethodCallHandler(null); + }, [viewId.value, onSelected]); + + return ClipRRect( + borderRadius: BorderRadius.circular(26), + child: UiKitView( + key: const ValueKey('ios-native-message-action-surface'), + viewType: 'buzz/native_message_action_surface', + creationParams: { + 'actions': [ + for (final action in actions) action.toPlatformArguments(), + ], + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) => viewId.value = id, + ), + ); + } +} + +class _TestIosNativeMessageActionSurface extends StatelessWidget { + final List<_IosNativeMessageAction> actions; + final ValueChanged onSelected; + + const _TestIosNativeMessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Material( + key: const ValueKey('ios-native-message-action-surface'), + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(26), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in actions) + SizedBox( + height: action.group == _IosNativeMessageActionGroup.promoted + ? _iosActionPromotedHeight + : _iosActionRowHeight, + child: ListTile( + key: ValueKey('ios-native-action-${action.id}'), + title: Text(action.title), + onTap: () => onSelected(action.id), + ), + ), + ], + ), + ), + ); + } +} + +double _iosActionSurfacePreferredHeight(List<_IosNativeMessageAction> actions) { + final groups = {for (final action in actions) action.group}; + final promoted = groups.contains(_IosNativeMessageActionGroup.promoted); + final rowCount = actions + .where((action) => action.group != _IosNativeMessageActionGroup.promoted) + .length; + return (_iosActionVerticalInset * 2) + + (promoted ? _iosActionPromotedHeight : 0) + + (rowCount * _iosActionRowHeight) + + (math.max(0, groups.length - 1) * _iosActionSeparatorHeight); +} diff --git a/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart b/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart new file mode 100644 index 0000000000..9dbb6524b2 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart @@ -0,0 +1,233 @@ +part of '../message_actions.dart'; + +const _nativeMessageActionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', +); + +List<_IosNativeMessageAction> _buildIosNativeMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, +}) { + final actions = <_IosNativeMessageAction>[]; + final messages = allMessages; + final canRemind = ref.read(reminderServiceProvider) != null; + + if (!message.isSystem) { + if (messages != null) { + actions.add( + _IosNativeMessageAction( + id: 'reply', + title: 'Reply', + symbol: 'arrowshape.turn.up.left', + group: _IosNativeMessageActionGroup.promoted, + onSelected: () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: messages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ); + } + actions.add( + _IosNativeMessageAction( + id: 'copyLink', + title: 'Copy link', + symbol: 'link', + group: _IosNativeMessageActionGroup.promoted, + onSelected: () { + if (!context.mounted) return; + copyToClipboard( + context, + messageLinkFor(message: message, channelId: channelId), + message: 'Message link copied', + ); + }, + ), + ); + if (canRemind) { + actions.add( + _IosNativeMessageAction( + id: 'remind', + title: 'Remind me', + symbol: 'clock', + group: _IosNativeMessageActionGroup.promoted, + onSelected: () { + if (!context.mounted) return; + showRemindMeLaterSheet( + context: Navigator.of(context, rootNavigator: true).context, + ref: ref, + target: ReminderTarget( + eventId: message.id, + channelId: channelId, + preview: message.content.characters + .take(_reminderPreviewLength) + .toString(), + authorPubkey: message.pubkey, + ), + ); + }, + ), + ); + } + + final readState = ref.read(readStateProvider); + if (readState.isReady) { + final unread = isMessageUnread( + readState, + channelId: channelId, + messageId: message.id, + createdAt: message.createdAt, + threadRootId: message.rootId, + ); + actions.add( + _IosNativeMessageAction( + id: unread ? 'markRead' : 'markUnread', + title: unread ? 'Mark read' : 'Mark unread', + symbol: unread ? 'envelope.open' : 'envelope.badge', + group: _IosNativeMessageActionGroup.triage, + onSelected: () { + final notifier = ref.read(readStateProvider.notifier); + if (unread) { + notifier.markContextRead( + msgContextKey(message.id), + message.createdAt, + ); + } else { + notifier.markContextUnread( + msgContextKey(message.id), + channelId: channelId, + ); + } + }, + ), + ); + } + + final rootId = message.rootId ?? message.id; + final following = ref.read(threadFollowsProvider).isFollowing(rootId); + actions.add( + _IosNativeMessageAction( + id: following ? 'unfollowThread' : 'followThread', + title: following ? 'Unfollow thread' : 'Follow thread', + symbol: following ? 'bell.slash' : 'bell', + group: _IosNativeMessageActionGroup.triage, + onSelected: () { + final notifier = ref.read(threadFollowsProvider.notifier); + if (following) { + notifier.unfollowThread(rootId); + } else { + notifier.followThread(rootId); + } + }, + ), + ); + actions.add( + _IosNativeMessageAction( + id: 'copyText', + title: 'Copy text', + symbol: 'doc.on.doc', + group: _IosNativeMessageActionGroup.export, + onSelected: () => + Clipboard.setData(ClipboardData(text: message.content)), + ), + ); + } + + if (canManageMessage) { + actions.add( + _IosNativeMessageAction( + id: 'edit', + title: 'Edit message', + symbol: 'pencil', + group: _IosNativeMessageActionGroup.manage, + onSelected: () { + if (!context.mounted) return; + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ); + actions.add( + _IosNativeMessageAction( + id: 'delete', + title: 'Delete message', + symbol: 'trash', + group: _IosNativeMessageActionGroup.manage, + destructive: true, + onSelected: () { + if (!context.mounted) return; + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + ); + }, + ), + ); + } + + return actions; +} + +enum _IosNativeMessageActionGroup { promoted, triage, export, manage } + +class _IosNativeMessageAction { + final String id; + final String title; + final String symbol; + final _IosNativeMessageActionGroup group; + final bool destructive; + final FutureOr Function() onSelected; + + const _IosNativeMessageAction({ + required this.id, + required this.title, + required this.symbol, + required this.group, + required this.onSelected, + this.destructive = false, + }); + + Map toPlatformArguments() => { + 'id': id, + 'title': title, + 'symbol': symbol, + 'group': group.name, + 'destructive': destructive, + }; +} + +Future _supportsIosNativeMessageActionSurface() async { + try { + return await _nativeMessageActionSurfaceChannel.invokeMethod( + 'isSupported', + ) ?? + false; + } on MissingPluginException { + return false; + } on PlatformException { + return false; + } +} diff --git a/mobile/lib/features/channels/message_actions/message_actions_sheet.dart b/mobile/lib/features/channels/message_actions/message_actions_sheet.dart new file mode 100644 index 0000000000..b307519b65 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/message_actions_sheet.dart @@ -0,0 +1,117 @@ +part of '../message_actions.dart'; + +void _showMessageActionsSheet({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, +}) { + showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + showCloseButton: false, + builder: (sheetContext) => SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _QuickReactionRow( + message: message, + sheetContext: sheetContext, + pageContext: context, + pageRef: ref, + ), + const SizedBox(height: Grid.xs), + if (!message.isSystem) ...[ + // Fast actions: respond now, hand off context, defer. + _FastActionsRow( + message: message, + channelId: channelId, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + pageContext: context, + ), + const SizedBox(height: Grid.xs), + // Triage: come back to this message later. + _MarkReadUnreadTile(message: message, channelId: channelId), + _FollowThreadTile(message: message), + const SheetDivider(), + // Export: take the content out of the conversation. + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + final data = ClipboardData(text: message.content); + Clipboard.setData(data); + }, + ), + ], + if (canManageMessage) ...[ + if (!message.isSystem) const SheetDivider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.pencil), + title: const Text('Edit message'), + onTap: () { + Navigator.of(sheetContext).pop(); + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete message', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + ); + }, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart index dfbc127861..f5ba80a7b1 100644 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ b/mobile/lib/features/channels/message_actions/reaction_popover.dart @@ -1,8 +1,11 @@ part of '../message_actions.dart'; -const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2); +const _reactionTrayMaxWidth = + (_quickReactionDesiredTargetSize * (_quickReactionEmojiLimit + 1)) + + (Grid.xxs * 2); const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2); const _reactionTraySpringAllowance = 16.0; +const _reactionTrayMessageGap = Grid.twelve; const _reactionPopoverDuration = Duration(milliseconds: 320); final _reactionSpringCurve = _SpringEaseOutCurve( duration: const Duration(milliseconds: 260), @@ -13,37 +16,75 @@ final _reactionSpringCurve = _SpringEaseOutCurve( /// /// The conversation is frosted around [anchorRect] so the selected message /// remains visually connected to the tray. -void _showMessageReactionPopover({ +_MessageReactionPopoverHandle _showMessageReactionPopover({ required BuildContext context, required WidgetRef ref, required TimelineMessage message, required Rect anchorRect, + Rect? originalAnchorRect, + ui.Image? anchorSnapshot, required EdgeInsets spotlightPadding, + bool alignTrayToAnchorStart = false, + bool triggerHaptic = true, }) { - unawaited(HapticFeedback.mediumImpact()); + if (triggerHaptic) unawaited(HapticFeedback.mediumImpact()); final reduceMotion = MediaQuery.disableAnimationsOf(context); - showGeneralDialog( + final contextReady = Completer(); + final closed = showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Dismiss reaction picker', barrierColor: Colors.transparent, transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration, transitionBuilder: (context, animation, secondaryAnimation, child) => child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageReactionPopover( - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - ), + pageBuilder: (dialogContext, animation, secondaryAnimation) { + if (!contextReady.isCompleted) contextReady.complete(dialogContext); + return _MessageReactionPopover( + anchorRect: anchorRect, + originalAnchorRect: originalAnchorRect ?? anchorRect, + anchorSnapshot: anchorSnapshot, + spotlightPadding: spotlightPadding, + alignTrayToAnchorStart: alignTrayToAnchorStart, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + ); + }, + ); + return _MessageReactionPopoverHandle( + contextReady: contextReady.future, + closed: closed, ); } -class _MessageReactionPopover extends StatelessWidget { +class _MessageReactionPopoverHandle { + final Future contextReady; + final Future closed; + bool _isOpen = true; + + _MessageReactionPopoverHandle({ + required this.contextReady, + required this.closed, + }) { + unawaited(closed.whenComplete(() => _isOpen = false)); + } + + Future close() async { + if (!_isOpen) return; + _isOpen = false; + final context = await contextReady; + if (context.mounted) Navigator.of(context).pop(); + await closed; + } +} + +class _MessageReactionPopover extends HookWidget { final Rect anchorRect; + final Rect originalAnchorRect; + final ui.Image? anchorSnapshot; final EdgeInsets spotlightPadding; + final bool alignTrayToAnchorStart; final Animation animation; final TimelineMessage message; final BuildContext pageContext; @@ -51,7 +92,10 @@ class _MessageReactionPopover extends StatelessWidget { const _MessageReactionPopover({ required this.anchorRect, + required this.originalAnchorRect, + required this.anchorSnapshot, required this.spotlightPadding, + required this.alignTrayToAnchorStart, required this.animation, required this.message, required this.pageContext, @@ -60,6 +104,11 @@ class _MessageReactionPopover extends StatelessWidget { @override Widget build(BuildContext context) { + useEffect(() { + // The popover owns the transient snapshot. Releasing it when this widget + // unmounts keeps the image alive through the complete dismissal paint. + return () => anchorSnapshot?.dispose(); + }, [anchorSnapshot]); final mediaQuery = MediaQuery.of(context); return LayoutBuilder( @@ -69,15 +118,17 @@ class _MessageReactionPopover extends StatelessWidget { constraints.maxHeight - mediaQuery.padding.bottom - mediaQuery.viewInsets.bottom; - final availableAbove = anchorRect.top - Grid.xxs - safeTop; - final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs; + final availableAbove = + anchorRect.top - _reactionTrayMessageGap - safeTop; + final availableBelow = + safeBottom - anchorRect.bottom - _reactionTrayMessageGap; final showAbove = availableAbove >= _reactionTrayMaxHeight || (availableBelow < _reactionTrayMaxHeight && availableAbove >= availableBelow); final proposedTop = showAbove - ? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs - : anchorRect.bottom + Grid.xxs; + ? anchorRect.top - _reactionTrayMaxHeight - _reactionTrayMessageGap + : anchorRect.bottom + _reactionTrayMessageGap; final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight); final top = proposedTop.clamp(safeTop, maxTop).toDouble(); final trayScaleAlignment = showAbove @@ -88,9 +139,10 @@ class _MessageReactionPopover extends StatelessWidget { constraints.maxWidth - (Grid.xxs * 2), ); final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs; - final left = (anchorRect.center.dx - (trayWidth / 2)) - .clamp(Grid.xxs, maxLeft) - .toDouble(); + final preferredLeft = alignTrayToAnchorStart + ? anchorRect.left + : anchorRect.center.dx - (trayWidth / 2); + final left = preferredLeft.clamp(Grid.xxs, maxLeft).toDouble(); return Stack( children: [ @@ -104,24 +156,81 @@ class _MessageReactionPopover extends StatelessWidget { curve: Curves.easeOutCubic, ).transform(animation.value); final sigma = 20 * blurProgress; + final background = BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: ColoredBox( + color: context.colors.inverseSurface.withValues( + alpha: 0.10 * blurProgress, + ), + ), + ); + if (anchorSnapshot != null) return background; return ClipPath( key: const ValueKey('reaction-popover-background'), clipper: _OutsideAnchorClipper( anchorRect, spotlightPadding, ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), - child: ColoredBox( - color: context.colors.inverseSurface.withValues( - alpha: 0.10 * blurProgress, - ), - ), - ), + child: background, ); }, ), ), + if (anchorSnapshot != null) + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final movement = const Interval( + 0, + 0.56, + curve: Curves.easeInOutCubic, + ).transform(animation.value); + final displayRect = Rect.lerp( + originalAnchorRect, + anchorRect, + movement, + )!; + return Stack( + children: [ + Positioned.fromRect( + rect: displayRect, + child: DecoratedBox( + key: const ValueKey( + 'message-action-preview-container', + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant + .withValues(alpha: 0.65), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.md), + child: RawImage( + image: anchorSnapshot, + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -162,7 +271,7 @@ class _MessageReactionPopover extends StatelessWidget { final springExpansion = _reactionSpringCurve.transform( expansion, ); - final width = lerpDouble( + final width = ui.lerpDouble( _reactionTrayMaxHeight, trayWidth, springExpansion, @@ -172,7 +281,7 @@ class _MessageReactionPopover extends StatelessWidget { opacity: appearance, child: Transform.scale( alignment: trayScaleAlignment, - scale: lerpDouble(0.95, 1, appearance)!, + scale: ui.lerpDouble(0.95, 1, appearance)!, child: Align( alignment: Alignment.centerLeft, child: SizedBox( diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart index d894e4a035..a63e9e92a1 100644 --- a/mobile/lib/features/channels/message_long_press_region.dart +++ b/mobile/lib/features/channels/message_long_press_region.dart @@ -1,8 +1,4 @@ -import 'dart:async'; - -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; /// An [InkWell] whose long press is observed above interactive descendants. class MessageLongPressInkWell extends StatelessWidget { @@ -35,12 +31,12 @@ class MessageLongPressInkWell extends StatelessWidget { } } -/// Detects a message long press without competing with interactive descendants. +/// Detects a message long press through Flutter's gesture arena. /// /// Links, media, reactions, and other nested controls keep their normal tap -/// gestures. Moving far enough to scroll cancels the timer; recognizing the -/// hold cancels the pointer so a descendant tap cannot fire on release. -class _MessageLongPressRegion extends HookWidget { +/// gestures. A drag lets the surrounding scrollable win, while a completed +/// hold rejects descendant taps without manually cancelling the pointer. +class _MessageLongPressRegion extends StatelessWidget { final ValueChanged onLongPress; final Widget child; @@ -51,60 +47,17 @@ class _MessageLongPressRegion extends HookWidget { @override Widget build(BuildContext context) { - final activePointer = useRef(null); - final origin = useRef(null); - final timer = useRef(null); - - void cancel() { - timer.value?.cancel(); - timer.value = null; - activePointer.value = null; - origin.value = null; - } - - useEffect(() => cancel, const []); - void recognize() { final renderObject = context.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) return; onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size); } - void handlePointerDown(PointerDownEvent event) { - if (activePointer.value != null) return; - activePointer.value = event.pointer; - origin.value = event.position; - timer.value = Timer(kLongPressTimeout, () { - final pointer = activePointer.value; - if (pointer == null) return; - timer.value = null; - activePointer.value = null; - origin.value = null; - GestureBinding.instance.cancelPointer(pointer); - recognize(); - }); - } - - void handlePointerMove(PointerMoveEvent event) { - if (event.pointer != activePointer.value) return; - final start = origin.value; - if (start == null) return; - final delta = event.position - start; - if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel(); - } - - void handlePointerEnd(PointerEvent event) { - if (event.pointer == activePointer.value) cancel(); - } - return Semantics( onLongPress: recognize, - child: Listener( + child: GestureDetector( behavior: HitTestBehavior.translucent, - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerEnd, - onPointerCancel: handlePointerEnd, + onLongPressStart: (_) => recognize(), child: child, ), ); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 2ce862ea71..f5ef0136f6 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,4 +1,7 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -34,6 +37,8 @@ import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; +part 'thread_detail_page/nested_thread_summary_row.dart'; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -592,116 +597,6 @@ ThreadSummary _buildNestedSummary( /// Tappable summary row shown below a reply that itself has replies. /// Pushes a new [ThreadDetailPage] for the nested thread. -class _NestedThreadSummaryRow extends ConsumerWidget { - final ThreadSummary summary; - final TimelineMessage replyMessage; - final List allMessages; - final String channelId; - final String? currentPubkey; - final bool isMember; - final bool isArchived; - - const _NestedThreadSummaryRow({ - required this.summary, - required this.replyMessage, - required this.allMessages, - required this.channelId, - required this.currentPubkey, - required this.isMember, - required this.isArchived, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - - return GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: replyMessage, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - child: Padding( - key: ValueKey('nested-thread-summary-${replyMessage.id}'), - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Stacked participant avatars. - SizedBox( - width: - 32.0 + - (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, - height: 32, - child: Stack( - children: [ - for (var i = 0; i < summary.participantPubkeys.length; i++) - Positioned( - left: i * 20.0, - child: SmallAvatar( - pubkey: summary.participantPubkeys[i], - userCache: userCache, - size: 32, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.primary, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - ), - TextSpan( - text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} - class _ThreadTailMetricsObserver with WidgetsBindingObserver { final VoidCallback onMetricsChanged; @@ -711,7 +606,7 @@ class _ThreadTailMetricsObserver with WidgetsBindingObserver { void didChangeMetrics() => onMetricsChanged(); } -class _ThreadMessage extends ConsumerWidget { +class _ThreadMessage extends HookConsumerWidget { final TimelineMessage message; final Map channelNames; final String channelId; @@ -741,6 +636,27 @@ class _ThreadMessage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final actionSnapshotKey = useMemoized(GlobalKey.new, const []); + final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context); + final nativeMenuPresented = useState(false); + + Future captureActionSnapshot() async { + await WidgetsBinding.instance.endOfFrame; + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + throw StateError('Message snapshot is unavailable'); + } + return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio); + } + + Rect? actionSnapshotRect() { + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + final pk = message.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? @@ -750,7 +666,6 @@ class _ThreadMessage extends ConsumerWidget { currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); - final userCache = ref.watch(userCacheProvider); final knownAgentPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), @@ -778,7 +693,9 @@ class _ThreadMessage extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); - void openMessageActions(Rect anchorRect) { + void openMessageActions(Rect _) { + final anchorRect = actionSnapshotRect(); + if (anchorRect == null) return; showMessageActions( context: context, ref: ref, @@ -790,6 +707,9 @@ class _ThreadMessage extends ConsumerWidget { isMember: isMember, isArchived: isArchived, anchorRect: anchorRect, + captureAnchorSnapshot: captureActionSnapshot, + onPopoverPresented: () => nativeMenuPresented.value = true, + onPopoverDismissed: () => nativeMenuPresented.value = false, ); } @@ -810,154 +730,196 @@ class _ThreadMessage extends ConsumerWidget { // trailing gutter. InkWell still clips its ink to [borderRadius], // while leaving overflowing message content visible. clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('thread-message-row-${message.id}'), - onLongPress: openMessageActions, - borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + child: Opacity( + key: ValueKey('thread-message-native-source-${message.id}'), + opacity: nativeMenuPresented.value ? 0 : 1, + child: MessageLongPressInkWell( + key: ValueKey('thread-message-row-${message.id}'), + onLongPress: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + KeyedSubtree( + key: ValueKey( + 'thread-message-action-snapshot-${message.id}', + ), + child: RepaintBoundary( + key: actionSnapshotKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + child: _Avatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: + FontStyle.italic, + ), + ), + ], + ], ), ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: + Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: + allMessages!, + channelId: channelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), + ), + ], ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: - isMember && - !isArchived && - (isThreadHead || message.reactions.isNotEmpty), - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ), ), - ), - ], + ], + ), ), ), - ), - ], + if (isThreadHead || message.reactions.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + ), + child: ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart new file mode 100644 index 0000000000..1d9961b9b6 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart @@ -0,0 +1,111 @@ +part of '../thread_detail_page.dart'; + +class _NestedThreadSummaryRow extends ConsumerWidget { + final ThreadSummary summary; + final TimelineMessage replyMessage; + final List allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + + const _NestedThreadSummaryRow({ + required this.summary, + required this.replyMessage, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: replyMessage, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: Padding( + key: ValueKey('nested-thread-summary-${replyMessage.id}'), + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + top: Grid.half, + bottom: Grid.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Stacked participant avatars. + SizedBox( + width: + 32.0 + + (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, + height: 32, + child: Stack( + children: [ + for (var i = 0; i < summary.participantPubkeys.length; i++) + Positioned( + left: i * 20.0, + child: SmallAvatar( + pubkey: summary.participantPubkeys[i], + userCache: userCache, + size: 32, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.primary, + ), + ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 15cf79a313..d5c3d76252 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -37,6 +37,9 @@ import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _channelId = 'test-channel'; +const _nativeMessageActionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', +); /// Shared mock prefs for providers that read [savedPrefsProvider] /// (e.g. the compose bar's draft store). Initialized in [main]. @@ -183,6 +186,7 @@ Widget _buildTestable({ Map>> pendingThreadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, bool disableAnimations = false, + TargetPlatform platform = TargetPlatform.android, RelaySessionNotifier? relaySessionNotifier, }) { final resolvedChannel = channel ?? _testChannel; @@ -241,7 +245,7 @@ Widget _buildTestable({ savedPrefsProvider.overrideWithValue(_testPrefs), ], child: MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.light().copyWith(platform: platform), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: textScaler, @@ -1086,6 +1090,367 @@ void main() { expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); }); + testWidgets( + 'iOS reaction-only messages use the lifted preview without actions', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'ios-reaction-only-target', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'alice', + }, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + platform: TargetPlatform.iOS, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress( + find.byKey( + const ValueKey('system-message-row-ios-reaction-only-target'), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsNothing, + ); + expect( + tester + .widget( + find.byKey( + const ValueKey( + 'system-message-native-source-ios-reaction-only-target', + ), + ), + ) + .opacity, + 0, + ); + + Navigator.of( + tester.element( + find.byKey(const ValueKey('message-action-preview-container')), + ), + ).pop(); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey( + const ValueKey( + 'system-message-native-source-ios-reaction-only-target', + ), + ), + ) + .opacity, + 1, + ); + }, + ); + + testWidgets('iOS composes its tray, lifted message, and native actions', ( + tester, + ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + null, + ), + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'native-context-target', + pubkey: 'alice', + content: 'Native context menu', + createdAt: 1000, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + platform: TargetPlatform.iOS, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey( + const ValueKey('message-row-native-context-target'), + ); + await tester.longPress(row); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey( + const ValueKey('message-native-source-native-context-target'), + ), + ) + .opacity, + 0, + ); + + Navigator.of( + tester.element( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ), + ).pop(); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey( + const ValueKey('message-native-source-native-context-target'), + ), + ) + .opacity, + 1, + ); + }); + + testWidgets('message snapshot matches a message without reactions', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'bare-snapshot-target', + pubkey: 'alice', + content: 'Message without reactions', + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + platform: TargetPlatform.iOS, + ), + ); + await tester.pumpAndSettle(); + + final rowRect = tester.getRect( + find.byKey(const ValueKey('message-row-bare-snapshot-target')), + ); + final snapshotRect = tester.getRect( + find.byKey( + const ValueKey('message-action-snapshot-bare-snapshot-target'), + ), + ); + + expect(snapshotRect, rowRect); + expect( + find.descendant( + of: find.byKey(const ValueKey('message-row-bare-snapshot-target')), + matching: find.byType(ReactionRow), + ), + findsNothing, + ); + }); + + testWidgets('iOS lifted snapshot excludes attached reactions', ( + tester, + ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + null, + ), + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'reacted-snapshot-target', + pubkey: 'alice', + content: 'Message with an attached reaction', + ), + _reaction( + id: 'attached-reaction', + targetId: 'reacted-snapshot-target', + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + platform: TargetPlatform.iOS, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey( + const ValueKey('message-row-reacted-snapshot-target'), + ); + final snapshot = find.byKey( + const ValueKey('message-action-snapshot-reacted-snapshot-target'), + ); + final attachedReactions = find.descendant( + of: row, + matching: find.byType(ReactionRow), + ); + final rowRect = tester.getRect(row); + final snapshotRect = tester.getRect(snapshot); + final reactionRect = tester.getRect(attachedReactions); + + expect(snapshotRect.bottom, lessThanOrEqualTo(reactionRect.top)); + expect(rowRect.height, greaterThan(snapshotRect.height)); + + await tester.longPress(row); + await tester.pumpAndSettle(); + + final rawPreview = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('message-action-preview-container')), + matching: find.byType(RawImage), + ), + ); + expect( + rawPreview.image?.width, + (snapshotRect.width * tester.view.devicePixelRatio).round(), + ); + expect( + rawPreview.image?.height, + (snapshotRect.height * tester.view.devicePixelRatio).round(), + ); + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + + Navigator.of( + tester.element( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ), + ).pop(); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'iOS long press survives a pending support check and reliably reopens', + (tester) async { + final firstSupport = Completer(); + var supportRequests = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + (call) async { + if (call.method != 'isSupported') return null; + supportRequests += 1; + if (supportRequests == 1) return firstSupport.future; + return true; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + null, + ), + ); + final userCache = _FakeUserCacheNotifier({ + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'native-rebuild-target', + pubkey: 'alice', + content: 'Reliable native context menu', + createdAt: 1000, + ), + ], + userCacheNotifier: userCache, + platform: TargetPlatform.iOS, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey( + const ValueKey('message-row-native-rebuild-target'), + ); + final firstGesture = await tester.startGesture(tester.getCenter(row)); + await tester.pump(const Duration(milliseconds: 600)); + expect(supportRequests, 1); + await firstGesture.up(); + + userCache.replace( + const UserProfile(pubkey: 'alice', displayName: 'Alice Updated'), + ); + await tester.pump(); + firstSupport.complete(true); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + + for (var attempt = 0; attempt < 3; attempt++) { + Navigator.of( + tester.element( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ), + ).pop(); + await tester.pumpAndSettle(); + + await tester.longPress(row); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + } + + Navigator.of( + tester.element( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ), + ).pop(); + await tester.pumpAndSettle(); + expect(supportRequests, 4); + }, + ); + testWidgets('reaction popover leaves existing reactions in the blur', ( tester, ) async { diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 842a97211b..239d54d46b 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,17 +1,28 @@ +import 'dart:ui' as ui; + +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/message_actions.dart'; +import 'package:buzz/features/channels/message_long_press_region.dart'; +import 'package:buzz/features/channels/recent_emoji_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:buzz/shared/reminders/reminder_service.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; const _channelId = 'chan-1'; +const _nativeMessageActionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', +); TimelineMessage _message({ String id = 'msg-1', @@ -19,6 +30,7 @@ TimelineMessage _message({ int createdAt = 1000, bool isSystem = false, String? rootId, + List reactions = const [], }) => TimelineMessage( id: id, pubkey: pubkey, @@ -26,6 +38,7 @@ TimelineMessage _message({ content: 'hello world', isSystem: isSystem, rootId: rootId, + reactions: reactions, ); class _FakeReadStateNotifier extends ReadStateNotifier { @@ -73,6 +86,26 @@ class _FakeReadStateNotifier extends ReadStateNotifier { ); } +class _RecordingChannelActions extends ChannelActions { + final List<(String, String)> addedReactions = []; + + _RecordingChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); + + @override + Future addReaction(String eventId, String emoji) async { + addedReactions.add((eventId, emoji)); + } +} + ReadStateState _readState(Map contexts, {bool isReady = true}) => ReadStateState( isReady: isReady, @@ -107,6 +140,8 @@ Future _pumpSheet( bool canManageMessage = false, List? allMessages, ReminderService? reminderService, + TargetPlatform platform = TargetPlatform.android, + Rect? anchorRect, }) async { await tester.pumpWidget( ProviderScope( @@ -122,9 +157,10 @@ Future _pumpSheet( // No signing identity → "Remind me" hidden by default; individual // tests opt in by passing a stub service. reminderServiceProvider.overrideWithValue(reminderService), + channelActionsProvider.overrideWith(_RecordingChannelActions.new), ], child: MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.light().copyWith(platform: platform), home: Scaffold( body: Consumer( builder: (context, ref, _) => TextButton( @@ -137,6 +173,10 @@ Future _pumpSheet( allMessages: allMessages, currentPubkey: 'self', isMember: true, + anchorRect: anchorRect, + captureAnchorSnapshot: anchorRect == null + ? null + : () async => _testSnapshot(), ), child: const Text('open'), ), @@ -180,8 +220,600 @@ Future _pumpImageSheet( await tester.pumpAndSettle(); } +ui.Image _testSnapshot() { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawColor(Colors.white, ui.BlendMode.src); + final picture = recorder.endRecording(); + final image = picture.toImageSync(300, 72); + picture.dispose(); + return image; +} + +class _IosPopoverHarness { + final ProviderContainer container; + final ValueNotifier popoverPresented; + + const _IosPopoverHarness({ + required this.container, + required this.popoverPresented, + }); +} + +Future<_IosPopoverHarness> _pumpIosPopover( + WidgetTester tester, { + required TimelineMessage message, + required SharedPreferences prefs, + List? allMessages, + ReminderService? reminderService, + Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72), + bool settlePresentation = true, +}) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + null, + ), + ); + final popoverPresented = ValueNotifier(false); + addTearDown(popoverPresented.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier(_readState(const {_channelId: 100000})), + ), + reminderServiceProvider.overrideWithValue(reminderService), + channelActionsProvider.overrideWith(_RecordingChannelActions.new), + ], + child: MaterialApp( + theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS), + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + return TextButton( + key: const ValueKey('open-ios-popover'), + onPressed: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: _channelId, + canManageMessage: false, + allMessages: allMessages, + currentPubkey: 'self', + isMember: true, + anchorRect: anchorRect, + captureAnchorSnapshot: () async => _testSnapshot(), + onPopoverPresented: () => popoverPresented.value = true, + onPopoverDismissed: () => popoverPresented.value = false, + ), + child: const Text('open iOS actions'), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('open-ios-popover'))); + if (settlePresentation) { + await tester.pumpAndSettle(); + } else { + for (var index = 0; index < 6; index++) { + await tester.pump(); + } + } + final container = ProviderScope.containerOf( + tester.element(find.byKey(const ValueKey('open-ios-popover'))), + ); + return _IosPopoverHarness( + container: container, + popoverPresented: popoverPresented, + ); +} + +Future _dismissIosPopover(WidgetTester tester) async { + Navigator.of( + tester.element( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ), + ).pop(); + await tester.pumpAndSettle(); +} + +Iterable _nativeActionTitles() => find + .byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'ios-native-action-', + ), + ) + .evaluate() + .map((element) => (element.widget as ListTile).title as Text) + .map((text) => text.data!); + void main() { + testWidgets( + 'message long press keeps taps and scrolling while repeated holds win', + (tester) async { + var parentTaps = 0; + var nestedTaps = 0; + var longPresses = 0; + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + Material( + child: MessageLongPressInkWell( + key: const ValueKey('parent-gesture-target'), + onTap: () => parentTaps += 1, + onLongPress: (_) => longPresses += 1, + child: const SizedBox(height: 80, width: 300), + ), + ), + Material( + child: MessageLongPressInkWell( + onLongPress: (_) => longPresses += 1, + child: GestureDetector( + key: const ValueKey('nested-gesture-target'), + behavior: HitTestBehavior.opaque, + onTap: () => nestedTaps += 1, + child: const SizedBox(height: 80, width: 300), + ), + ), + ), + const SizedBox(height: 900), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('parent-gesture-target'))); + await tester.tap(find.byKey(const ValueKey('nested-gesture-target'))); + await tester.pump(); + expect(parentTaps, 1); + expect(nestedTaps, 1); + + for (var index = 0; index < 5; index++) { + await tester.longPress( + find.byKey(const ValueKey('nested-gesture-target')), + ); + await tester.pump(); + expect(longPresses, index + 1); + expect(nestedTaps, 1); + } + + final drag = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))), + ); + await drag.moveBy(const Offset(0, -30)); + await tester.pump(); + await drag.moveBy(const Offset(0, -70)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + await drag.up(); + await tester.pumpAndSettle(); + + expect(longPresses, 5); + expect(scrollController.offset, greaterThan(0)); + }, + ); + group('showMessageActions', () { + testWidgets( + 'composes the Flutter tray, lifted preview, and native actions', + (tester) async { + final prefs = await _mockPrefs(); + final harness = await _pumpIosPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + ); + + expect(harness.popoverPresented.value, isTrue); + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + expect(_nativeActionTitles(), [ + 'Reply', + 'Copy link', + 'Remind me', + 'Mark unread', + 'Follow thread', + 'Copy text', + ]); + + await _dismissIosPopover(tester); + }, + ); + + testWidgets('uses a compact independently centered action surface', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpIosPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + ); + + final surfaceRect = tester.getRect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + ); + final previewRect = tester.getRect( + find.byKey(const ValueKey('message-action-preview-container')), + ); + final previewImageRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('message-action-preview-container')), + matching: find.byType(RawImage), + ), + ); + final trayRect = tester.getRect( + find.byKey(const ValueKey('reaction-popover-tray')), + ); + + expect(surfaceRect.width, 288); + expect(surfaceRect.height, 231); + expect(previewRect.size, const Size(316, 88)); + expect(previewImageRect.left - previewRect.left, 8); + expect(previewImageRect.top - previewRect.top, 8); + expect(previewRect.right - previewImageRect.right, 8); + expect(previewRect.bottom - previewImageRect.bottom, 8); + expect(previewRect.top - trayRect.bottom, Grid.twelve); + expect(surfaceRect.center.dx, closeTo(previewRect.center.dx, 0.01)); + expect(surfaceRect.center.dx, closeTo(trayRect.center.dx, 0.01)); + expect(surfaceRect.width, lessThan(previewRect.width)); + + await _dismissIosPopover(tester); + }); + + testWidgets('reaction-only tray matches the composed message spacing', ( + tester, + ) async { + const anchorRect = Rect.fromLTWH(32, 260, 300, 72); + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: prefs, + anchorRect: anchorRect, + ); + + final trayRect = tester.getRect( + find.byKey(const ValueKey('reaction-popover-tray')), + ); + expect(anchorRect.top - trayRect.bottom, Grid.twelve); + }); + + testWidgets( + 'iOS reaction-only messages lift the preview without an action surface', + (tester) async { + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: prefs, + platform: TargetPlatform.iOS, + anchorRect: const Rect.fromLTWH(32, 260, 300, 72), + ); + + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsNothing, + ); + expect(find.byType(BottomSheet), findsNothing); + final trayRect = tester.getRect( + find.byKey(const ValueKey('reaction-popover-tray')), + ); + final previewRect = tester.getRect( + find.byKey(const ValueKey('message-action-preview-container')), + ); + expect(previewRect.top - trayRect.bottom, Grid.twelve); + expect(trayRect.center.dx, closeTo(previewRect.center.dx, 0.01)); + expect( + (trayRect.top + previewRect.bottom) / 2, + closeTo( + tester.view.physicalSize.height / tester.view.devicePixelRatio / 2, + 0.01, + ), + ); + + Navigator.of( + tester.element( + find.byKey(const ValueKey('message-action-preview-container')), + ), + ).pop(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('popover uses contiguous transparent reaction hit targets', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpIosPopover(tester, message: _message(), prefs: prefs); + + final targets = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', + ), + ); + final targetKeys = [ + for (final element in targets.evaluate()) element.widget.key!, + ]; + final targetRects = [ + for (final key in targetKeys) tester.getRect(find.byKey(key)), + ]; + final trayRect = tester.getRect( + find.byKey(const ValueKey('reaction-popover-tray')), + ); + + expect(targetRects, hasLength(6)); + for (final rect in targetRects) { + expect(rect.size.width, greaterThanOrEqualTo(44)); + expect(rect.size.height, greaterThanOrEqualTo(44)); + } + for (var index = 0; index < targetRects.length - 1; index++) { + expect( + targetRects[index + 1].left - targetRects[index].right, + closeTo(0, 0.01), + ); + } + expect(targetRects.first.left - trayRect.left, Grid.xxs); + expect(trayRect.right - targetRects.last.right, Grid.xxs); + expect( + trayRect.width, + targetRects.last.right - targetRects.first.left + (Grid.xxs * 2), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('quick-reaction-\u{1F44D}')), + matching: find.byType(NativeEmojiGlyph), + ), + ) + .size, + 28, + ); + final addReactionIcon = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('quick-reaction-more')), + matching: find.byType(Icon), + ), + ); + expect(addReactionIcon.icon, LucideIcons.smilePlus); + expect(addReactionIcon.size, 24); + for (final key in targetKeys) { + final target = find.byKey(key); + expect( + tester + .widget( + find.descendant(of: target, matching: find.byType(Material)), + ) + .color, + Colors.transparent, + ); + final inkWell = tester.widget( + find.descendant(of: target, matching: find.byType(InkWell)), + ); + expect(inkWell.highlightColor, isNot(Colors.transparent)); + expect(inkWell.splashColor, isNot(Colors.transparent)); + } + + await _dismissIosPopover(tester); + }); + + testWidgets('popover does not persist selected reaction feedback', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpIosPopover( + tester, + message: _message( + reactions: const [ + TimelineReaction( + emoji: '\u{1F44D}', + count: 1, + reactedByCurrentUser: true, + userPubkeys: ['self'], + currentUserReactionId: 'reaction-1', + ), + ], + ), + prefs: prefs, + ); + + final selectedTarget = find.byKey( + const ValueKey('quick-reaction-\u{1F44D}'), + ); + expect( + tester + .widget( + find.descendant( + of: selectedTarget, + matching: find.byType(Material), + ), + ) + .color, + Colors.transparent, + ); + + await _dismissIosPopover(tester); + }); + + testWidgets('popover reactions do not reorder the quick-reaction ranking', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpIosPopover( + tester, + message: _message(), + prefs: prefs, + ); + expect(harness.container.read(recentEmojiProvider), isEmpty); + + await tester.tap(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))); + await tester.pumpAndSettle(); + + expect(harness.container.read(recentEmojiProvider), isEmpty); + expect( + (harness.container.read(channelActionsProvider) + as _RecordingChannelActions) + .addedReactions, + const [('msg-1', '\u{1F44D}')], + ); + }); + + testWidgets('restores its source after the composed popover dismisses', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpIosPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + ); + expect(harness.popoverPresented.value, isTrue); + await _dismissIosPopover(tester); + expect(harness.popoverPresented.value, isFalse); + }); + + testWidgets('combined reaction tray expands right from a fixed left edge', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpIosPopover( + tester, + message: _message(), + prefs: prefs, + settlePresentation: false, + ); + + await tester.pump(const Duration(milliseconds: 60)); + final tray = find.byKey(const ValueKey('reaction-popover-tray')); + final firstLeft = tester.getTopLeft(tray).dx; + final firstWidth = tester.getSize(tray).width; + + await tester.pump(const Duration(milliseconds: 80)); + final secondLeft = tester.getTopLeft(tray).dx; + final secondWidth = tester.getSize(tray).width; + + expect(secondLeft, closeTo(firstLeft, 0.01)); + expect(secondWidth, greaterThan(firstWidth)); + await tester.pumpAndSettle(); + await _dismissIosPopover(tester); + }); + + testWidgets('runs a selected native action after the popover dismisses', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpIosPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + ); + + await tester.tap( + find.byKey(const ValueKey('ios-native-action-followThread')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing); + expect(harness.container.read(threadFollowsProvider).followedRootIds, { + 'root-9', + }); + }); + + testWidgets('can reopen the composed iOS message actions', (tester) async { + final prefs = await _mockPrefs(); + await _pumpIosPopover(tester, message: _message(), prefs: prefs); + + await _dismissIosPopover(tester); + await tester.tap(find.byKey(const ValueKey('open-ios-popover'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('ios-native-message-action-surface')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + await _dismissIosPopover(tester); + }); + + testWidgets('falls back to the Flutter sheet on older iOS versions', ( + tester, + ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + (call) async => call.method == 'isSupported' ? false : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + _nativeMessageActionSurfaceChannel, + null, + ), + ); + final prefs = await _mockPrefs(); + + await _pumpSheet( + tester, + message: _message(), + prefs: prefs, + platform: TargetPlatform.iOS, + anchorRect: const Rect.fromLTWH(32, 260, 300, 72), + ); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text('Copy text'), findsOneWidget); + expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing); + }); + testWidgets('shows parity actions for a regular message', (tester) async { final prefs = await _mockPrefs(); await _pumpSheet(tester, message: _message(), prefs: prefs); @@ -212,6 +844,37 @@ void main() { tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))), const Size.square(52), ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('quick-reaction-\u{1F44D}')), + matching: find.byType(Material), + ), + ) + .color, + isNot(Colors.transparent), + ); + }); + + testWidgets('existing sheet still updates the quick-reaction ranking', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpSheet(tester, message: _message(), prefs: prefs); + final container = ProviderScope.containerOf( + tester.element(find.text('open')), + ); + expect(container.read(recentEmojiProvider), isEmpty); + + await tester.tap( + find.byKey(const ValueKey('quick-reaction-\u{2764}\u{FE0F}')), + ); + await tester.pumpAndSettle(); + + expect(container.read(recentEmojiProvider).map((entry) => entry.emoji), [ + '\u{2764}\u{FE0F}', + ]); }); testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '