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