feat(mobile): native iOS message menus and reaction sheets (#8055)

This commit is contained in:
klopez4212
2026-10-02 20:59:05 +01:00
committed by GitHub
parent 5a4b2d3dcc
commit ea143ea64b
14 changed files with 2374 additions and 59 deletions

View File

@@ -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 = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
AB1200112F40400100A17E01 /* NativeMessageMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageMenu.swift; sourceTree = "<group>"; };
AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = "<group>"; };
AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = "<group>"; };
/* 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 */,

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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<NSURLRequest, UIImage>()
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
}
}

View File

@@ -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..<controller.tableView(table, numberOfRowsInSection: 0)).map {
controller.tableView(table, cellForRowAt: IndexPath(row: $0, section: 0)).accessibilityLabel ?? ""
}
}
func descendants(_ view: UIView) -> [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)")
}
}

View File

@@ -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(

View File

@@ -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<void> 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(

View File

@@ -0,0 +1,243 @@
part of '../message_actions.dart';
Future<bool> _showNativeMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
required Rect anchorRect,
required Future<ui.Image> Function()? captureAnchorSnapshot,
required FocusNode? composerFocusNode,
required VoidCallback? restoreComposerFocus,
required ValueChanged<bool>? 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 = <String, VoidCallback>{};
final actions = <Map<String, Object?>>[];
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<void>(
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<Object?, Object?>? 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;
});
}

View File

@@ -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>(
() => LongPressGestureRecognizer(
duration: defaultTargetPlatform == TargetPlatform.iOS
? _iosMessageLongPressDuration
? iosMessageLongPressDuration
: null,
),
(recognizer) {

View File

@@ -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,7 +239,21 @@ class _ReactionPill extends HookConsumerWidget {
return null;
}, [reacted, messageId, reaction.emoji]);
return GestureDetector(
return Semantics(
onLongPress: onLongPress,
child: RawGestureDetector(
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => 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
@@ -242,7 +263,6 @@ class _ReactionPill extends HookConsumerWidget {
}
onTap();
},
onLongPress: onLongPress,
child: _PillSurface(
highlighted: reacted,
minWidth: _pillMinWidth,
@@ -267,6 +287,8 @@ class _ReactionPill extends HookConsumerWidget {
],
),
),
),
),
);
}
}
@@ -318,12 +340,22 @@ class _ReactionEmoji extends StatelessWidget {
}
}
void showReactionDetailSheet({
Future<void> showReactionDetailSheet({
required BuildContext context,
required String channelId,
required List<TimelineReaction> reactions,
required String initialEmoji,
}) {
}) async {
if (reactions.isEmpty) return;
if (await _showNativeReactionDetails(
context,
channelId,
reactions,
initialEmoji,
)) {
return;
}
if (!context.mounted) return;
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@@ -526,3 +558,130 @@ class _ReactorAvatar extends StatelessWidget {
);
}
}
Future<bool> _showNativeReactionDetails(
BuildContext context,
String channelId,
List<TimelineReaction> 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 = <String, ({String name, String? avatarUrl})>{};
Map<String, Object?> profiles({bool onlyChanges = false}) {
final cache = container.read(userCacheProvider);
final names = container.read(channelIdentityNamesProvider(channelId));
final result = <String, Object?>{};
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();
}
});
}

View File

@@ -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 = <String, VoidCallback>{};
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<bool> withLease(Future<bool> 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<Map<Object?, Object?>?> present(
String method,
Map<String, Object?> 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<Object?, Object?>(method, {
...arguments,
if (onPresented != null) 'requestId': requestId,
});
} on MissingPluginException {
return null;
} on PlatformException {
return null;
} finally {
_presentedCallbacks.remove(requestId);
}
}
}

View File

@@ -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<void> _pumpSheet(
bool canManageMessage = false,
List<TimelineMessage>? allMessages,
ReminderService? reminderService,
Rect? anchorRect,
bool nativePresentation = false,
}) async {
Future<void>? presentation;
await tester.pumpWidget(
ProviderScope(
overrides: [
@@ -133,12 +140,16 @@ Future<void> _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<void> _pumpSheet(
),
),
);
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<Object?, Object?>? payload;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
if (call.method == 'supportsMessage') return {'supported': true};
payload = call.arguments as Map<Object?, Object?>;
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<Uint8List>());
expect(notifier.markedUnread, ['msg:msg-1']);
final actions = (payload!['actions'] as List)
.cast<Map<Object?, Object?>>();
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<Object?, Object?>? payload;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
if (call.method == 'supportsMessage') return {'supported': true};
payload = call.arguments as Map<Object?, Object?>;
return <String, Object?>{};
},
);
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<Uint8List>());
expect(find.byType(BottomSheet), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
for (final selectedAction in <String?>[
null,
'follow',
'unmount',
'unmountFailure',
'overlap',
]) {
testWidgets('native focus and source lifecycle for $selectedAction', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final focus = FocusNode();
late Completer<void> ready;
late Completer<Map<String, Object?>> result;
await tester.runAsync(() async {
ready = Completer<void>();
result = Completer<Map<String, Object?>>();
});
final visibility = <bool>[];
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<void> presentation;
Future<void> 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<Map<String, Object?>> result;
late Completer<Map<String, Object?>> support;
late Completer<void> entered;
late Completer<void> 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<void> 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<void> reaction() => showReactionDetailSheet(
context: pageContext,
channelId: _channelId,
reactions: const [
TimelineReaction(
emoji: '❤️',
count: 1,
reactedByCurrentUser: false,
userPubkeys: [],
),
],
initialEmoji: '❤️',
);
late Future<void> 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}
: <String, Object?>{},
);
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();

View File

@@ -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<String> userPubkeys = const ['alice'],
List<String> userPubkeys = const [_alice],
}) => TimelineReaction(
emoji: emoji,
count: count,
@@ -57,6 +67,7 @@ Future<ProviderContainer> _pumpRow(
bool showAddButton = false,
VoidCallback? onAddReaction,
String messageId = _messageId,
VoidCallback? onMessageLongPress,
}) async {
await tester.pumpWidget(
WidgetHelpers.testable(
@@ -64,10 +75,12 @@ Future<ProviderContainer> _pumpRow(
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
_alice: const UserProfile(pubkey: _alice, displayName: 'Alice'),
}),
),
],
child: MessageLongPressInkWell(
onLongPress: (_) => onMessageLongPress?.call(),
child: ReactionRow(
messageId: messageId,
channelId: 'channel',
@@ -77,6 +90,7 @@ Future<ProviderContainer> _pumpRow(
onAddReaction: onAddReaction,
),
),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(tester.element(find.byType(ReactionRow)));
@@ -90,11 +104,252 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => _profiles;
void replace(Map<String, UserProfile> profiles) => state = profiles;
@override
Future<bool> preload(List<String> 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 = <Map<Object?, Object?>>[];
final mergedProfiles = <Object?, Object?>{};
final completion = Completer<Map<String, Object?>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
if (call.method == 'reactions' || call.method == 'updateProfiles') {
final args = call.arguments as Map<Object?, Object?>;
mergedProfiles.addAll(args['profiles'] as Map);
calls.add({...args, 'profiles': Map.of(mergedProfiles)});
}
if (call.method == 'reactions') return completion.future;
return <String, Object?>{};
},
);
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<Object?> labels(Map<Object?, Object?> 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(<String, Object?>{});
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<Map<String, Object?>>();
final updates = <Map>[];
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 <String, Object?>{};
},
);
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 = <String, UserProfile>{};
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<int>(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(<String, Object?>{});
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<Object?, Object?>? payload;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
if (call.method == 'reactions') {
payload = call.arguments as Map<Object?, Object?>;
}
return <String, Object?>{};
},
);
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]),
],
);