mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish mobile message actions (#5873)
## Summary - Add a lifted-message long-press popover with the reaction tray and grouped actions. - Use a native action surface - Preserve taps and scrolling, exclude attached reactions from the lifted preview, and align the composition to the bottom safe area. <img width="1080" height="2424" alt="Screenshot_20260814-160732" src="https://github.com/user-attachments/assets/a9d5fc91-4ee3-427c-8199-1c3be2b4e98a" /> ## Testing - `bin/just mobile-check` - `flutter test test/features/channels/message_actions_test.dart` - `flutter test` (1,364 tests) - Manual interaction review on iPhone and Pixel 10 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
|
||||
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; };
|
||||
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; };
|
||||
4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */; };
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
@@ -63,6 +64,7 @@
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
|
||||
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = "<group>"; };
|
||||
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = "<group>"; };
|
||||
4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = "<group>"; };
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -188,6 +190,7 @@
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
|
||||
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
|
||||
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
|
||||
4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
@@ -425,6 +428,7 @@
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
|
||||
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
|
||||
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,
|
||||
4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import UserNotifications
|
||||
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
|
||||
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
|
||||
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
||||
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -113,6 +114,27 @@ import UserNotifications
|
||||
messenger: messenger,
|
||||
parentViewController: nativeAttachmentRegistrar?.viewController
|
||||
)
|
||||
|
||||
if #available(iOS 16.0, *),
|
||||
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzNativeMessageActionSurface"
|
||||
) {
|
||||
nativeMessageActionsRegistrar.register(
|
||||
NativeMessageActionSurfaceFactory(messenger: messenger),
|
||||
withId: "buzz/native_message_action_surface"
|
||||
)
|
||||
nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
|
||||
name: "buzz/native_message_action_surface",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
|
||||
guard call.method == "isSupported" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleQrScannerMethodCall(
|
||||
|
||||
453
mobile/ios/Runner/NativeMessageActionSurface.swift
Normal file
453
mobile/ios/Runner/NativeMessageActionSurface.swift
Normal file
@@ -0,0 +1,453 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
struct NativeMessageActionDefinition {
|
||||
enum Group: String, CaseIterable {
|
||||
case primary
|
||||
case utility
|
||||
case destructive
|
||||
}
|
||||
|
||||
let id: String
|
||||
let title: String
|
||||
let symbol: String
|
||||
let group: Group
|
||||
let isDestructive: Bool
|
||||
|
||||
init?(arguments: [String: Any]) {
|
||||
guard
|
||||
let id = arguments["id"] as? String,
|
||||
let title = arguments["title"] as? String,
|
||||
let symbol = arguments["symbol"] as? String,
|
||||
let groupName = arguments["group"] as? String,
|
||||
let group = Group(rawValue: groupName)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.symbol = symbol
|
||||
self.group = group
|
||||
isDestructive = arguments["destructive"] as? Bool ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeMessageActionSurfaceLayout {
|
||||
static let minimumRowHeight: CGFloat = 48
|
||||
static let rowVerticalPadding: CGFloat = 4
|
||||
static let separatorHeight: CGFloat = 0.5
|
||||
static let verticalInset: CGFloat = 4
|
||||
static let horizontalInset: CGFloat = 16
|
||||
static let iconColumnWidth: CGFloat = 32
|
||||
static let iconToTextSpacing: CGFloat = 12
|
||||
|
||||
static var cornerRadius: CGFloat {
|
||||
if #available(iOS 26.0, *) {
|
||||
return 33
|
||||
}
|
||||
return 12
|
||||
}
|
||||
|
||||
static func populatedGroups(
|
||||
actions: [NativeMessageActionDefinition]
|
||||
) -> [NativeMessageActionDefinition.Group] {
|
||||
NativeMessageActionDefinition.Group.allCases.filter { group in
|
||||
actions.contains { $0.group == group }
|
||||
}
|
||||
}
|
||||
|
||||
static func separatorCount(
|
||||
actions: [NativeMessageActionDefinition]
|
||||
) -> Int {
|
||||
max(0, populatedGroups(actions: actions).count - 1)
|
||||
}
|
||||
|
||||
static func rowHeight(
|
||||
minimumHeight: CGFloat = minimumRowHeight,
|
||||
compatibleWith traitCollection: UITraitCollection? = nil
|
||||
) -> CGFloat {
|
||||
let labelHeight = UIFont.preferredFont(
|
||||
forTextStyle: .body,
|
||||
compatibleWith: traitCollection
|
||||
).lineHeight
|
||||
return max(
|
||||
minimumHeight,
|
||||
ceil(labelHeight + (rowVerticalPadding * 2))
|
||||
)
|
||||
}
|
||||
|
||||
static func preferredHeight(
|
||||
actions: [NativeMessageActionDefinition],
|
||||
minimumRowHeight: CGFloat = minimumRowHeight,
|
||||
compatibleWith traitCollection: UITraitCollection? = nil
|
||||
) -> CGFloat {
|
||||
let resolvedRowHeight = rowHeight(
|
||||
minimumHeight: minimumRowHeight,
|
||||
compatibleWith: traitCollection
|
||||
)
|
||||
return (verticalInset * 2)
|
||||
+ (CGFloat(actions.count) * resolvedRowHeight)
|
||||
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
enum NativeMessageActionSurfaceAppearance {
|
||||
// The native list is only one sibling inside the Flutter-owned dialog.
|
||||
// Keeping it non-modal leaves the reaction tray and dismiss barrier
|
||||
// reachable to VoiceOver.
|
||||
static let actionListAccessibilityViewIsModal = false
|
||||
|
||||
static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle {
|
||||
switch value as? String {
|
||||
case "dark":
|
||||
return .dark
|
||||
case "light":
|
||||
return .light
|
||||
default:
|
||||
return .unspecified
|
||||
}
|
||||
}
|
||||
|
||||
static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
|
||||
guard !reduceTransparency else { return nil }
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
let effect = UIGlassEffect(style: .regular)
|
||||
effect.isInteractive = true
|
||||
return effect
|
||||
}
|
||||
return UIBlurEffect(style: .systemMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionRowControl: UIControl {
|
||||
let actionImageView: UIImageView
|
||||
let actionTitleLabel = UILabel()
|
||||
|
||||
init(
|
||||
definition: NativeMessageActionDefinition,
|
||||
foregroundColor: UIColor,
|
||||
destructiveColor: UIColor,
|
||||
minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
|
||||
compatibleWith traitCollection: UITraitCollection? = nil,
|
||||
onSelected: @escaping () -> Void
|
||||
) {
|
||||
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
|
||||
super.init(frame: .zero)
|
||||
|
||||
let color = definition.isDestructive ? destructiveColor : foregroundColor
|
||||
actionImageView.tintColor = color
|
||||
actionImageView.contentMode = .center
|
||||
|
||||
actionTitleLabel.text = definition.title
|
||||
actionTitleLabel.textColor = color
|
||||
actionTitleLabel.font = UIFont.preferredFont(
|
||||
forTextStyle: .body,
|
||||
compatibleWith: traitCollection
|
||||
)
|
||||
actionTitleLabel.adjustsFontForContentSizeCategory = true
|
||||
actionTitleLabel.numberOfLines = 0
|
||||
actionTitleLabel.lineBreakMode = .byWordWrapping
|
||||
actionTitleLabel.setContentCompressionResistancePriority(
|
||||
.required,
|
||||
for: .vertical
|
||||
)
|
||||
|
||||
let iconColumn = UIView()
|
||||
iconColumn.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconColumn.addSubview(actionImageView)
|
||||
addSubview(iconColumn)
|
||||
addSubview(actionTitleLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
iconColumn.leadingAnchor.constraint(
|
||||
equalTo: leadingAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.horizontalInset
|
||||
),
|
||||
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
iconColumn.widthAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
|
||||
),
|
||||
iconColumn.heightAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
|
||||
),
|
||||
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
|
||||
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
|
||||
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
|
||||
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
|
||||
actionTitleLabel.leadingAnchor.constraint(
|
||||
equalTo: iconColumn.trailingAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
|
||||
),
|
||||
actionTitleLabel.trailingAnchor.constraint(
|
||||
equalTo: trailingAnchor,
|
||||
constant: -NativeMessageActionSurfaceLayout.horizontalInset
|
||||
),
|
||||
actionTitleLabel.topAnchor.constraint(
|
||||
greaterThanOrEqualTo: topAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
|
||||
),
|
||||
actionTitleLabel.bottomAnchor.constraint(
|
||||
lessThanOrEqualTo: bottomAnchor,
|
||||
constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
|
||||
),
|
||||
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
heightAnchor.constraint(
|
||||
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
|
||||
minimumHeight: minimumHeight,
|
||||
compatibleWith: traitCollection
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
accessibilityLabel = definition.title
|
||||
accessibilityTraits = .button
|
||||
isAccessibilityElement = true
|
||||
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is unavailable")
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor =
|
||||
isHighlighted
|
||||
? actionTitleLabel.textColor.withAlphaComponent(0.08)
|
||||
: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSeparatorView: UIView {
|
||||
init(color: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = color
|
||||
heightAnchor.constraint(
|
||||
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
|
||||
).isActive = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSurfaceFactory: NSObject,
|
||||
FlutterPlatformViewFactory
|
||||
{
|
||||
private let messenger: FlutterBinaryMessenger
|
||||
|
||||
init(messenger: FlutterBinaryMessenger) {
|
||||
self.messenger = messenger
|
||||
super.init()
|
||||
}
|
||||
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
|
||||
func create(
|
||||
withFrame frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
arguments args: Any?
|
||||
) -> FlutterPlatformView {
|
||||
NativeMessageActionSurfacePlatformView(
|
||||
frame: frame,
|
||||
viewIdentifier: viewId,
|
||||
messenger: messenger,
|
||||
arguments: args
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class NativeMessageActionSurfacePlatformView: NSObject,
|
||||
FlutterPlatformView
|
||||
{
|
||||
private let surfaceView: UIView
|
||||
private let backdropView: UIVisualEffectView
|
||||
private let channel: FlutterMethodChannel
|
||||
|
||||
init(
|
||||
frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
messenger: FlutterBinaryMessenger,
|
||||
arguments args: Any?
|
||||
) {
|
||||
let arguments = args as? [String: Any]
|
||||
let surfaceColor = Self.color(
|
||||
from: arguments?["surfaceColor"],
|
||||
fallback: .systemBackground
|
||||
)
|
||||
let foregroundColor = Self.color(
|
||||
from: arguments?["foregroundColor"],
|
||||
fallback: .label
|
||||
)
|
||||
let separatorColor = Self.color(
|
||||
from: arguments?["separatorColor"],
|
||||
fallback: .separator
|
||||
)
|
||||
let destructiveColor = Self.color(
|
||||
from: arguments?["errorColor"],
|
||||
fallback: .systemRed
|
||||
)
|
||||
let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
|
||||
from: arguments?["interfaceStyle"]
|
||||
)
|
||||
var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
|
||||
if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
|
||||
requestedRowHeight.doubleValue.isFinite
|
||||
{
|
||||
minimumRowHeight = max(
|
||||
minimumRowHeight,
|
||||
CGFloat(requestedRowHeight.doubleValue)
|
||||
)
|
||||
}
|
||||
let actionArguments = arguments?["actions"] as? [[String: Any]]
|
||||
let actions =
|
||||
actionArguments?.compactMap(
|
||||
NativeMessageActionDefinition.init(arguments:)
|
||||
) ?? []
|
||||
|
||||
surfaceView = UIView(frame: frame)
|
||||
backdropView = UIVisualEffectView(
|
||||
effect: NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
|
||||
)
|
||||
)
|
||||
channel = FlutterMethodChannel(
|
||||
name: "buzz/native_message_action_surface/\(viewId)",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
super.init()
|
||||
|
||||
surfaceView.backgroundColor = .clear
|
||||
surfaceView.clipsToBounds = false
|
||||
surfaceView.accessibilityViewIsModal =
|
||||
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
|
||||
surfaceView.overrideUserInterfaceStyle = interfaceStyle
|
||||
|
||||
backdropView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropView.overrideUserInterfaceStyle = interfaceStyle
|
||||
backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
|
||||
backdropView.layer.cornerCurve = .continuous
|
||||
backdropView.layer.masksToBounds = true
|
||||
if backdropView.effect == nil {
|
||||
backdropView.backgroundColor = surfaceColor
|
||||
}
|
||||
surfaceView.addSubview(backdropView)
|
||||
NSLayoutConstraint.activate([
|
||||
backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
|
||||
backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
|
||||
backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
|
||||
backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
|
||||
])
|
||||
|
||||
if #unavailable(iOS 26.0) {
|
||||
surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
|
||||
surfaceView.layer.shadowRadius = 32
|
||||
surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
|
||||
surfaceView.layer.shadowColor = UIColor.black.cgColor
|
||||
surfaceView.layer.shadowOpacity = 0.2
|
||||
}
|
||||
|
||||
install(
|
||||
actions: actions,
|
||||
foregroundColor: foregroundColor,
|
||||
destructiveColor: destructiveColor,
|
||||
separatorColor: separatorColor,
|
||||
minimumRowHeight: minimumRowHeight
|
||||
)
|
||||
}
|
||||
|
||||
func view() -> UIView {
|
||||
surfaceView
|
||||
}
|
||||
|
||||
private func install(
|
||||
actions: [NativeMessageActionDefinition],
|
||||
foregroundColor: UIColor,
|
||||
destructiveColor: UIColor,
|
||||
separatorColor: UIColor,
|
||||
minimumRowHeight: CGFloat
|
||||
) {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.alwaysBounceVertical = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 0
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backdropView.contentView.addSubview(scrollView)
|
||||
scrollView.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(
|
||||
equalTo: scrollView.contentLayoutGuide.topAnchor,
|
||||
constant: NativeMessageActionSurfaceLayout.verticalInset
|
||||
),
|
||||
stack.bottomAnchor.constraint(
|
||||
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
|
||||
constant: -NativeMessageActionSurfaceLayout.verticalInset
|
||||
),
|
||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
var installedGroup = false
|
||||
for group in NativeMessageActionDefinition.Group.allCases {
|
||||
let groupActions = actions.filter { $0.group == group }
|
||||
guard !groupActions.isEmpty else { continue }
|
||||
if installedGroup {
|
||||
stack.addArrangedSubview(
|
||||
NativeMessageActionSeparatorView(color: separatorColor)
|
||||
)
|
||||
}
|
||||
for definition in groupActions {
|
||||
stack.addArrangedSubview(
|
||||
NativeMessageActionRowControl(
|
||||
definition: definition,
|
||||
foregroundColor: foregroundColor,
|
||||
destructiveColor: destructiveColor,
|
||||
minimumHeight: minimumRowHeight,
|
||||
onSelected: { [weak self] in self?.select(definition) }
|
||||
)
|
||||
)
|
||||
}
|
||||
installedGroup = true
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ definition: NativeMessageActionDefinition) {
|
||||
channel.invokeMethod("selected", arguments: ["id": definition.id])
|
||||
}
|
||||
|
||||
private static func color(from value: Any?, fallback: UIColor) -> UIColor {
|
||||
guard let number = value as? NSNumber else { return fallback }
|
||||
let color = number.uint32Value
|
||||
let alpha = CGFloat((color >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((color >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((color >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(color & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,157 @@ class RunnerTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
|
||||
let actionArguments: [[String: Any]] = [
|
||||
[
|
||||
"id": "reply", "title": "Reply",
|
||||
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
||||
],
|
||||
[
|
||||
"id": "copyText", "title": "Copy text",
|
||||
"symbol": "doc.on.doc", "group": "utility",
|
||||
],
|
||||
[
|
||||
"id": "delete", "title": "Delete message",
|
||||
"symbol": "trash", "group": "destructive", "destructive": true,
|
||||
],
|
||||
]
|
||||
let definitions = try actionArguments.map { arguments in
|
||||
try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments))
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
|
||||
[.primary, .utility, .destructive]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
|
||||
2
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceLayout.preferredHeight(
|
||||
actions: definitions,
|
||||
compatibleWith: UITraitCollection(
|
||||
preferredContentSizeCategory: .large
|
||||
)
|
||||
),
|
||||
153
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws {
|
||||
let definition = try XCTUnwrap(
|
||||
NativeMessageActionDefinition(
|
||||
arguments: [
|
||||
"id": "reply", "title": "Reply",
|
||||
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
||||
]
|
||||
)
|
||||
)
|
||||
var selected = false
|
||||
let row = NativeMessageActionRowControl(
|
||||
definition: definition,
|
||||
foregroundColor: .label,
|
||||
destructiveColor: .systemRed,
|
||||
onSelected: { selected = true }
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String,
|
||||
UIFont.TextStyle.body.rawValue
|
||||
)
|
||||
XCTAssertNotNil(row.actionImageView.image)
|
||||
row.sendActions(for: .touchUpInside)
|
||||
XCTAssertTrue(selected)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeMessageActionRowExpandsForAccessibilityTypography() throws {
|
||||
let traits = UITraitCollection(
|
||||
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
||||
)
|
||||
let definition = try XCTUnwrap(
|
||||
NativeMessageActionDefinition(
|
||||
arguments: [
|
||||
"id": "followThread", "title": "Follow thread",
|
||||
"symbol": "bell", "group": "utility",
|
||||
]
|
||||
)
|
||||
)
|
||||
let row = NativeMessageActionRowControl(
|
||||
definition: definition,
|
||||
foregroundColor: .label,
|
||||
destructiveColor: .systemRed,
|
||||
compatibleWith: traits,
|
||||
onSelected: {}
|
||||
)
|
||||
let fittingSize = row.systemLayoutSizeFitting(
|
||||
CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
)
|
||||
row.frame = CGRect(origin: .zero, size: fittingSize)
|
||||
row.layoutIfNeeded()
|
||||
let labelFrame = row.convert(
|
||||
row.actionTitleLabel.bounds,
|
||||
from: row.actionTitleLabel
|
||||
)
|
||||
|
||||
XCTAssertGreaterThan(fittingSize.height, 48)
|
||||
XCTAssertGreaterThan(labelFrame.height, 0)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
labelFrame.minY,
|
||||
NativeMessageActionSurfaceLayout.rowVerticalPadding
|
||||
)
|
||||
XCTAssertLessThanOrEqual(
|
||||
labelFrame.maxY,
|
||||
fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding
|
||||
)
|
||||
XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0)
|
||||
XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeMessageActionSurfaceUsesSystemMaterial() {
|
||||
let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: false
|
||||
)
|
||||
if #available(iOS 26.0, *) {
|
||||
XCTAssertTrue(effect is UIGlassEffect)
|
||||
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33)
|
||||
} else {
|
||||
XCTAssertTrue(effect is UIBlurEffect)
|
||||
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12)
|
||||
}
|
||||
XCTAssertNil(
|
||||
NativeMessageActionSurfaceAppearance.backdropEffect(
|
||||
reduceTransparency: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNativeMessageActionListDoesNotHideDialogSiblings() {
|
||||
XCTAssertFalse(
|
||||
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
|
||||
)
|
||||
}
|
||||
|
||||
func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() {
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"),
|
||||
.dark
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "light"),
|
||||
.light
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "system"),
|
||||
.unspecified
|
||||
)
|
||||
}
|
||||
|
||||
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
||||
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
@@ -138,6 +138,8 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final composerDockHeight = useState(0.0);
|
||||
final composerFocusNode = useFocusNode();
|
||||
final restoreComposerFocus = useRef<VoidCallback?>(null);
|
||||
final sendMessage = ref.read(sendMessageProvider);
|
||||
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
@@ -473,6 +475,12 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
composerBottomInset: showsComposer
|
||||
? composerDockHeight.value
|
||||
: 0,
|
||||
composerFocusNode: showsComposer
|
||||
? composerFocusNode
|
||||
: null,
|
||||
restoreComposerFocus: showsComposer
|
||||
? () => restoreComposerFocus.value?.call()
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -521,6 +529,9 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
focusNode: composerFocusNode,
|
||||
onFocusRestorerChanged: (restoreFocus) =>
|
||||
restoreComposerFocus.value = restoreFocus,
|
||||
channelName: resolvedChannel.isDm
|
||||
? ''
|
||||
: resolvedChannel.name,
|
||||
|
||||
@@ -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;
|
||||
@@ -9,6 +9,8 @@ class _MessageBubble extends ConsumerWidget {
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
final FocusNode? composerFocusNode;
|
||||
final VoidCallback? restoreComposerFocus;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
@@ -19,10 +21,13 @@ class _MessageBubble extends ConsumerWidget {
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
this.composerFocusNode,
|
||||
this.restoreComposerFocus,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
@@ -72,7 +77,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
void openMessageActions(MessageLongPressDetails details) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -83,7 +88,12 @@ class _MessageBubble extends ConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
anchorRect: details.anchorRect,
|
||||
captureAnchorSnapshot: details.captureSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: details.setSourceHidden,
|
||||
onPopoverDismissed: () => details.setSourceHidden(false),
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +108,10 @@ class _MessageBubble extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
// Tap opens the thread; long-press still opens the action sheet.
|
||||
// MessageContent handles mention, channel-link, and media taps.
|
||||
onTap: allMessages == null
|
||||
@@ -122,143 +133,164 @@ class _MessageBubble extends ConsumerWidget {
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () => showUserProfileSheet(context, message.pubkey),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
),
|
||||
child: ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) => toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,6 +15,8 @@ class _MessageList extends HookConsumerWidget {
|
||||
final bool isArchived;
|
||||
final double appBarTitleContentHeight;
|
||||
final double composerBottomInset;
|
||||
final FocusNode? composerFocusNode;
|
||||
final VoidCallback? restoreComposerFocus;
|
||||
|
||||
const _MessageList({
|
||||
required this.entries,
|
||||
@@ -31,6 +33,8 @@ class _MessageList extends HookConsumerWidget {
|
||||
required this.isArchived,
|
||||
required this.appBarTitleContentHeight,
|
||||
required this.composerBottomInset,
|
||||
this.composerFocusNode,
|
||||
this.restoreComposerFocus,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -804,6 +808,8 @@ class _MessageList extends HookConsumerWidget {
|
||||
allMessages: allMessages,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
),
|
||||
if (entry.summary != null)
|
||||
_ThreadSummaryRow(
|
||||
|
||||
@@ -10,6 +10,12 @@ class ComposeBar extends HookConsumerWidget {
|
||||
/// prepare focus-dependent layout (for example, following a thread tail).
|
||||
final VoidCallback? onFocusRequested;
|
||||
|
||||
/// Parent-owned if set; otherwise internally created and disposed.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Receives a restorer which becomes a no-op after replacement/unmount.
|
||||
final ValueChanged<VoidCallback>? onFocusRestorerChanged;
|
||||
|
||||
/// Optional thread IDs for thread-scoped typing indicators.
|
||||
final String? threadHeadId;
|
||||
final String? rootId;
|
||||
@@ -20,6 +26,8 @@ class ComposeBar extends HookConsumerWidget {
|
||||
this.hintText,
|
||||
this.threadHeadId,
|
||||
this.rootId,
|
||||
this.focusNode,
|
||||
this.onFocusRestorerChanged,
|
||||
this.onFocusRequested,
|
||||
required this.onSend,
|
||||
});
|
||||
@@ -31,15 +39,9 @@ class ComposeBar extends HookConsumerWidget {
|
||||
() => controller.text,
|
||||
);
|
||||
useEffect(() => controller.dispose, [controller]);
|
||||
// Restore and persist unsent text as a local draft so the Activity
|
||||
// inbox Drafts filter reflects real composer state.
|
||||
//
|
||||
// The effect is additionally keyed on the active relay + pubkey identity:
|
||||
// provider-level namespacing alone cannot protect a composer that stays
|
||||
// mounted through an in-place community/account switch — the controller
|
||||
// would retain the old identity's text and the next edit would persist it
|
||||
// into the new identity's store. On identity change we replace the
|
||||
// controller content with the new identity's own saved draft (or clear).
|
||||
// Draft identity is part of the effect key because an in-place account or
|
||||
// community switch can leave this composer mounted. Reload that identity's
|
||||
// draft so old text cannot be persisted into the new identity's store.
|
||||
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
|
||||
final draftRevision = useRef(0);
|
||||
final draftIdentity =
|
||||
@@ -50,15 +52,13 @@ class ComposeBar extends HookConsumerWidget {
|
||||
defaultTargetPlatform != TargetPlatform.android,
|
||||
);
|
||||
final androidImeFallbackTimer = useRef<Timer?>(null);
|
||||
final focusNode = useFocusNode();
|
||||
final ownedFocusNode = useFocusNode();
|
||||
final focusNode = this.focusNode ?? ownedFocusNode;
|
||||
useEffect(
|
||||
() =>
|
||||
() => androidImeFallbackTimer.value?.cancel(),
|
||||
[androidImeFallbackTimer],
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
() => _dismissComposerKeyboard(focusNode),
|
||||
() => () {
|
||||
androidImeFallbackTimer.value?.cancel();
|
||||
_dismissComposerKeyboard(focusNode);
|
||||
},
|
||||
[focusNode],
|
||||
);
|
||||
final isEmojiPickerOpen = useState(false);
|
||||
@@ -863,6 +863,13 @@ class ComposeBar extends HookConsumerWidget {
|
||||
androidImeFallbackTimer: androidImeFallbackTimer,
|
||||
);
|
||||
|
||||
_useComposerFocusRestorer(
|
||||
onChanged: onFocusRestorerChanged,
|
||||
isExpanded: isComposerExpanded,
|
||||
focusNode: focusNode,
|
||||
expand: expandComposer,
|
||||
);
|
||||
|
||||
final suggestionPanel = _composerSuggestionPanel(
|
||||
channelSuggestions: channelSuggestions,
|
||||
mentionSuggestions: suggestions,
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
void _useComposerFocusRestorer({
|
||||
required ValueChanged<VoidCallback>? onChanged,
|
||||
required ValueNotifier<bool> isExpanded,
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback expand,
|
||||
}) {
|
||||
useEffect(() {
|
||||
if (onChanged == null) return null;
|
||||
|
||||
var isCurrent = true;
|
||||
void restoreFocus() {
|
||||
if (!isCurrent) return;
|
||||
if (isExpanded.value) {
|
||||
focusNode.requestFocus();
|
||||
} else {
|
||||
expand();
|
||||
}
|
||||
}
|
||||
|
||||
onChanged(restoreFocus);
|
||||
return () => isCurrent = false;
|
||||
}, [onChanged, focusNode]);
|
||||
}
|
||||
|
||||
void _useComposerChannelNames(
|
||||
_MarkdownEditingController controller,
|
||||
AsyncValue<List<Channel>> channelsAsync,
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -37,11 +39,29 @@ import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
part 'message_actions/reaction_popover.dart';
|
||||
part 'message_actions/quick_reaction_row.dart';
|
||||
part 'message_actions/message_action_popover.dart';
|
||||
part 'message_actions/message_reaction_tray.dart';
|
||||
|
||||
/// Preview length for reminder targets — matches desktop's
|
||||
/// `msg.body.slice(0, 100)`.
|
||||
const _reminderPreviewLength = 100;
|
||||
|
||||
/// Presents the actions for [message] as an anchored popover when both
|
||||
/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet.
|
||||
///
|
||||
/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid
|
||||
/// until its future completes, and the returned image becomes this function's
|
||||
/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports
|
||||
/// whether the constrained layout actually renders the lifted preview;
|
||||
/// [onPopoverDismissed] runs after the route completes while [context] is still
|
||||
/// mounted. Neither callback runs for sheet fallback or a failed capture.
|
||||
///
|
||||
/// [composerFocusNode] remains caller-owned and must outlive the popover. If it
|
||||
/// has focus when this function is called, the popover unfocuses it and invokes
|
||||
/// [restoreComposerFocus] only after a dismissal with no selected action. The
|
||||
/// restorer must remain callable for the same lifetime and no-op if its composer
|
||||
/// is later disposed or replaced.
|
||||
void showMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
@@ -51,8 +71,13 @@ void showMessageActions({
|
||||
List<TimelineMessage>? allMessages,
|
||||
String? currentPubkey,
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
Rect? anchorRect,
|
||||
Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
VoidCallback? onPopoverDismissed,
|
||||
FocusNode? composerFocusNode,
|
||||
VoidCallback? restoreComposerFocus,
|
||||
bool isArchived = false,
|
||||
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
|
||||
}) {
|
||||
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
|
||||
@@ -67,6 +92,26 @@ void showMessageActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tryShowMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -485,9 +530,6 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Promoted actions for the three dominant mobile jobs: respond now (Reply),
|
||||
/// hand off context (Copy link — the `buzz://message` link is the workspace's
|
||||
/// context-transfer primitive), and defer (Remind me).
|
||||
class _FastActionsRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final String channelId;
|
||||
@@ -638,127 +680,9 @@ class _FastActionTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The row of one-tap reactions at the top of the action sheet, plus the "+"
|
||||
/// tile that opens the full picker.
|
||||
///
|
||||
/// 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.
|
||||
class _QuickReactionRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
|
||||
/// The sheet's context, popped before the reaction fires.
|
||||
final BuildContext sheetContext;
|
||||
|
||||
/// The long-pressed message's page context — survives the sheet pop, so the
|
||||
/// picker opened from "+" isn't torn down with the sheet.
|
||||
final BuildContext pageContext;
|
||||
|
||||
/// The long-pressed message's page ref. The picker callback outlives this
|
||||
/// bottom sheet, so it must not read through the sheet's disposed ref.
|
||||
final WidgetRef pageRef;
|
||||
|
||||
/// Drives the staged glyph reveal when this row is shown in the popover.
|
||||
/// The bottom sheet leaves this null and retains its existing static row.
|
||||
final Animation<double>? presentationAnimation;
|
||||
|
||||
const _QuickReactionRow({
|
||||
required this.message,
|
||||
required this.sheetContext,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.presentationAnimation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final emoji = quickReactionEmoji(
|
||||
ref.watch(recentEmojiProvider),
|
||||
customShortcodes: {
|
||||
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
|
||||
},
|
||||
);
|
||||
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);
|
||||
// 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);
|
||||
pageRef.read(channelActionsProvider).addReaction(message.id, value);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const desiredCircleSize = 52.0;
|
||||
const minimumCircleSize = 44.0;
|
||||
final itemCount = emoji.length + 1;
|
||||
final gapCount = itemCount - 1;
|
||||
final circleSize =
|
||||
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
|
||||
.clamp(minimumCircleSize, desiredCircleSize)
|
||||
.toDouble();
|
||||
final gap =
|
||||
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
|
||||
.clamp(0.0, Grid.twelve)
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (var index = 0; index < emoji.length; index++)
|
||||
_ReactionItemReveal(
|
||||
key: ValueKey('quick-reaction-${emoji[index]}'),
|
||||
animation: presentationAnimation,
|
||||
index: index,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
react(emoji[index]);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: emoji[index],
|
||||
customByShortcode: customByShortcode,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ReactionItemReveal(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
animation: presentationAnimation,
|
||||
index: emoji.length,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < circles.length; index++) ...[
|
||||
circles[index],
|
||||
if (index < circles.length - 1) SizedBox(width: gap),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionItemReveal extends StatelessWidget {
|
||||
final Animation<double>? animation;
|
||||
final int index;
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _messageActionRowHeight = 48.0;
|
||||
const _messageActionRowVerticalPadding = Grid.xxs;
|
||||
const _messageActionSeparatorHeight = 0.5;
|
||||
const _messageActionVerticalInset = Grid.half;
|
||||
const _messageActionMenuMaxWidth = 288.0;
|
||||
const _messageActionPreviewMaxWidth = 358.0;
|
||||
const _messageActionPreviewInset = Grid.xxs;
|
||||
const _messageActionGap = Grid.twelve;
|
||||
const _messageActionReactionSelection = '__reaction__';
|
||||
const _messageActionTransitionDuration = _reactionPopoverDuration;
|
||||
const _iosMessageActionTransitionDuration = Duration(milliseconds: 220);
|
||||
const _iosNativeMessageActionSurfaceChannel = MethodChannel(
|
||||
'buzz/native_message_action_surface',
|
||||
);
|
||||
|
||||
bool _messageActionsPresentationInFlight = false;
|
||||
bool? _iosNativeMessageActionSurfaceSupported;
|
||||
|
||||
Future<bool> _supportsIosNativeMessageActionSurface() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
final cached = _iosNativeMessageActionSurfaceSupported;
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final supported =
|
||||
await _iosNativeMessageActionSurfaceChannel.invokeMethod<bool>(
|
||||
'isSupported',
|
||||
) ??
|
||||
false;
|
||||
_iosNativeMessageActionSurfaceSupported = supported;
|
||||
return supported;
|
||||
} on MissingPluginException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
} on PlatformException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _tryShowMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect? anchorRect,
|
||||
required Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
}) {
|
||||
if (anchorRect == null || captureAnchorSnapshot == null) return false;
|
||||
final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false;
|
||||
unawaited(
|
||||
_showMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
shouldRestoreComposerFocus: shouldRestoreComposerFocus,
|
||||
).then((shown) {
|
||||
if (shown || !context.mounted) return;
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _showMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect anchorRect,
|
||||
required Future<ui.Image> Function() captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
required bool shouldRestoreComposerFocus,
|
||||
}) async {
|
||||
if (_messageActionsPresentationInFlight) return true;
|
||||
_messageActionsPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
final actions = _buildPopoverMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
if (actions.isEmpty) return false;
|
||||
final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
|
||||
final ui.Image snapshot;
|
||||
try {
|
||||
snapshot = await captureAnchorSnapshot();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
final useIosNativeActionSurface = await nativeActionSurfaceSupport;
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
if (shouldRestoreComposerFocus) composerFocusNode!.unfocus();
|
||||
|
||||
String? selectedActionId;
|
||||
final dialogRoute = RawDialogRoute<String>(
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss message actions',
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: isIos
|
||||
? _iosMessageActionTransitionDuration
|
||||
: _messageActionTransitionDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_MessageActionsPopover(
|
||||
anchorRect: anchorRect,
|
||||
anchorSnapshot: snapshot,
|
||||
animation: animation,
|
||||
message: message,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
actions: actions,
|
||||
useIosNativeActionSurface: useIosNativeActionSurface,
|
||||
onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
),
|
||||
);
|
||||
var routePushed = false;
|
||||
try {
|
||||
final popResult = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(dialogRoute);
|
||||
routePushed = true;
|
||||
selectedActionId = await popResult;
|
||||
} finally {
|
||||
if (routePushed) await dialogRoute.completed;
|
||||
snapshot.dispose();
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
|
||||
for (final action in actions) {
|
||||
if (action.id != selectedActionId) continue;
|
||||
await Future<void>.sync(action.onSelected);
|
||||
break;
|
||||
}
|
||||
if (selectedActionId == null &&
|
||||
shouldRestoreComposerFocus &&
|
||||
context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
_messageActionsPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<_PopoverMessageAction> _buildPopoverMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
}) {
|
||||
final actions = <_PopoverMessageAction>[];
|
||||
final messages = allMessages;
|
||||
final canRemind = ref.read(reminderServiceProvider) != null;
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (messages != null) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'reply',
|
||||
title: 'Reply',
|
||||
icon: LucideIcons.messageSquareReply,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: messages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyLink',
|
||||
title: 'Copy link',
|
||||
icon: LucideIcons.link2,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (canRemind) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'remind',
|
||||
title: 'Remind me',
|
||||
icon: LucideIcons.clock,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: Navigator.of(context, rootNavigator: true).context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = isMessageUnread(
|
||||
readState,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
createdAt: message.createdAt,
|
||||
threadRootId: message.rootId,
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: unread ? 'markRead' : 'markUnread',
|
||||
title: unread ? 'Mark read' : 'Mark unread',
|
||||
icon: unread ? LucideIcons.mailCheck : LucideIcons.mailOpen,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: following ? 'unfollowThread' : 'followThread',
|
||||
title: following ? 'Unfollow thread' : 'Follow thread',
|
||||
icon: following ? LucideIcons.bellOff : LucideIcons.bellRing,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyText',
|
||||
title: 'Copy text',
|
||||
icon: LucideIcons.copy,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () =>
|
||||
Clipboard.setData(ClipboardData(text: message.content)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (canManageMessage) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'edit',
|
||||
title: 'Edit message',
|
||||
icon: LucideIcons.pencil,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'delete',
|
||||
title: 'Delete message',
|
||||
icon: LucideIcons.trash2,
|
||||
group: _PopoverMessageActionGroup.destructive,
|
||||
destructive: true,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const actionOrder = {
|
||||
'reply': 0,
|
||||
'markRead': 1,
|
||||
'markUnread': 1,
|
||||
'edit': 2,
|
||||
'copyText': 3,
|
||||
'copyLink': 4,
|
||||
'remind': 5,
|
||||
'followThread': 6,
|
||||
'unfollowThread': 6,
|
||||
'delete': 7,
|
||||
};
|
||||
actions.sort(
|
||||
(left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!),
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
enum _PopoverMessageActionGroup { primary, utility, destructive }
|
||||
|
||||
class _PopoverMessageAction {
|
||||
final String id;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final _PopoverMessageActionGroup group;
|
||||
final bool destructive;
|
||||
final FutureOr<void> Function() onSelected;
|
||||
|
||||
const _PopoverMessageAction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.onSelected,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
String get iosSymbol => switch (id) {
|
||||
'reply' => 'arrowshape.turn.up.left',
|
||||
'markRead' => 'envelope.open',
|
||||
'markUnread' => 'envelope.badge',
|
||||
'edit' => 'pencil',
|
||||
'copyText' => 'doc.on.doc',
|
||||
'copyLink' => 'link',
|
||||
'remind' => 'clock',
|
||||
'followThread' => 'bell',
|
||||
'unfollowThread' => 'bell.slash',
|
||||
'delete' => 'trash',
|
||||
_ => 'ellipsis',
|
||||
};
|
||||
|
||||
Map<String, Object> toPlatformArguments() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': iosSymbol,
|
||||
'group': group.name,
|
||||
'destructive': destructive,
|
||||
};
|
||||
}
|
||||
|
||||
class _IosNativeMessageActionSurface extends HookWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final double rowHeight;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _IosNativeMessageActionSurface({
|
||||
required this.actions,
|
||||
required this.rowHeight,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewId = useState<int?>(null);
|
||||
useEffect(() {
|
||||
final id = viewId.value;
|
||||
if (id == null) return null;
|
||||
final channel = MethodChannel('buzz/native_message_action_surface/$id');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method != 'selected' || call.arguments is! Map) return;
|
||||
final actionId = (call.arguments as Map)['id'];
|
||||
if (actionId is String) onSelected(actionId);
|
||||
});
|
||||
return () => channel.setMethodCallHandler(null);
|
||||
}, [viewId.value, onSelected]);
|
||||
|
||||
return UiKitView(
|
||||
key: const ValueKey('ios-native-message-action-surface'),
|
||||
viewType: 'buzz/native_message_action_surface',
|
||||
creationParams: <String, Object>{
|
||||
'actions': [for (final action in actions) action.toPlatformArguments()],
|
||||
'surfaceColor': context.colors.surface.toARGB32(),
|
||||
'foregroundColor': context.colors.onSurface.toARGB32(),
|
||||
'separatorColor': context.colors.outlineVariant.toARGB32(),
|
||||
'errorColor': context.colors.error.toARGB32(),
|
||||
'interfaceStyle': context.colors.brightness.name,
|
||||
'rowHeight': rowHeight,
|
||||
},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated: (id) => viewId.value = id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionsPopover extends HookWidget {
|
||||
final Rect anchorRect;
|
||||
final ui.Image anchorSnapshot;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final bool useIosNativeActionSurface;
|
||||
final ValueChanged<bool>? onPreviewVisibilityChanged;
|
||||
|
||||
const _MessageActionsPopover({
|
||||
required this.anchorRect,
|
||||
required this.anchorSnapshot,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.actions,
|
||||
required this.useIosNativeActionSurface,
|
||||
required this.onPreviewVisibilityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
void selectAction(String actionId) => select(actionId);
|
||||
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 -
|
||||
mediaQuery.viewInsets.bottom -
|
||||
Grid.xxs;
|
||||
final availableWidth = math.max(1.0, safeRight - safeLeft);
|
||||
final availableHeight = math.max(1.0, safeBottom - safeTop);
|
||||
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
|
||||
final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth);
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
final preferredMenuHeight = menuLayout.preferredHeight;
|
||||
final minimumMenuHeight = math.min(
|
||||
menuLayout.rowHeight,
|
||||
availableHeight,
|
||||
);
|
||||
final showReactionTray =
|
||||
availableHeight >=
|
||||
_reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight;
|
||||
final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0;
|
||||
final trayGap = showReactionTray ? _messageActionGap : 0.0;
|
||||
final heightAfterTray = availableHeight - trayHeight - trayGap;
|
||||
final showPreview =
|
||||
showReactionTray &&
|
||||
heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0;
|
||||
final previewMinimumHeight = showPreview ? 48.0 : 0.0;
|
||||
final previewGap = showPreview ? _messageActionGap : 0.0;
|
||||
final menuBudget = math.max(
|
||||
minimumMenuHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
previewMinimumHeight -
|
||||
previewGap,
|
||||
);
|
||||
final menuHeight = math.min(preferredMenuHeight, menuBudget);
|
||||
final previewMaximumHeight = showPreview
|
||||
? math.max(
|
||||
previewMinimumHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
menuHeight -
|
||||
previewGap,
|
||||
)
|
||||
: 0.0;
|
||||
final previewInsetExtent = _messageActionPreviewInset * 2;
|
||||
final previewSize = showPreview
|
||||
? () {
|
||||
final previewWidthRatio =
|
||||
(math.min(_messageActionPreviewMaxWidth, availableWidth) -
|
||||
previewInsetExtent) /
|
||||
math.max(anchorRect.width, 1);
|
||||
final previewHeightRatio =
|
||||
(previewMaximumHeight - previewInsetExtent) /
|
||||
math.max(anchorRect.height, 1);
|
||||
final previewScale = math.min(
|
||||
1.0,
|
||||
math.max(
|
||||
0.0,
|
||||
math.min(previewWidthRatio, previewHeightRatio),
|
||||
),
|
||||
);
|
||||
return Size(
|
||||
(anchorRect.width * previewScale) + previewInsetExtent,
|
||||
(anchorRect.height * previewScale) + previewInsetExtent,
|
||||
);
|
||||
}()
|
||||
: Size.zero;
|
||||
final totalHeight =
|
||||
trayHeight + trayGap + previewSize.height + previewGap + menuHeight;
|
||||
final contentTop = math.max(safeTop, safeBottom - totalHeight);
|
||||
final surfaceLeft =
|
||||
safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2);
|
||||
final trayRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
contentTop,
|
||||
trayWidth,
|
||||
trayHeight,
|
||||
);
|
||||
final trayHostWidth = math.min(
|
||||
trayWidth + _reactionTraySpringAllowance,
|
||||
safeRight - surfaceLeft,
|
||||
);
|
||||
final previewRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
trayRect.bottom + trayGap,
|
||||
previewSize.width,
|
||||
previewSize.height,
|
||||
);
|
||||
final menuRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
previewRect.bottom + previewGap,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
);
|
||||
return _MessageActionPreviewVisibility(
|
||||
visible: showPreview,
|
||||
onChanged: onPreviewVisibilityChanged,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
|
||||
sigmaY: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8,
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = Curves.easeOutCubic.transform(
|
||||
animation.value,
|
||||
);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('message-actions-background'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: 0.14 * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('message-actions-backdrop'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
if (showPreview)
|
||||
Positioned.fromRect(
|
||||
rect: previewRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: RepaintBoundary(
|
||||
child: _LiftedMessagePreview(
|
||||
anchorSnapshot: anchorSnapshot,
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final movement =
|
||||
(defaultTargetPlatform == TargetPlatform.iOS
|
||||
? Curves.easeOutCubic
|
||||
: Curves.easeInOutCubic)
|
||||
.transform(animation.value);
|
||||
final sourceRect = anchorRect.inflate(
|
||||
_messageActionPreviewInset,
|
||||
);
|
||||
final translation = Offset(
|
||||
ui.lerpDouble(
|
||||
sourceRect.left - previewRect.left,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
ui.lerpDouble(
|
||||
sourceRect.top - previewRect.top,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
);
|
||||
final scaleX = ui.lerpDouble(
|
||||
sourceRect.width / previewRect.width,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
final scaleY = ui.lerpDouble(
|
||||
sourceRect.height / previewRect.height,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
return Transform.translate(
|
||||
offset: translation,
|
||||
child: Transform(
|
||||
alignment: Alignment.topLeft,
|
||||
transform: Matrix4.diagonal3Values(scaleX, scaleY, 1),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showReactionTray)
|
||||
Positioned(
|
||||
left: trayRect.left,
|
||||
top: trayRect.top,
|
||||
width: trayHostWidth,
|
||||
height: trayRect.height,
|
||||
child: _MessageReactionTray(
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: _messageActionReactionSelection,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: menuRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: useIosNativeActionSurface
|
||||
? _IosNativeMessageActionSurface(
|
||||
actions: actions,
|
||||
rowHeight: menuLayout.rowHeight,
|
||||
onSelected: selectAction,
|
||||
)
|
||||
: _MessageActionSurface(
|
||||
actions: actions,
|
||||
onSelected: selectAction,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.08,
|
||||
0.82,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final fadedChild = Opacity(
|
||||
opacity: appearance,
|
||||
child: child,
|
||||
);
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return fadedChild;
|
||||
}
|
||||
return Transform.scale(
|
||||
alignment: Alignment.topLeft,
|
||||
scale: ui.lerpDouble(0.96, 1, appearance)!,
|
||||
child: fadedChild,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionPreviewVisibility extends HookWidget {
|
||||
final bool visible;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final Widget child;
|
||||
const _MessageActionPreviewVisibility({
|
||||
required this.visible,
|
||||
required this.onChanged,
|
||||
required this.child,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) onChanged?.call(visible);
|
||||
});
|
||||
return null;
|
||||
}, [visible, onChanged]);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class _LiftedMessagePreview extends StatelessWidget {
|
||||
final ui.Image anchorSnapshot;
|
||||
|
||||
const _LiftedMessagePreview({required this.anchorSnapshot});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('message-action-preview'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.7),
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_messageActionPreviewInset),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.xs),
|
||||
child: RawImage(
|
||||
image: anchorSnapshot,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurface extends StatelessWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionSurface({
|
||||
required this.actions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
return Material(
|
||||
key: const ValueKey('message-action-surface'),
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.55),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _messageActionVerticalInset,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
if (index > 0 &&
|
||||
actions[index - 1].group != actions[index].group)
|
||||
Divider(
|
||||
key: ValueKey(
|
||||
'message-action-divider-${actions[index].group.name}',
|
||||
),
|
||||
height: _messageActionSeparatorHeight,
|
||||
thickness: _messageActionSeparatorHeight,
|
||||
indent: Grid.xs,
|
||||
endIndent: Grid.xs,
|
||||
),
|
||||
_MessageActionRow(
|
||||
action: actions[index],
|
||||
height: menuLayout.rowHeight,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionRow extends StatelessWidget {
|
||||
final _PopoverMessageAction action;
|
||||
final double height;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionRow({
|
||||
required this.action,
|
||||
required this.height,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = action.destructive
|
||||
? context.colors.error
|
||||
: context.colors.onSurface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: action.title,
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
key: ValueKey('message-action-${action.id}'),
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onSelected(action.id);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Icon(action.icon, size: 22, color: foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurfaceLayout {
|
||||
final double rowHeight, preferredHeight;
|
||||
|
||||
const _MessageActionSurfaceLayout({
|
||||
required this.rowHeight,
|
||||
required this.preferredHeight,
|
||||
});
|
||||
|
||||
factory _MessageActionSurfaceLayout.from(
|
||||
BuildContext context,
|
||||
List<_PopoverMessageAction> actions,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Message action',
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final rowHeight = math.max(
|
||||
_messageActionRowHeight,
|
||||
textPainter.height + (_messageActionRowVerticalPadding * 2),
|
||||
);
|
||||
textPainter.dispose();
|
||||
|
||||
var separatorCount = 0;
|
||||
for (var index = 1; index < actions.length; index++) {
|
||||
if (actions[index - 1].group != actions[index].group) separatorCount += 1;
|
||||
}
|
||||
final preferredHeight =
|
||||
(_messageActionVerticalInset * 2) +
|
||||
(actions.length * rowHeight) +
|
||||
(separatorCount * _messageActionSeparatorHeight);
|
||||
return _MessageActionSurfaceLayout(
|
||||
rowHeight: rowHeight,
|
||||
preferredHeight: preferredHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _MessageReactionTray extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object popResult;
|
||||
final void Function(Object? result, VoidCallback effect) onSelected;
|
||||
|
||||
const _MessageReactionTray({
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.popResult,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('message-action-reaction-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: Alignment.bottomLeft,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _QuickReactionRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
|
||||
/// The sheet's context, popped before the reaction fires.
|
||||
final BuildContext sheetContext;
|
||||
|
||||
/// The long-pressed message's page context — survives the sheet pop, so the
|
||||
/// picker opened from "+" isn't torn down with the sheet.
|
||||
final BuildContext pageContext;
|
||||
|
||||
/// The long-pressed message's page ref. The picker callback outlives this
|
||||
/// bottom sheet, so it must not read through the sheet's disposed ref.
|
||||
final WidgetRef pageRef;
|
||||
|
||||
/// Drives the staged glyph reveal when this row is shown in the popover.
|
||||
/// The bottom sheet leaves this null and retains its existing static row.
|
||||
final Animation<double>? presentationAnimation;
|
||||
|
||||
final Object? popResult;
|
||||
|
||||
/// Selects exactly one popover result and side effect. Bottom sheets leave
|
||||
/// this null and retain their existing local dismissal behavior.
|
||||
final void Function(Object? result, VoidCallback effect)? onSelected;
|
||||
|
||||
const _QuickReactionRow({
|
||||
required this.message,
|
||||
required this.sheetContext,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.presentationAnimation,
|
||||
this.popResult,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final emoji = quickReactionEmoji(
|
||||
ref.watch(recentEmojiProvider),
|
||||
customShortcodes: {
|
||||
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
|
||||
},
|
||||
);
|
||||
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);
|
||||
// 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);
|
||||
pageRef.read(channelActionsProvider).addReaction(message.id, value);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const desiredCircleSize = 52.0;
|
||||
const minimumCircleSize = 44.0;
|
||||
final itemCount = emoji.length + 1;
|
||||
final gapCount = itemCount - 1;
|
||||
final circleSize =
|
||||
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
|
||||
.clamp(minimumCircleSize, desiredCircleSize)
|
||||
.toDouble();
|
||||
final gap =
|
||||
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
|
||||
.clamp(0.0, Grid.twelve)
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (var index = 0; index < emoji.length; index++)
|
||||
_ReactionItemReveal(
|
||||
key: ValueKey('quick-reaction-${emoji[index]}'),
|
||||
animation: presentationAnimation,
|
||||
index: index,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
void effect() => react(emoji[index]);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: emoji[index],
|
||||
customByShortcode: customByShortcode,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ReactionItemReveal(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
animation: presentationAnimation,
|
||||
index: emoji.length,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
void effect() =>
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < circles.length; index++) ...[
|
||||
circles[index],
|
||||
if (index < circles.length - 1) SizedBox(width: gap),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ void _showMessageReactionPopover({
|
||||
);
|
||||
}
|
||||
|
||||
class _MessageReactionPopover extends StatelessWidget {
|
||||
class _MessageReactionPopover extends HookWidget {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
final Animation<double> animation;
|
||||
@@ -61,6 +61,14 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -125,7 +133,7 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -133,73 +141,15 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
left: left,
|
||||
width: trayWidth + _reactionTraySpringAllowance,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: AnimatedBuilder(
|
||||
child: _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('reaction-popover-tray'),
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(
|
||||
0.16,
|
||||
0.92,
|
||||
).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(
|
||||
expansion,
|
||||
);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: trayScaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('reaction-popover-tray'),
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: trayScaleAlignment,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -209,6 +159,99 @@ class _MessageReactionPopover extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedReactionTray extends StatelessWidget {
|
||||
final Key trayKey;
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final AlignmentGeometry scaleAlignment;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object? popResult;
|
||||
final void Function(Object? result, VoidCallback effect)? onSelected;
|
||||
|
||||
const _AnimatedReactionTray({
|
||||
required this.trayKey,
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.scaleAlignment,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.popResult,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(0.16, 0.92).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(expansion);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: scaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
key: trayKey,
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideAnchorClipper extends CustomClipper<Path> {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
|
||||
@@ -1,111 +1,217 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
const _iosMessageLongPressDuration = Duration(milliseconds: 200);
|
||||
const _maxMessageSnapshotDimension = 2048.0;
|
||||
|
||||
double _messageSnapshotPixelRatio(Size size, double devicePixelRatio) {
|
||||
final longestSide = size.longestSide;
|
||||
if (!longestSide.isFinite || longestSide <= 0) return devicePixelRatio;
|
||||
return math.min(devicePixelRatio, _maxMessageSnapshotDimension / longestSide);
|
||||
}
|
||||
|
||||
/// Geometry and snapshot controls for a completed message long press.
|
||||
///
|
||||
/// Call [captureSnapshot] while the source is still mounted, then use
|
||||
/// [setSourceHidden] to hide it behind the lifted preview. Restore the source
|
||||
/// before the preview is disposed or dismissed.
|
||||
class MessageLongPressDetails {
|
||||
/// The long-pressed source bounds in global logical coordinates.
|
||||
final Rect anchorRect;
|
||||
|
||||
/// Captures the current source as an image for the lifted preview.
|
||||
///
|
||||
/// The returned future can fail if the source is no longer mounted.
|
||||
final Future<ui.Image> Function() captureSnapshot;
|
||||
|
||||
/// Hides or reveals the mounted source while its preview is displayed.
|
||||
final ValueChanged<bool> setSourceHidden;
|
||||
|
||||
/// Creates the details passed to a completed long-press callback.
|
||||
const MessageLongPressDetails({
|
||||
required this.anchorRect,
|
||||
required this.captureSnapshot,
|
||||
required this.setSourceHidden,
|
||||
});
|
||||
}
|
||||
|
||||
/// An [InkWell] whose long press is observed above interactive descendants.
|
||||
class MessageLongPressInkWell extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final ValueChanged<Rect>? onLongPress;
|
||||
|
||||
/// Handles a completed long press with snapshot and source-visibility access.
|
||||
///
|
||||
/// When provided, this callback takes precedence over [onLongPress].
|
||||
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? highlightColor;
|
||||
|
||||
/// Identifies the [RepaintBoundary] to capture for the lifted preview.
|
||||
///
|
||||
/// The key's current context must resolve to a boundary covering the message
|
||||
/// content. When omitted, this widget inserts and owns that boundary.
|
||||
final GlobalKey? snapshotKey;
|
||||
final Widget child;
|
||||
|
||||
const MessageLongPressInkWell({
|
||||
super.key,
|
||||
this.onTap,
|
||||
required this.onLongPress,
|
||||
this.onLongPress,
|
||||
this.onLongPressDetails,
|
||||
this.borderRadius,
|
||||
this.highlightColor,
|
||||
this.snapshotKey,
|
||||
required this.child,
|
||||
});
|
||||
}) : assert(onLongPress != null || onLongPressDetails != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _MessageLongPressRegion(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
child: child,
|
||||
),
|
||||
onLongPressDetails: onLongPressDetails,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
externalSnapshotKey: snapshotKey,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects a message long press without competing with interactive descendants.
|
||||
/// Detects a message long press through Flutter's gesture arena.
|
||||
///
|
||||
/// Links, media, reactions, and other nested controls keep their normal tap
|
||||
/// gestures. Moving far enough to scroll cancels the timer; recognizing the
|
||||
/// hold cancels the pointer so a descendant tap cannot fire on release.
|
||||
/// gestures, while a completed hold wins over descendant taps.
|
||||
class _MessageLongPressRegion extends HookWidget {
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<Rect>? onLongPress;
|
||||
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? highlightColor;
|
||||
final GlobalKey? externalSnapshotKey;
|
||||
final Widget child;
|
||||
|
||||
const _MessageLongPressRegion({
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onLongPressDetails,
|
||||
required this.borderRadius,
|
||||
required this.highlightColor,
|
||||
required this.externalSnapshotKey,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activePointer = useRef<int?>(null);
|
||||
final origin = useRef<Offset?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
|
||||
void cancel() {
|
||||
timer.value?.cancel();
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
}
|
||||
|
||||
useEffect(() => cancel, const []);
|
||||
final fallbackSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final snapshotKey = externalSnapshotKey ?? fallbackSnapshotKey;
|
||||
final sourceHidden = useState(false);
|
||||
|
||||
void recognize() {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
|
||||
}
|
||||
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
||||
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
|
||||
return;
|
||||
}
|
||||
final anchorRect =
|
||||
renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||
|
||||
void handlePointerDown(PointerDownEvent event) {
|
||||
if (activePointer.value != null) return;
|
||||
activePointer.value = event.pointer;
|
||||
origin.value = event.position;
|
||||
timer.value = Timer(kLongPressTimeout, () {
|
||||
final pointer = activePointer.value;
|
||||
if (pointer == null) return;
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
GestureBinding.instance.cancelPointer(pointer);
|
||||
recognize();
|
||||
});
|
||||
}
|
||||
final detailsCallback = onLongPressDetails;
|
||||
if (detailsCallback == null) {
|
||||
onLongPress?.call(anchorRect);
|
||||
return;
|
||||
}
|
||||
final maxSnapshotPixelRatio = math.min(
|
||||
MediaQuery.devicePixelRatioOf(context),
|
||||
2.0,
|
||||
);
|
||||
|
||||
void handlePointerMove(PointerMoveEvent event) {
|
||||
if (event.pointer != activePointer.value) return;
|
||||
final start = origin.value;
|
||||
if (start == null) return;
|
||||
final delta = event.position - start;
|
||||
if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
|
||||
}
|
||||
Future<ui.Image> captureSnapshot() async {
|
||||
RenderRepaintBoundary? boundary;
|
||||
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
||||
if (renderObject is RenderRepaintBoundary && renderObject.hasSize) {
|
||||
boundary = renderObject;
|
||||
}
|
||||
if (boundary == null) {
|
||||
throw StateError('Message snapshot is unavailable');
|
||||
}
|
||||
final snapshotPixelRatio = _messageSnapshotPixelRatio(
|
||||
boundary.size,
|
||||
maxSnapshotPixelRatio,
|
||||
);
|
||||
try {
|
||||
return await boundary.toImage(pixelRatio: snapshotPixelRatio);
|
||||
} catch (_) {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
final retryBoundary = snapshotKey.currentContext?.findRenderObject();
|
||||
if (retryBoundary is! RenderRepaintBoundary ||
|
||||
!retryBoundary.hasSize) {
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await retryBoundary.toImage(
|
||||
pixelRatio: _messageSnapshotPixelRatio(
|
||||
retryBoundary.size,
|
||||
maxSnapshotPixelRatio,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (snapshotPixelRatio <= 1) rethrow;
|
||||
return retryBoundary.toImage(
|
||||
pixelRatio: _messageSnapshotPixelRatio(retryBoundary.size, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handlePointerEnd(PointerEvent event) {
|
||||
if (event.pointer == activePointer.value) cancel();
|
||||
void setSourceHidden(bool hidden) {
|
||||
if (!context.mounted) return;
|
||||
sourceHidden.value = hidden;
|
||||
}
|
||||
|
||||
detailsCallback(
|
||||
MessageLongPressDetails(
|
||||
anchorRect: anchorRect,
|
||||
captureSnapshot: captureSnapshot,
|
||||
setSourceHidden: setSourceHidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
onLongPress: recognize,
|
||||
child: Listener(
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerEnd,
|
||||
onPointerCancel: handlePointerEnd,
|
||||
child: child,
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
duration: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? _iosMessageLongPressDuration
|
||||
: null,
|
||||
),
|
||||
(recognizer) {
|
||||
recognizer.onLongPressStart = (_) => recognize();
|
||||
},
|
||||
),
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
child: Opacity(
|
||||
opacity: sourceHidden.value ? 0 : 1,
|
||||
child: externalSnapshotKey == null
|
||||
? RepaintBoundary(key: snapshotKey, child: child)
|
||||
: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ part 'thread_detail_page/nested_thread_summary_row.dart';
|
||||
part 'thread_detail_helpers.dart';
|
||||
part 'thread_detail_page/tail_alignment.dart';
|
||||
part 'thread_detail_page/thread_message.dart';
|
||||
part 'thread_detail_page/avatar.dart';
|
||||
|
||||
/// Full-screen thread detail page.
|
||||
///
|
||||
@@ -72,6 +73,8 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appView = View.of(context);
|
||||
final composerDockHeight = useState(0.0);
|
||||
final composerFocusNode = useFocusNode();
|
||||
final restoreComposerFocus = useRef<VoidCallback?>(null);
|
||||
final settledImeBottomInset = useState(
|
||||
usesFixedAndroidImeViewport
|
||||
? appView.viewInsets.bottom / appView.devicePixelRatio
|
||||
@@ -635,6 +638,9 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
isThreadHead: true,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: () =>
|
||||
restoreComposerFocus.value?.call(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -714,6 +720,9 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: () =>
|
||||
restoreComposerFocus.value?.call(),
|
||||
),
|
||||
if (nestedSummary != null)
|
||||
_NestedThreadSummaryRow(
|
||||
@@ -750,6 +759,9 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
_ThreadTypingIndicator(entries: threadTyping),
|
||||
ComposeBar(
|
||||
channelId: channelId,
|
||||
focusNode: composerFocusNode,
|
||||
onFocusRestorerChanged: (restoreFocus) =>
|
||||
restoreComposerFocus.value = restoreFocus,
|
||||
hintText: 'Reply in thread\u2026',
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
|
||||
28
mobile/lib/features/channels/thread_detail_page/avatar.dart
Normal file
28
mobile/lib/features/channels/thread_detail_page/avatar.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({required this.profile, required this.pubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: messageAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
class _ThreadMessage extends ConsumerWidget {
|
||||
class _ThreadMessage extends HookConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final Map<String, String> channelNames;
|
||||
final String channelId;
|
||||
@@ -10,6 +10,8 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
final FocusNode? composerFocusNode;
|
||||
final VoidCallback? restoreComposerFocus;
|
||||
|
||||
/// Whether this is the message the thread hangs off, which keeps a standing
|
||||
/// "+" where replies only get one once they carry a reaction.
|
||||
@@ -26,10 +28,13 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
this.isThreadHead = false,
|
||||
this.composerFocusNode,
|
||||
this.restoreComposerFocus,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
@@ -67,7 +72,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
void openMessageActions(MessageLongPressDetails details) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -78,7 +83,12 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
anchorRect: details.anchorRect,
|
||||
captureAnchorSnapshot: details.captureSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: details.setSourceHidden,
|
||||
onPopoverDismissed: () => details.setSourceHidden(false),
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,151 +111,174 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _Avatar(profile: profile, pubkey: message.pubkey),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _Avatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton:
|
||||
isMember &&
|
||||
!isArchived &&
|
||||
(isThreadHead || message.reactions.isNotEmpty),
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isThreadHead || message.reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
),
|
||||
child: ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -255,30 +288,3 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({required this.profile, required this.pubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: messageAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,8 +1478,19 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.text('Copy text'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -6214,7 +6225,9 @@ void main() {
|
||||
const ValueKey('thread-message-group-reply-29'),
|
||||
);
|
||||
final composer = find.byKey(const ValueKey('composer-surface'));
|
||||
await tester.drag(list, const Offset(0, 24));
|
||||
// Clear the gesture arena's touch slop so this represents a deliberate
|
||||
// tail-detaching drag rather than a long-press hold with small motion.
|
||||
await tester.drag(list, const Offset(0, 48));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getBottomLeft(latest).dy,
|
||||
|
||||
@@ -182,6 +182,9 @@ Widget _buildComposeBar({
|
||||
RelayConfigNotifier Function()? relayConfig,
|
||||
PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(),
|
||||
VoidCallback? onFocusRequested,
|
||||
FocusNode? focusNode,
|
||||
ValueChanged<VoidCallback>? onFocusRestorerChanged,
|
||||
String composeBarKey = 'compose-bar',
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
@@ -229,7 +232,10 @@ Widget _buildComposeBar({
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposeBar(
|
||||
key: ValueKey(composeBarKey),
|
||||
channelId: 'channel-1',
|
||||
focusNode: focusNode,
|
||||
onFocusRestorerChanged: onFocusRestorerChanged,
|
||||
onFocusRequested: onFocusRequested,
|
||||
onSend: onSend,
|
||||
),
|
||||
@@ -560,6 +566,184 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('uses a parent-owned focus node when provided', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
focusNode: focusNode,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).focusNode,
|
||||
same(focusNode),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('restores the collapsed editor before requesting focus', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
VoidCallback? restoreFocus;
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
focusNode: focusNode,
|
||||
onFocusRestorerChanged: (callback) => restoreFocus = callback,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
focusNode.unfocus();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
restoreFocus!();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('keeps hook order when the parent focus node changes', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
focusNode: focusNode,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('invalidates a registered focus restorer on unmount', (
|
||||
tester,
|
||||
) async {
|
||||
final callbacks = <VoidCallback>[];
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onFocusRestorerChanged: callbacks.add,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
final registeredRestorer = callbacks.single;
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
|
||||
registeredRestorer();
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('does not let an old restorer mutate a replacement composer', (
|
||||
tester,
|
||||
) async {
|
||||
final callbacks = <VoidCallback>[];
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
composeBarKey: 'first-compose-bar',
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onFocusRestorerChanged: callbacks.add,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
final oldRestorer = callbacks.single;
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
composeBarKey: 'second-compose-bar',
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onFocusRestorerChanged: callbacks.add,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
expect(callbacks, hasLength(2));
|
||||
|
||||
oldRestorer();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
callbacks.last();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('starts Android composer motion with the first IME metrics', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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/shared/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/shared/reminders/reminder_service.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -180,8 +185,928 @@ Future<void> _pumpImageSheet(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<ui.Image> _testMessageSnapshot() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(recorder);
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTWH(0, 0, 300, 72),
|
||||
ui.Paint()..color = const Color(0xffeeeeee),
|
||||
);
|
||||
return recorder.endRecording().toImage(300, 72);
|
||||
}
|
||||
|
||||
class _MessageActionsPopoverHarness {
|
||||
final ProviderContainer container;
|
||||
final ValueNotifier<bool> sourceHidden;
|
||||
|
||||
const _MessageActionsPopoverHarness({
|
||||
required this.container,
|
||||
required this.sourceHidden,
|
||||
});
|
||||
}
|
||||
|
||||
Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover(
|
||||
WidgetTester tester, {
|
||||
required TimelineMessage message,
|
||||
required SharedPreferences prefs,
|
||||
ReadStateNotifier Function()? readStateOverride,
|
||||
bool canManageMessage = false,
|
||||
List<TimelineMessage>? allMessages,
|
||||
ReminderService? reminderService,
|
||||
bool disableAnimations = false,
|
||||
EdgeInsets viewInsets = EdgeInsets.zero,
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
FocusNode? composerFocusNode,
|
||||
bool composerInitiallyFocused = false,
|
||||
bool launcherOnNestedRoute = false,
|
||||
ChannelActions Function(Ref ref)? createChannelActions,
|
||||
Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72),
|
||||
}) async {
|
||||
final sourceHidden = ValueNotifier(false);
|
||||
|
||||
Widget launcherPage() => Scaffold(
|
||||
key: const ValueKey('message-actions-underlying-page'),
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => Column(
|
||||
children: [
|
||||
if (composerFocusNode != null)
|
||||
TextField(focusNode: composerFocusNode),
|
||||
TextButton(
|
||||
key: const ValueKey('open-message-actions-popover'),
|
||||
onPressed: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: _channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot:
|
||||
captureAnchorSnapshot ?? _testMessageSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: (visible) =>
|
||||
sourceHidden.value = visible,
|
||||
onPopoverDismissed: () => sourceHidden.value = false,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: composerFocusNode?.requestFocus,
|
||||
),
|
||||
child: const Text('open message actions'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
readStateProvider.overrideWith(
|
||||
readStateOverride ??
|
||||
() => _FakeReadStateNotifier(
|
||||
_readState(const {_channelId: 100000}),
|
||||
),
|
||||
),
|
||||
reminderServiceProvider.overrideWithValue(reminderService),
|
||||
if (createChannelActions != null)
|
||||
channelActionsProvider.overrideWith(createChannelActions),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
disableAnimations: disableAnimations,
|
||||
viewInsets: viewInsets,
|
||||
textScaler: textScaler,
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
home: launcherOnNestedRoute
|
||||
? Builder(
|
||||
builder: (context) => Scaffold(
|
||||
key: const ValueKey('message-actions-root-page'),
|
||||
body: TextButton(
|
||||
key: const ValueKey('push-message-actions-launcher'),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => launcherPage()),
|
||||
),
|
||||
child: const Text('push launcher'),
|
||||
),
|
||||
),
|
||||
)
|
||||
: launcherPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (launcherOnNestedRoute) {
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('push-message-actions-launcher')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
if (composerInitiallyFocused) {
|
||||
composerFocusNode!.requestFocus();
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.byKey(const ValueKey('open-message-actions-popover')));
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byKey(const ValueKey('open-message-actions-popover'))),
|
||||
);
|
||||
return _MessageActionsPopoverHarness(
|
||||
container: container,
|
||||
sourceHidden: sourceHidden,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissMessageActionsPopover(WidgetTester tester) async {
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('message-action-surface'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _FakeChannelActions extends ChannelActions {
|
||||
final reactions = <({String eventId, String emoji})>[];
|
||||
|
||||
_FakeChannelActions(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 {
|
||||
reactions.add((eventId: eventId, emoji: emoji));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'message long press keeps taps and scrolling while repeated holds win',
|
||||
(tester) async {
|
||||
var parentTaps = 0;
|
||||
var nestedTaps = 0;
|
||||
var longPresses = 0;
|
||||
final scrollController = ScrollController();
|
||||
addTearDown(scrollController.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('parent-gesture-target'),
|
||||
onTap: () => parentTaps += 1,
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: const SizedBox(height: 80, width: 300),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
child: MessageLongPressInkWell(
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('nested-gesture-target'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => nestedTaps += 1,
|
||||
child: const SizedBox(height: 80, width: 300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 900),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('parent-gesture-target')));
|
||||
await tester.tap(find.byKey(const ValueKey('nested-gesture-target')));
|
||||
await tester.pump();
|
||||
expect(parentTaps, 1);
|
||||
expect(nestedTaps, 1);
|
||||
|
||||
for (var index = 0; index < 5; index++) {
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('nested-gesture-target')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(longPresses, index + 1);
|
||||
expect(nestedTaps, 1);
|
||||
}
|
||||
|
||||
final drag = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))),
|
||||
);
|
||||
await drag.moveBy(const Offset(0, -30));
|
||||
await tester.pump();
|
||||
await drag.moveBy(const Offset(0, -70));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await drag.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(longPresses, 5);
|
||||
expect(scrollController.offset, greaterThan(0));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS message long press recognizes at 200 ms', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
var longPresses = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('ios-long-press-target'),
|
||||
onLongPress: (_) => longPresses += 1,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('ios-long-press-target'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 199));
|
||||
expect(longPresses, 0);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
expect(longPresses, 1);
|
||||
await gesture.up();
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'message long press captures the content inside the ink surface',
|
||||
(tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
MessageLongPressDetails? longPressDetails;
|
||||
ui.Image? snapshot;
|
||||
addTearDown(() => snapshot?.dispose());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('snapshot-gesture-target'),
|
||||
onLongPressDetails: (details) => longPressDetails = details,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('snapshot-gesture-target')),
|
||||
);
|
||||
expect(longPressDetails, isNotNull);
|
||||
|
||||
final capture = longPressDetails!.captureSnapshot();
|
||||
await tester.pump();
|
||||
snapshot = await capture;
|
||||
|
||||
expect(snapshot.width, 240);
|
||||
expect(snapshot.height, 80);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('message snapshot bounds very tall raster dimensions', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(800, 5000);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
MessageLongPressDetails? longPressDetails;
|
||||
ui.Image? snapshot;
|
||||
addTearDown(() => snapshot?.dispose());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('tall-snapshot-gesture-target'),
|
||||
onLongPressDetails: (details) => longPressDetails = details,
|
||||
child: const SizedBox(width: 240, height: 4096),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('tall-snapshot-gesture-target')),
|
||||
);
|
||||
expect(longPressDetails, isNotNull);
|
||||
|
||||
final capture = longPressDetails!.captureSnapshot();
|
||||
await tester.pump();
|
||||
snapshot = await capture;
|
||||
|
||||
expect(snapshot.width, 120);
|
||||
expect(snapshot.height, 2048);
|
||||
});
|
||||
|
||||
testWidgets('message snapshot can exclude attached reaction content', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final snapshotKey = GlobalKey();
|
||||
MessageLongPressDetails? longPressDetails;
|
||||
ui.Image? snapshot;
|
||||
addTearDown(() => snapshot?.dispose());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Material(
|
||||
child: MessageLongPressInkWell(
|
||||
key: const ValueKey('separate-snapshot-gesture-target'),
|
||||
snapshotKey: snapshotKey,
|
||||
onLongPressDetails: (details) => longPressDetails = details,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
key: snapshotKey,
|
||||
child: const SizedBox(width: 240, height: 80),
|
||||
),
|
||||
Listener(
|
||||
key: const ValueKey('attached-reactions'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const SizedBox(width: 240, height: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(find.byKey(const ValueKey('attached-reactions')));
|
||||
expect(longPressDetails, isNotNull);
|
||||
expect(longPressDetails!.anchorRect.height, 80);
|
||||
|
||||
final capture = longPressDetails!.captureSnapshot();
|
||||
await tester.pump();
|
||||
snapshot = await capture;
|
||||
|
||||
expect(snapshot.width, 240);
|
||||
expect(snapshot.height, 80);
|
||||
});
|
||||
|
||||
group('showMessageActions', () {
|
||||
testWidgets('composes the tray, lifted preview, and compact actions', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
expect(harness.sourceHidden.value, isTrue);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.text('Reply'), findsOneWidget);
|
||||
expect(find.text('Copy link'), findsOneWidget);
|
||||
expect(find.text('Remind me'), findsOneWidget);
|
||||
expect(find.text('Follow thread'), findsOneWidget);
|
||||
|
||||
final trayRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
);
|
||||
final previewRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
);
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
final trayMaterial = tester.widget<Material>(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
);
|
||||
final actionMaterial = tester.widget<Material>(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
expect(previewRect.top, greaterThan(trayRect.bottom));
|
||||
expect(actionRect.top, greaterThan(previewRect.bottom));
|
||||
expect(previewRect.left, trayRect.left);
|
||||
expect(actionRect.left, trayRect.left);
|
||||
expect(actionRect.width, 288);
|
||||
expect(actionMaterial.color, trayMaterial.color);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
expect(harness.sourceHidden.value, isFalse);
|
||||
});
|
||||
|
||||
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||
testWidgets(
|
||||
'${platform.name} composition keeps the action menu near the safe bottom',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
try {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
final logicalHeight =
|
||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1));
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('keeps the action menu above the software keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
const keyboardInset = 300.0;
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
|
||||
);
|
||||
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
final logicalHeight =
|
||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
expect(
|
||||
actionRect.bottom,
|
||||
closeTo(logicalHeight - keyboardInset - Grid.xxs, 0.1),
|
||||
);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('collapses fixed sections in a short keyboard viewport', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(800, 220);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
const keyboardInset = 100.0;
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(harness.sourceHidden.value, isFalse);
|
||||
final actionRect = tester.getRect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
);
|
||||
expect(actionRect.top, greaterThanOrEqualTo(Grid.xxs));
|
||||
expect(
|
||||
actionRect.bottom,
|
||||
lessThanOrEqualTo(220 - keyboardInset - Grid.xxs),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('keeps tall message previews within the visible viewport', (
|
||||
tester,
|
||||
) async {
|
||||
const keyboardInset = 300.0;
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(pubkey: 'self'),
|
||||
prefs: prefs,
|
||||
canManageMessage: true,
|
||||
allMessages: [_message(pubkey: 'self')],
|
||||
reminderService: _stubReminderService(),
|
||||
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
|
||||
anchorRect: const Rect.fromLTWH(32, 40, 300, 2000),
|
||||
);
|
||||
|
||||
final logicalHeight =
|
||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
final visibleBottom = logicalHeight - keyboardInset - Grid.xxs;
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const ValueKey('message-action-preview')))
|
||||
.top,
|
||||
greaterThanOrEqualTo(Grid.xxs),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const ValueKey('message-action-surface')))
|
||||
.bottom,
|
||||
lessThanOrEqualTo(visibleBottom),
|
||||
);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('restores composer focus only after a dismissed popover', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final prefs = await _mockPrefs();
|
||||
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
composerFocusNode: focusNode,
|
||||
composerInitiallyFocused: true,
|
||||
);
|
||||
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('leaves an initially unfocused composer unfocused', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final prefs = await _mockPrefs();
|
||||
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
composerFocusNode: focusNode,
|
||||
);
|
||||
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('does not restore composer focus after opening reactions', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final prefs = await _mockPrefs();
|
||||
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
composerFocusNode: focusNode,
|
||||
composerInitiallyFocused: true,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('quick-reaction-more')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('does not restore composer focus after selecting an action', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final prefs = await _mockPrefs();
|
||||
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(rootId: 'root-9'),
|
||||
prefs: prefs,
|
||||
composerFocusNode: focusNode,
|
||||
composerInitiallyFocused: true,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-action-followThread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('runs an action after dismissal and can reopen', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(rootId: 'root-9'),
|
||||
prefs: prefs,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-action-followThread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.sourceHidden.value, isFalse);
|
||||
expect(harness.container.read(threadFollowsProvider).followedRootIds, {
|
||||
'root-9',
|
||||
});
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('open-message-actions-popover')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('ignores repeat action taps once dismissal starts', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
final harness = await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(rootId: 'root-9'),
|
||||
prefs: prefs,
|
||||
launcherOnNestedRoute: true,
|
||||
);
|
||||
final action = find.byKey(const ValueKey('message-action-followThread'));
|
||||
final actionWidget = tester.widget<InkWell>(action);
|
||||
|
||||
actionWidget.onTap!.call();
|
||||
actionWidget.onTap!.call();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-underlying-page')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-root-page')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(harness.container.read(threadFollowsProvider).followedRootIds, {
|
||||
'root-9',
|
||||
});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('ignores repeat backdrop taps once dismissal starts', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
launcherOnNestedRoute: true,
|
||||
);
|
||||
final backdrop = tester.widget<GestureDetector>(
|
||||
find.byKey(const ValueKey('message-actions-backdrop')),
|
||||
);
|
||||
|
||||
backdrop.onTap!.call();
|
||||
backdrop.onTap!.call();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-underlying-page')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-root-page')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('ignores repeat quick reactions once dismissal starts', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
late _FakeChannelActions actions;
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
launcherOnNestedRoute: true,
|
||||
createChannelActions: (ref) => actions = _FakeChannelActions(ref),
|
||||
);
|
||||
final reaction = find.byKey(const ValueKey('quick-reaction-\u{1F44D}'));
|
||||
final detector = tester.widget<GestureDetector>(
|
||||
find.descendant(of: reaction, matching: find.byType(GestureDetector)),
|
||||
);
|
||||
|
||||
detector.onTap!.call();
|
||||
detector.onTap!.call();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-underlying-page')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-actions-root-page')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(actions.reactions, [(eventId: 'msg-1', emoji: '\u{1F44D}')]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('fallback action rows grow with accessibility text', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(rootId: 'root-9'),
|
||||
prefs: prefs,
|
||||
textScaler: const TextScaler.linear(3),
|
||||
);
|
||||
|
||||
final rowFinder = find.byKey(
|
||||
const ValueKey('message-action-followThread'),
|
||||
);
|
||||
expect(tester.getSize(rowFinder).height, greaterThan(48));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders primary, utility, and destructive action groups', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
canManageMessage: true,
|
||||
allMessages: [_message()],
|
||||
reminderService: _stubReminderService(),
|
||||
);
|
||||
|
||||
const actionIds = [
|
||||
'reply',
|
||||
'markUnread',
|
||||
'edit',
|
||||
'copyText',
|
||||
'copyLink',
|
||||
'remind',
|
||||
'followThread',
|
||||
'delete',
|
||||
];
|
||||
final actionTops = [
|
||||
for (final actionId in actionIds)
|
||||
tester
|
||||
.getTopLeft(find.byKey(ValueKey('message-action-$actionId')))
|
||||
.dy,
|
||||
];
|
||||
expect(actionTops, orderedEquals([...actionTops]..sort()));
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-divider-utility')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-divider-destructive')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion presents the complete surface immediately', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
disableAnimations: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-reaction-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-surface')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await _dismissMessageActionsPopover(tester);
|
||||
});
|
||||
|
||||
testWidgets('keeps the snapshot alive through the reverse transition', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
ui.Image? snapshot;
|
||||
try {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpMessageActionsPopover(
|
||||
tester,
|
||||
message: _message(),
|
||||
prefs: prefs,
|
||||
captureAnchorSnapshot: () async {
|
||||
snapshot = await _testMessageSnapshot();
|
||||
return snapshot!;
|
||||
},
|
||||
);
|
||||
|
||||
final image = snapshot!;
|
||||
expect(image.debugDisposed, isFalse);
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('message-action-surface'))),
|
||||
).pop();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-action-preview')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(image.debugDisposed, isFalse);
|
||||
await tester.pump(const Duration(milliseconds: 110));
|
||||
expect(image.debugDisposed, isFalse);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(image.debugDisposed, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
final image = snapshot;
|
||||
if (image != null && !image.debugDisposed) image.dispose();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shows parity actions for a regular message', (tester) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpSheet(tester, message: _message(), prefs: prefs);
|
||||
|
||||
Reference in New Issue
Block a user