From ea143ea64beb79abacf423920cd70bbf23698b64 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 2 Oct 2026 20:59:05 +0100 Subject: [PATCH] feat(mobile): native iOS message menus and reaction sheets (#8055) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 16 + mobile/ios/Runner/AppDelegate.swift | 7 + mobile/ios/Runner/NativeMessageMenu.swift | 353 ++++++++++++++ ...NativeMessagePresentationCoordinator.swift | 109 +++++ mobile/ios/Runner/NativeReactionDetails.swift | 433 +++++++++++++++++ .../NativeMessageImageLoaderTests.swift | 229 +++++++++ .../channel_detail_page/system_rows.dart | 17 +- .../features/channels/message_actions.dart | 29 +- .../message_actions/native_actions.dart | 243 ++++++++++ .../channels/message_long_press_region.dart | 5 +- .../lib/features/channels/reaction_row.dart | 223 +++++++-- .../widgets/native_message_presentation.dart | 59 +++ .../channels/message_actions_test.dart | 435 +++++++++++++++++- .../features/channels/reaction_row_test.dart | 275 ++++++++++- 14 files changed, 2374 insertions(+), 59 deletions(-) create mode 100644 mobile/ios/Runner/NativeMessageMenu.swift create mode 100644 mobile/ios/Runner/NativeMessagePresentationCoordinator.swift create mode 100644 mobile/ios/Runner/NativeReactionDetails.swift create mode 100644 mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift create mode 100644 mobile/lib/features/channels/message_actions/native_actions.dart create mode 100644 mobile/lib/shared/widgets/native_message_presentation.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 755923548e..6cc9f95289 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -46,6 +46,10 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AB1200002F40400100A17E01 /* NativeMessagePresentationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */; }; + AB1200012F40400100A17E01 /* NativeMessageMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200112F40400100A17E01 /* NativeMessageMenu.swift */; }; + AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200122F40400100A17E01 /* NativeReactionDetails.swift */; }; + AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -137,6 +141,10 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessagePresentationCoordinator.swift; sourceTree = ""; }; + AB1200112F40400100A17E01 /* NativeMessageMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageMenu.swift; sourceTree = ""; }; + AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = ""; }; + AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -172,6 +180,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */, BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); @@ -245,6 +254,9 @@ BZZ0000000000000000000A /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */, + AB1200112F40400100A17E01 /* NativeMessageMenu.swift */, + AB1200122F40400100A17E01 /* NativeReactionDetails.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, @@ -564,6 +576,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */, BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -572,6 +585,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1200002F40400100A17E01 /* NativeMessagePresentationCoordinator.swift in Sources */, + AB1200012F40400100A17E01 /* NativeMessageMenu.swift in Sources */, + AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 271e76c9dd..bbd758ab8e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -8,6 +8,7 @@ import os.log @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var nativeMessagePresentationCoordinator: NativeMessagePresentationCoordinator? private var mediaUploadChannel: FlutterMethodChannel? private var pushChannel: FlutterMethodChannel? private let apnsRegistrationBuffer = APNsRegistrationBuffer() @@ -201,6 +202,12 @@ import os.log withId: "buzz/theme_pagination_glass" ) } + nativeMessagePresentationCoordinator = NativeMessagePresentationCoordinator( + messenger: messenger, + parentViewController: engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeMessagePresentation" + )?.viewController + ) let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeAttachmentPopover" diff --git a/mobile/ios/Runner/NativeMessageMenu.swift b/mobile/ios/Runner/NativeMessageMenu.swift new file mode 100644 index 0000000000..a068be3390 --- /dev/null +++ b/mobile/ios/Runner/NativeMessageMenu.swift @@ -0,0 +1,353 @@ +import UIKit + +/// Signal-style context presentation composed entirely of public UIKit views. +/// Flutter supplies an anchor and action IDs; it retains all mutation authority. +final class NativeMessageMenuViewController: UIViewController { + var onPreviewReady: ((@escaping () -> Void) -> Void)? + var onSelect: (([String: String]) -> Void)? + private let data: [String: Any] + private let sourceRect: CGRect + private let preview: UIView + private let backdrop = UIVisualEffectView() + private let content = UIStackView() + private let scroll = NativeMessageScrollView() + private var contentTop: NSLayoutConstraint? + private var closing = false + private var previewHost: UIView? + private var actionMenu: UIView? + private var sourceTransform = CGAffineTransform.identity + private var reactionTray: UIView? + private var reactionButtons: [UIView] = [] + + static func sourceRect(_ data: [String: Any]) -> CGRect? { + guard let x = data["x"] as? Double, let y = data["y"] as? Double, + let width = data["width"] as? Double, let height = data["height"] as? Double, + x.isFinite, y.isFinite, width.isFinite, height.isFinite, + width > 0, height > 0 else { return nil } + return CGRect(x: x, y: y, width: width, height: height) + } + + init(data: [String: Any], sourceRect: CGRect, preview: UIView) { + self.data = data + self.sourceRect = sourceRect + self.preview = preview + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.accessibilityViewIsModal = true + backdrop.frame = view.bounds + backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(backdrop) + let dismiss = UIButton(primaryAction: UIAction { [weak self] _ in self?.select([:]) }) + dismiss.accessibilityLabel = "Dismiss message menu" + dismiss.frame = view.bounds + dismiss.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(dismiss) + + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.showsVerticalScrollIndicator = false + scroll.contentInsetAdjustmentBehavior = .never + // Keep all painted actions inside the tappable viewport. Inner padding + // gives glass shadows space without exposing off-viewport content. + scroll.clipsToBounds = true + view.addSubview(scroll) + content.axis = .vertical + content.spacing = 12 + content.alignment = .leading + content.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(content) + scroll.passthroughView = content + contentTop = content.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor) + contentTop?.isActive = true + let safe = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: safe.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: safe.trailingAnchor), + scroll.topAnchor.constraint(equalTo: safe.topAnchor, constant: 12), + scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -12), + content.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16), + content.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16), + content.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -48), + content.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor, constant: -32), + ]) + let reactionTray = makeReactionTray() + self.reactionTray = reactionTray + content.addArrangedSubview(reactionTray) + reactionTray.widthAnchor.constraint(lessThanOrEqualTo: content.widthAnchor).isActive = true + let trayWidth = reactionTray.widthAnchor.constraint(equalToConstant: 324) + trayWidth.priority = .defaultHigh + trayWidth.isActive = true + // Preserve the actual message rendering, including media and markdown. + preview.layer.cornerRadius = 0 + preview.layer.cornerCurve = .continuous + preview.clipsToBounds = true + preview.isAccessibilityElement = true + preview.accessibilityLabel = data["previewLabel"] as? String + let previewHost = UIView() + self.previewHost = previewHost + previewHost.clipsToBounds = true + previewHost.layer.cornerRadius = 0 + previewHost.addSubview(preview) + content.addArrangedSubview(previewHost) + let width = min(sourceRect.width, max(1, view.bounds.width - 32)) + let height = min(sourceRect.height * width / sourceRect.width, 200) + previewHost.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true + previewHost.heightAnchor.constraint(equalToConstant: height).isActive = true + preview.frame = CGRect(x: 0, y: 0, width: width, height: sourceRect.height * width / sourceRect.width) + let actions = data["actions"] as? [[String: Any]] ?? [] + if !actions.isEmpty { + let menu = NativeMessageSurface(radius: 16) + actionMenu = menu + let rows = UIStackView() + rows.axis = .vertical + for (index, action) in actions.enumerated() { + if index > 0 { + let divider = UIView() + divider.backgroundColor = .separator + divider.heightAnchor.constraint(equalToConstant: 1 / traitCollection.displayScale).isActive = true + rows.addArrangedSubview(divider) + } + let button = UIButton(primaryAction: UIAction { [weak self] _ in + guard let id = action["id"] as? String else { return } + self?.select(["action": id]) + }) + let color: UIColor = action["destructive"] as? Bool == true ? .systemRed : .label + let title = UILabel() + title.text = action["title"] as? String + title.font = .preferredFont(forTextStyle: .body) + title.adjustsFontForContentSizeCategory = true + title.numberOfLines = 0 + title.textColor = color + let icon = UIImageView(image: UIImage(systemName: action["symbol"] as? String ?? "ellipsis", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))) + icon.tintColor = color + icon.contentMode = .center + icon.widthAnchor.constraint(equalToConstant: 24).isActive = true + let row = UIStackView(arrangedSubviews: [title, icon]) + row.alignment = .center + row.spacing = 20 + row.isUserInteractionEnabled = false + row.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 16), + row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -16), + row.topAnchor.constraint(equalTo: button.topAnchor, constant: 12), + row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -12), + button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + ]) + button.accessibilityLabel = title.text + button.configurationUpdateHandler = { button in + button.backgroundColor = button.isHighlighted ? .tertiarySystemFill : .clear + } + rows.addArrangedSubview(button) + } + menu.embed(rows) + content.addArrangedSubview(menu) + menu.widthAnchor.constraint(lessThanOrEqualTo: content.widthAnchor).isActive = true + let menuWidth = menu.widthAnchor.constraint(equalToConstant: 280) + menuWidth.priority = .defaultHigh + menuWidth.isActive = true + } + } + + private func makeReactionTray() -> UIView { + let surface = NativeMessageSurface(radius: 30) + let trayScroll = UIScrollView() + trayScroll.showsHorizontalScrollIndicator = false + let row = UIStackView() + row.spacing = 4 + for reaction in data["reactions"] as? [[String: Any]] ?? [] { + guard let emoji = reaction["emoji"] as? String else { continue } + let button = UIButton(primaryAction: UIAction { [weak self] _ in + self?.select(["action": "reaction", "emoji": emoji]) + }) + button.accessibilityLabel = reaction["label"] as? String ?? emoji + let selected = reaction["selected"] as? Bool == true + button.accessibilityValue = selected ? "Selected. Remove reaction" : "Add reaction" + button.backgroundColor = selected ? .tertiarySystemFill : .clear + button.layer.cornerRadius = 24 + let glyph = NativeMessageGlyph(data: reaction, size: 28) + glyph.isUserInteractionEnabled = false + button.addSubview(glyph) + glyph.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + glyph.centerXAnchor.constraint(equalTo: button.centerXAnchor), + glyph.centerYAnchor.constraint(equalTo: button.centerYAnchor), + glyph.widthAnchor.constraint(equalToConstant: 32), + glyph.heightAnchor.constraint(equalToConstant: 32), + button.widthAnchor.constraint(equalToConstant: 48), + button.heightAnchor.constraint(equalToConstant: 48), + ]) + row.addArrangedSubview(button) + reactionButtons.append(button) + } + let more = UIButton(primaryAction: UIAction { [weak self] _ in self?.select(["action": "more"]) }) + more.setImage(UIImage(systemName: "plus"), for: .normal) + more.tintColor = .secondaryLabel + more.accessibilityLabel = "More emoji" + more.widthAnchor.constraint(equalToConstant: 48).isActive = true + row.addArrangedSubview(more) + reactionButtons.append(more) + row.translatesAutoresizingMaskIntoConstraints = false + trayScroll.addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.leadingAnchor, constant: 6), + row.trailingAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.trailingAnchor, constant: -6), + row.topAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.topAnchor, constant: 6), + row.bottomAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.bottomAnchor, constant: -6), + row.heightAnchor.constraint(equalTo: trayScroll.frameLayoutGuide.heightAnchor, constant: -12), + ]) + surface.embed(trayScroll) + surface.heightAnchor.constraint(equalToConstant: 60).isActive = true + return surface + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // The scroll view lays out its content after this callback. Measure the + // complete stack explicitly so the first presentation cannot use a stale + // (or zero) height and leave actions below the screen. + let size = content.systemLayoutSizeFitting( + CGSize(width: max(1, scroll.bounds.width - 32), height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel) + let available = max(0, scroll.bounds.height - size.height - 48) + let top = max(32, min(sourceRect.minY - scroll.frame.minY - 72, available)) + if contentTop?.constant != top { + contentTop?.constant = top + scroll.layoutIfNeeded() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reactionTray?.alpha = 0 + actionMenu?.alpha = 0 + previewHost?.alpha = 0 + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !closing, previewHost != nil else { return } + view.layoutIfNeeded() + if let onPreviewReady { + onPreviewReady { [weak self] in self?.animateIn() } + } else { + animateIn() + } + } + + private func animateIn() { + guard !closing, let previewHost else { return } + let reducedMotion = UIAccessibility.isReduceMotionEnabled + let target = previewHost.convert(previewHost.bounds, to: view) + sourceTransform = CGAffineTransform( + translationX: sourceRect.minX - target.minX, + y: sourceRect.minY - target.minY) + if !reducedMotion { + previewHost.transform = sourceTransform + actionMenu?.transform = CGAffineTransform(translationX: 0, y: sourceRect.minY - target.minY) + .scaledBy(x: 0.95, y: 0.95) + for button in reactionButtons { + button.alpha = 0 + button.transform = CGAffineTransform(translationX: 0, y: 24) + } + } + previewHost.alpha = 1 + UIView.animate(withDuration: reducedMotion ? 0 : 0.14) { + self.backdrop.effect = UIBlurEffect(style: .systemUltraThinMaterial) + } + UIView.animate(withDuration: reducedMotion ? 0 : 0.25, delay: 0, + usingSpringWithDamping: 0.8, initialSpringVelocity: 1, + options: [.beginFromCurrentState, .allowUserInteraction]) { + previewHost.transform = .identity + self.actionMenu?.transform = .identity + self.actionMenu?.alpha = 1 + } completion: { _ in + guard !self.closing else { return } + UIAccessibility.post(notification: .screenChanged, argument: self.content) + } + UIView.animate(withDuration: reducedMotion ? 0 : 0.2) { + self.reactionTray?.alpha = 1 + } + guard !reducedMotion else { return } + for (index, button) in reactionButtons.enumerated() { + UIView.animate(withDuration: 0.2, delay: Double(index) * 0.01, + options: [.beginFromCurrentState, .curveEaseOut, .allowUserInteraction]) { + button.alpha = 1 + button.transform = .identity + } + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + select([:]) // The Flutter anchor is no longer valid after rotation. + } + + override func accessibilityPerformEscape() -> Bool { select([:]); return true } + + private func select(_ value: [String: String]) { + guard !closing else { return } + closing = true + onSelect?(value) + } + + func animateOut(completion: @escaping () -> Void) { + UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.16, + delay: 0, options: [.beginFromCurrentState, .curveEaseOut]) { + self.reactionTray?.alpha = 0 + self.actionMenu?.alpha = 0 + if !UIAccessibility.isReduceMotionEnabled { + self.previewHost?.transform = self.sourceTransform + self.actionMenu?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + } + self.backdrop.effect = nil + } completion: { _ in completion() } + } +} + +final class NativeMessageSurface: UIVisualEffectView { + init(radius: CGFloat) { + let effect: UIVisualEffect + if #available(iOS 26.0, *) { + effect = UIGlassEffect(style: .regular) + } else { + effect = UIBlurEffect(style: .systemMaterial) + } + super.init(effect: effect) + layer.cornerRadius = radius + layer.cornerCurve = .continuous + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + func embed(_ child: UIView) { + child.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(child) + NSLayoutConstraint.activate([ + child.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + child.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + child.topAnchor.constraint(equalTo: contentView.topAnchor), + child.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} + +/// Transparent space around the cards belongs to the dismissal backdrop. +private final class NativeMessageScrollView: UIScrollView { + weak var passthroughView: UIView? + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit === self || hit === passthroughView ? nil : hit + } +} diff --git a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift new file mode 100644 index 0000000000..4a6d846c1f --- /dev/null +++ b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift @@ -0,0 +1,109 @@ +import Flutter +import UIKit + +/// Owns one UIKit message surface and completes its Flutter request on dismissal. +final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private let channel: FlutterMethodChannel + private weak var parent: UIViewController? + private weak var presented: UIViewController? + private var pendingResult: FlutterResult? + private var requestID: String? + + init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { + channel = FlutterMethodChannel(name: "buzz/native_message_presentation", binaryMessenger: messenger) + parent = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { result(nil); return } + self.handle(call, result: result) + } + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let data = call.arguments as? [String: Any] else { result(nil); return } + if call.method == "supportsMessage" { + result(["supported": true]) + return + } + if call.method == "updateProfiles" { + if data["requestId"] as? String == requestID, + let sheet = presented as? NativeReactionDetailsViewController, + let profiles = data["profiles"] as? [String: [String: Any]] { + sheet.updateProfiles(profiles) + } + result([:]) + return + } + guard call.method == "message" || call.method == "reactions" else { + result(FlutterMethodNotImplemented) + return + } + // Ignore a second long press while presentation/dismissal is in flight. + guard pendingResult == nil else { result(["busy": true]); return } + let root = parent ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController + guard let parent = root, parent.view.window != nil else { result(nil); return } + guard parent.presentedViewController == nil else { result(["busy": true]); return } + let controller: UIViewController + if call.method == "message" { + guard let rect = NativeMessageMenuViewController.sourceRect(data), + let bytes = data["previewBytes"] as? FlutterStandardTypedData, + let image = UIImage(data: bytes.data) else { result(nil); return } + // Capture the Flutter message boundary, never the screen behind it. + // Its transparent pixels and exclusion of attached reactions are preserved. + let snapshot = UIImageView(image: image) + snapshot.contentMode = .scaleAspectFit + let menu = NativeMessageMenuViewController(data: data, sourceRect: rect, preview: snapshot) + menu.onPreviewReady = { [weak self] ready in + self?.channel.invokeMethod("messagePresented", arguments: data["requestId"]) { _ in ready() } + } + menu.onSelect = { [weak self] value in self?.dismiss(value) } + menu.modalPresentationStyle = .overFullScreen + controller = menu + } else { + let sheet = NativeReactionDetailsViewController(data: data) + sheet.onClose = { [weak self] in self?.dismiss([:]) } + sheet.modalPresentationStyle = .pageSheet + if let presentation = sheet.sheetPresentationController { + presentation.overrideTraitCollection = UITraitCollection(userInterfaceStyle: sheet.overrideUserInterfaceStyle) + presentation.detents = [.medium(), .large()] + presentation.prefersGrabberVisible = true + presentation.prefersScrollingExpandsWhenScrolledToEdge = true + } + controller = sheet + } + controller.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + pendingResult = result + requestID = data["requestId"] as? String + presented = controller + controller.presentationController?.delegate = self + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + parent.present(controller, animated: call.method == "reactions" && !UIAccessibility.isReduceMotionEnabled) + } + + private func dismiss(_ value: [String: String]) { + guard let presented else { complete(value); return } + let completion: () -> Void = { [weak self] in self?.complete(value) } + if let menu = presented as? NativeMessageMenuViewController { + menu.animateOut { + menu.dismiss(animated: false, completion: completion) + } + } else { + presented.dismiss(animated: !UIAccessibility.isReduceMotionEnabled, completion: completion) + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + complete([:]) + } + + private func complete(_ value: [String: String]) { + let result = pendingResult + pendingResult = nil + presented = nil + requestID = nil + result?(value) + } +} diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift new file mode 100644 index 0000000000..00657f133d --- /dev/null +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -0,0 +1,433 @@ +import UIKit +import ImageIO + +/// Native resizable sheet: selected emoji first, with All and per-emoji filters. +final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource { + var onClose: (() -> Void)? + private let sheetColor: UIColor + private let foregroundColor: UIColor + private let reactions: [[String: Any]] + private var profiles: [String: [String: Any]] + private var selectedEmoji: String? + private let table = UITableView(frame: .zero, style: .plain) + private let filters = UIStackView() + private var filterHeight: NSLayoutConstraint? + private var rows: [(String, [String: Any])] = [] + + private func rebuildRows() { + rows = reactions.filter { selectedEmoji == nil || $0["emoji"] as? String == selectedEmoji } + .flatMap { reaction in + (reaction["users"] as? [String] ?? []).map { ($0, reaction) } + } + } + + init(data: [String: Any]) { + func color(_ key: String, fallback: UIColor) -> UIColor { + guard let value = data[key] as? NSNumber else { return fallback } + let argb = value.uint32Value + return UIColor(red: CGFloat((argb >> 16) & 255) / 255, + green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, + alpha: CGFloat((argb >> 24) & 255) / 255) + } + sheetColor = color("sheetColor", fallback: .systemGroupedBackground) + foregroundColor = color("foregroundColor", fallback: .label) + reactions = data["reactions"] as? [[String: Any]] ?? [] + profiles = data["profiles"] as? [String: [String: Any]] ?? [:] + let initial = data["initialEmoji"] as? String + selectedEmoji = reactions.contains { $0["emoji"] as? String == initial } ? initial : nil + super.init(nibName: nil, bundle: nil) + rebuildRows() + overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = sheetColor + view.accessibilityViewIsModal = true + let title = UILabel() + title.text = "Reactions" + title.font = .preferredFont(forTextStyle: .headline) + title.adjustsFontForContentSizeCategory = true + title.textAlignment = .center + title.textColor = foregroundColor + title.accessibilityTraits = .header + let close = NativeReactionCloseButton(primaryAction: UIAction { [weak self] _ in + UIImpactFeedbackGenerator(style: .light).impactOccurred() + self?.onClose?() + }) + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.baseBackgroundColor = .secondarySystemBackground + } + configuration.cornerStyle = .capsule + configuration.image = UIImage(systemName: "xmark", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + configuration.baseForegroundColor = foregroundColor + close.configuration = configuration + close.tintColor = foregroundColor + close.accessibilityLabel = "Close reactions" + let header = UIView() + for child in [title, close] { + child.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(child) + } + NSLayoutConstraint.activate([ + header.heightAnchor.constraint(equalToConstant: max(56, title.font.lineHeight + 16)), + title.centerXAnchor.constraint(equalTo: header.centerXAnchor), + title.centerYAnchor.constraint(equalTo: close.centerYAnchor), + title.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 64), + title.trailingAnchor.constraint(lessThanOrEqualTo: header.trailingAnchor, constant: -64), + title.topAnchor.constraint(greaterThanOrEqualTo: header.topAnchor, constant: 4), + close.widthAnchor.constraint(equalToConstant: 40), + close.heightAnchor.constraint(equalToConstant: 40), + close.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -2), + close.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -2), + ]) + let filterScroll = UIScrollView() + filterScroll.showsHorizontalScrollIndicator = false + filters.spacing = 8 + filters.translatesAutoresizingMaskIntoConstraints = false + filterScroll.addSubview(filters) + NSLayoutConstraint.activate([ + filters.leadingAnchor.constraint(equalTo: filterScroll.contentLayoutGuide.leadingAnchor), + filters.trailingAnchor.constraint(equalTo: filterScroll.contentLayoutGuide.trailingAnchor), + filters.topAnchor.constraint(equalTo: filterScroll.contentLayoutGuide.topAnchor), + filters.bottomAnchor.constraint(equalTo: filterScroll.contentLayoutGuide.bottomAnchor), + filters.heightAnchor.constraint(equalTo: filterScroll.frameLayoutGuide.heightAnchor), + ]) + rebuildFilters() + filterHeight = filterScroll.heightAnchor.constraint(equalToConstant: 48) + filterHeight?.isActive = true + table.dataSource = self + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = 64 + table.backgroundColor = .clear + table.separatorInset = UIEdgeInsets(top: 0, left: 72, bottom: 0, right: 16) + let layout = UIStackView(arrangedSubviews: [header, filterScroll, table]) + layout.axis = .vertical + layout.spacing = 12 + layout.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(layout) + NSLayoutConstraint.activate([ + layout.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + layout.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), + layout.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 4), + layout.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + // Horizontal scrolling must not constrain Dynamic Type button height. + filterHeight?.constant = filters.arrangedSubviews.reduce(CGFloat(48)) { height, button in + max(height, button.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height) + } + } + + private func rebuildFilters() { + for child in filters.arrangedSubviews { child.removeFromSuperview() } + let count = reactions.reduce(0) { $0 + ($1["count"] as? Int ?? 0) } + addFilter(title: "All \(count)", emoji: nil, reaction: nil) + for reaction in reactions { + guard let emoji = reaction["emoji"] as? String else { continue } + addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction) + } + } + + private func addFilter(title: String, emoji: String?, reaction: [String: Any]?) { + var config = UIButton.Configuration.tinted() + config.cornerStyle = .capsule + config.title = reaction == nil ? title : " \(title)" + config.baseBackgroundColor = selectedEmoji == emoji ? .systemBlue : .tertiarySystemFill + config.baseForegroundColor = selectedEmoji == emoji ? .systemBlue : .label + let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + guard self.selectedEmoji != emoji else { return } + self.selectedEmoji = emoji + self.rebuildRows() + self.rebuildFilters() + self.table.reloadData() + self.table.setContentOffset(.zero, animated: false) + UISelectionFeedbackGenerator().selectionChanged() + }) + button.accessibilityLabel = reaction.map { "\($0["label"] as? String ?? emoji ?? "") \(title)" } ?? title + if selectedEmoji == emoji { button.accessibilityTraits.insert(.selected) } + if let reaction { + let glyph = NativeMessageGlyph(data: reaction, size: 22) + glyph.isUserInteractionEnabled = false + glyph.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(glyph) + NSLayoutConstraint.activate([ + glyph.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12), + glyph.centerYAnchor.constraint(equalTo: button.centerYAnchor), + glyph.widthAnchor.constraint(equalToConstant: 26), + glyph.heightAnchor.constraint(equalToConstant: 26), + ]) + } + filters.addArrangedSubview(button) + } + + func updateProfiles(_ profiles: [String: [String: Any]]) { + self.profiles.merge(profiles) { _, new in new } + guard isViewLoaded else { return } + let affected = (table.indexPathsForVisibleRows ?? []).filter { + profiles[rows[$0.row].0] != nil + } + if !affected.isEmpty { table.reloadRows(at: affected, with: .none) } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let (pubkey, reaction) = rows[indexPath.row] + let profile = profiles[pubkey] ?? [:] + let name = profile["name"] as? String ?? String(pubkey.prefix(8)) + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.backgroundColor = .clear + cell.selectionStyle = .none + let avatar = NativeMessageGlyph(data: profile.merging(["emoji": profile["avatarEmoji"] as? String ?? String(name.prefix(1))]) { _, new in new }, size: 18) + if let color = profile["avatarColor"] as? NSNumber { + let argb = color.uint32Value + avatar.backgroundColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255, + green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, + alpha: CGFloat((argb >> 24) & 255) / 255) + } else { + avatar.backgroundColor = .tertiarySystemFill + } + avatar.layer.cornerRadius = 20 + avatar.clipsToBounds = true + let label = UILabel() + label.text = name + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + let emoji = NativeMessageGlyph(data: reaction, size: 26) + let row = UIStackView(arrangedSubviews: [avatar, label, emoji]) + row.alignment = .center + row.spacing = 12 + row.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(row) + NSLayoutConstraint.activate([ + avatar.widthAnchor.constraint(equalToConstant: 40), avatar.heightAnchor.constraint(equalToConstant: 40), + emoji.widthAnchor.constraint(equalToConstant: 32), emoji.heightAnchor.constraint(equalToConstant: 32), + row.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor), + row.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor), + row.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10), + row.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10), + ]) + cell.isAccessibilityElement = true + cell.accessibilityLabel = "\(name), \(reaction["label"] as? String ?? reaction["emoji"] as? String ?? "reaction")" + return cell + } + + override func accessibilityPerformEscape() -> Bool { onClose?(); return true } +} + +/// Match the profile sheet's 40-point glass control and 44-point hit target. +private final class NativeReactionCloseButton: UIButton { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -2, dy: -2).contains(point) + } +} + +/// Short-lived image loader. Auth is supplied for the specific URL by Buzz's +/// existing media auth service; redirects are refused to avoid forwarding it. +final class NativeMessageGlyph: UIView { + private var cancelLoad: (() -> Void)? + private let label = UILabel() + private let image = UIImageView() + + init(data: [String: Any], size: CGFloat) { + super.init(frame: .zero) + label.text = data["emoji"] as? String + label.textAlignment = .center + label.font = .systemFont(ofSize: size) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.4 + image.contentMode = .scaleAspectFit + for child in [label, image] { + child.translatesAutoresizingMaskIntoConstraints = false + addSubview(child) + NSLayoutConstraint.activate([ + child.leadingAnchor.constraint(equalTo: leadingAnchor), + child.trailingAnchor.constraint(equalTo: trailingAnchor), + child.topAnchor.constraint(equalTo: topAnchor), + child.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + guard let raw = data["url"] as? String, let url = URL(string: raw), + ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { return } + var request = URLRequest(url: url) + request.allHTTPHeaderFields = data["headers"] as? [String: String] + request.timeoutInterval = 10 + cancelLoad = NativeMessageImageLoader.shared.load(request) { [weak self] loaded in + guard let self, let loaded else { return } + image.image = UIAccessibility.isReduceMotionEnabled ? loaded.images?.first ?? loaded : loaded + label.isHidden = true + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + deinit { cancelLoad?() } +} + +/// Shared, main-queue-owned loader. Requests include auth headers in their cache +/// key; duplicate consumers share one download and cancellation releases their +/// subscription. No more than four bodies are admitted, each capped at 8 MiB. +final class NativeMessageImageLoader: NSObject, URLSessionDataDelegate { + static let shared = NativeMessageImageLoader() + static let maximumBytes = 8 * 1024 * 1024 + private final class Download { + let request: URLRequest + var callbacks: [UUID: (UIImage?) -> Void] = [:] + var task: URLSessionDataTask? + var bytes = Data() + init(_ request: URLRequest) { self.request = request } + } + private let configuration: URLSessionConfiguration + private let limit: Int + private lazy var session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) + private var downloads: [URLRequest: Download] = [:] + private var active: [Int: Download] = [:] + private var queue: [Download] = [] + private let cache = NSCache() + + init(configuration: URLSessionConfiguration = .ephemeral, maximumConcurrent: Int = 4) { + precondition(maximumConcurrent > 0) + self.configuration = configuration + self.limit = maximumConcurrent + super.init() + cache.totalCostLimit = 16 * 1024 * 1024 + } + + /// Call on the main queue; the returned cancellation is safe from any queue. + func load(_ request: URLRequest, completion: @escaping (UIImage?) -> Void) -> () -> Void { + dispatchPrecondition(condition: .onQueue(.main)) + if let image = cache.object(forKey: request as NSURLRequest) { + completion(image) + return {} + } + let id = UUID() + let download = downloads[request] ?? Download(request) + download.callbacks[id] = completion + if downloads[request] == nil { + downloads[request] = download + queue.append(download) + admit() + } + return { [weak self, weak download] in + DispatchQueue.main.async { + guard let self, let download else { return } + download.callbacks.removeValue(forKey: id) + if download.callbacks.isEmpty { + download.task?.cancel() + self.finish(download, image: nil) + } + } + } + } + + private func admit() { + while active.count < limit, !queue.isEmpty { + let download = queue.removeFirst() + let task = session.dataTask(with: download.request) + download.task = task + active[task.taskIdentifier] = download + task.resume() + } + } + + private func finish(_ download: Download, image: UIImage?) { + guard downloads[download.request] === download else { return } + downloads.removeValue(forKey: download.request) + queue.removeAll { $0 === download } + if let task = download.task { active.removeValue(forKey: task.taskIdentifier) } + if let image { + let frames = image.images ?? [image] + let cost = frames.reduce(0) { $0 + ($1.cgImage.map { $0.bytesPerRow * $0.height } ?? 0) } + cache.setObject(image, forKey: download.request as NSURLRequest, cost: cost) + } + let callbacks = Array(download.callbacks.values) + download.callbacks.removeAll() + admit() + for callback in callbacks { callback(image) } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let download = active[dataTask.taskIdentifier] else { completionHandler(.cancel); return } + let declaredLength = (response as? HTTPURLResponse)? + .value(forHTTPHeaderField: "Content-Length").flatMap(Int64.init) ?? -1 + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + declaredLength <= Int64(Self.maximumBytes), + response.expectedContentLength <= Int64(Self.maximumBytes) else { + completionHandler(.cancel) + finish(download, image: nil) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let download = active[dataTask.taskIdentifier] else { return } + guard data.count <= Self.maximumBytes - download.bytes.count else { + dataTask.cancel() + finish(download, image: nil) + return + } + download.bytes.append(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let download = active[task.taskIdentifier] else { return } + guard error == nil else { finish(download, image: nil); return } + let bytes = download.bytes + download.bytes = Data() + // Keep the admission slot through decoding, bounding CPU and decoded images + // as well as network bodies. Cancellation still suppresses delivery/cache. + DispatchQueue.global(qos: .userInitiated).async { [weak self, weak download] in + let image = Self.decode(bytes) + DispatchQueue.main.async { + guard let self, let download else { return } + self.finish(download, image: image) + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) // Never forward supplied auth to another URL. + } + + private static func decode(_ bytes: Data) -> UIImage? { + guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil } + let count = CGImageSourceGetCount(source) + let step = max(1, Int(ceil(Double(count) / 60))) + var frames: [UIImage] = [] + var duration: TimeInterval = 0 + for index in stride(from: 0, to: count, by: step) { + guard let image = CGImageSourceCreateThumbnailAtIndex(source, index, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 120, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary) else { continue } + frames.append(UIImage(cgImage: image)) + let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any] + let animation = (properties?[kCGImagePropertyGIFDictionary as String] + ?? properties?[kCGImagePropertyPNGDictionary as String] ?? properties?["{WebP}"]) as? [String: Any] + let delay = (animation?["UnclampedDelayTime"] ?? animation?["DelayTime"]) as? Double ?? 0.1 + duration += max(0.02, delay) * Double(step) + } + guard let first = frames.first else { return nil } + return frames.count > 1 ? UIImage.animatedImage(with: frames, duration: duration) : first + } +} diff --git a/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift b/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift new file mode 100644 index 0000000000..e7d591f91f --- /dev/null +++ b/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift @@ -0,0 +1,229 @@ +import UIKit +import XCTest +@testable import Buzz + +final class NativeMessageImageLoaderTests: XCTestCase { + private func loader(limit: Int = 1) -> NativeMessageImageLoader { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MessageImageProtocol.self] + return NativeMessageImageLoader(configuration: configuration, maximumConcurrent: limit) + } + + @MainActor func testRejectsDeclaredOversizeWithoutFinishingBody() async { + let done = expectation(description: "rejected") + MessageImageProtocol.start = { transport in + transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse( + url: transport.request.url!, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Length": "\(NativeMessageImageLoader.maximumBytes + 1)", "Content-Type": "image/png"] + )!, cacheStoragePolicy: .notAllowed) + // Let URLProtocol deliver its headers without finishing the body. A + // completion-handler byte check would hang here, rather than reject. + transport.client?.urlProtocol(transport, didLoad: Data(count: 1024)) + } + let loader = loader() + _ = loader.load(URLRequest(url: URL(string: "https://example.com/declared")!)) { image in + XCTAssertNil(image) + done.fulfill() + } + await fulfillment(of: [done], timeout: 2) + } + + @MainActor func testRejectsChunkedOversizeWithoutWaitingForCompletion() async { + let done = expectation(description: "stream cancelled") + MessageImageProtocol.start = { transport in + transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse( + url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )!, cacheStoragePolicy: .notAllowed) + transport.client?.urlProtocol(transport, didLoad: Data(count: NativeMessageImageLoader.maximumBytes)) + transport.client?.urlProtocol(transport, didLoad: Data([0])) + // No finish callback: the streaming byte guard must terminate the load. + } + let loader = loader() + _ = loader.load(URLRequest(url: URL(string: "https://example.com/chunked")!)) { image in + XCTAssertNil(image) + done.fulfill() + } + await fulfillment(of: [done], timeout: 2) + } + + @MainActor func testDeduplicatesAndCancellationReleasesAdmission() async { + let first = expectation(description: "first admitted") + let second = expectation(description: "second admitted after cancellation") + var started = [String]() + MessageImageProtocol.start = { transport in + DispatchQueue.main.async { + started.append(transport.request.url!.path) + if started.count == 1 { first.fulfill() } else { second.fulfill() } + } + } + let loader = loader() + let request = URLRequest(url: URL(string: "https://example.com/shared")!) + let cancelFirst = loader.load(request) { _ in XCTFail("cancelled subscriber called") } + let cancelDuplicate = loader.load(request) { _ in XCTFail("cancelled duplicate called") } + let cancelQueued = loader.load(URLRequest(url: URL(string: "https://example.com/queued")!)) { _ in + XCTFail("cancelled queued subscriber called") + } + let cancelNext = loader.load(URLRequest(url: URL(string: "https://example.com/next")!)) { _ in } + await fulfillment(of: [first], timeout: 2) + XCTAssertEqual(started, ["/shared"]) + cancelFirst() + cancelQueued() + // One subscriber remains: cancelling the first must not cancel its peer. + let turn = expectation(description: "cancellation processed") + DispatchQueue.main.async { turn.fulfill() } + await fulfillment(of: [turn], timeout: 2) + XCTAssertEqual(started, ["/shared"]) + cancelDuplicate() + await fulfillment(of: [second], timeout: 2) + XCTAssertEqual(started, ["/shared", "/next"]) + cancelNext() + } + + @MainActor func testCachesDecodedImages() async { + let done = expectation(description: "decoded") + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).pngData { _ in } + var requests = 0 + MessageImageProtocol.start = { transport in + requests += 1 + transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse( + url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )!, cacheStoragePolicy: .notAllowed) + transport.client?.urlProtocol(transport, didLoad: image) + transport.client?.urlProtocolDidFinishLoading(transport) + } + let loader = loader() + let request = URLRequest(url: URL(string: "https://example.com/cached")!) + _ = loader.load(request) { image in XCTAssertNotNil(image); done.fulfill() } + await fulfillment(of: [done], timeout: 2) + var cached = false + _ = loader.load(request) { image in cached = image != nil } + XCTAssertTrue(cached) + XCTAssertEqual(requests, 1) + } +} + +private final class MessageImageProtocol: URLProtocol { + static var start: ((MessageImageProtocol) -> Void)? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { Self.start?(self) } + override func stopLoading() {} +} + + +final class NativeReactionDetailsTests: XCTestCase { + @MainActor func testInlineEmojiAvatarRendersEmojiAndBackground() { + let controller = NativeReactionDetailsViewController(data: [ + "reactions": [["emoji": "❤️", "label": "Heart", "users": ["person"]]], + "profiles": ["person": ["name": "Kenny", "avatarEmoji": "😊", "avatarColor": NSNumber(value: UInt32(0xFFFF6B9A))]], + ]) + let cell = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0)) + let row = cell.contentView.subviews.compactMap { $0 as? UIStackView }.first! + let avatar = row.arrangedSubviews[0] as! NativeMessageGlyph + XCTAssertEqual(avatar.subviews.compactMap { $0 as? UILabel }.first?.text, "😊") + XCTAssertEqual(avatar.backgroundColor, UIColor(red: 1, green: 107.0 / 255, blue: 154.0 / 255, alpha: 1)) + controller.updateProfiles(["person": ["name": "Kenny", "avatarEmoji": "🎉", "avatarColor": NSNumber(value: UInt32(0xFFFFE75C))]]) + let updated = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0)) + let updatedRow = updated.contentView.subviews.compactMap { $0 as? UIStackView }.first! + XCTAssertEqual(updatedRow.arrangedSubviews[0].subviews.compactMap { $0 as? UILabel }.first?.text, "🎉") + } + + @MainActor func testFiltersPreserveRowsAndUpdatedAccessibilityNames() { + let controller = NativeReactionDetailsViewController(data: [ + "initialEmoji": "❤️", + "reactions": [ + ["emoji": "❤️", "label": "Heart", "count": 2, "users": ["human", "agent"]], + ["emoji": "🔥", "label": "Fire", "count": 1, "users": ["agent"]], + ], + "profiles": ["human": ["name": "Honey"], "agent": ["name": "Honey (agent)"]], + ]) + controller.loadViewIfNeeded() + let table = UITableView() + func labels() -> [String] { + (0.. [UIView] { + view.subviews.flatMap { [$0] + descendants($0) } + } + func select(_ label: String) { + let button = descendants(controller.view).compactMap { $0 as? UIButton } + .first { $0.accessibilityLabel == label } + XCTAssertNotNil(button) + button?.sendActions(for: .primaryActionTriggered) + } + XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart"]) + select("All 3") + XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart", "Honey (agent), Fire"]) + select("Fire 1") + XCTAssertEqual(labels(), ["Honey (agent), Fire"]) + controller.updateProfiles(["agent": ["name": "Helper (agent)"]]) + XCTAssertEqual(labels(), ["Helper (agent), Fire"]) + select("Heart 2") + XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart"]) + } +} + + +final class NativeMessageLayoutTests: XCTestCase { + private func descendants(_ view: UIView) -> [UIView] { + view.subviews.flatMap { [$0] + descendants($0) } + } + + @MainActor func testReactionFiltersFitAccessibilityText() { + let parent = UIViewController() + let controller = NativeReactionDetailsViewController(data: [ + "reactions": [["emoji": "❤️", "label": "Heart", "count": 12345, "users": ["a"]]], + ]) + parent.addChild(controller) + parent.setOverrideTraitCollection(UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge), forChild: controller) + parent.view.addSubview(controller.view) + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + let filter = descendants(controller.view).compactMap { $0 as? UIButton } + .first { $0.accessibilityLabel == "All 12345" } + XCTAssertNotNil(filter) + let viewport = descendants(controller.view).compactMap { $0 as? UIScrollView } + .first { !($0 is UITableView) } + XCTAssertNotNil(viewport) + let needed = filter?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 0 + XCTAssertGreaterThan(needed, 48) + XCTAssertGreaterThanOrEqual(viewport?.bounds.height ?? 0, needed) + XCTAssertGreaterThanOrEqual(filter?.bounds.height ?? 0, needed) + } + + @MainActor func testOversizedMenuStaysInsideShortViewportAndScrollsToLastAction() { + let controller = NativeMessageMenuViewController(data: [ + "actions": (0..<12).map { ["id": "action\($0)", "title": "Action \($0)", "symbol": "star"] }, + "reactions": [["emoji": "❤️", "label": "Heart"]], + ], sourceRect: CGRect(x: 16, y: 180, width: 288, height: 80), preview: UIView()) + controller.onPreviewReady = { _ in } + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { window.isHidden = true } + controller.loadViewIfNeeded() + // Constrain the available viewport as a bottom obstruction would. The + // production bottom anchor remains the system keyboard layout guide. + controller.additionalSafeAreaInsets.bottom = 200 + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + let scroll = controller.view.subviews.compactMap { $0 as? UIScrollView }.first! + scroll.layoutIfNeeded() + XCTAssertTrue(scroll.clipsToBounds) + XCTAssertGreaterThan(scroll.contentSize.height, scroll.bounds.height) + XCTAssertLessThanOrEqual(scroll.frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY) + let content = scroll.subviews.compactMap { $0 as? UIStackView }.first! + XCTAssertEqual(content.frame.minX, 16, accuracy: 0.5) + XCTAssertGreaterThanOrEqual(content.frame.minY, 16) + XCTAssertNil(scroll.hitTest(CGPoint(x: 40, y: scroll.bounds.maxY + 1), with: nil)) + scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height), animated: false) + scroll.layoutIfNeeded() + let last = descendants(content).compactMap { $0 as? UIButton } + .first { $0.accessibilityLabel == "Action 11" }! + let lastRect = last.convert(last.bounds, to: scroll) + XCTAssertTrue(scroll.bounds.contains(lastRect), "viewport=\(scroll.bounds) last=\(lastRect) content=\(scroll.contentSize)") + } +} 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 245e4c192f..be7f00081f 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -79,14 +79,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; + void openReactionPopover(MessageLongPressDetails details) { showMessageActions( context: context, ref: ref, @@ -97,7 +90,8 @@ class _SystemMessageRow extends HookConsumerWidget { currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, - anchorRect: spotlightRect, + anchorRect: details.anchorRect, + captureAnchorSnapshot: details.captureSnapshot, popoverSpotlightPadding: EdgeInsets.fromLTRB( Grid.xxs, Grid.xxs, @@ -113,7 +107,8 @@ class _SystemMessageRow extends HookConsumerWidget { clipBehavior: Clip.antiAlias, child: MessageLongPressInkWell( key: ValueKey('system-message-row-${message.id}'), - onLongPress: openReactionPopover, + onLongPressDetails: openReactionPopover, + snapshotKey: spotlightKey, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), child: Padding( @@ -124,7 +119,7 @@ class _SystemMessageRow extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - KeyedSubtree( + RepaintBoundary( key: spotlightKey, child: groupedMembership != null ? _MembershipSystemMessageContent( diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index a8f5bbe08f..6276f8a309 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -25,6 +25,8 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/widgets/sheet_divider.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/native_message_presentation.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; @@ -45,6 +47,7 @@ part 'message_actions/quick_reaction_row.dart'; part 'message_actions/message_action_popover.dart'; part 'message_actions/message_action_popover_widgets.dart'; part 'message_actions/message_reaction_tray.dart'; +part 'message_actions/native_actions.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. @@ -71,7 +74,7 @@ final _messageActionBackdropFilter = ImageFilter.blur( /// [restoreComposerFocus] only after a dismissal with no selected action. The /// restorer must remain callable for the same lifetime and no-op if its composer /// is later disposed or replaced. -void showMessageActions({ +Future showMessageActions({ required BuildContext context, required WidgetRef ref, required TimelineMessage message, @@ -88,7 +91,29 @@ void showMessageActions({ VoidCallback? restoreComposerFocus, bool isArchived = false, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), -}) { +}) async { + if (NativeMessagePresentation.isSupportedPlatform && + anchorRect != null && + await _showNativeMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, + onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, + onPopoverDismissed: onPopoverDismissed, + )) { + return; + } + if (!context.mounted) return; final hasReactionOnlyActions = message.isSystem && !canManageMessage; if (anchorRect != null && hasReactionOnlyActions) { _showMessageReactionPopover( diff --git a/mobile/lib/features/channels/message_actions/native_actions.dart b/mobile/lib/features/channels/message_actions/native_actions.dart new file mode 100644 index 0000000000..8d2020ad31 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/native_actions.dart @@ -0,0 +1,243 @@ +part of '../message_actions.dart'; + +Future _showNativeMessageActions({ + 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 FocusNode? composerFocusNode, + required VoidCallback? restoreComposerFocus, + required ValueChanged? onPopoverPreviewVisibilityChanged, + required VoidCallback? onPopoverDismissed, +}) async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return false; + return NativeMessagePresentation.withLease(() async { + if (captureAnchorSnapshot == null) return false; + final hadComposerFocus = composerFocusNode?.hasFocus ?? false; + final support = await NativeMessagePresentation.present( + 'supportsMessage', + {}, + ); + if (support?['supported'] != true) return false; + if (!context.mounted) return true; + final community = ref.read(relayConfigProvider); + Uint8List? previewBytes; + try { + final snapshot = await captureAnchorSnapshot(); + try { + final bytes = await snapshot.toByteData(format: ui.ImageByteFormat.png); + previewBytes = bytes?.buffer.asUint8List(); + } finally { + snapshot.dispose(); + } + } catch (_) { + return false; + } + if (!context.mounted || ref.read(relayConfigProvider) != community) { + return true; + } + if (previewBytes == null) return false; + final callbacks = {}; + final actions = >[]; + void action( + String id, + String title, + String symbol, + VoidCallback callback, { + bool destructive = false, + }) { + callbacks[id] = callback; + actions.add({ + 'id': id, + 'title': title, + 'symbol': symbol, + 'destructive': destructive, + }); + } + + if (!message.isSystem) { + if (allMessages != null) { + action('reply', 'Reply', 'arrowshape.turn.up.left', () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }); + } + action('copyLink', 'Copy link', 'link', () { + copyToClipboard( + context, + messageLinkFor(message: message, channelId: channelId), + message: 'Message link copied', + ); + }); + if (ref.read(reminderServiceProvider) != null) { + action('remind', 'Remind me', 'clock', () { + showRemindMeLaterSheet( + context: 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, + ); + action('read', unread ? 'Mark read' : 'Mark unread', 'envelope', () { + 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); + action( + 'follow', + following ? 'Unfollow thread' : 'Follow thread', + following ? 'bell.slash' : 'bell', + () { + final notifier = ref.read(threadFollowsProvider.notifier); + if (following) { + notifier.unfollowThread(rootId); + } else { + notifier.followThread(rootId); + } + }, + ); + action('copy', 'Copy text', 'doc.on.doc', () { + Clipboard.setData(ClipboardData(text: message.content)); + }); + } + if (canManageMessage) { + action('edit', 'Edit message', 'pencil', () { + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }); + action('delete', 'Delete message', 'trash', () { + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + ); + }, destructive: true); + } + final palette = ref.read(customEmojiListProvider); + final emojis = quickReactionEmoji( + ref.read(recentEmojiProvider), + customShortcodes: {for (final e in palette) e.shortcode.toLowerCase()}, + ); + final dataset = ref.read(emojiDatasetOrEmptyProvider); + Map? response; + var ownsPreview = false; + if (hadComposerFocus) composerFocusNode!.unfocus(); + try { + response = await NativeMessagePresentation.present( + 'message', + { + 'x': anchorRect.left, + 'y': anchorRect.top, + 'width': anchorRect.width, + 'height': anchorRect.height, + 'dark': Theme.of(context).brightness == Brightness.dark, + 'previewLabel': message.content, + 'previewBytes': previewBytes, + 'actions': actions, + 'reactions': [ + for (final emoji in emojis) + { + 'emoji': emoji, + 'label': dataset.displayName(emoji), + 'selected': message.reactions.any( + (r) => r.emoji == emoji && r.reactedByCurrentUser, + ), + if (reactionEmojiUrl(emoji, palette) case final String url) ...{ + 'url': url, + 'headers': mediaGetHeadersFor(ref, url), + }, + }, + ], + }, + onPresented: () { + if (!context.mounted || ownsPreview) return; + ownsPreview = true; + onPopoverPreviewVisibilityChanged?.call(true); + }, + ); + } finally { + if (ownsPreview) { + onPopoverPreviewVisibilityChanged?.call(false); + if (context.mounted) onPopoverDismissed?.call(); + } + if (hadComposerFocus && + response?['busy'] != true && + response?['action'] == null && + context.mounted) { + restoreComposerFocus?.call(); + } + } + if (response == null) return false; + // A native controller can outlive the Flutter page that opened it. + if (!context.mounted || ref.read(relayConfigProvider) != community) { + return true; + } + final selected = response['action']; + if (selected == 'reaction' && emojis.contains(response['emoji'])) { + final emoji = response['emoji']! as String; + final existing = message.reactions.where((r) => r.emoji == emoji); + if (existing.isNotEmpty && existing.first.reactedByCurrentUser) { + toggleReaction(ref, message, emoji); + } else { + ref.read(recentEmojiProvider.notifier).record(emoji); + armReactionBurst(ref, message, emoji); + ref.read(channelActionsProvider).addReaction(message.id, emoji); + } + } else if (selected == 'more') { + showAddReactionPicker(context: context, ref: ref, message: message); + } else { + callbacks[selected]?.call(); + } + return true; + }); +} diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart index caf84815a3..526271c8c4 100644 --- a/mobile/lib/features/channels/message_long_press_region.dart +++ b/mobile/lib/features/channels/message_long_press_region.dart @@ -7,7 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -const _iosMessageLongPressDuration = Duration(milliseconds: 200); +/// Shared hold deadline so nested reaction pills win before the message menu. +const iosMessageLongPressDuration = Duration(milliseconds: 200); const _maxMessageSnapshotDimension = 2048.0; double _messageSnapshotPixelRatio(Size size, double devicePixelRatio) { @@ -193,7 +194,7 @@ class _MessageLongPressRegion extends HookWidget { GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer( duration: defaultTargetPlatform == TargetPlatform.iOS - ? _iosMessageLongPressDuration + ? iosMessageLongPressDuration : null, ), (recognizer) { diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 9c70ba4bd6..4c012c755e 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,8 +9,11 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/native_message_presentation.dart'; +import '../../shared/relay/media_auth.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_burst.dart'; +import '../../shared/emoji/emoji_avatar.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/emoji/positive_emoji.dart'; @@ -17,6 +23,7 @@ import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'recent_emoji_provider.dart'; +import 'message_long_press_region.dart'; import 'timeline_message.dart'; /// Pill geometry, ported from desktop's `REACTION_PILL_BASE_CLASSES` in @@ -232,39 +239,54 @@ class _ReactionPill extends HookConsumerWidget { return null; }, [reacted, messageId, reaction.emoji]); - return GestureDetector( - key: ValueKey('reaction-pill-${reaction.emoji}'), - onTap: () { - // The pill is already here, so burst straight away rather than waiting - // for the relay echo — desktop does the same on a pill click. - if (!reacted && isPositiveEmojiParticle(reaction.emoji)) { - burstEmojiFromContext(ref, context, reaction.emoji); - } - onTap(); - }, + return Semantics( onLongPress: onLongPress, - child: _PillSurface( - highlighted: reacted, - minWidth: _pillMinWidth, - child: Row( - mainAxisSize: MainAxisSize.min, - // Centered so a short reaction sits mid-pill once the min width kicks - // in rather than hugging the left edge. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _ReactionEmoji(reaction: reaction, size: _pillGlyphSize), - const SizedBox(width: _pillGap), - // Desktop shows the count even at 1; hiding it made a fresh - // reaction jump in width the moment a second person joined. - Text( - '${reaction.count}', - style: reactionCountTextStyle.copyWith( - color: reacted - ? context.colors.primary - : context.colors.onSurfaceVariant, + child: RawGestureDetector( + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + duration: defaultTargetPlatform == TargetPlatform.iOS + ? iosMessageLongPressDuration + : null, + ), + (recognizer) => recognizer.onLongPress = onLongPress, ), + }, + child: GestureDetector( + key: ValueKey('reaction-pill-${reaction.emoji}'), + onTap: () { + // The pill is already here, so burst straight away rather than waiting + // for the relay echo — desktop does the same on a pill click. + if (!reacted && isPositiveEmojiParticle(reaction.emoji)) { + burstEmojiFromContext(ref, context, reaction.emoji); + } + onTap(); + }, + child: _PillSurface( + highlighted: reacted, + minWidth: _pillMinWidth, + child: Row( + mainAxisSize: MainAxisSize.min, + // Centered so a short reaction sits mid-pill once the min width kicks + // in rather than hugging the left edge. + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ReactionEmoji(reaction: reaction, size: _pillGlyphSize), + const SizedBox(width: _pillGap), + // Desktop shows the count even at 1; hiding it made a fresh + // reaction jump in width the moment a second person joined. + Text( + '${reaction.count}', + style: reactionCountTextStyle.copyWith( + color: reacted + ? context.colors.primary + : context.colors.onSurfaceVariant, + ), + ), + ], ), - ], + ), ), ), ); @@ -318,12 +340,22 @@ class _ReactionEmoji extends StatelessWidget { } } -void showReactionDetailSheet({ +Future showReactionDetailSheet({ required BuildContext context, required String channelId, required List reactions, required String initialEmoji, -}) { +}) async { + if (reactions.isEmpty) return; + if (await _showNativeReactionDetails( + context, + channelId, + reactions, + initialEmoji, + )) { + return; + } + if (!context.mounted) return; showBuzzModalBottomSheet( context: context, isScrollControlled: true, @@ -526,3 +558,130 @@ class _ReactorAvatar extends StatelessWidget { ); } } + +Future _showNativeReactionDetails( + BuildContext context, + String channelId, + List reactions, + String initialEmoji, +) async { + if (!NativeMessagePresentation.isSupportedPlatform) return false; + return NativeMessagePresentation.withLease(() async { + final container = ProviderScope.containerOf(context, listen: false); + final requestId = DateTime.now().microsecondsSinceEpoch.toString(); + final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList(); + final sent = {}; + Map profiles({bool onlyChanges = false}) { + final cache = container.read(userCacheProvider); + final names = container.read(channelIdentityNamesProvider(channelId)); + final result = {}; + for (final pubkey in pubkeys) { + final value = ( + name: names.labelFor(pubkey), + avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl, + ); + if (onlyChanges && sent[pubkey] == value) continue; + sent[pubkey] = value; + final url = value.avatarUrl; + // Buzz's emoji-avatar SVG is a small value object, not arbitrary native SVG. + final avatar = url != null && url.length <= 8192 + ? parseEmojiAvatarDataUrl(url) + : null; + final validAvatar = + avatar != null && + avatar.emoji.length <= 64 && + avatar.emoji.characters.length == 1; + result[pubkey] = { + 'name': value.name, + if (validAvatar) ...{ + 'avatarEmoji': avatar.emoji, + 'avatarColor': avatar.colorValue, + } else if (url != null && !url.startsWith('data:')) ...{ + 'url': url, + 'headers': container + .read(mediaGetAuthServiceProvider) + .headersFor(url), + }, + }; + } + return result; + } + + Timer? timer; + var disposed = false; + var flushing = false; + var dirty = false; + void scheduleProfiles() { + dirty = true; + if (disposed || timer != null || flushing) return; + timer = Timer(const Duration(milliseconds: 50), () async { + timer = null; + flushing = true; + dirty = false; + try { + final changes = profiles(onlyChanges: true).entries.toList(); + // Backpressure bounds each platform message; only changed users cross + // the bridge, even when hydration publishes several thousand-user pages. + for ( + var start = 0; + start < changes.length && !disposed; + start += 256 + ) { + await NativeMessagePresentation.present('updateProfiles', { + 'requestId': requestId, + 'profiles': Map.fromEntries(changes.skip(start).take(256)), + }); + } + } finally { + flushing = false; + if (dirty && !disposed) scheduleProfiles(); + } + }); + } + + final subscription = container.listen( + userCacheProvider, + (_, _) => scheduleProfiles(), + ); + final namesSubscription = container.listen( + channelIdentityNamesProvider(channelId), + (_, _) => scheduleProfiles(), + ); + try { + final dataset = container.read(emojiDatasetOrEmptyProvider); + final sheetColors = utilitySurfaceThemeData( + Theme.of(context), + ).colorScheme; + final pending = NativeMessagePresentation.present('reactions', { + 'requestId': requestId, + 'initialEmoji': initialEmoji, + 'sheetColor': sheetColors.surface.toARGB32(), + 'foregroundColor': sheetColors.primary.toARGB32(), + 'dark': Theme.of(context).brightness == Brightness.dark, + 'profiles': profiles(), + 'reactions': [ + for (final reaction in reactions) + { + 'emoji': reaction.emoji, + 'label': dataset.displayName(reaction.emoji), + 'count': reaction.count, + 'users': reaction.userPubkeys.toSet().toList(), + if (reaction.emojiUrl case final String url) ...{ + 'url': url, + 'headers': container + .read(mediaGetAuthServiceProvider) + .headersFor(url), + }, + }, + ], + }); + container.read(userCacheProvider.notifier).preload(pubkeys); + return await pending != null; + } finally { + disposed = true; + timer?.cancel(); + subscription.close(); + namesSubscription.close(); + } + }); +} diff --git a/mobile/lib/shared/widgets/native_message_presentation.dart b/mobile/lib/shared/widgets/native_message_presentation.dart new file mode 100644 index 0000000000..5c76d3f789 --- /dev/null +++ b/mobile/lib/shared/widgets/native_message_presentation.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// UIKit presentation for message actions and reaction membership on iOS. +/// A null response means unavailable; a dismissed native surface is handled. +class NativeMessagePresentation { + /// Whether the current platform can host UIKit. + static bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + /// Transport shared with the iOS presentation coordinator. + static const channel = MethodChannel('buzz/native_message_presentation'); + + static final _presentedCallbacks = {}; + static var _nextRequest = 0; + static bool _active = false; + + /// Owns preflight through dismissal across message and reaction surfaces. + /// An overlapping request is consumed instead of opening a fallback surface. + static Future withLease(Future Function() presentation) async { + if (_active) return true; + _active = true; + try { + return await presentation(); + } finally { + _active = false; + } + } + + /// Completes after dismissal so callers can safely open the next surface. + static Future?> present( + String method, + Map arguments, { + VoidCallback? onPresented, + }) async { + if (!isSupportedPlatform) return null; + final requestId = 'message-${_nextRequest++}'; + if (onPresented != null) { + _presentedCallbacks[requestId] = onPresented; + channel.setMethodCallHandler((call) async { + if (call.method == 'messagePresented') { + _presentedCallbacks[call.arguments]?.call(); + } + }); + } + try { + return await channel.invokeMapMethod(method, { + ...arguments, + if (onPresented != null) 'requestId': requestId, + }); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } finally { + _presentedCallbacks.remove(requestId); + } + } +} diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index a8c44e3492..5c542507f0 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:ui' as ui; +import 'package:flutter/services.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/message_actions.dart'; +import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; @@ -11,6 +14,7 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -112,7 +116,10 @@ Future _pumpSheet( bool canManageMessage = false, List? allMessages, ReminderService? reminderService, + Rect? anchorRect, + bool nativePresentation = false, }) async { + Future? presentation; await tester.pumpWidget( ProviderScope( overrides: [ @@ -133,12 +140,16 @@ Future _pumpSheet( home: Scaffold( body: Consumer( builder: (context, ref, _) => TextButton( - onPressed: () => showMessageActions( + onPressed: () => presentation = showMessageActions( context: context, ref: ref, message: message, channelId: _channelId, canManageMessage: canManageMessage, + anchorRect: anchorRect, + captureAnchorSnapshot: nativePresentation + ? _testMessageSnapshot + : null, allMessages: allMessages, currentPubkey: 'self', isMember: true, @@ -150,7 +161,14 @@ Future _pumpSheet( ), ), ); - await tester.tap(find.text('open')); + if (nativePresentation) { + await tester.runAsync(() async { + await tester.tap(find.text('open')); + await presentation; + }); + } else { + await tester.tap(find.text('open')); + } await tester.pumpAndSettle(); } @@ -581,6 +599,398 @@ void main() { expect(snapshot.height, 80); }); + testWidgets( + 'native menu respects ownership and dispatches the target read action', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + Map? payload; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'supportsMessage') return {'supported': true}; + payload = call.arguments as Map; + return {'action': 'read'}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + final notifier = _FakeReadStateNotifier( + _readState(const {_channelId: 100000}), + ); + await _pumpSheet( + tester, + message: _message(), + prefs: await _mockPrefs(), + readStateOverride: () => notifier, + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + nativePresentation: true, + ); + expect(payload?['previewBytes'], isA()); + expect(notifier.markedUnread, ['msg:msg-1']); + final actions = (payload!['actions'] as List) + .cast>(); + expect(actions.map((a) => a['id']), isNot(contains('edit'))); + expect(actions.map((a) => a['id']), isNot(contains('delete'))); + expect(find.text('Copy text'), findsNothing); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('system messages use the native tray without action rows', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + Map? payload; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'supportsMessage') return {'supported': true}; + payload = call.arguments as Map; + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: await _mockPrefs(), + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + nativePresentation: true, + ); + expect(payload?['actions'], isEmpty); + expect(payload?['reactions'], isNotEmpty); + expect(payload?['previewBytes'], isA()); + expect(find.byType(BottomSheet), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + for (final selectedAction in [ + null, + 'follow', + 'unmount', + 'unmountFailure', + 'overlap', + ]) { + testWidgets('native focus and source lifecycle for $selectedAction', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final focus = FocusNode(); + late Completer ready; + late Completer> result; + await tester.runAsync(() async { + ready = Completer(); + result = Completer>(); + }); + final visibility = []; + var dismissed = 0; + var focusRestores = 0; + late BuildContext pageContext; + late WidgetRef pageRef; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'supportsMessage') return {'supported': true}; + expect(visibility, isEmpty); + final args = call.arguments as Map; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + NativeMessagePresentation.channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('messagePresented', args['requestId']), + ), + (_) {}, + ); + ready.complete(); + return result.future; + }, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(await _mockPrefs()), + myPubkeyProvider.overrideWithValue('self'), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + _readState(const {_channelId: 100000}), + ), + ), + reminderServiceProvider.overrideWithValue(null), + ], + child: MaterialApp( + home: Consumer( + builder: (context, ref, _) { + pageContext = context; + pageRef = ref; + return Scaffold(body: TextField(focusNode: focus)); + }, + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + late Future presentation; + Future openMenu() => showMessageActions( + context: pageContext, + ref: pageRef, + message: _message(), + channelId: _channelId, + canManageMessage: false, + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + captureAnchorSnapshot: _testMessageSnapshot, + composerFocusNode: focus, + restoreComposerFocus: () { + focusRestores++; + focus.requestFocus(); + }, + onPopoverPreviewVisibilityChanged: visibility.add, + onPopoverDismissed: () => dismissed++, + ); + await tester.runAsync(() async { + presentation = openMenu(); + if (selectedAction == 'overlap') { + // A is awaiting preflight/capture, so both gestures could see focus. + await openMenu(); + expect(focusRestores, 0); + } + await ready.future; + }); + await tester.pump(); + expect(focus.hasFocus, isFalse); + expect(visibility, [true]); + if (selectedAction == 'overlap') { + await tester.runAsync(openMenu); + await tester.pump(); + expect(focus.hasFocus, isFalse); + expect(focusRestores, 0); + } + if (selectedAction?.startsWith('unmount') == true) { + await tester.pumpWidget(const SizedBox.shrink()); + } + await tester.runAsync(() async { + if (selectedAction == 'unmountFailure') { + result.completeError(PlatformException(code: 'presentation-failed')); + } else { + result.complete({ + if (selectedAction == 'follow') 'action': selectedAction, + }); + } + await presentation; + }); + await tester.pump(); + final restores = selectedAction == null || selectedAction == 'overlap'; + expect(focus.hasFocus, restores); + expect(focusRestores, restores ? 1 : 0); + expect(visibility, [true, false]); + expect(dismissed, selectedAction?.startsWith('unmount') == true ? 0 : 1); + await tester.pumpWidget(const SizedBox.shrink()); + focus.dispose(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ); + debugDefaultTargetPlatformOverride = null; + }); + } + + for (final (reactionFirst, missingSnapshot) in [ + (false, false), + (true, false), + (true, true), + ]) { + testWidgets( + 'cross-surface native ownership reactionFirst=$reactionFirst missingSnapshot=$missingSnapshot', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final focus = FocusNode(); + late Completer> result; + late Completer> support; + late Completer entered; + late Completer ready; + await tester.runAsync(() async { + result = Completer(); + support = Completer(); + entered = Completer(); + ready = Completer(); + }); + var messages = 0; + var reactions = 0; + var restores = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'supportsMessage') { + entered.complete(); + return support.future; + } + if (call.method == 'message') messages++; + if (call.method == 'reactions') reactions++; + ready.complete(); + return result.future; + }, + ); + late BuildContext pageContext; + late WidgetRef pageRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(await _mockPrefs()), + myPubkeyProvider.overrideWithValue('self'), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + _readState(const {_channelId: 100000}), + ), + ), + reminderServiceProvider.overrideWithValue(null), + ], + child: MaterialApp( + home: Consumer( + builder: (context, ref, _) { + pageContext = context; + pageRef = ref; + return Scaffold( + body: TextField(focusNode: focus, showCursor: false), + ); + }, + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + Future message() => showMessageActions( + context: pageContext, + ref: pageRef, + message: _message(), + channelId: _channelId, + canManageMessage: false, + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + captureAnchorSnapshot: missingSnapshot ? null : _testMessageSnapshot, + composerFocusNode: focus, + restoreComposerFocus: () { + restores++; + focus.requestFocus(); + }, + ); + Future reaction() => showReactionDetailSheet( + context: pageContext, + channelId: _channelId, + reactions: const [ + TimelineReaction( + emoji: '❤️', + count: 1, + reactedByCurrentUser: false, + userPubkeys: [], + ), + ], + initialEmoji: '❤️', + ); + late Future owner; + await tester.runAsync(() async { + if (reactionFirst) { + owner = reaction(); + await ready.future; + await message(); + } else { + owner = message(); + await entered.future; + await reaction(); + expect(reactions, 0); + support.complete({'supported': true}); + await ready.future; + } + }); + await tester.pump(); + expect(messages, reactionFirst ? 0 : 1); + expect(reactions, reactionFirst ? 1 : 0); + expect(restores, 0); + expect(focus.hasFocus, reactionFirst); + expect(find.byType(BottomSheet), findsNothing); + await tester.runAsync(() async { + result.complete({}); + await owner; + }); + await tester.pumpAndSettle(); + expect(focus.hasFocus, isTrue); + expect(restores, reactionFirst ? 0 : 1); + await tester.pumpWidget(const SizedBox.shrink()); + focus.dispose(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + + testWidgets('native dismissal does not open the Flutter sheet', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async => call.method == 'supportsMessage' + ? {'supported': true} + : {}, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpSheet( + tester, + message: _message(), + prefs: await _mockPrefs(), + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + nativePresentation: true, + ); + expect(find.text('Copy text'), findsNothing); + expect(find.text('open'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('unavailable native presentation falls back to message actions', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpSheet( + tester, + message: _message(), + prefs: await _mockPrefs(), + canManageMessage: true, + anchorRect: const Rect.fromLTWH(20, 200, 300, 80), + ); + expect(find.text('Edit message'), findsOneWidget); + expect(find.text('Delete message'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + group('showMessageActions', () { testWidgets('composes the tray, lifted preview, and compact actions', ( tester, @@ -644,6 +1054,17 @@ void main() { '${platform.name} composition keeps the action menu near the safe bottom', (tester) async { debugDefaultTargetPlatformOverride = platform; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (_) async => null, + ); + addTearDown( + () => + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); try { final prefs = await _mockPrefs(); await _pumpMessageActionsPopover( @@ -1069,6 +1490,16 @@ void main() { tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); ui.Image? snapshot; try { final prefs = await _mockPrefs(); diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index c8d73f13ad..1915cd15c8 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -1,3 +1,8 @@ +import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'dart:async'; +import 'package:buzz/features/channels/channel_identity_names_provider.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/emoji/emoji_burst.dart'; @@ -6,11 +11,16 @@ import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; +const _alice = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const _fire = '\u{1F525}'; /// 👀 — a real reaction that is deliberately not in the positive-burst set. @@ -20,7 +30,7 @@ TimelineReaction _reaction({ String emoji = _fire, int count = 1, bool reactedByCurrentUser = false, - List userPubkeys = const ['alice'], + List userPubkeys = const [_alice], }) => TimelineReaction( emoji: emoji, count: count, @@ -57,6 +67,7 @@ Future _pumpRow( bool showAddButton = false, VoidCallback? onAddReaction, String messageId = _messageId, + VoidCallback? onMessageLongPress, }) async { await tester.pumpWidget( WidgetHelpers.testable( @@ -64,17 +75,20 @@ Future _pumpRow( emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), userCacheProvider.overrideWith( () => _FakeUserCacheNotifier({ - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + _alice: const UserProfile(pubkey: _alice, displayName: 'Alice'), }), ), ], - child: ReactionRow( - messageId: messageId, - channelId: 'channel', - reactions: reactions, - onToggle: onToggle ?? (_) {}, - showAddButton: showAddButton, - onAddReaction: onAddReaction, + child: MessageLongPressInkWell( + onLongPress: (_) => onMessageLongPress?.call(), + child: ReactionRow( + messageId: messageId, + channelId: 'channel', + reactions: reactions, + onToggle: onToggle ?? (_) {}, + showAddButton: showAddButton, + onAddReaction: onAddReaction, + ), ), ), ); @@ -90,11 +104,252 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override Map build() => _profiles; + void replace(Map profiles) => state = profiles; + @override Future preload(List pubkeys) async => true; } void main() { + testWidgets( + 'native reactor labels retain channel qualifications and live updates', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final bot = 'b' * 64; + final roster = 'c' * 64; + var directoryName = 'Honey'; + final calls = >[]; + final mergedProfiles = {}; + final completion = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions' || call.method == 'updateProfiles') { + final args = call.arguments as Map; + mergedProfiles.addAll(args['profiles'] as Map); + calls.add({...args, 'profiles': Map.of(mergedProfiles)}); + } + if (call.method == 'reactions') return completion.future; + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier({ + _alice: const UserProfile(pubkey: _alice, displayName: 'Honey'), + }), + ), + channelIdentityNamesProvider('channel').overrideWith( + (ref) => + IdentityNameSources( + profiles: ref.watch(userCacheProvider), + agentDisplayNames: {bot: directoryName}, + ).scope( + [_alice, bot, roster], + agentPubkeys: {bot}, + fallbackNames: {roster: 'Roster name'}, + ), + ), + ], + child: ReactionRow( + messageId: _messageId, + channelId: 'channel', + reactions: [ + _reaction(userPubkeys: [_alice, bot, roster]), + ], + onToggle: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ReactionRow)), + ); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pumpAndSettle(); + List labels(Map payload) { + final profiles = payload['profiles'] as Map; + return [ + for (final key in [_alice, bot, roster]) + (profiles[key] as Map)['name'], + ]; + } + + expect(labels(calls.first), ['Honey', 'Honey (agent)', 'Roster name']); + // Directory/roster changes must propagate without a user-cache event. + directoryName = 'Helper'; + container.invalidate(channelIdentityNamesProvider('channel')); + await tester.pumpAndSettle(); + expect(labels(calls.last), ['Honey', 'Helper', 'Roster name']); + (container.read(userCacheProvider.notifier) as _FakeUserCacheNotifier) + .replace({ + _alice: const UserProfile(pubkey: _alice, displayName: 'Helper'), + }); + await tester.pumpAndSettle(); + expect(labels(calls.last), ['Helper', 'Helper (agent)', 'Roster name']); + expect(calls.map((c) => c['requestId']).toSet(), hasLength(1)); + completion.complete({}); + await tester.pumpAndSettle(); + final count = calls.length; + directoryName = 'Closed'; + container.invalidate(channelIdentityNamesProvider('channel')); + await tester.pumpAndSettle(); + expect(calls, hasLength(count)); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets( + 'native hydration sends bounded diffs and preserves emoji avatars', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final pubkeys = [ + for (var i = 1; i <= 2500; i++) i.toRadixString(16).padLeft(64, '0'), + ]; + final completion = Completer>(); + final updates = []; + Map? initial; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + final args = call.arguments as Map; + if (call.method == 'reactions') { + initial = args['profiles'] as Map; + return completion.future; + } + if (call.method == 'updateProfiles') { + updates.add(args['profiles'] as Map); + } + return {}; + }, + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + channelIdentityNamesProvider('channel').overrideWith( + (ref) => IdentityNameSources( + profiles: ref.watch(userCacheProvider), + ).scope(pubkeys), + ), + ], + child: ReactionRow( + messageId: _messageId, + channelId: 'channel', + reactions: [_reaction(userPubkeys: pubkeys)], + onToggle: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ReactionRow)), + ); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pumpAndSettle(); + expect(initial, hasLength(2500)); + final cache = {}; + for (var start = 0; start < pubkeys.length; start += 1000) { + for (final key in pubkeys.skip(start).take(1000)) { + cache[key] = UserProfile( + pubkey: key, + displayName: 'Person $key', + avatarUrl: key == pubkeys.first + ? emojiAvatarDataUrl('😊', 0xFFFF6B9A) + : null, + ); + } + (container.read(userCacheProvider.notifier) as _FakeUserCacheNotifier) + .replace(Map.of(cache)); + await tester.pumpAndSettle(); + } + expect( + updates.length, + 10, + ); // 4 + 4 + 2 chunks, rather than full-set payloads. + expect(updates.every((p) => p.length <= 256), isTrue); + expect(updates.fold(0, (sum, p) => sum + p.length), 2500); + final avatar = updates.first[pubkeys.first] as Map; + expect(avatar['avatarEmoji'], '😊'); + expect(avatar['avatarColor'], 0xFFFF6B9A); + expect(avatar.containsKey('url'), isFalse); + cache['f' * 64] = UserProfile(pubkey: 'f' * 64, displayName: 'Unrelated'); + (container.read(userCacheProvider.notifier) as _FakeUserCacheNotifier) + .replace(Map.of(cache)); + await tester.pumpAndSettle(); + expect(updates, hasLength(10)); + completion.complete({}); + await tester.pumpAndSettle(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets( + 'holding a pill opens native membership with the selected emoji without toggling', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + Map? payload; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + } + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + var toggles = 0; + var messageHolds = 0; + await _pumpRow( + tester, + reactions: [ + _reaction(), + _reaction(emoji: _eyes), + ], + onToggle: (_) => toggles++, + onMessageLongPress: () => messageHolds++, + ); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_eyes')), + ); + await tester.pumpAndSettle(); + expect(toggles, 0); + expect(messageHolds, 0); + expect(payload!['initialEmoji'], _eyes); + expect((payload!['reactions'] as List).length, 2); + final profiles = payload!['profiles'] as Map; + expect((profiles[_alice] as Map)['name'], 'Alice'); + expect(find.byType(BottomSheet), findsNothing); + debugDefaultTargetPlatformOverride = null; + }, + ); + group('ReactionRow', () { testWidgets('shows the count even at one, matching desktop', ( tester, @@ -188,7 +443,7 @@ void main() { await _pumpRow( tester, reactions: [ - _reaction(count: 1, userPubkeys: const ['alice']), + _reaction(count: 1, userPubkeys: const [_alice]), ], );