mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(mobile): native iOS message menus and reaction sheets (#8055)
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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"
|
||||
|
||||
353
mobile/ios/Runner/NativeMessageMenu.swift
Normal file
353
mobile/ios/Runner/NativeMessageMenu.swift
Normal 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
|
||||
}
|
||||
}
|
||||
109
mobile/ios/Runner/NativeMessagePresentationCoordinator.swift
Normal file
109
mobile/ios/Runner/NativeMessagePresentationCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
433
mobile/ios/Runner/NativeReactionDetails.swift
Normal file
433
mobile/ios/Runner/NativeReactionDetails.swift
Normal 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
|
||||
}
|
||||
}
|
||||
229
mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift
Normal file
229
mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
243
mobile/lib/features/channels/message_actions/native_actions.dart
Normal file
243
mobile/lib/features/channels/message_actions/native_actions.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -6,8 +9,11 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/native_message_presentation.dart';
|
||||
import '../../shared/relay/media_auth.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/emoji/emoji_avatar.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/emoji/positive_emoji.dart';
|
||||
@@ -17,6 +23,7 @@ import 'channel_identity_names_provider.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'emoji_picker.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
/// Pill geometry, ported from desktop's `REACTION_PILL_BASE_CLASSES` in
|
||||
@@ -232,39 +239,54 @@ class _ReactionPill extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [reacted, messageId, reaction.emoji]);
|
||||
|
||||
return GestureDetector(
|
||||
key: ValueKey('reaction-pill-${reaction.emoji}'),
|
||||
onTap: () {
|
||||
// The pill is already here, so burst straight away rather than waiting
|
||||
// for the relay echo — desktop does the same on a pill click.
|
||||
if (!reacted && isPositiveEmojiParticle(reaction.emoji)) {
|
||||
burstEmojiFromContext(ref, context, reaction.emoji);
|
||||
}
|
||||
onTap();
|
||||
},
|
||||
return Semantics(
|
||||
onLongPress: onLongPress,
|
||||
child: _PillSurface(
|
||||
highlighted: reacted,
|
||||
minWidth: _pillMinWidth,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// Centered so a short reaction sits mid-pill once the min width kicks
|
||||
// in rather than hugging the left edge.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: _pillGlyphSize),
|
||||
const SizedBox(width: _pillGap),
|
||||
// Desktop shows the count even at 1; hiding it made a fresh
|
||||
// reaction jump in width the moment a second person joined.
|
||||
Text(
|
||||
'${reaction.count}',
|
||||
style: reactionCountTextStyle.copyWith(
|
||||
color: reacted
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
duration: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? iosMessageLongPressDuration
|
||||
: null,
|
||||
),
|
||||
(recognizer) => recognizer.onLongPress = onLongPress,
|
||||
),
|
||||
},
|
||||
child: GestureDetector(
|
||||
key: ValueKey('reaction-pill-${reaction.emoji}'),
|
||||
onTap: () {
|
||||
// The pill is already here, so burst straight away rather than waiting
|
||||
// for the relay echo — desktop does the same on a pill click.
|
||||
if (!reacted && isPositiveEmojiParticle(reaction.emoji)) {
|
||||
burstEmojiFromContext(ref, context, reaction.emoji);
|
||||
}
|
||||
onTap();
|
||||
},
|
||||
child: _PillSurface(
|
||||
highlighted: reacted,
|
||||
minWidth: _pillMinWidth,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// Centered so a short reaction sits mid-pill once the min width kicks
|
||||
// in rather than hugging the left edge.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: _pillGlyphSize),
|
||||
const SizedBox(width: _pillGap),
|
||||
// Desktop shows the count even at 1; hiding it made a fresh
|
||||
// reaction jump in width the moment a second person joined.
|
||||
Text(
|
||||
'${reaction.count}',
|
||||
style: reactionCountTextStyle.copyWith(
|
||||
color: reacted
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -318,12 +340,22 @@ class _ReactionEmoji extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void showReactionDetailSheet({
|
||||
Future<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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
59
mobile/lib/shared/widgets/native_message_presentation.dart
Normal file
59
mobile/lib/shared/widgets/native_message_presentation.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
if (nativePresentation) {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('open'));
|
||||
await presentation;
|
||||
});
|
||||
} else {
|
||||
await tester.tap(find.text('open'));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -581,6 +599,398 @@ void main() {
|
||||
expect(snapshot.height, 80);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'native menu respects ownership and dispatches the target read action',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
Map<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();
|
||||
|
||||
@@ -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,17 +75,20 @@ Future<ProviderContainer> _pumpRow(
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier({
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
_alice: const UserProfile(pubkey: _alice, displayName: 'Alice'),
|
||||
}),
|
||||
),
|
||||
],
|
||||
child: ReactionRow(
|
||||
messageId: messageId,
|
||||
channelId: 'channel',
|
||||
reactions: reactions,
|
||||
onToggle: onToggle ?? (_) {},
|
||||
showAddButton: showAddButton,
|
||||
onAddReaction: onAddReaction,
|
||||
child: MessageLongPressInkWell(
|
||||
onLongPress: (_) => onMessageLongPress?.call(),
|
||||
child: ReactionRow(
|
||||
messageId: messageId,
|
||||
channelId: 'channel',
|
||||
reactions: reactions,
|
||||
onToggle: onToggle ?? (_) {},
|
||||
showAddButton: showAddButton,
|
||||
onAddReaction: onAddReaction,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -90,11 +104,252 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
@override
|
||||
Map<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]),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user