mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile message actions
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
|
||||
4A71C0092F40500100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* NativeMessageActionSurface.swift */; };
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
@@ -59,6 +60,7 @@
|
||||
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
|
||||
4A71C00A2F40500100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = "<group>"; };
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -182,6 +184,7 @@
|
||||
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
|
||||
4A71C00A2F40500100A17E01 /* NativeMessageActionSurface.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
@@ -417,6 +420,7 @@
|
||||
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
|
||||
4A71C0092F40500100A17E01 /* NativeMessageActionSurface.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import UserNotifications
|
||||
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
|
||||
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
|
||||
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
||||
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -95,6 +96,27 @@ import UserNotifications
|
||||
messenger: messenger,
|
||||
parentViewController: nativeAttachmentRegistrar?.viewController
|
||||
)
|
||||
|
||||
if #available(iOS 16.0, *),
|
||||
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzNativeMessageActionSurface"
|
||||
) {
|
||||
nativeMessageActionsRegistrar.register(
|
||||
NativeMessageActionSurfaceFactory(messenger: messenger),
|
||||
withId: "buzz/native_message_action_surface"
|
||||
)
|
||||
nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
|
||||
name: "buzz/native_message_action_surface",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
|
||||
guard call.method == "isSupported" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleQrScannerMethodCall(
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
struct NativeMessageActionDefinition {
|
||||
enum Group: String, CaseIterable {
|
||||
case primary
|
||||
case utility
|
||||
case destructive
|
||||
}
|
||||
|
||||
let id: String
|
||||
let title: String
|
||||
let symbol: String
|
||||
let group: Group
|
||||
let isDestructive: Bool
|
||||
|
||||
init?(arguments: [String: Any]) {
|
||||
guard
|
||||
let id = arguments["id"] as? String,
|
||||
let title = arguments["title"] as? String,
|
||||
let symbol = arguments["symbol"] as? String,
|
||||
let groupName = arguments["group"] as? String,
|
||||
let group = Group(rawValue: groupName)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.symbol = symbol
|
||||
self.group = group
|
||||
isDestructive = arguments["destructive"] as? Bool ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeMessageActionSurfaceLayout {
|
||||
static let rowHeight: CGFloat = 48
|
||||
static let separatorHeight: CGFloat = 0.5
|
||||
static let verticalInset: CGFloat = 4
|
||||
static let horizontalInset: CGFloat = 16
|
||||
static let iconColumnWidth: CGFloat = 32
|
||||
static let iconToTextSpacing: CGFloat = 12
|
||||
|
||||
static var cornerRadius: CGFloat {
|
||||
if #available(iOS 26.0, *) {
|
||||
return 33
|
||||
}
|
||||
return 12
|
||||
}
|
||||
|
||||
static func populatedGroups(
|
||||
actions: [NativeMessageActionDefinition]
|
||||
) -> [NativeMessageActionDefinition.Group] {
|
||||
NativeMessageActionDefinition.Group.allCases.filter { group in
|
||||
actions.contains { $0.group == group }
|
||||
}
|
||||
}
|
||||
|
||||
static func separatorCount(
|
||||
actions: [NativeMessageActionDefinition]
|
||||
) -> Int {
|
||||
max(0, populatedGroups(actions: actions).count - 1)
|
||||
}
|
||||
|
||||
static func preferredHeight(
|
||||
actions: [NativeMessageActionDefinition]
|
||||
) -> CGFloat {
|
||||
(verticalInset * 2)
|
||||
+ (CGFloat(actions.count) * rowHeight)
|
||||
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
enum NativeMessageActionSurfaceAppearance {
|
||||
static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
|
||||
guard !reduceTransparency else { return nil }
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
let effect = UIGlassEffect(style: .regular)
|
||||
effect.isInteractive = true
|
||||
return effect
|
||||
}
|
||||
return UIBlurEffect(style: .systemMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionRowControl: UIControl {
|
||||
let actionImageView: UIImageView
|
||||
let actionTitleLabel = UILabel()
|
||||
|
||||
init(
|
||||
definition: NativeMessageActionDefinition,
|
||||
foregroundColor: UIColor,
|
||||
destructiveColor: UIColor,
|
||||
onSelected: @escaping () -> Void
|
||||
) {
|
||||
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
|
||||
super.init(frame: .zero)
|
||||
|
||||
let color = definition.isDestructive ? destructiveColor : foregroundColor
|
||||
actionImageView.tintColor = color
|
||||
actionImageView.contentMode = .center
|
||||
|
||||
actionTitleLabel.text = definition.title
|
||||
actionTitleLabel.textColor = color
|
||||
actionTitleLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
actionTitleLabel.adjustsFontForContentSizeCategory = true
|
||||
actionTitleLabel.numberOfLines = 1
|
||||
actionTitleLabel.adjustsFontSizeToFitWidth = true
|
||||
actionTitleLabel.minimumScaleFactor = 0.8
|
||||
|
||||
let iconColumn = UIView()
|
||||
iconColumn.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconColumn.addSubview(actionImageView)
|
||||
addSubview(iconColumn)
|
||||
addSubview(actionTitleLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
iconColumn.leadingAnchor.constraint(
|
||||
equalTo: leadingAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.horizontalInset
|
||||
),
|
||||
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
iconColumn.widthAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
|
||||
),
|
||||
iconColumn.heightAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
|
||||
),
|
||||
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
|
||||
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
|
||||
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
|
||||
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
|
||||
actionTitleLabel.leadingAnchor.constraint(
|
||||
equalTo: iconColumn.trailingAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
|
||||
),
|
||||
actionTitleLabel.trailingAnchor.constraint(
|
||||
equalTo: trailingAnchor,
|
||||
constant: -NativeMessageActionSurfaceLayout.horizontalInset
|
||||
),
|
||||
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
heightAnchor.constraint(
|
||||
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight
|
||||
),
|
||||
])
|
||||
|
||||
accessibilityLabel = definition.title
|
||||
accessibilityTraits = .button
|
||||
isAccessibilityElement = true
|
||||
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is unavailable")
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor =
|
||||
isHighlighted
|
||||
? actionTitleLabel.textColor.withAlphaComponent(0.08)
|
||||
: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSeparatorView: UIView {
|
||||
init(color: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = color
|
||||
heightAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
|
||||
).isActive = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSurfaceFactory: NSObject,
|
||||
FlutterPlatformViewFactory
|
||||
{
|
||||
private let messenger: FlutterBinaryMessenger
|
||||
|
||||
init(messenger: FlutterBinaryMessenger) {
|
||||
self.messenger = messenger
|
||||
super.init()
|
||||
}
|
||||
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
|
||||
func create(
|
||||
withFrame frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
arguments args: Any?
|
||||
) -> FlutterPlatformView {
|
||||
NativeMessageActionSurfacePlatformView(
|
||||
frame: frame,
|
||||
viewIdentifier: viewId,
|
||||
messenger: messenger,
|
||||
arguments: args
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSurfacePlatformView: NSObject,
|
||||
FlutterPlatformView
|
||||
{
|
||||
private let surfaceView: UIView
|
||||
private let backdropView: UIVisualEffectView
|
||||
private let channel: FlutterMethodChannel
|
||||
|
||||
init(
|
||||
frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
messenger: FlutterBinaryMessenger,
|
||||
arguments args: Any?
|
||||
) {
|
||||
let arguments = args as? [String: Any]
|
||||
let surfaceColor = Self.color(
|
||||
from: arguments?["surfaceColor"],
|
||||
fallback: .systemBackground
|
||||
)
|
||||
let foregroundColor = Self.color(
|
||||
from: arguments?["foregroundColor"],
|
||||
fallback: .label
|
||||
)
|
||||
let separatorColor = Self.color(
|
||||
from: arguments?["separatorColor"],
|
||||
fallback: .separator
|
||||
)
|
||||
let destructiveColor = Self.color(
|
||||
from: arguments?["errorColor"],
|
||||
fallback: .systemRed
|
||||
)
|
||||
let actionArguments = arguments?["actions"] as? [[String: Any]]
|
||||
let actions =
|
||||
actionArguments?.compactMap(
|
||||
NativeMessageActionDefinition.init(arguments:)
|
||||
) ?? []
|
||||
|
||||
surfaceView = UIView(frame: frame)
|
||||
backdropView = UIVisualEffectView(
|
||||
effect: NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
|
||||
)
|
||||
)
|
||||
channel = FlutterMethodChannel(
|
||||
name: "buzz/native_message_action_surface/\(viewId)",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
super.init()
|
||||
|
||||
surfaceView.backgroundColor = .clear
|
||||
surfaceView.clipsToBounds = false
|
||||
surfaceView.accessibilityViewIsModal = true
|
||||
|
||||
backdropView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
|
||||
backdropView.layer.cornerCurve = .continuous
|
||||
backdropView.layer.masksToBounds = true
|
||||
if backdropView.effect == nil {
|
||||
backdropView.backgroundColor = surfaceColor
|
||||
}
|
||||
surfaceView.addSubview(backdropView)
|
||||
NSLayoutConstraint.activate([
|
||||
backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
|
||||
backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
|
||||
backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
|
||||
backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
|
||||
])
|
||||
|
||||
if #unavailable(iOS 26.0) {
|
||||
surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
|
||||
surfaceView.layer.shadowRadius = 32
|
||||
surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
|
||||
surfaceView.layer.shadowColor = UIColor.black.cgColor
|
||||
surfaceView.layer.shadowOpacity = 0.2
|
||||
}
|
||||
|
||||
install(
|
||||
actions: actions,
|
||||
foregroundColor: foregroundColor,
|
||||
destructiveColor: destructiveColor,
|
||||
separatorColor: separatorColor
|
||||
)
|
||||
}
|
||||
|
||||
func view() -> UIView {
|
||||
surfaceView
|
||||
}
|
||||
|
||||
private func install(
|
||||
actions: [NativeMessageActionDefinition],
|
||||
foregroundColor: UIColor,
|
||||
destructiveColor: UIColor,
|
||||
separatorColor: UIColor
|
||||
) {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.alwaysBounceVertical = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 0
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backdropView.contentView.addSubview(scrollView)
|
||||
scrollView.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(
|
||||
equalTo: scrollView.contentLayoutGuide.topAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.verticalInset
|
||||
),
|
||||
stack.bottomAnchor.constraint(
|
||||
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
|
||||
constant: -NativeMessageActionSurfaceLayout.verticalInset
|
||||
),
|
||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
var installedGroup = false
|
||||
for group in NativeMessageActionDefinition.Group.allCases {
|
||||
let groupActions = actions.filter { $0.group == group }
|
||||
guard !groupActions.isEmpty else { continue }
|
||||
if installedGroup {
|
||||
stack.addArrangedSubview(
|
||||
NativeMessageActionSeparatorView(color: separatorColor)
|
||||
)
|
||||
}
|
||||
for definition in groupActions {
|
||||
stack.addArrangedSubview(
|
||||
NativeMessageActionRowControl(
|
||||
definition: definition,
|
||||
foregroundColor: foregroundColor,
|
||||
destructiveColor: destructiveColor,
|
||||
onSelected: { [weak self] in self?.select(definition) }
|
||||
)
|
||||
)
|
||||
}
|
||||
installedGroup = true
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ definition: NativeMessageActionDefinition) {
|
||||
channel.invokeMethod("selected", arguments: ["id": definition.id])
|
||||
}
|
||||
|
||||
private static func color(from value: Any?, fallback: UIColor) -> UIColor {
|
||||
guard let number = value as? NSNumber else { return fallback }
|
||||
let color = number.uint32Value
|
||||
let alpha = CGFloat((color >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((color >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((color >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(color & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,85 @@ class RunnerTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
|
||||
let actionArguments: [[String: Any]] = [
|
||||
[
|
||||
"id": "reply", "title": "Reply",
|
||||
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
||||
],
|
||||
[
|
||||
"id": "copyText", "title": "Copy text",
|
||||
"symbol": "doc.on.doc", "group": "utility",
|
||||
],
|
||||
[
|
||||
"id": "delete", "title": "Delete message",
|
||||
"symbol": "trash", "group": "destructive", "destructive": true,
|
||||
],
|
||||
]
|
||||
let definitions = try actionArguments.map { arguments in
|
||||
try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments))
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
|
||||
[.primary, .utility, .destructive]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
|
||||
2
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.preferredHeight(actions: definitions),
|
||||
153
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws {
|
||||
let definition = try XCTUnwrap(
|
||||
NativeMessageActionDefinition(
|
||||
arguments: [
|
||||
"id": "reply", "title": "Reply",
|
||||
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
||||
]
|
||||
)
|
||||
)
|
||||
var selected = false
|
||||
let row = NativeMessageActionRowControl(
|
||||
definition: definition,
|
||||
foregroundColor: .label,
|
||||
destructiveColor: .systemRed,
|
||||
onSelected: { selected = true }
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String,
|
||||
UIFont.TextStyle.body.rawValue
|
||||
)
|
||||
XCTAssertNotNil(row.actionImageView.image)
|
||||
row.sendActions(for: .touchUpInside)
|
||||
XCTAssertTrue(selected)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeMessageActionSurfaceUsesSystemMaterial() {
|
||||
let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: false
|
||||
)
|
||||
if #available(iOS 26.0, *) {
|
||||
XCTAssertTrue(effect is UIGlassEffect)
|
||||
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33)
|
||||
} else {
|
||||
XCTAssertTrue(effect is UIBlurEffect)
|
||||
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12)
|
||||
}
|
||||
XCTAssertNil(
|
||||
NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
||||
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _MessageBubble extends ConsumerWidget {
|
||||
class _MessageBubble extends HookConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final bool showAuthor;
|
||||
final Map<String, String> channelNames;
|
||||
@@ -23,6 +23,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
@@ -72,7 +73,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
void openMessageActions(MessageLongPressDetails details) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -83,7 +84,10 @@ class _MessageBubble extends ConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
anchorRect: details.anchorRect,
|
||||
captureAnchorSnapshot: details.captureSnapshot,
|
||||
onPopoverPresented: () => details.setSourceHidden(true),
|
||||
onPopoverDismissed: () => details.setSourceHidden(false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +102,10 @@ class _MessageBubble extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
// Tap opens the thread; long-press still opens the action sheet.
|
||||
// MessageContent handles mention, channel-link, and media taps.
|
||||
onTap: allMessages == null
|
||||
@@ -122,143 +127,164 @@ class _MessageBubble extends ConsumerWidget {
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () => showUserProfileSheet(context, message.pubkey),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
),
|
||||
child: ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) => toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -37,6 +39,7 @@ import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
part 'message_actions/reaction_popover.dart';
|
||||
part 'message_actions/message_action_popover.dart';
|
||||
|
||||
/// Preview length for reminder targets — matches desktop's
|
||||
/// `msg.body.slice(0, 100)`.
|
||||
@@ -53,6 +56,9 @@ void showMessageActions({
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
Rect? anchorRect,
|
||||
Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
VoidCallback? onPopoverPresented,
|
||||
VoidCallback? onPopoverDismissed,
|
||||
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
|
||||
}) {
|
||||
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
|
||||
@@ -67,6 +73,24 @@ void showMessageActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tryShowMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPresented: onPopoverPresented,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _messageActionRowHeight = 48.0;
|
||||
const _messageActionSeparatorHeight = 0.5;
|
||||
const _messageActionVerticalInset = Grid.half;
|
||||
const _messageActionMenuMaxWidth = 288.0;
|
||||
const _messageActionPreviewMaxWidth = 358.0;
|
||||
const _messageActionPreviewInset = Grid.xxs;
|
||||
const _messageActionGap = Grid.twelve;
|
||||
const _messageActionTransitionDuration = _reactionPopoverDuration;
|
||||
const _iosMessageActionTransitionDuration = Duration(milliseconds: 220);
|
||||
const _iosNativeMessageActionSurfaceChannel = MethodChannel(
|
||||
'buzz/native_message_action_surface',
|
||||
);
|
||||
|
||||
bool _messageActionsPresentationInFlight = false;
|
||||
bool? _iosNativeMessageActionSurfaceSupported;
|
||||
|
||||
Future<bool> _supportsIosNativeMessageActionSurface() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
final cached = _iosNativeMessageActionSurfaceSupported;
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final supported =
|
||||
await _iosNativeMessageActionSurfaceChannel.invokeMethod<bool>(
|
||||
'isSupported',
|
||||
) ??
|
||||
false;
|
||||
_iosNativeMessageActionSurfaceSupported = supported;
|
||||
return supported;
|
||||
} on MissingPluginException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
} on PlatformException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _tryShowMessageActionsPopover({
|
||||
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 VoidCallback? onPopoverPresented,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
}) {
|
||||
if (anchorRect == null || captureAnchorSnapshot == null) return false;
|
||||
unawaited(
|
||||
_showMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPresented: onPopoverPresented,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
).then((shown) {
|
||||
if (shown || !context.mounted) return;
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _showMessageActionsPopover({
|
||||
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 VoidCallback? onPopoverPresented,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
}) async {
|
||||
if (_messageActionsPresentationInFlight) return true;
|
||||
_messageActionsPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
final actions = _buildPopoverMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
if (actions.isEmpty) return false;
|
||||
final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
|
||||
final ui.Image snapshot;
|
||||
try {
|
||||
snapshot = await captureAnchorSnapshot();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
final useIosNativeActionSurface = await nativeActionSurfaceSupport;
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
onPopoverPresented?.call();
|
||||
|
||||
String? selectedActionId;
|
||||
try {
|
||||
selectedActionId = await showGeneralDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss message actions',
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: isIos
|
||||
? _iosMessageActionTransitionDuration
|
||||
: _messageActionTransitionDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_MessageActionsPopover(
|
||||
anchorRect: anchorRect,
|
||||
anchorSnapshot: snapshot,
|
||||
animation: animation,
|
||||
message: message,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
actions: actions,
|
||||
useIosNativeActionSurface: useIosNativeActionSurface,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
snapshot.dispose();
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
|
||||
for (final action in actions) {
|
||||
if (action.id != selectedActionId) continue;
|
||||
await Future<void>.sync(action.onSelected);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
_messageActionsPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<_PopoverMessageAction> _buildPopoverMessageActions({
|
||||
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,
|
||||
}) {
|
||||
final actions = <_PopoverMessageAction>[];
|
||||
final messages = allMessages;
|
||||
final canRemind = ref.read(reminderServiceProvider) != null;
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (messages != null) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'reply',
|
||||
title: 'Reply',
|
||||
icon: LucideIcons.messageSquareReply,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: messages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyLink',
|
||||
title: 'Copy link',
|
||||
icon: LucideIcons.link2,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (canRemind) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'remind',
|
||||
title: 'Remind me',
|
||||
icon: LucideIcons.clock,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: Navigator.of(context, rootNavigator: true).context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = isMessageUnread(
|
||||
readState,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
createdAt: message.createdAt,
|
||||
threadRootId: message.rootId,
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: unread ? 'markRead' : 'markUnread',
|
||||
title: unread ? 'Mark read' : 'Mark unread',
|
||||
icon: unread ? LucideIcons.mailCheck : LucideIcons.mailOpen,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: following ? 'unfollowThread' : 'followThread',
|
||||
title: following ? 'Unfollow thread' : 'Follow thread',
|
||||
icon: following ? LucideIcons.bellOff : LucideIcons.bellRing,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyText',
|
||||
title: 'Copy text',
|
||||
icon: LucideIcons.copy,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () =>
|
||||
Clipboard.setData(ClipboardData(text: message.content)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (canManageMessage) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'edit',
|
||||
title: 'Edit message',
|
||||
icon: LucideIcons.pencil,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'delete',
|
||||
title: 'Delete message',
|
||||
icon: LucideIcons.trash2,
|
||||
group: _PopoverMessageActionGroup.destructive,
|
||||
destructive: true,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const actionOrder = {
|
||||
'reply': 0,
|
||||
'markRead': 1,
|
||||
'markUnread': 1,
|
||||
'edit': 2,
|
||||
'copyText': 3,
|
||||
'copyLink': 4,
|
||||
'remind': 5,
|
||||
'followThread': 6,
|
||||
'unfollowThread': 6,
|
||||
'delete': 7,
|
||||
};
|
||||
actions.sort(
|
||||
(left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!),
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
enum _PopoverMessageActionGroup { primary, utility, destructive }
|
||||
|
||||
class _PopoverMessageAction {
|
||||
final String id;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final _PopoverMessageActionGroup group;
|
||||
final bool destructive;
|
||||
final FutureOr<void> Function() onSelected;
|
||||
|
||||
const _PopoverMessageAction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.onSelected,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
String get iosSymbol => switch (id) {
|
||||
'reply' => 'arrowshape.turn.up.left',
|
||||
'markRead' => 'envelope.open',
|
||||
'markUnread' => 'envelope.badge',
|
||||
'edit' => 'pencil',
|
||||
'copyText' => 'doc.on.doc',
|
||||
'copyLink' => 'link',
|
||||
'remind' => 'clock',
|
||||
'followThread' => 'bell',
|
||||
'unfollowThread' => 'bell.slash',
|
||||
'delete' => 'trash',
|
||||
_ => 'ellipsis',
|
||||
};
|
||||
|
||||
Map<String, Object> toPlatformArguments() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': iosSymbol,
|
||||
'group': group.name,
|
||||
'destructive': destructive,
|
||||
};
|
||||
}
|
||||
|
||||
class _IosNativeMessageActionSurface extends HookWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _IosNativeMessageActionSurface({
|
||||
required this.actions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewId = useState<int?>(null);
|
||||
useEffect(() {
|
||||
final id = viewId.value;
|
||||
if (id == null) return null;
|
||||
final channel = MethodChannel('buzz/native_message_action_surface/$id');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method != 'selected' || call.arguments is! Map) return;
|
||||
final actionId = (call.arguments as Map)['id'];
|
||||
if (actionId is String) onSelected(actionId);
|
||||
});
|
||||
return () => channel.setMethodCallHandler(null);
|
||||
}, [viewId.value, onSelected]);
|
||||
|
||||
return UiKitView(
|
||||
key: const ValueKey('ios-native-message-action-surface'),
|
||||
viewType: 'buzz/native_message_action_surface',
|
||||
creationParams: <String, Object>{
|
||||
'actions': [for (final action in actions) action.toPlatformArguments()],
|
||||
'surfaceColor': context.colors.surface.toARGB32(),
|
||||
'foregroundColor': context.colors.onSurface.toARGB32(),
|
||||
'separatorColor': context.colors.outlineVariant.toARGB32(),
|
||||
'errorColor': context.colors.error.toARGB32(),
|
||||
},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated: (id) => viewId.value = id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionsPopover extends StatelessWidget {
|
||||
final Rect anchorRect;
|
||||
final ui.Image anchorSnapshot;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final bool useIosNativeActionSurface;
|
||||
|
||||
const _MessageActionsPopover({
|
||||
required this.anchorRect,
|
||||
required this.anchorSnapshot,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.actions,
|
||||
required this.useIosNativeActionSurface,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final safeLeft = mediaQuery.padding.left + Grid.xxs;
|
||||
final safeRight =
|
||||
constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
|
||||
final safeTop = mediaQuery.padding.top + Grid.xxs;
|
||||
final safeBottom =
|
||||
constraints.maxHeight - mediaQuery.padding.bottom - Grid.xxs;
|
||||
final availableWidth = math.max(1.0, safeRight - safeLeft);
|
||||
final availableHeight = math.max(1.0, safeBottom - safeTop);
|
||||
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
|
||||
final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth);
|
||||
final preferredMenuHeight = _messageActionSurfacePreferredHeight(
|
||||
actions,
|
||||
);
|
||||
final menuBudget = math.max(
|
||||
_messageActionRowHeight,
|
||||
availableHeight -
|
||||
_reactionTrayMaxHeight -
|
||||
(_messageActionGap * 2) -
|
||||
72,
|
||||
);
|
||||
final menuHeight = math.min(preferredMenuHeight, menuBudget);
|
||||
final previewMaximumHeight = math.max(
|
||||
48.0,
|
||||
availableHeight -
|
||||
_reactionTrayMaxHeight -
|
||||
menuHeight -
|
||||
(_messageActionGap * 2),
|
||||
);
|
||||
final previewInsetExtent = _messageActionPreviewInset * 2;
|
||||
final previewWidthRatio =
|
||||
(math.min(_messageActionPreviewMaxWidth, availableWidth) -
|
||||
previewInsetExtent) /
|
||||
math.max(anchorRect.width, 1);
|
||||
final previewHeightRatio =
|
||||
(previewMaximumHeight - previewInsetExtent) /
|
||||
math.max(anchorRect.height, 1);
|
||||
final previewScale = math.min(
|
||||
1.0,
|
||||
math.max(0.1, math.min(previewWidthRatio, previewHeightRatio)),
|
||||
);
|
||||
final previewSize = Size(
|
||||
(anchorRect.width * previewScale) + previewInsetExtent,
|
||||
(anchorRect.height * previewScale) + previewInsetExtent,
|
||||
);
|
||||
final totalHeight =
|
||||
_reactionTrayMaxHeight +
|
||||
_messageActionGap +
|
||||
previewSize.height +
|
||||
_messageActionGap +
|
||||
menuHeight;
|
||||
final contentTop = math.max(safeTop, safeBottom - totalHeight);
|
||||
final surfaceLeft =
|
||||
safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2);
|
||||
final trayRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
contentTop,
|
||||
trayWidth,
|
||||
_reactionTrayMaxHeight,
|
||||
);
|
||||
final trayHostWidth = math.min(
|
||||
trayWidth + _reactionTraySpringAllowance,
|
||||
safeRight - surfaceLeft,
|
||||
);
|
||||
final previewRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
trayRect.bottom + _messageActionGap,
|
||||
previewSize.width,
|
||||
previewSize.height,
|
||||
);
|
||||
final menuRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
previewRect.bottom + _messageActionGap,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
|
||||
sigmaY: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = Curves.easeOutCubic.transform(
|
||||
animation.value,
|
||||
);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('message-actions-background'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: 0.14 * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: previewRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: RepaintBoundary(
|
||||
child: _LiftedMessagePreview(anchorSnapshot: anchorSnapshot),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final movement =
|
||||
(defaultTargetPlatform == TargetPlatform.iOS
|
||||
? Curves.easeOutCubic
|
||||
: Curves.easeInOutCubic)
|
||||
.transform(animation.value);
|
||||
final sourceRect = anchorRect.inflate(
|
||||
_messageActionPreviewInset,
|
||||
);
|
||||
final translation = Offset(
|
||||
ui.lerpDouble(
|
||||
sourceRect.left - previewRect.left,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
ui.lerpDouble(
|
||||
sourceRect.top - previewRect.top,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
);
|
||||
final scaleX = ui.lerpDouble(
|
||||
sourceRect.width / previewRect.width,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
final scaleY = ui.lerpDouble(
|
||||
sourceRect.height / previewRect.height,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
return Transform.translate(
|
||||
offset: translation,
|
||||
child: Transform(
|
||||
alignment: Alignment.topLeft,
|
||||
transform: Matrix4.diagonal3Values(scaleX, scaleY, 1),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: trayRect.left,
|
||||
top: trayRect.top,
|
||||
width: trayHostWidth,
|
||||
height: trayRect.height,
|
||||
child: _MessageReactionTray(
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: menuRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: useIosNativeActionSurface
|
||||
? _IosNativeMessageActionSurface(
|
||||
actions: actions,
|
||||
onSelected: (actionId) =>
|
||||
Navigator.of(context).pop(actionId),
|
||||
)
|
||||
: _MessageActionSurface(
|
||||
actions: actions,
|
||||
onSelected: (actionId) =>
|
||||
Navigator.of(context).pop(actionId),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.08,
|
||||
0.82,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final fadedChild = Opacity(opacity: appearance, child: child);
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return fadedChild;
|
||||
}
|
||||
return Transform.scale(
|
||||
alignment: Alignment.topLeft,
|
||||
scale: ui.lerpDouble(0.96, 1, appearance)!,
|
||||
child: fadedChild,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiftedMessagePreview extends StatelessWidget {
|
||||
final ui.Image anchorSnapshot;
|
||||
|
||||
const _LiftedMessagePreview({required this.anchorSnapshot});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('message-action-preview'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.7),
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_messageActionPreviewInset),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.xs),
|
||||
child: RawImage(
|
||||
image: anchorSnapshot,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageReactionTray extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
|
||||
const _MessageReactionTray({
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('message-action-reaction-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: Alignment.bottomLeft,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurface extends StatelessWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionSurface({
|
||||
required this.actions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: const ValueKey('message-action-surface'),
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.55),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _messageActionVerticalInset,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
if (index > 0 &&
|
||||
actions[index - 1].group != actions[index].group)
|
||||
Divider(
|
||||
key: ValueKey(
|
||||
'message-action-divider-${actions[index].group.name}',
|
||||
),
|
||||
height: _messageActionSeparatorHeight,
|
||||
thickness: _messageActionSeparatorHeight,
|
||||
indent: Grid.xs,
|
||||
endIndent: Grid.xs,
|
||||
),
|
||||
_MessageActionRow(
|
||||
action: actions[index],
|
||||
onSelected: onSelected,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionRow extends StatelessWidget {
|
||||
final _PopoverMessageAction action;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionRow({required this.action, required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = action.destructive
|
||||
? context.colors.error
|
||||
: context.colors.onSurface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: action.title,
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
key: ValueKey('message-action-${action.id}'),
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onSelected(action.id);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: _messageActionRowHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Icon(action.icon, size: 22, color: foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _messageActionSurfacePreferredHeight(
|
||||
List<_PopoverMessageAction> actions,
|
||||
) {
|
||||
var separatorCount = 0;
|
||||
for (var index = 1; index < actions.length; index++) {
|
||||
if (actions[index - 1].group != actions[index].group) separatorCount += 1;
|
||||
}
|
||||
return (_messageActionVerticalInset * 2) +
|
||||
(actions.length * _messageActionRowHeight) +
|
||||
(separatorCount * _messageActionSeparatorHeight);
|
||||
}
|
||||
@@ -133,73 +133,14 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
left: left,
|
||||
width: trayWidth + _reactionTraySpringAllowance,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: AnimatedBuilder(
|
||||
child: _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('reaction-popover-tray'),
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(
|
||||
0.16,
|
||||
0.92,
|
||||
).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(
|
||||
expansion,
|
||||
);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: trayScaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('reaction-popover-tray'),
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: trayScaleAlignment,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -209,6 +150,93 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedReactionTray extends StatelessWidget {
|
||||
final Key trayKey;
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final AlignmentGeometry scaleAlignment;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
|
||||
const _AnimatedReactionTray({
|
||||
required this.trayKey,
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.scaleAlignment,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(0.16, 0.92).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(expansion);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: scaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
key: trayKey,
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideAnchorClipper extends CustomClipper<Path> {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
|
||||
@@ -1,111 +1,178 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
const _iosMessageLongPressDuration = Duration(milliseconds: 200);
|
||||
|
||||
/// Geometry and snapshot controls for a completed message long press.
|
||||
class MessageLongPressDetails {
|
||||
final Rect anchorRect;
|
||||
final Future<ui.Image> Function() captureSnapshot;
|
||||
final ValueChanged<bool> setSourceHidden;
|
||||
|
||||
const MessageLongPressDetails({
|
||||
required this.anchorRect,
|
||||
required this.captureSnapshot,
|
||||
required this.setSourceHidden,
|
||||
});
|
||||
}
|
||||
|
||||
/// An [InkWell] whose long press is observed above interactive descendants.
|
||||
class MessageLongPressInkWell extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final ValueChanged<Rect>? onLongPress;
|
||||
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? highlightColor;
|
||||
final GlobalKey? snapshotKey;
|
||||
final Widget child;
|
||||
|
||||
const MessageLongPressInkWell({
|
||||
super.key,
|
||||
this.onTap,
|
||||
required this.onLongPress,
|
||||
this.onLongPress,
|
||||
this.onLongPressDetails,
|
||||
this.borderRadius,
|
||||
this.highlightColor,
|
||||
this.snapshotKey,
|
||||
required this.child,
|
||||
});
|
||||
}) : assert(onLongPress != null || onLongPressDetails != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _MessageLongPressRegion(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
child: child,
|
||||
),
|
||||
onLongPressDetails: onLongPressDetails,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
externalSnapshotKey: snapshotKey,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects a message long press without competing with interactive descendants.
|
||||
/// Detects a message long press through Flutter's gesture arena.
|
||||
///
|
||||
/// Links, media, reactions, and other nested controls keep their normal tap
|
||||
/// gestures. Moving far enough to scroll cancels the timer; recognizing the
|
||||
/// hold cancels the pointer so a descendant tap cannot fire on release.
|
||||
/// gestures, while a completed hold wins over descendant taps.
|
||||
class _MessageLongPressRegion extends HookWidget {
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<Rect>? onLongPress;
|
||||
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? highlightColor;
|
||||
final GlobalKey? externalSnapshotKey;
|
||||
final Widget child;
|
||||
|
||||
const _MessageLongPressRegion({
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onLongPressDetails,
|
||||
required this.borderRadius,
|
||||
required this.highlightColor,
|
||||
required this.externalSnapshotKey,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activePointer = useRef<int?>(null);
|
||||
final origin = useRef<Offset?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
|
||||
void cancel() {
|
||||
timer.value?.cancel();
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
}
|
||||
|
||||
useEffect(() => cancel, const []);
|
||||
final fallbackSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final snapshotKey = externalSnapshotKey ?? fallbackSnapshotKey;
|
||||
final sourceHidden = useState(false);
|
||||
|
||||
void recognize() {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
|
||||
}
|
||||
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
||||
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
|
||||
return;
|
||||
}
|
||||
final anchorRect =
|
||||
renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||
|
||||
void handlePointerDown(PointerDownEvent event) {
|
||||
if (activePointer.value != null) return;
|
||||
activePointer.value = event.pointer;
|
||||
origin.value = event.position;
|
||||
timer.value = Timer(kLongPressTimeout, () {
|
||||
final pointer = activePointer.value;
|
||||
if (pointer == null) return;
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
GestureBinding.instance.cancelPointer(pointer);
|
||||
recognize();
|
||||
});
|
||||
}
|
||||
final detailsCallback = onLongPressDetails;
|
||||
if (detailsCallback == null) {
|
||||
onLongPress?.call(anchorRect);
|
||||
return;
|
||||
}
|
||||
final snapshotPixelRatio = math.min(
|
||||
MediaQuery.devicePixelRatioOf(context),
|
||||
2.0,
|
||||
);
|
||||
|
||||
void handlePointerMove(PointerMoveEvent event) {
|
||||
if (event.pointer != activePointer.value) return;
|
||||
final start = origin.value;
|
||||
if (start == null) return;
|
||||
final delta = event.position - start;
|
||||
if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
|
||||
}
|
||||
Future<ui.Image> captureSnapshot() async {
|
||||
RenderRepaintBoundary? boundary;
|
||||
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
||||
if (renderObject is RenderRepaintBoundary && renderObject.hasSize) {
|
||||
boundary = renderObject;
|
||||
}
|
||||
if (boundary == null) {
|
||||
throw StateError('Message snapshot is unavailable');
|
||||
}
|
||||
try {
|
||||
return await boundary.toImage(pixelRatio: snapshotPixelRatio);
|
||||
} catch (_) {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
final retryBoundary = snapshotKey.currentContext?.findRenderObject();
|
||||
if (retryBoundary is! RenderRepaintBoundary ||
|
||||
!retryBoundary.hasSize) {
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await retryBoundary.toImage(pixelRatio: snapshotPixelRatio);
|
||||
} catch (_) {
|
||||
if (snapshotPixelRatio <= 1) rethrow;
|
||||
return retryBoundary.toImage(pixelRatio: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handlePointerEnd(PointerEvent event) {
|
||||
if (event.pointer == activePointer.value) cancel();
|
||||
void setSourceHidden(bool hidden) {
|
||||
if (!context.mounted) return;
|
||||
sourceHidden.value = hidden;
|
||||
}
|
||||
|
||||
detailsCallback(
|
||||
MessageLongPressDetails(
|
||||
anchorRect: anchorRect,
|
||||
captureSnapshot: captureSnapshot,
|
||||
setSourceHidden: setSourceHidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
onLongPress: recognize,
|
||||
child: Listener(
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerEnd,
|
||||
onPointerCancel: handlePointerEnd,
|
||||
child: child,
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
duration: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? _iosMessageLongPressDuration
|
||||
: null,
|
||||
),
|
||||
(recognizer) {
|
||||
recognizer.onLongPressStart = (_) => recognize();
|
||||
},
|
||||
),
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
child: Opacity(
|
||||
opacity: sourceHidden.value ? 0 : 1,
|
||||
child: externalSnapshotKey == null
|
||||
? RepaintBoundary(key: snapshotKey, child: child)
|
||||
: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ part 'thread_detail_page/nested_thread_summary_row.dart';
|
||||
part 'thread_detail_helpers.dart';
|
||||
part 'thread_detail_page/tail_alignment.dart';
|
||||
part 'thread_detail_page/thread_message.dart';
|
||||
part 'thread_detail_page/avatar.dart';
|
||||
|
||||
/// Full-screen thread detail page.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({required this.profile, required this.pubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: messageAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
class _ThreadMessage extends ConsumerWidget {
|
||||
class _ThreadMessage extends HookConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final Map<String, String> channelNames;
|
||||
final String channelId;
|
||||
@@ -30,6 +30,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
@@ -67,7 +68,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
void openMessageActions(MessageLongPressDetails details) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -78,7 +79,10 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
anchorRect: details.anchorRect,
|
||||
captureAnchorSnapshot: details.captureSnapshot,
|
||||
onPopoverPresented: () => details.setSourceHidden(true),
|
||||
onPopoverDismissed: () => details.setSourceHidden(false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,151 +105,174 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _Avatar(profile: profile, pubkey: message.pubkey),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _Avatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton:
|
||||
isMember &&
|
||||
!isArchived &&
|
||||
(isThreadHead || message.reactions.isNotEmpty),
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isThreadHead || message.reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
),
|
||||
child: ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -255,30 +282,3 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({required this.profile, required this.pubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: messageAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,8 +1478,19 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.text('Copy text'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -6057,7 +6068,9 @@ void main() {
|
||||
const ValueKey('thread-message-group-reply-29'),
|
||||
);
|
||||
final composer = find.byKey(const ValueKey('composer-surface'));
|
||||
await tester.drag(list, const Offset(0, 24));
|
||||
// Clear the gesture arena's touch slop so this represents a deliberate
|
||||
// tail-detaching drag rather than a long-press hold with small motion.
|
||||
await tester.drag(list, const Offset(0, 48));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getBottomLeft(latest).dy,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/features/channels/message_actions.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';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/shared/reminders/reminder_service.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -180,8 +184,483 @@ Future<void> _pumpImageSheet(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<ui.Image> _testMessageSnapshot() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(recorder);
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTWH(0, 0, 300, 72),
|
||||
ui.Paint()..color = const Color(0xffeeeeee),
|
||||
);
|
||||
return recorder.endRecording().toImage(300, 72);
|
||||
}
|
||||
|
||||
class _MessageActionsPopoverHarness {
|
||||
final ProviderContainer container;
|
||||
final ValueNotifier<bool> sourceHidden;
|
||||
|
||||
const _MessageActionsPopoverHarness({
|
||||
required this.container,
|
||||
required this.sourceHidden,
|
||||
});
|
||||
}
|
||||
|
||||
Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover(
|
||||
WidgetTester tester, {
|
||||
required TimelineMessage message,
|
||||
required SharedPreferences prefs,
|
||||
ReadStateNotifier Function()? readStateOverride,
|
||||
bool canManageMessage = false,
|
||||
List<TimelineMessage>? allMessages,
|
||||
ReminderService? reminderService,
|
||||
bool disableAnimations = false,
|
||||
}) async {
|
||||
final sourceHidden = ValueNotifier(false);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
readStateProvider.overrideWith(
|
||||
readStateOverride ??
|
||||
() => _FakeReadStateNotifier(
|
||||
_readState(const {_channelId: 100000}),
|
||||
),
|
||||
),
|
||||
reminderServiceProvider.overrideWithValue(reminderService),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
key: const ValueKey('open-message-actions-popover'),
|
||||
onPressed: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: _channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
anchorRect: const Rect.fromLTWH(32, 260, 300, 72),
|
||||
captureAnchorSnapshot: _testMessageSnapshot,
|
||||
onPopoverPresented: () => sourceHidden.value = true,
|
||||
onPopoverDismissed: () => sourceHidden.value = false,
|
||||
),
|
||||
child: const Text('open message actions'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('open-message-actions-popover')));
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byKey(const ValueKey('open-message-actions-popover'))),
|
||||
);
|
||||
return _MessageActionsPopoverHarness(
|
||||
container: container,
|
||||
sourceHidden: sourceHidden,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissMessageActionsPopover(WidgetTester tester) async {
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('message-action-surface'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'message long press keeps taps and scrolling while repeated holds win',
|
||||
(tester) async {
|
||||
var parentTaps = 0;
|
||||
var nestedTaps = 0;
|
||||
var longPresses = 0;
|
||||
final scrollController = ScrollController();
|
||||
addTearDown(scrollController.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('parent-gesture-target'),
|
||||
onTap: () => parentTaps += 1,
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: const SizedBox(height: 80, width: 300),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
child: MessageLongPressInkWell(
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('nested-gesture-target'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => nestedTaps += 1,
|
||||
child: const SizedBox(height: 80, width: 300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 900),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('parent-gesture-target')));
|
||||
await tester.tap(find.byKey(const ValueKey('nested-gesture-target')));
|
||||
await tester.pump();
|
||||
expect(parentTaps, 1);
|
||||
expect(nestedTaps, 1);
|
||||
|
||||
for (var index = 0; index < 5; index++) {
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('nested-gesture-target')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(longPresses, index + 1);
|
||||
expect(nestedTaps, 1);
|
||||
}
|
||||
|
||||
final drag = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))),
|
||||
);
|
||||
await drag.moveBy(const Offset(0, -30));
|
||||
await tester.pump();
|
||||
await drag.moveBy(const Offset(0, -70));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await drag.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(longPresses, 5);
|
||||
expect(scrollController.offset, greaterThan(0));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS message long press recognizes at 200 ms', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
var longPresses = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('ios-long-press-target'),
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('ios-long-press-target'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 199));
|
||||
expect(longPresses, 0);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
expect(longPresses, 1);
|
||||
await gesture.up();
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'message long press captures the content inside the ink surface',
|
||||
(tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
MessageLongPressDetails? longPressDetails;
|
||||
ui.Image? snapshot;
|
||||
addTearDown(() => snapshot?.dispose());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('snapshot-gesture-target'),
|
||||
onLongPressDetails: (details) => longPressDetails = details,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('snapshot-gesture-target')),
|
||||
);
|
||||
expect(longPressDetails, isNotNull);
|
||||
|
||||
final capture = longPressDetails!.captureSnapshot();
|
||||
await tester.pump();
|
||||
snapshot = await capture;
|
||||
|
||||
expect(snapshot.width, 240);
|
||||
expect(snapshot.height, 80);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('message snapshot can exclude attached reaction content', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final snapshotKey = GlobalKey();
|
||||
MessageLongPressDetails? longPressDetails;
|
||||
ui.Image? snapshot;
|
||||
addTearDown(() => snapshot?.dispose());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('separate-snapshot-gesture-target'),
|
||||
snapshotKey: snapshotKey,
|
||||
onLongPressDetails: (details) => longPressDetails = details,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
key: snapshotKey,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
Listener(
|
||||
key: const ValueKey('attached-reactions'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const SizedBox(width: 240, height: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(find.byKey(const ValueKey('attached-reactions')));
|
||||
expect(longPressDetails, isNotNull);
|
||||
expect(longPressDetails!.anchorRect.height, 80);
|
||||
|
||||
final capture = longPressDetails!.captureSnapshot();
|
||||
await tester.pump();
|
||||
snapshot = await capture;
|
||||
|
||||
expect(snapshot.width, 240);
|
||||
expect(snapshot.height, 80);
|
||||
});
|
||||
|
||||
group('showMessageActions', () {
|
||||
testWidgets('composes the tray, lifted preview, and compact actions', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
expect(harness.sourceHidden.value, isTrue);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.text('Reply'), findsOneWidget);
|
||||
expect(find.text('Copy link'), findsOneWidget);
|
||||
expect(find.text('Remind me'), findsOneWidget);
|
||||
expect(find.text('Follow thread'), findsOneWidget);
|
||||
|
||||
final trayRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
);
|
||||
final previewRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
);
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
final trayMaterial = tester.widget<Material>(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
);
|
||||
final actionMaterial = tester.widget<Material>(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
expect(previewRect.top, greaterThan(trayRect.bottom));
|
||||
expect(actionRect.top, greaterThan(previewRect.bottom));
|
||||
expect(previewRect.left, trayRect.left);
|
||||
expect(actionRect.left, trayRect.left);
|
||||
expect(actionRect.width, 288);
|
||||
expect(actionMaterial.color, trayMaterial.color);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
expect(harness.sourceHidden.value, isFalse);
|
||||
});
|
||||
|
||||
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||
testWidgets(
|
||||
'${platform.name} composition keeps the action menu near the safe bottom',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
try {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
final logicalHeight =
|
||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1));
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('runs an action after dismissal and can reopen', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(rootId: 'root-9'),
|
||||
prefs: prefs,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-action-followThread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.sourceHidden.value, isFalse);
|
||||
expect(harness.container.read(threadFollowsProvider).followedRootIds, {
|
||||
'root-9',
|
||||
});
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('open-message-actions-popover')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders primary, utility, and destructive action groups', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
canManageMessage: true,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
const actionIds = [
|
||||
'reply',
|
||||
'markUnread',
|
||||
'edit',
|
||||
'copyText',
|
||||
'copyLink',
|
||||
'remind',
|
||||
'followThread',
|
||||
'delete',
|
||||
];
|
||||
final actionTops = [
|
||||
for (final actionId in actionIds)
|
||||
tester
|
||||
.getTopLeft(find.byKey(ValueKey('message-action-$actionId')))
|
||||
.dy,
|
||||
];
|
||||
expect(actionTops, orderedEquals([...actionTops]..sort()));
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-divider-utility')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-divider-destructive')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion presents the complete surface immediately', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
disableAnimations: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('shows parity actions for a regular message', (tester) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpSheet(tester, message: _message(), prefs: prefs);
|
||||
|
||||
Reference in New Issue
Block a user