checkpoint: preserve iOS message action experiment

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-09 13:01:52 +01:00
parent 5bf78671f4
commit 30c01bae81
18 changed files with 3640 additions and 711 deletions

View File

@@ -14,6 +14,7 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
4A71C0092F40500100A17E01 /* NativeMessageActionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -59,6 +60,7 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionsCoordinator.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -182,6 +184,7 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* NativeMessageActionsCoordinator.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -417,6 +420,7 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* NativeMessageActionsCoordinator.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);

View File

@@ -10,6 +10,7 @@ import UserNotifications
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -95,6 +96,26 @@ import UserNotifications
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
if #available(iOS 16.0, *),
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMessageActionSurface"
) {
nativeMessageActionsRegistrar.register(
NativeMessageActionSurfaceFactory(messenger: messenger),
withId: "buzz/native_message_action_surface"
)
nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
name: "buzz/native_message_action_surface",
binaryMessenger: messenger
)
nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
result(true)
}
}
}
private static func handleQrScannerMethodCall(

View File

@@ -0,0 +1,432 @@
import Flutter
import UIKit
struct NativeMessageActionDefinition: Equatable {
enum Group: String, CaseIterable {
case promoted
case triage
case export
case manage
}
let id: String
let title: String
let symbol: String
let group: Group
let isDestructive: Bool
init?(arguments: [String: Any]) {
guard
let id = arguments["id"] as? String,
let title = arguments["title"] as? String,
let symbol = arguments["symbol"] as? String,
let groupName = arguments["group"] as? String,
let group = Group(rawValue: groupName)
else {
return nil
}
self.id = id
self.title = title
self.symbol = symbol
self.group = group
isDestructive = arguments["destructive"] as? Bool ?? false
}
}
enum NativeMessageActionSurfaceLayout {
static let surfaceWidth: CGFloat = 288
static let promotedHeight: CGFloat = 72
static let rowHeight: CGFloat = 50
static let separatorHeight: CGFloat = 0.5
static let verticalInset: CGFloat = 4
static let rowHorizontalInset: CGFloat = 18
static let separatorHorizontalInset: CGFloat = rowHorizontalInset
static let promotedHorizontalInset: CGFloat = separatorHorizontalInset
static let rowIconColumnWidth: CGFloat = 24
static let rowIconToTextSpacing: CGFloat = 14
static let rowVerticalInset: CGFloat = 8
static func populatedGroups(
actions: [NativeMessageActionDefinition]
) -> [NativeMessageActionDefinition.Group] {
NativeMessageActionDefinition.Group.allCases.filter { group in
actions.contains { $0.group == group }
}
}
static func separatorCount(
actions: [NativeMessageActionDefinition]
) -> Int {
max(0, populatedGroups(actions: actions).count - 1)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition]
) -> CGFloat {
let promoted = actions.contains { $0.group == .promoted }
let rowCount = actions.filter { $0.group != .promoted }.count
return (verticalInset * 2)
+ (promoted ? promotedHeight : 0)
+ (CGFloat(rowCount) * rowHeight)
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSeparatorView: UIView {
let lineView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
).isActive = true
lineView.backgroundColor = .separator
lineView.translatesAutoresizingMaskIntoConstraints = false
addSubview(lineView)
NSLayoutConstraint.activate([
lineView.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.separatorHorizontalInset
),
lineView.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.separatorHorizontalInset
),
lineView.topAnchor.constraint(equalTo: topAnchor),
lineView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
}
@available(iOS 16.0, *)
final class NativeMessageActionRowControl: UIControl {
let actionImageView: UIImageView
let actionTitleLabel: UILabel
init(
definition: NativeMessageActionDefinition,
onSelected: @escaping () -> Void
) {
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
actionTitleLabel = UILabel()
super.init(frame: .zero)
let foregroundColor: UIColor = definition.isDestructive ? .systemRed : .label
actionImageView.tintColor = foregroundColor
actionImageView.contentMode = .center
actionImageView.preferredSymbolConfiguration =
NativeMessageActionSurfaceStyle.defaultSymbolConfiguration
actionTitleLabel.text = definition.title
actionTitleLabel.textColor = foregroundColor
actionTitleLabel.font = NativeMessageActionSurfaceStyle.rowFont
actionTitleLabel.adjustsFontForContentSizeCategory = true
actionTitleLabel.numberOfLines = 0
let iconColumn = UIView()
iconColumn.translatesAutoresizingMaskIntoConstraints = false
actionImageView.translatesAutoresizingMaskIntoConstraints = false
iconColumn.addSubview(actionImageView)
NSLayoutConstraint.activate([
iconColumn.widthAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.rowIconColumnWidth
),
iconColumn.heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.rowIconColumnWidth
),
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
])
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(iconColumn)
addSubview(actionTitleLabel)
NSLayoutConstraint.activate([
iconColumn.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.rowHorizontalInset
),
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
actionTitleLabel.leadingAnchor.constraint(
equalTo: iconColumn.trailingAnchor,
constant: NativeMessageActionSurfaceLayout.rowIconToTextSpacing
),
actionTitleLabel.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.rowHorizontalInset
),
actionTitleLabel.topAnchor.constraint(
equalTo: topAnchor,
constant: NativeMessageActionSurfaceLayout.rowVerticalInset
),
actionTitleLabel.bottomAnchor.constraint(
equalTo: bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.rowVerticalInset
),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight
),
])
accessibilityLabel = definition.title
accessibilityTraits = .button
isAccessibilityElement = true
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
override var isHighlighted: Bool {
didSet {
backgroundColor =
isHighlighted
? UIColor.label.withAlphaComponent(0.08)
: .clear
}
}
}
enum NativeMessageActionSurfaceStyle {
static let promotedTextStyle = UIFont.TextStyle.footnote
static let rowTextStyle = UIFont.TextStyle.body
static var promotedFont: UIFont {
UIFont.preferredFont(forTextStyle: promotedTextStyle)
}
static var rowFont: UIFont {
UIFont.preferredFont(forTextStyle: rowTextStyle)
}
static var defaultSymbolConfiguration: UIImage.SymbolConfiguration? {
nil
}
}
@available(iOS 16.0, *)
final class NativeMessageActionPromotedRow: UIView {
let buttons: [UIButton]
init(
actions: [NativeMessageActionDefinition],
onSelected: @escaping (NativeMessageActionDefinition) -> Void
) {
buttons = actions.map { definition in
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: definition.symbol)
configuration.imagePlacement = .top
configuration.imagePadding = 5
configuration.baseForegroundColor = .label
configuration.contentInsets = NSDirectionalEdgeInsets(
top: NativeMessageActionSurfaceLayout.rowVerticalInset,
leading: 4,
bottom: NativeMessageActionSurfaceLayout.rowVerticalInset,
trailing: 4
)
configuration.title = definition.title
configuration.preferredSymbolConfigurationForImage =
NativeMessageActionSurfaceStyle.defaultSymbolConfiguration
configuration.titleTextAttributesTransformer =
UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = NativeMessageActionSurfaceStyle.promotedFont
return outgoing
}
let button = UIButton(configuration: configuration)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.accessibilityLabel = definition.title
button.addAction(
UIAction { _ in onSelected(definition) },
for: .touchUpInside
)
return button
}
super.init(frame: .zero)
let stack = UIStackView(arrangedSubviews: buttons)
stack.axis = .horizontal
stack.distribution = .fillEqually
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.promotedHorizontalInset
),
stack.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.promotedHorizontalInset
),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.promotedHeight
),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfaceFactory: NSObject,
FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeMessageActionSurfacePlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfacePlatformView: NSObject,
FlutterPlatformView
{
private let surfaceView: UIVisualEffectView
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
surfaceView = UIVisualEffectView(
effect: UIBlurEffect(style: .systemMaterial)
)
channel = FlutterMethodChannel(
name: "buzz/native_message_action_surface/\(viewId)",
binaryMessenger: messenger
)
super.init()
surfaceView.frame = frame
surfaceView.clipsToBounds = true
surfaceView.layer.cornerRadius = 26
surfaceView.layer.cornerCurve = .continuous
surfaceView.layer.borderWidth = 0.5
surfaceView.layer.borderColor = UIColor.separator.withAlphaComponent(0.45).cgColor
surfaceView.accessibilityViewIsModal = true
let actionArguments = (args as? [String: Any])?["actions"] as? [[String: Any]]
let actions =
actionArguments?.compactMap(
NativeMessageActionDefinition.init(arguments:)
) ?? []
install(actions: actions)
}
func view() -> UIView {
surfaceView
}
private func install(actions: [NativeMessageActionDefinition]) {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
surfaceView.contentView.addSubview(scrollView)
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: surfaceView.contentView.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: surfaceView.contentView.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: surfaceView.contentView.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: surfaceView.contentView.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.topAnchor,
constant: NativeMessageActionSurfaceLayout.verticalInset
),
stack.bottomAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.verticalInset
),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
var installedGroup = false
for group in NativeMessageActionDefinition.Group.allCases {
let groupActions = actions.filter { $0.group == group }
guard !groupActions.isEmpty else { continue }
if installedGroup {
stack.addArrangedSubview(makeSeparator())
}
if group == .promoted {
stack.addArrangedSubview(makePromotedRow(actions: groupActions))
} else {
for definition in groupActions {
stack.addArrangedSubview(makeActionRow(definition: definition))
}
}
installedGroup = true
}
}
private func makePromotedRow(
actions: [NativeMessageActionDefinition]
) -> UIView {
NativeMessageActionPromotedRow(actions: actions) { [weak self] definition in
self?.select(definition)
}
}
private func makeActionRow(
definition: NativeMessageActionDefinition
) -> UIView {
NativeMessageActionRowControl(definition: definition) { [weak self] in
self?.select(definition)
}
}
private func makeSeparator() -> UIView {
NativeMessageActionSeparatorView()
}
private func select(_ definition: NativeMessageActionDefinition) {
channel.invokeMethod("selected", arguments: ["id": definition.id])
}
}

View File

@@ -7,6 +7,289 @@ import XCTest
class RunnerTests: XCTestCase {
@available(iOS 16.0, *)
@MainActor
func testNativeMessageActionsUseTheComposedNativeSurfaceLayout() throws {
let definitions = try [
makeNativeMessageAction(
id: "reply",
title: "Reply",
symbol: "arrowshape.turn.up.left",
group: "promoted"
),
makeNativeMessageAction(
id: "copyLink",
title: "Copy link",
symbol: "link",
group: "promoted"
),
makeNativeMessageAction(
id: "remindMe",
title: "Remind me",
symbol: "bell",
group: "promoted"
),
makeNativeMessageAction(
id: "markUnread",
title: "Mark unread",
symbol: "envelope.badge",
group: "triage"
),
makeNativeMessageAction(
id: "copyText",
title: "Copy text",
symbol: "doc.on.doc",
group: "export"
),
makeNativeMessageAction(
id: "edit",
title: "Edit message",
symbol: "pencil",
group: "manage"
),
makeNativeMessageAction(
id: "delete",
title: "Delete message",
symbol: "trash",
group: "manage",
destructive: true
),
]
XCTAssertEqual(
NativeMessageActionSurfaceLayout.preferredHeight(actions: definitions),
281.5
)
XCTAssertEqual(NativeMessageActionSurfaceLayout.surfaceWidth, 288)
XCTAssertEqual(
NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
[.promoted, .triage, .export, .manage]
)
XCTAssertEqual(
NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
3
)
XCTAssertEqual(definitions.first?.group, .promoted)
XCTAssertTrue(definitions.last?.isDestructive == true)
}
func testNativeMessageActionSurfaceUsesDynamicSystemTypographyAndSymbols() {
XCTAssertEqual(
NativeMessageActionSurfaceStyle.promotedTextStyle,
.footnote
)
XCTAssertEqual(NativeMessageActionSurfaceStyle.rowTextStyle, .body)
XCTAssertEqual(
NativeMessageActionSurfaceStyle.promotedFont.fontDescriptor.object(
forKey: .textStyle
) as? String,
UIFont.TextStyle.footnote.rawValue
)
XCTAssertEqual(
NativeMessageActionSurfaceStyle.rowFont.fontDescriptor.object(
forKey: .textStyle
) as? String,
UIFont.TextStyle.body.rawValue
)
XCTAssertNil(NativeMessageActionSurfaceStyle.defaultSymbolConfiguration)
}
@available(iOS 16.0, *)
@MainActor
func testNativeMessageActionRowsShareIconAndTextColumns() throws {
let definitions = try [
makeNativeMessageAction(
id: "markUnread",
title: "Mark unread",
symbol: "envelope.badge",
group: "triage"
),
makeNativeMessageAction(
id: "followThread",
title: "Follow thread",
symbol: "bell",
group: "triage"
),
makeNativeMessageAction(
id: "copyText",
title: "Copy text",
symbol: "doc.on.doc",
group: "export"
),
]
let rows = definitions.map {
NativeMessageActionRowControl(definition: $0, onSelected: {})
}
for row in rows {
row.frame = CGRect(
x: 0,
y: 0,
width: NativeMessageActionSurfaceLayout.surfaceWidth,
height: NativeMessageActionSurfaceLayout.rowHeight
)
row.layoutIfNeeded()
}
let iconAlignmentCenters = rows.map {
let frame = $0.actionImageView.convert($0.actionImageView.bounds, to: $0)
return frame.inset(by: $0.actionImageView.alignmentRectInsets).midX
}
let textLeadingEdges = rows.map {
$0.actionTitleLabel.convert($0.actionTitleLabel.bounds, to: $0).minX
}
let expectedIconCenter =
NativeMessageActionSurfaceLayout.rowHorizontalInset
+ (NativeMessageActionSurfaceLayout.rowIconColumnWidth / 2)
let expectedTextLeading =
NativeMessageActionSurfaceLayout.rowHorizontalInset
+ NativeMessageActionSurfaceLayout.rowIconColumnWidth
+ NativeMessageActionSurfaceLayout.rowIconToTextSpacing
for iconCenter in iconAlignmentCenters {
XCTAssertEqual(iconCenter, expectedIconCenter, accuracy: 0.01)
}
for textLeadingEdge in textLeadingEdges {
XCTAssertEqual(textLeadingEdge, expectedTextLeading, accuracy: 0.01)
}
XCTAssertTrue(
rows.allSatisfy {
$0.actionTitleLabel.adjustsFontForContentSizeCategory
})
}
@available(iOS 16.0, *)
@MainActor
func testNativeMessageActionSeparatorsShareGeometry() {
let separators = [
NativeMessageActionSeparatorView(),
NativeMessageActionSeparatorView(),
NativeMessageActionSeparatorView(),
]
for separator in separators {
separator.frame = CGRect(
x: 0,
y: 0,
width: NativeMessageActionSurfaceLayout.surfaceWidth,
height: NativeMessageActionSurfaceLayout.separatorHeight
)
separator.layoutIfNeeded()
}
let lineFrames = separators.map { $0.lineView.frame }
XCTAssertTrue(lineFrames.dropFirst().allSatisfy { $0 == lineFrames.first })
XCTAssertEqual(
lineFrames.first?.minX,
NativeMessageActionSurfaceLayout.separatorHorizontalInset
)
XCTAssertEqual(
lineFrames.first?.width,
NativeMessageActionSurfaceLayout.surfaceWidth
- (NativeMessageActionSurfaceLayout.separatorHorizontalInset * 2)
)
XCTAssertEqual(
lineFrames.first?.height,
NativeMessageActionSurfaceLayout.separatorHeight
)
}
@available(iOS 16.0, *)
@MainActor
func testPromotedActionsStayInsideTheDividerBoundsAndShareWidth() throws {
let definitions = try [
makeNativeMessageAction(
id: "reply",
title: "Reply",
symbol: "arrowshape.turn.up.left",
group: "promoted"
),
makeNativeMessageAction(
id: "copyLink",
title: "Copy link",
symbol: "link",
group: "promoted"
),
makeNativeMessageAction(
id: "remindMe",
title: "Remind me",
symbol: "bell",
group: "promoted"
),
]
let row = NativeMessageActionPromotedRow(
actions: definitions,
onSelected: { _ in }
)
row.frame = CGRect(
x: 0,
y: 0,
width: NativeMessageActionSurfaceLayout.surfaceWidth,
height: NativeMessageActionSurfaceLayout.promotedHeight
)
row.layoutIfNeeded()
let buttonFrames = row.buttons.map { button in
button.convert(button.bounds, to: row)
}
let firstFrame = try XCTUnwrap(buttonFrames.first)
let lastFrame = try XCTUnwrap(buttonFrames.last)
let expectedWidth =
(NativeMessageActionSurfaceLayout.surfaceWidth
- (NativeMessageActionSurfaceLayout.promotedHorizontalInset * 2))
/ CGFloat(definitions.count)
XCTAssertEqual(
firstFrame.minX,
NativeMessageActionSurfaceLayout.separatorHorizontalInset,
accuracy: 0.01
)
XCTAssertEqual(
lastFrame.maxX,
NativeMessageActionSurfaceLayout.surfaceWidth
- NativeMessageActionSurfaceLayout.separatorHorizontalInset,
accuracy: 0.01
)
for frame in buttonFrames {
XCTAssertEqual(frame.width, expectedWidth, accuracy: 0.01)
}
let remindMeLabel = try XCTUnwrap(row.buttons.last?.titleLabel)
let remindMeInsets = try XCTUnwrap(row.buttons.last?.configuration).contentInsets
XCTAssertLessThanOrEqual(
remindMeLabel.intrinsicContentSize.width
+ remindMeInsets.leading
+ remindMeInsets.trailing,
lastFrame.width
)
for index in 0..<(buttonFrames.count - 1) {
XCTAssertEqual(
buttonFrames[index].maxX,
buttonFrames[index + 1].minX,
accuracy: 0.01
)
}
}
private func makeNativeMessageAction(
id: String,
title: String,
symbol: String,
group: String,
destructive: Bool = false
) throws -> NativeMessageActionDefinition {
try XCTUnwrap(
NativeMessageActionDefinition(
arguments: [
"id": id,
"title": title,
"symbol": symbol,
"group": group,
"destructive": destructive,
]
)
)
}
func testRelativeTrackInsertionTimesPreserveAudioDelay() {
let times = AppDelegate.relativeTrackInsertionTimes(
videoStart: CMTime(seconds: 1, preferredTimescale: 600),

View File

@@ -1,9 +1,10 @@
import 'dart:async';
import 'dart:math' show min;
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/rendering.dart'
show RenderRepaintBoundary, ScrollDirection;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';

View File

@@ -1,6 +1,6 @@
part of '../channel_detail_page.dart';
class _MessageBubble extends ConsumerWidget {
class _MessageBubble extends HookConsumerWidget {
final TimelineMessage message;
final bool showAuthor;
final Map<String, String> channelNames;
@@ -23,6 +23,27 @@ class _MessageBubble extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final actionSnapshotKey = useMemoized(GlobalKey.new, const []);
final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context);
final nativeMenuPresented = useState(false);
Future<ui.Image> captureActionSnapshot() async {
await WidgetsBinding.instance.endOfFrame;
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
throw StateError('Message snapshot is unavailable');
}
return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio);
}
Rect? actionSnapshotRect() {
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
return null;
}
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final profile =
@@ -33,7 +54,6 @@ class _MessageBubble extends ConsumerWidget {
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
@@ -64,7 +84,9 @@ class _MessageBubble extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);
void openMessageActions(Rect anchorRect) {
void openMessageActions(Rect _) {
final anchorRect = actionSnapshotRect();
if (anchorRect == null) return;
showMessageActions(
context: context,
ref: ref,
@@ -76,6 +98,9 @@ class _MessageBubble extends ConsumerWidget {
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureActionSnapshot,
onPopoverPresented: () => nativeMenuPresented.value = true,
onPopoverDismissed: () => nativeMenuPresented.value = false,
);
}
@@ -88,170 +113,206 @@ class _MessageBubble extends ConsumerWidget {
// gutter. InkWell still clips its ink to [borderRadius], while leaving
// overflowing message content visible.
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
// Tap opens the thread; long-press still opens the action sheet.
// MessageContent handles mention, channel-link, and media taps.
onTap: allMessages == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: Opacity(
key: ValueKey('message-native-source-${message.id}'),
opacity: nativeMenuPresented.value ? 0 : 1,
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
// Tap opens the thread; long-press still opens the action sheet.
// MessageContent handles mention, channel-link, and media taps.
onTap: allMessages == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
),
),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(context, message.pubkey),
child: _UserAvatar(
profile: profile,
pubkey: message.pubkey,
),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
),
nameColor: context.colors.onSurface,
metadataColor:
context.colors.onSurfaceVariant,
onAuthorTap: () => showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'message-author-${message.id}',
),
usernameKey: ValueKey(
'message-username-${message.id}',
),
timestampKey: ValueKey(
'message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
KeyedSubtree(
key: ValueKey('message-action-snapshot-${message.id}'),
child: RepaintBoundary(
key: actionSnapshotKey,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: _UserAvatar(
profile: profile,
pubkey: message.pubkey,
),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.half : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(
profile,
),
timestamp: formatMessageTime(
message.createdAt,
),
nameColor:
context.colors.onSurface,
metadataColor: context
.colors
.onSurfaceVariant,
onAuthorTap: () =>
showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'message-author-${message.id}',
),
usernameKey: ValueKey(
'message-username-${message.id}',
),
timestampKey: ValueKey(
'message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context
.textTheme
.labelSmall
?.copyWith(
color: context
.colors
.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId:
currentChannelId,
currentPubkey:
currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(
viewerContext,
).maybePop();
}
},
),
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
],
),
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
],
],
),
),
),
),
],
if (message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
),
child: ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
],
),
),
),
),

View File

@@ -574,7 +574,7 @@ class _JumpToLatestButton extends StatelessWidget {
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: const ValueKey('channel-jump-to-latest-surface'),
decoration: BoxDecoration(

View File

@@ -21,10 +21,29 @@ class _SystemMessageRow extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotlightKey = useMemoized(() => GlobalKey());
final actionSnapshotKey = useMemoized(GlobalKey.new, const []);
final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context);
final popoverPresented = useState(false);
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();
Future<ui.Image> captureActionSnapshot() async {
await WidgetsBinding.instance.endOfFrame;
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
throw StateError('System message snapshot is unavailable');
}
return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio);
}
Rect? actionSnapshotRect() {
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
return null;
}
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
final userCache = ref.watch(userCacheProvider);
final sourceMessages = groupedMessages ?? [message];
final groupedMembership = _membershipDisplayEvent(sourceMessages);
@@ -79,13 +98,7 @@ class _SystemMessageRow extends HookConsumerWidget {
}
void openReactionPopover(Rect anchorRect) {
final spotlightRenderObject = spotlightKey.currentContext
?.findRenderObject();
final spotlightRect =
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
? spotlightRenderObject.localToGlobal(Offset.zero) &
spotlightRenderObject.size
: anchorRect;
final spotlightRect = actionSnapshotRect() ?? anchorRect;
showMessageActions(
context: context,
ref: ref,
@@ -97,12 +110,15 @@ class _SystemMessageRow extends HookConsumerWidget {
isMember: isMember,
isArchived: isArchived,
anchorRect: spotlightRect,
captureAnchorSnapshot: captureActionSnapshot,
popoverSpotlightPadding: EdgeInsets.fromLTRB(
Grid.xxs,
Grid.xxs,
Grid.xxs,
reactions.isEmpty ? Grid.xxs : Grid.quarter,
),
onPopoverPresented: () => popoverPresented.value = true,
onPopoverDismissed: () => popoverPresented.value = false,
);
}
@@ -110,78 +126,82 @@ class _SystemMessageRow extends HookConsumerWidget {
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: MessageLongPressInkWell(
key: ValueKey('system-message-row-${message.id}'),
onLongPress: openReactionPopover,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
KeyedSubtree(
key: spotlightKey,
child: groupedMembership != null
? _MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
: messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null
? _MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
: Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
child: Opacity(
key: ValueKey('system-message-native-source-${message.id}'),
opacity: popoverPresented.value ? 0 : 1,
child: MessageLongPressInkWell(
key: ValueKey('system-message-row-${message.id}'),
onLongPress: openReactionPopover,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RepaintBoundary(
key: actionSnapshotKey,
child: groupedMembership != null
? _MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
: messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null
? _MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
: Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
_messageTimestamp(
context,
message.createdAt,
key: ValueKey(
'system-message-timestamp-${message.id}',
_messageTimestamp(
context,
message.createdAt,
key: ValueKey(
'system-message-timestamp-${message.id}',
),
),
),
],
),
),
if (reactions.isNotEmpty)
Padding(
padding: EdgeInsets.only(
left:
(usesMessageStyleLayout ? messageAvatarSize : 36) +
(usesMessageStyleLayout
? messageAvatarContentGap
: Grid.xxs),
),
child: ReactionRow(
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
],
),
),
],
if (reactions.isNotEmpty)
Padding(
padding: EdgeInsets.only(
left:
(usesMessageStyleLayout ? messageAvatarSize : 36) +
(usesMessageStyleLayout
? messageAvatarContentGap
: Grid.xxs),
),
child: ReactionRow(
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
),
],
),
),
),
),

View File

@@ -1,12 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
@@ -37,6 +38,9 @@ import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
part 'message_actions/reaction_popover.dart';
part 'message_actions/ios_native_message_actions.dart';
part 'message_actions/ios_message_actions_popover.dart';
part 'message_actions/message_actions_sheet.dart';
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
@@ -53,10 +57,38 @@ void showMessageActions({
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
Future<ui.Image> Function()? captureAnchorSnapshot,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
VoidCallback? onPopoverPresented,
VoidCallback? onPopoverDismissed,
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
if (Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosReactionOnlyPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
}),
);
return;
}
_showMessageReactionPopover(
context: context,
ref: ref,
@@ -67,109 +99,53 @@ void showMessageActions({
return;
}
showBuzzModalBottomSheet<void>(
if (anchorRect != null &&
Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosMessageActionsPopover(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}),
);
return;
}
_showMessageActionsSheet(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
),
),
),
),
),
),
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}
@@ -644,6 +620,11 @@ class _FastActionTile extends StatelessWidget {
/// The emoji shown are the user's own frequently-used set (desktop's
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
/// the row is full on a fresh install.
const _quickReactionEmojiLimit = 5;
const _quickReactionDesiredTargetSize = 52.0;
const _quickReactionMinimumTargetSize = 44.0;
const _quickReactionGlyphSize = 28.0;
class _QuickReactionRow extends ConsumerWidget {
final TimelineMessage message;
@@ -672,22 +653,26 @@ class _QuickReactionRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usesPopoverTreatment = presentationAnimation != null;
final customEmoji = ref.watch(customEmojiListProvider);
final emoji = quickReactionEmoji(
ref.watch(recentEmojiProvider),
customShortcodes: {
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
},
limit: _quickReactionEmojiLimit,
);
final customByShortcode = {
for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
};
void react(String value) {
// The generic picker is also used for composing and statuses. Record
// recency here, at the reaction call site, so only reactions drive the
// quick-reaction row.
pageRef.read(recentEmojiProvider.notifier).record(value);
// Keep the composed popover's tray stable across repeated opens. The
// existing sheet continues to update the frequently-used ranking exactly
// as it did before the iOS presentation was introduced.
if (!usesPopoverTreatment) {
pageRef.read(recentEmojiProvider.notifier).record(value);
}
// The sheet is on its way out, so the burst can't come from this tile —
// hand it to the pill that's about to appear in the timeline.
armReactionBurst(pageRef, message, value);
@@ -696,17 +681,19 @@ class _QuickReactionRow extends ConsumerWidget {
return LayoutBuilder(
builder: (context, constraints) {
const desiredCircleSize = 52.0;
const minimumCircleSize = 44.0;
final maximumGap = usesPopoverTreatment ? 0.0 : Grid.twelve;
final itemCount = emoji.length + 1;
final gapCount = itemCount - 1;
final circleSize =
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
.clamp(minimumCircleSize, desiredCircleSize)
((constraints.maxWidth - (maximumGap * gapCount)) / itemCount)
.clamp(
_quickReactionMinimumTargetSize,
_quickReactionDesiredTargetSize,
)
.toDouble();
final gap =
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
.clamp(0.0, Grid.twelve)
.clamp(0.0, maximumGap)
.toDouble();
final circles = <Widget>[
for (var index = 0; index < emoji.length; index++)
@@ -716,6 +703,8 @@ class _QuickReactionRow extends ConsumerWidget {
index: index,
child: _QuickReactionCircle(
size: circleSize,
semanticLabel: emoji[index],
showIdleBackground: !usesPopoverTreatment,
onTap: () {
Navigator.of(sheetContext).pop();
react(emoji[index]);
@@ -732,12 +721,14 @@ class _QuickReactionRow extends ConsumerWidget {
index: emoji.length,
child: _QuickReactionCircle(
size: circleSize,
semanticLabel: 'Add reaction',
showIdleBackground: !usesPopoverTreatment,
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(context: pageContext, onSelect: react);
},
child: Icon(
LucideIcons.plus,
LucideIcons.smilePlus,
size: 24,
color: context.colors.onSurfaceVariant,
),
@@ -817,47 +808,60 @@ class _QuickReactionGlyph extends StatelessWidget {
return CustomEmojiImage(
shortcode: custom.shortcode,
url: custom.url,
size: 24,
size: _quickReactionGlyphSize,
);
}
}
return NativeEmojiGlyph(emoji: value, size: 24);
return NativeEmojiGlyph(emoji: value, size: _quickReactionGlyphSize);
}
}
/// Circular filled tap target shared by the quick-reaction emojis and the
/// "+" emoji-picker tile — one treatment, one place to change it.
/// Circular tap target shared by the quick-reaction emojis and the "+"
/// emoji-picker tile. Popovers keep the idle surface transparent while sheets
/// retain their filled treatment; both preserve the same hit and press areas.
class _QuickReactionCircle extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final double size;
final String semanticLabel;
final bool showIdleBackground;
const _QuickReactionCircle({
required this.onTap,
required this.child,
required this.size,
required this.semanticLabel,
required this.showIdleBackground,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color.lerp(
context.colors.surface,
context.colors.primaryContainer,
0.78,
final filledColor = Color.lerp(
context.colors.surface,
context.colors.primaryContainer,
0.78,
);
return Semantics(
button: true,
label: semanticLabel,
excludeSemantics: true,
child: SizedBox.square(
dimension: size,
child: Material(
color: showIdleBackground ? filledColor : Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
highlightColor: context.colors.primary.withValues(alpha: 0.12),
splashColor: context.colors.primary.withValues(alpha: 0.14),
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
child: Center(child: child),
),
shape: BoxShape.circle,
),
child: child,
),
);
}

View File

@@ -0,0 +1,590 @@
part of '../message_actions.dart';
const _iosActionPromotedHeight = 72.0;
const _iosActionRowHeight = 50.0;
const _iosActionSeparatorHeight = 0.5;
const _iosActionVerticalInset = 4.0;
const _iosContextGap = Grid.twelve;
const _iosContextMenuMaxWidth = 288.0;
const _iosContextPreviewMaxWidth = 358.0;
const _iosContextPreviewInset = Grid.xxs;
const _iosContextTransitionDuration = Duration(milliseconds: 240);
bool _iosMessageActionsPresentationInFlight = false;
Future<bool> _showIosReactionOnlyPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required Rect anchorRect,
required Future<ui.Image> Function() captureAnchorSnapshot,
required EdgeInsets spotlightPadding,
required VoidCallback? onPopoverPresented,
required VoidCallback? onPopoverDismissed,
}) async {
if (_iosMessageActionsPresentationInFlight) return true;
_iosMessageActionsPresentationInFlight = true;
try {
final ui.Image snapshot;
try {
snapshot = await captureAnchorSnapshot();
} catch (_) {
return false;
}
if (!context.mounted) {
snapshot.dispose();
return false;
}
unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
onPopoverPresented?.call();
try {
await showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss reaction picker',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: _iosContextTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_IosMessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
spotlightPadding: spotlightPadding,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
actions: const [],
),
);
} finally {
snapshot.dispose();
if (context.mounted) onPopoverDismissed?.call();
}
return true;
} finally {
_iosMessageActionsPresentationInFlight = false;
}
}
Future<bool> _showIosMessageActionsPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
required Rect anchorRect,
required Future<ui.Image> Function() captureAnchorSnapshot,
required EdgeInsets spotlightPadding,
required VoidCallback? onPopoverPresented,
required VoidCallback? onPopoverDismissed,
}) async {
if (_iosMessageActionsPresentationInFlight) return true;
_iosMessageActionsPresentationInFlight = true;
try {
return await _presentIosMessageActionsPopover(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: spotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
);
} finally {
_iosMessageActionsPresentationInFlight = false;
}
}
Future<bool> _presentIosMessageActionsPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
required Rect anchorRect,
required Future<ui.Image> Function() captureAnchorSnapshot,
required EdgeInsets spotlightPadding,
required VoidCallback? onPopoverPresented,
required VoidCallback? onPopoverDismissed,
}) async {
if (!await _supportsIosNativeMessageActionSurface() || !context.mounted) {
return false;
}
final actions = _buildIosNativeMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
if (actions.isEmpty) return false;
final ui.Image snapshot;
try {
snapshot = await captureAnchorSnapshot();
} catch (_) {
return false;
}
if (!context.mounted) {
snapshot.dispose();
return false;
}
unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
onPopoverPresented?.call();
String? selectedActionId;
try {
selectedActionId = await showGeneralDialog<String>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss message actions',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: _iosContextTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_IosMessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
spotlightPadding: spotlightPadding,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
actions: actions,
),
);
} finally {
snapshot.dispose();
if (context.mounted) onPopoverDismissed?.call();
}
final selectedAction = actions
.where((action) => action.id == selectedActionId)
.firstOrNull;
if (selectedAction != null) await selectedAction.onSelected();
return true;
}
class _IosMessageActionsPopover extends StatelessWidget {
final Rect anchorRect;
final ui.Image anchorSnapshot;
final EdgeInsets spotlightPadding;
final Animation<double> animation;
final TimelineMessage message;
final BuildContext pageContext;
final WidgetRef pageRef;
final List<_IosNativeMessageAction> actions;
const _IosMessageActionsPopover({
required this.anchorRect,
required this.anchorSnapshot,
required this.spotlightPadding,
required this.animation,
required this.message,
required this.pageContext,
required this.pageRef,
required this.actions,
});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final safeLeft = mediaQuery.padding.left + Grid.xxs;
final safeRight =
constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
final safeTop = mediaQuery.padding.top + Grid.xxs;
final safeBottom =
constraints.maxHeight - mediaQuery.padding.bottom - Grid.xxs;
final availableWidth = safeRight - safeLeft;
final availableHeight = safeBottom - safeTop;
final hasActionMenu = actions.isNotEmpty;
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
final menuWidth = hasActionMenu
? math.min(_iosContextMenuMaxWidth, availableWidth)
: 0.0;
final menuHeight = hasActionMenu
? math.min(
_iosActionSurfacePreferredHeight(actions),
math.max(
_iosActionRowHeight * 3,
availableHeight -
_reactionTrayMaxHeight -
(_iosContextGap * 2) -
56,
),
)
: 0.0;
final contextGapCount = hasActionMenu ? 2 : 1;
final previewMaximumHeight = math.max(
56,
availableHeight -
_reactionTrayMaxHeight -
menuHeight -
(_iosContextGap * contextGapCount),
);
final previewInsetExtent = _iosContextPreviewInset * 2;
final previewScale = math.min(
1,
math.min(
(math.min(_iosContextPreviewMaxWidth, availableWidth) -
previewInsetExtent) /
anchorRect.width,
(previewMaximumHeight - previewInsetExtent) / anchorRect.height,
),
);
final previewContentSize = Size(
anchorRect.width * previewScale,
anchorRect.height * previewScale,
);
final previewSize = Size(
previewContentSize.width + previewInsetExtent,
previewContentSize.height + previewInsetExtent,
);
final trayLeft = safeLeft + ((availableWidth - trayWidth) / 2);
final previewLeft =
safeLeft + ((availableWidth - previewSize.width) / 2);
final totalHeight =
_reactionTrayMaxHeight +
_iosContextGap +
previewSize.height +
(hasActionMenu ? _iosContextGap + menuHeight : 0);
final contentTop = safeTop + ((availableHeight - totalHeight) / 2);
final targetPreviewRect = Rect.fromLTWH(
previewLeft,
contentTop + _reactionTrayMaxHeight + _iosContextGap,
previewSize.width,
previewSize.height,
);
final menuRect = hasActionMenu
? Rect.fromLTWH(
safeLeft + ((availableWidth - menuWidth) / 2),
targetPreviewRect.bottom + _iosContextGap,
menuWidth,
menuHeight,
)
: null;
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final progress = Curves.easeOutCubic.transform(
animation.value,
);
return BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 14 * progress,
sigmaY: 14 * progress,
),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.12 * progress,
),
),
);
},
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
),
),
AnimatedBuilder(
animation: animation,
builder: (context, child) {
final movement = Curves.easeInOutCubic.transform(
animation.value,
);
final displayRect = Rect.lerp(
anchorRect.inflate(_iosContextPreviewInset),
targetPreviewRect,
movement,
)!;
return Positioned.fromRect(rect: displayRect, child: child!);
},
child: DecoratedBox(
key: const ValueKey('message-action-preview-container'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(_iosContextPreviewInset),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.xs),
child: RawImage(
image: anchorSnapshot,
fit: BoxFit.fill,
filterQuality: FilterQuality.medium,
),
),
),
),
),
Positioned(
left: trayLeft,
top: contentTop,
width: trayWidth,
height: _reactionTrayMaxHeight,
child: _IosReactionTray(
animation: animation,
trayWidth: trayWidth,
message: message,
pageContext: pageContext,
pageRef: pageRef,
),
),
if (menuRect != null)
Positioned.fromRect(
rect: menuRect,
child: AnimatedBuilder(
animation: animation,
child: _IosNativeMessageActionSurface(
actions: actions,
onSelected: (actionId) =>
Navigator.of(context).pop(actionId),
),
builder: (context, child) {
final appearance = const Interval(
0.12,
0.72,
curve: Curves.easeOutCubic,
).transform(animation.value);
return Opacity(
opacity: appearance,
child: Transform.scale(
alignment: Alignment.topLeft,
scale: ui.lerpDouble(0.96, 1, appearance)!,
child: child,
),
);
},
),
),
],
);
},
);
}
}
class _IosReactionTray extends StatelessWidget {
final Animation<double> animation;
final double trayWidth;
final TimelineMessage message;
final BuildContext pageContext;
final WidgetRef pageRef;
const _IosReactionTray({
required this.animation,
required this.trayWidth,
required this.message,
required this.pageContext,
required this.pageRef,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: _QuickReactionRow(
message: message,
sheetContext: context,
pageContext: pageContext,
pageRef: pageRef,
presentationAnimation: animation,
),
),
builder: (context, child) {
final appearance = const Interval(
0.02,
0.34,
curve: Curves.easeOutCubic,
).transform(animation.value);
final expansion = _reactionSpringCurve.transform(
const Interval(0.08, 0.92).transform(animation.value),
);
return Opacity(
opacity: appearance,
child: Transform.scale(
alignment: Alignment.centerLeft,
scale: ui.lerpDouble(0.95, 1, appearance)!,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
key: const ValueKey('reaction-popover-tray'),
width: ui.lerpDouble(
_reactionTrayMaxHeight,
trayWidth,
expansion,
),
height: _reactionTrayMaxHeight,
child: Material(
color: context.colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.2),
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: trayWidth,
maxWidth: trayWidth,
minHeight: _reactionTrayMaxHeight,
maxHeight: _reactionTrayMaxHeight,
child: child,
),
),
),
),
),
);
},
);
}
}
class _IosNativeMessageActionSurface extends HookWidget {
final List<_IosNativeMessageAction> actions;
final ValueChanged<String> onSelected;
const _IosNativeMessageActionSurface({
required this.actions,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
if (!Platform.isIOS) {
return _TestIosNativeMessageActionSurface(
actions: actions,
onSelected: onSelected,
);
}
final viewId = useState<int?>(null);
useEffect(() {
final id = viewId.value;
if (id == null) return null;
final channel = MethodChannel('buzz/native_message_action_surface/$id');
channel.setMethodCallHandler((call) async {
if (call.method != 'selected' || call.arguments is! Map) return;
final actionId = (call.arguments as Map)['id'];
if (actionId is String) onSelected(actionId);
});
return () => channel.setMethodCallHandler(null);
}, [viewId.value, onSelected]);
return ClipRRect(
borderRadius: BorderRadius.circular(26),
child: UiKitView(
key: const ValueKey('ios-native-message-action-surface'),
viewType: 'buzz/native_message_action_surface',
creationParams: {
'actions': [
for (final action in actions) action.toPlatformArguments(),
],
},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) => viewId.value = id,
),
);
}
}
class _TestIosNativeMessageActionSurface extends StatelessWidget {
final List<_IosNativeMessageAction> actions;
final ValueChanged<String> onSelected;
const _TestIosNativeMessageActionSurface({
required this.actions,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('ios-native-message-action-surface'),
color: context.colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(26),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in actions)
SizedBox(
height: action.group == _IosNativeMessageActionGroup.promoted
? _iosActionPromotedHeight
: _iosActionRowHeight,
child: ListTile(
key: ValueKey('ios-native-action-${action.id}'),
title: Text(action.title),
onTap: () => onSelected(action.id),
),
),
],
),
),
);
}
}
double _iosActionSurfacePreferredHeight(List<_IosNativeMessageAction> actions) {
final groups = {for (final action in actions) action.group};
final promoted = groups.contains(_IosNativeMessageActionGroup.promoted);
final rowCount = actions
.where((action) => action.group != _IosNativeMessageActionGroup.promoted)
.length;
return (_iosActionVerticalInset * 2) +
(promoted ? _iosActionPromotedHeight : 0) +
(rowCount * _iosActionRowHeight) +
(math.max(0, groups.length - 1) * _iosActionSeparatorHeight);
}

View File

@@ -0,0 +1,233 @@
part of '../message_actions.dart';
const _nativeMessageActionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
List<_IosNativeMessageAction> _buildIosNativeMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
}) {
final actions = <_IosNativeMessageAction>[];
final messages = allMessages;
final canRemind = ref.read(reminderServiceProvider) != null;
if (!message.isSystem) {
if (messages != null) {
actions.add(
_IosNativeMessageAction(
id: 'reply',
title: 'Reply',
symbol: 'arrowshape.turn.up.left',
group: _IosNativeMessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
);
}
actions.add(
_IosNativeMessageAction(
id: 'copyLink',
title: 'Copy link',
symbol: 'link',
group: _IosNativeMessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
copyToClipboard(
context,
messageLinkFor(message: message, channelId: channelId),
message: 'Message link copied',
);
},
),
);
if (canRemind) {
actions.add(
_IosNativeMessageAction(
id: 'remind',
title: 'Remind me',
symbol: 'clock',
group: _IosNativeMessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
showRemindMeLaterSheet(
context: Navigator.of(context, rootNavigator: true).context,
ref: ref,
target: ReminderTarget(
eventId: message.id,
channelId: channelId,
preview: message.content.characters
.take(_reminderPreviewLength)
.toString(),
authorPubkey: message.pubkey,
),
);
},
),
);
}
final readState = ref.read(readStateProvider);
if (readState.isReady) {
final unread = isMessageUnread(
readState,
channelId: channelId,
messageId: message.id,
createdAt: message.createdAt,
threadRootId: message.rootId,
);
actions.add(
_IosNativeMessageAction(
id: unread ? 'markRead' : 'markUnread',
title: unread ? 'Mark read' : 'Mark unread',
symbol: unread ? 'envelope.open' : 'envelope.badge',
group: _IosNativeMessageActionGroup.triage,
onSelected: () {
final notifier = ref.read(readStateProvider.notifier);
if (unread) {
notifier.markContextRead(
msgContextKey(message.id),
message.createdAt,
);
} else {
notifier.markContextUnread(
msgContextKey(message.id),
channelId: channelId,
);
}
},
),
);
}
final rootId = message.rootId ?? message.id;
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
actions.add(
_IosNativeMessageAction(
id: following ? 'unfollowThread' : 'followThread',
title: following ? 'Unfollow thread' : 'Follow thread',
symbol: following ? 'bell.slash' : 'bell',
group: _IosNativeMessageActionGroup.triage,
onSelected: () {
final notifier = ref.read(threadFollowsProvider.notifier);
if (following) {
notifier.unfollowThread(rootId);
} else {
notifier.followThread(rootId);
}
},
),
);
actions.add(
_IosNativeMessageAction(
id: 'copyText',
title: 'Copy text',
symbol: 'doc.on.doc',
group: _IosNativeMessageActionGroup.export,
onSelected: () =>
Clipboard.setData(ClipboardData(text: message.content)),
),
);
}
if (canManageMessage) {
actions.add(
_IosNativeMessageAction(
id: 'edit',
title: 'Edit message',
symbol: 'pencil',
group: _IosNativeMessageActionGroup.manage,
onSelected: () {
if (!context.mounted) return;
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
);
actions.add(
_IosNativeMessageAction(
id: 'delete',
title: 'Delete message',
symbol: 'trash',
group: _IosNativeMessageActionGroup.manage,
destructive: true,
onSelected: () {
if (!context.mounted) return;
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
);
}
return actions;
}
enum _IosNativeMessageActionGroup { promoted, triage, export, manage }
class _IosNativeMessageAction {
final String id;
final String title;
final String symbol;
final _IosNativeMessageActionGroup group;
final bool destructive;
final FutureOr<void> Function() onSelected;
const _IosNativeMessageAction({
required this.id,
required this.title,
required this.symbol,
required this.group,
required this.onSelected,
this.destructive = false,
});
Map<String, Object> toPlatformArguments() => {
'id': id,
'title': title,
'symbol': symbol,
'group': group.name,
'destructive': destructive,
};
}
Future<bool> _supportsIosNativeMessageActionSurface() async {
try {
return await _nativeMessageActionSurfaceChannel.invokeMethod<bool>(
'isSupported',
) ??
false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}

View File

@@ -0,0 +1,117 @@
part of '../message_actions.dart';
void _showMessageActionsSheet({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
),
),
),
),
),
),
);
}

View File

@@ -1,8 +1,11 @@
part of '../message_actions.dart';
const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2);
const _reactionTrayMaxWidth =
(_quickReactionDesiredTargetSize * (_quickReactionEmojiLimit + 1)) +
(Grid.xxs * 2);
const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2);
const _reactionTraySpringAllowance = 16.0;
const _reactionTrayMessageGap = Grid.twelve;
const _reactionPopoverDuration = Duration(milliseconds: 320);
final _reactionSpringCurve = _SpringEaseOutCurve(
duration: const Duration(milliseconds: 260),
@@ -13,37 +16,75 @@ final _reactionSpringCurve = _SpringEaseOutCurve(
///
/// The conversation is frosted around [anchorRect] so the selected message
/// remains visually connected to the tray.
void _showMessageReactionPopover({
_MessageReactionPopoverHandle _showMessageReactionPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required Rect anchorRect,
Rect? originalAnchorRect,
ui.Image? anchorSnapshot,
required EdgeInsets spotlightPadding,
bool alignTrayToAnchorStart = false,
bool triggerHaptic = true,
}) {
unawaited(HapticFeedback.mediumImpact());
if (triggerHaptic) unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
showGeneralDialog<void>(
final contextReady = Completer<BuildContext>();
final closed = showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss reaction picker',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) => child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_MessageReactionPopover(
anchorRect: anchorRect,
spotlightPadding: spotlightPadding,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
),
pageBuilder: (dialogContext, animation, secondaryAnimation) {
if (!contextReady.isCompleted) contextReady.complete(dialogContext);
return _MessageReactionPopover(
anchorRect: anchorRect,
originalAnchorRect: originalAnchorRect ?? anchorRect,
anchorSnapshot: anchorSnapshot,
spotlightPadding: spotlightPadding,
alignTrayToAnchorStart: alignTrayToAnchorStart,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
);
},
);
return _MessageReactionPopoverHandle(
contextReady: contextReady.future,
closed: closed,
);
}
class _MessageReactionPopover extends StatelessWidget {
class _MessageReactionPopoverHandle {
final Future<BuildContext> contextReady;
final Future<void> closed;
bool _isOpen = true;
_MessageReactionPopoverHandle({
required this.contextReady,
required this.closed,
}) {
unawaited(closed.whenComplete(() => _isOpen = false));
}
Future<void> close() async {
if (!_isOpen) return;
_isOpen = false;
final context = await contextReady;
if (context.mounted) Navigator.of(context).pop();
await closed;
}
}
class _MessageReactionPopover extends HookWidget {
final Rect anchorRect;
final Rect originalAnchorRect;
final ui.Image? anchorSnapshot;
final EdgeInsets spotlightPadding;
final bool alignTrayToAnchorStart;
final Animation<double> animation;
final TimelineMessage message;
final BuildContext pageContext;
@@ -51,7 +92,10 @@ class _MessageReactionPopover extends StatelessWidget {
const _MessageReactionPopover({
required this.anchorRect,
required this.originalAnchorRect,
required this.anchorSnapshot,
required this.spotlightPadding,
required this.alignTrayToAnchorStart,
required this.animation,
required this.message,
required this.pageContext,
@@ -60,6 +104,11 @@ class _MessageReactionPopover extends StatelessWidget {
@override
Widget build(BuildContext context) {
useEffect(() {
// The popover owns the transient snapshot. Releasing it when this widget
// unmounts keeps the image alive through the complete dismissal paint.
return () => anchorSnapshot?.dispose();
}, [anchorSnapshot]);
final mediaQuery = MediaQuery.of(context);
return LayoutBuilder(
@@ -69,15 +118,17 @@ class _MessageReactionPopover extends StatelessWidget {
constraints.maxHeight -
mediaQuery.padding.bottom -
mediaQuery.viewInsets.bottom;
final availableAbove = anchorRect.top - Grid.xxs - safeTop;
final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs;
final availableAbove =
anchorRect.top - _reactionTrayMessageGap - safeTop;
final availableBelow =
safeBottom - anchorRect.bottom - _reactionTrayMessageGap;
final showAbove =
availableAbove >= _reactionTrayMaxHeight ||
(availableBelow < _reactionTrayMaxHeight &&
availableAbove >= availableBelow);
final proposedTop = showAbove
? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs
: anchorRect.bottom + Grid.xxs;
? anchorRect.top - _reactionTrayMaxHeight - _reactionTrayMessageGap
: anchorRect.bottom + _reactionTrayMessageGap;
final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight);
final top = proposedTop.clamp(safeTop, maxTop).toDouble();
final trayScaleAlignment = showAbove
@@ -88,9 +139,10 @@ class _MessageReactionPopover extends StatelessWidget {
constraints.maxWidth - (Grid.xxs * 2),
);
final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs;
final left = (anchorRect.center.dx - (trayWidth / 2))
.clamp(Grid.xxs, maxLeft)
.toDouble();
final preferredLeft = alignTrayToAnchorStart
? anchorRect.left
: anchorRect.center.dx - (trayWidth / 2);
final left = preferredLeft.clamp(Grid.xxs, maxLeft).toDouble();
return Stack(
children: [
@@ -104,24 +156,81 @@ class _MessageReactionPopover extends StatelessWidget {
curve: Curves.easeOutCubic,
).transform(animation.value);
final sigma = 20 * blurProgress;
final background = BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.10 * blurProgress,
),
),
);
if (anchorSnapshot != null) return background;
return ClipPath(
key: const ValueKey('reaction-popover-background'),
clipper: _OutsideAnchorClipper(
anchorRect,
spotlightPadding,
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.10 * blurProgress,
),
),
),
child: background,
);
},
),
),
if (anchorSnapshot != null)
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final movement = const Interval(
0,
0.56,
curve: Curves.easeInOutCubic,
).transform(animation.value);
final displayRect = Rect.lerp(
originalAnchorRect,
anchorRect,
movement,
)!;
return Stack(
children: [
Positioned.fromRect(
rect: displayRect,
child: DecoratedBox(
key: const ValueKey(
'message-action-preview-container',
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant
.withValues(alpha: 0.65),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: RawImage(
image: anchorSnapshot,
fit: BoxFit.fill,
filterQuality: FilterQuality.medium,
),
),
),
),
],
);
},
),
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -162,7 +271,7 @@ class _MessageReactionPopover extends StatelessWidget {
final springExpansion = _reactionSpringCurve.transform(
expansion,
);
final width = lerpDouble(
final width = ui.lerpDouble(
_reactionTrayMaxHeight,
trayWidth,
springExpansion,
@@ -172,7 +281,7 @@ class _MessageReactionPopover extends StatelessWidget {
opacity: appearance,
child: Transform.scale(
alignment: trayScaleAlignment,
scale: lerpDouble(0.95, 1, appearance)!,
scale: ui.lerpDouble(0.95, 1, appearance)!,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(

View File

@@ -1,8 +1,4 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// An [InkWell] whose long press is observed above interactive descendants.
class MessageLongPressInkWell extends StatelessWidget {
@@ -35,12 +31,12 @@ class MessageLongPressInkWell extends StatelessWidget {
}
}
/// Detects a message long press without competing with interactive descendants.
/// Detects a message long press through Flutter's gesture arena.
///
/// Links, media, reactions, and other nested controls keep their normal tap
/// gestures. Moving far enough to scroll cancels the timer; recognizing the
/// hold cancels the pointer so a descendant tap cannot fire on release.
class _MessageLongPressRegion extends HookWidget {
/// gestures. A drag lets the surrounding scrollable win, while a completed
/// hold rejects descendant taps without manually cancelling the pointer.
class _MessageLongPressRegion extends StatelessWidget {
final ValueChanged<Rect> onLongPress;
final Widget child;
@@ -51,60 +47,17 @@ class _MessageLongPressRegion extends HookWidget {
@override
Widget build(BuildContext context) {
final activePointer = useRef<int?>(null);
final origin = useRef<Offset?>(null);
final timer = useRef<Timer?>(null);
void cancel() {
timer.value?.cancel();
timer.value = null;
activePointer.value = null;
origin.value = null;
}
useEffect(() => cancel, const []);
void recognize() {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
}
void handlePointerDown(PointerDownEvent event) {
if (activePointer.value != null) return;
activePointer.value = event.pointer;
origin.value = event.position;
timer.value = Timer(kLongPressTimeout, () {
final pointer = activePointer.value;
if (pointer == null) return;
timer.value = null;
activePointer.value = null;
origin.value = null;
GestureBinding.instance.cancelPointer(pointer);
recognize();
});
}
void handlePointerMove(PointerMoveEvent event) {
if (event.pointer != activePointer.value) return;
final start = origin.value;
if (start == null) return;
final delta = event.position - start;
if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
}
void handlePointerEnd(PointerEvent event) {
if (event.pointer == activePointer.value) cancel();
}
return Semantics(
onLongPress: recognize,
child: Listener(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
onLongPressStart: (_) => recognize(),
child: child,
),
);

View File

@@ -1,4 +1,7 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@@ -34,6 +37,8 @@ import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'timeline_message.dart';
part 'thread_detail_page/nested_thread_summary_row.dart';
/// Full-screen thread detail page.
///
/// Shows the thread head message, direct replies, typing indicators scoped to
@@ -592,116 +597,6 @@ ThreadSummary _buildNestedSummary(
/// Tappable summary row shown below a reply that itself has replies.
/// Pushes a new [ThreadDetailPage] for the nested thread.
class _NestedThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage replyMessage;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _NestedThreadSummaryRow({
required this.summary,
required this.replyMessage,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width:
32.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.primary,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
@@ -711,7 +606,7 @@ class _ThreadTailMetricsObserver with WidgetsBindingObserver {
void didChangeMetrics() => onMetricsChanged();
}
class _ThreadMessage extends ConsumerWidget {
class _ThreadMessage extends HookConsumerWidget {
final TimelineMessage message;
final Map<String, String> channelNames;
final String channelId;
@@ -741,6 +636,27 @@ class _ThreadMessage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final actionSnapshotKey = useMemoized(GlobalKey.new, const []);
final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context);
final nativeMenuPresented = useState(false);
Future<ui.Image> captureActionSnapshot() async {
await WidgetsBinding.instance.endOfFrame;
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
throw StateError('Message snapshot is unavailable');
}
return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio);
}
Rect? actionSnapshotRect() {
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
return null;
}
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
@@ -750,7 +666,6 @@ class _ThreadMessage extends ConsumerWidget {
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
@@ -778,7 +693,9 @@ class _ThreadMessage extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);
void openMessageActions(Rect anchorRect) {
void openMessageActions(Rect _) {
final anchorRect = actionSnapshotRect();
if (anchorRect == null) return;
showMessageActions(
context: context,
ref: ref,
@@ -790,6 +707,9 @@ class _ThreadMessage extends ConsumerWidget {
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureActionSnapshot,
onPopoverPresented: () => nativeMenuPresented.value = true,
onPopoverDismissed: () => nativeMenuPresented.value = false,
);
}
@@ -810,154 +730,196 @@ class _ThreadMessage extends ConsumerWidget {
// trailing gutter. InkWell still clips its ink to [borderRadius],
// while leaving overflowing message content visible.
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
),
nameColor: context.colors.onSurface,
metadataColor:
context.colors.onSurfaceVariant,
onAuthorTap: () => showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'thread-message-author-${message.id}',
),
usernameKey: ValueKey(
'thread-message-username-${message.id}',
),
timestampKey: ValueKey(
'thread-message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: Opacity(
key: ValueKey('thread-message-native-source-${message.id}'),
opacity: nativeMenuPresented.value ? 0 : 1,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
KeyedSubtree(
key: ValueKey(
'thread-message-action-snapshot-${message.id}',
),
child: RepaintBoundary(
key: actionSnapshotKey,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(
context,
message.pubkey,
),
child: _Avatar(
profile: profile,
pubkey: message.pubkey,
),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.half : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(
profile,
),
timestamp: formatMessageTime(
message.createdAt,
),
nameColor:
context.colors.onSurface,
metadataColor: context
.colors
.onSurfaceVariant,
onAuthorTap: () =>
showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'thread-message-author-${message.id}',
),
usernameKey: ValueKey(
'thread-message-username-${message.id}',
),
timestampKey: ValueKey(
'thread-message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context
.textTheme
.labelSmall
?.copyWith(
color: context
.colors
.onSurfaceVariant,
fontStyle:
FontStyle.italic,
),
),
],
],
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow:
Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ThreadDetailPage(
threadHead: message,
allMessages:
allMessages!,
channelId: channelId,
currentPubkey:
currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(
viewerContext,
).maybePop();
}
},
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
],
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton:
isMember &&
!isArchived &&
(isThreadHead || message.reactions.isNotEmpty),
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
],
],
),
),
),
),
],
if (isThreadHead || message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
),
child: ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
],
),
),
),
),

View File

@@ -0,0 +1,111 @@
part of '../thread_detail_page.dart';
class _NestedThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage replyMessage;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _NestedThreadSummaryRow({
required this.summary,
required this.replyMessage,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width:
32.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.primary,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@@ -37,6 +37,9 @@ import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _channelId = 'test-channel';
const _nativeMessageActionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
/// Shared mock prefs for providers that read [savedPrefsProvider]
/// (e.g. the compose bar's draft store). Initialized in [main].
@@ -183,6 +186,7 @@ Widget _buildTestable({
Map<String, Future<List<NostrEvent>>> pendingThreadReplies = const {},
TextScaler textScaler = TextScaler.noScaling,
bool disableAnimations = false,
TargetPlatform platform = TargetPlatform.android,
RelaySessionNotifier? relaySessionNotifier,
}) {
final resolvedChannel = channel ?? _testChannel;
@@ -241,7 +245,7 @@ Widget _buildTestable({
savedPrefsProvider.overrideWithValue(_testPrefs),
],
child: MaterialApp(
theme: AppTheme.light(),
theme: AppTheme.light().copyWith(platform: platform),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: textScaler,
@@ -1086,6 +1090,367 @@ void main() {
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
});
testWidgets(
'iOS reaction-only messages use the lifted preview without actions',
(tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_systemMsg(
id: 'ios-reaction-only-target',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'alice',
},
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
platform: TargetPlatform.iOS,
),
);
await tester.pumpAndSettle();
await tester.longPress(
find.byKey(
const ValueKey('system-message-row-ios-reaction-only-target'),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsNothing,
);
expect(
tester
.widget<Opacity>(
find.byKey(
const ValueKey(
'system-message-native-source-ios-reaction-only-target',
),
),
)
.opacity,
0,
);
Navigator.of(
tester.element(
find.byKey(const ValueKey('message-action-preview-container')),
),
).pop();
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find.byKey(
const ValueKey(
'system-message-native-source-ios-reaction-only-target',
),
),
)
.opacity,
1,
);
},
);
testWidgets('iOS composes its tray, lifted message, and native actions', (
tester,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
null,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'native-context-target',
pubkey: 'alice',
content: 'Native context menu',
createdAt: 1000,
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
platform: TargetPlatform.iOS,
),
);
await tester.pumpAndSettle();
final row = find.byKey(
const ValueKey('message-row-native-context-target'),
);
await tester.longPress(row);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsOneWidget,
);
expect(
tester
.widget<Opacity>(
find.byKey(
const ValueKey('message-native-source-native-context-target'),
),
)
.opacity,
0,
);
Navigator.of(
tester.element(
find.byKey(const ValueKey('ios-native-message-action-surface')),
),
).pop();
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find.byKey(
const ValueKey('message-native-source-native-context-target'),
),
)
.opacity,
1,
);
});
testWidgets('message snapshot matches a message without reactions', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'bare-snapshot-target',
pubkey: 'alice',
content: 'Message without reactions',
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
platform: TargetPlatform.iOS,
),
);
await tester.pumpAndSettle();
final rowRect = tester.getRect(
find.byKey(const ValueKey('message-row-bare-snapshot-target')),
);
final snapshotRect = tester.getRect(
find.byKey(
const ValueKey('message-action-snapshot-bare-snapshot-target'),
),
);
expect(snapshotRect, rowRect);
expect(
find.descendant(
of: find.byKey(const ValueKey('message-row-bare-snapshot-target')),
matching: find.byType(ReactionRow),
),
findsNothing,
);
});
testWidgets('iOS lifted snapshot excludes attached reactions', (
tester,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
null,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'reacted-snapshot-target',
pubkey: 'alice',
content: 'Message with an attached reaction',
),
_reaction(
id: 'attached-reaction',
targetId: 'reacted-snapshot-target',
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
platform: TargetPlatform.iOS,
),
);
await tester.pumpAndSettle();
final row = find.byKey(
const ValueKey('message-row-reacted-snapshot-target'),
);
final snapshot = find.byKey(
const ValueKey('message-action-snapshot-reacted-snapshot-target'),
);
final attachedReactions = find.descendant(
of: row,
matching: find.byType(ReactionRow),
);
final rowRect = tester.getRect(row);
final snapshotRect = tester.getRect(snapshot);
final reactionRect = tester.getRect(attachedReactions);
expect(snapshotRect.bottom, lessThanOrEqualTo(reactionRect.top));
expect(rowRect.height, greaterThan(snapshotRect.height));
await tester.longPress(row);
await tester.pumpAndSettle();
final rawPreview = tester.widget<RawImage>(
find.descendant(
of: find.byKey(const ValueKey('message-action-preview-container')),
matching: find.byType(RawImage),
),
);
expect(
rawPreview.image?.width,
(snapshotRect.width * tester.view.devicePixelRatio).round(),
);
expect(
rawPreview.image?.height,
(snapshotRect.height * tester.view.devicePixelRatio).round(),
);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
Navigator.of(
tester.element(
find.byKey(const ValueKey('ios-native-message-action-surface')),
),
).pop();
await tester.pumpAndSettle();
});
testWidgets(
'iOS long press survives a pending support check and reliably reopens',
(tester) async {
final firstSupport = Completer<bool>();
var supportRequests = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
(call) async {
if (call.method != 'isSupported') return null;
supportRequests += 1;
if (supportRequests == 1) return firstSupport.future;
return true;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
null,
),
);
final userCache = _FakeUserCacheNotifier({
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
});
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'native-rebuild-target',
pubkey: 'alice',
content: 'Reliable native context menu',
createdAt: 1000,
),
],
userCacheNotifier: userCache,
platform: TargetPlatform.iOS,
),
);
await tester.pumpAndSettle();
final row = find.byKey(
const ValueKey('message-row-native-rebuild-target'),
);
final firstGesture = await tester.startGesture(tester.getCenter(row));
await tester.pump(const Duration(milliseconds: 600));
expect(supportRequests, 1);
await firstGesture.up();
userCache.replace(
const UserProfile(pubkey: 'alice', displayName: 'Alice Updated'),
);
await tester.pump();
firstSupport.complete(true);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsOneWidget,
);
expect(find.byType(BottomSheet), findsNothing);
for (var attempt = 0; attempt < 3; attempt++) {
Navigator.of(
tester.element(
find.byKey(const ValueKey('ios-native-message-action-surface')),
),
).pop();
await tester.pumpAndSettle();
await tester.longPress(row);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsOneWidget,
);
expect(find.byType(BottomSheet), findsNothing);
}
Navigator.of(
tester.element(
find.byKey(const ValueKey('ios-native-message-action-surface')),
),
).pop();
await tester.pumpAndSettle();
expect(supportRequests, 4);
},
);
testWidgets('reaction popover leaves existing reactions in the blur', (
tester,
) async {

View File

@@ -1,17 +1,28 @@
import 'dart:ui' as ui;
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/message_actions.dart';
import 'package:buzz/features/channels/message_long_press_region.dart';
import 'package:buzz/features/channels/recent_emoji_provider.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
const _channelId = 'chan-1';
const _nativeMessageActionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
TimelineMessage _message({
String id = 'msg-1',
@@ -19,6 +30,7 @@ TimelineMessage _message({
int createdAt = 1000,
bool isSystem = false,
String? rootId,
List<TimelineReaction> reactions = const [],
}) => TimelineMessage(
id: id,
pubkey: pubkey,
@@ -26,6 +38,7 @@ TimelineMessage _message({
content: 'hello world',
isSystem: isSystem,
rootId: rootId,
reactions: reactions,
);
class _FakeReadStateNotifier extends ReadStateNotifier {
@@ -73,6 +86,26 @@ class _FakeReadStateNotifier extends ReadStateNotifier {
);
}
class _RecordingChannelActions extends ChannelActions {
final List<(String, String)> addedReactions = [];
_RecordingChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
@override
Future<void> addReaction(String eventId, String emoji) async {
addedReactions.add((eventId, emoji));
}
}
ReadStateState _readState(Map<String, int> contexts, {bool isReady = true}) =>
ReadStateState(
isReady: isReady,
@@ -107,6 +140,8 @@ Future<void> _pumpSheet(
bool canManageMessage = false,
List<TimelineMessage>? allMessages,
ReminderService? reminderService,
TargetPlatform platform = TargetPlatform.android,
Rect? anchorRect,
}) async {
await tester.pumpWidget(
ProviderScope(
@@ -122,9 +157,10 @@ Future<void> _pumpSheet(
// No signing identity → "Remind me" hidden by default; individual
// tests opt in by passing a stub service.
reminderServiceProvider.overrideWithValue(reminderService),
channelActionsProvider.overrideWith(_RecordingChannelActions.new),
],
child: MaterialApp(
theme: AppTheme.light(),
theme: AppTheme.light().copyWith(platform: platform),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
@@ -137,6 +173,10 @@ Future<void> _pumpSheet(
allMessages: allMessages,
currentPubkey: 'self',
isMember: true,
anchorRect: anchorRect,
captureAnchorSnapshot: anchorRect == null
? null
: () async => _testSnapshot(),
),
child: const Text('open'),
),
@@ -180,8 +220,600 @@ Future<void> _pumpImageSheet(
await tester.pumpAndSettle();
}
ui.Image _testSnapshot() {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawColor(Colors.white, ui.BlendMode.src);
final picture = recorder.endRecording();
final image = picture.toImageSync(300, 72);
picture.dispose();
return image;
}
class _IosPopoverHarness {
final ProviderContainer container;
final ValueNotifier<bool> popoverPresented;
const _IosPopoverHarness({
required this.container,
required this.popoverPresented,
});
}
Future<_IosPopoverHarness> _pumpIosPopover(
WidgetTester tester, {
required TimelineMessage message,
required SharedPreferences prefs,
List<TimelineMessage>? allMessages,
ReminderService? reminderService,
Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72),
bool settlePresentation = true,
}) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
null,
),
);
final popoverPresented = ValueNotifier(false);
addTearDown(popoverPresented.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('self'),
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(_readState(const {_channelId: 100000})),
),
reminderServiceProvider.overrideWithValue(reminderService),
channelActionsProvider.overrideWith(_RecordingChannelActions.new),
],
child: MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) {
return TextButton(
key: const ValueKey('open-ios-popover'),
onPressed: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: _channelId,
canManageMessage: false,
allMessages: allMessages,
currentPubkey: 'self',
isMember: true,
anchorRect: anchorRect,
captureAnchorSnapshot: () async => _testSnapshot(),
onPopoverPresented: () => popoverPresented.value = true,
onPopoverDismissed: () => popoverPresented.value = false,
),
child: const Text('open iOS actions'),
);
},
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('open-ios-popover')));
if (settlePresentation) {
await tester.pumpAndSettle();
} else {
for (var index = 0; index < 6; index++) {
await tester.pump();
}
}
final container = ProviderScope.containerOf(
tester.element(find.byKey(const ValueKey('open-ios-popover'))),
);
return _IosPopoverHarness(
container: container,
popoverPresented: popoverPresented,
);
}
Future<void> _dismissIosPopover(WidgetTester tester) async {
Navigator.of(
tester.element(
find.byKey(const ValueKey('ios-native-message-action-surface')),
),
).pop();
await tester.pumpAndSettle();
}
Iterable<String> _nativeActionTitles() => find
.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'ios-native-action-',
),
)
.evaluate()
.map((element) => (element.widget as ListTile).title as Text)
.map((text) => text.data!);
void main() {
testWidgets(
'message long press keeps taps and scrolling while repeated holds win',
(tester) async {
var parentTaps = 0;
var nestedTaps = 0;
var longPresses = 0;
final scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
controller: scrollController,
child: Column(
children: [
Material(
child: MessageLongPressInkWell(
key: const ValueKey('parent-gesture-target'),
onTap: () => parentTaps += 1,
onLongPress: (_) => longPresses += 1,
child: const SizedBox(height: 80, width: 300),
),
),
Material(
child: MessageLongPressInkWell(
onLongPress: (_) => longPresses += 1,
child: GestureDetector(
key: const ValueKey('nested-gesture-target'),
behavior: HitTestBehavior.opaque,
onTap: () => nestedTaps += 1,
child: const SizedBox(height: 80, width: 300),
),
),
),
const SizedBox(height: 900),
],
),
),
),
),
);
await tester.tap(find.byKey(const ValueKey('parent-gesture-target')));
await tester.tap(find.byKey(const ValueKey('nested-gesture-target')));
await tester.pump();
expect(parentTaps, 1);
expect(nestedTaps, 1);
for (var index = 0; index < 5; index++) {
await tester.longPress(
find.byKey(const ValueKey('nested-gesture-target')),
);
await tester.pump();
expect(longPresses, index + 1);
expect(nestedTaps, 1);
}
final drag = await tester.startGesture(
tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))),
);
await drag.moveBy(const Offset(0, -30));
await tester.pump();
await drag.moveBy(const Offset(0, -70));
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
await drag.up();
await tester.pumpAndSettle();
expect(longPresses, 5);
expect(scrollController.offset, greaterThan(0));
},
);
group('showMessageActions', () {
testWidgets(
'composes the Flutter tray, lifted preview, and native actions',
(tester) async {
final prefs = await _mockPrefs();
final harness = await _pumpIosPopover(
tester,
message: _message(),
prefs: prefs,
allMessages: [_message()],
reminderService: _stubReminderService(),
);
expect(harness.popoverPresented.value, isTrue);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsOneWidget,
);
expect(find.byType(BottomSheet), findsNothing);
expect(_nativeActionTitles(), [
'Reply',
'Copy link',
'Remind me',
'Mark unread',
'Follow thread',
'Copy text',
]);
await _dismissIosPopover(tester);
},
);
testWidgets('uses a compact independently centered action surface', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpIosPopover(
tester,
message: _message(),
prefs: prefs,
allMessages: [_message()],
);
final surfaceRect = tester.getRect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
);
final previewRect = tester.getRect(
find.byKey(const ValueKey('message-action-preview-container')),
);
final previewImageRect = tester.getRect(
find.descendant(
of: find.byKey(const ValueKey('message-action-preview-container')),
matching: find.byType(RawImage),
),
);
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
expect(surfaceRect.width, 288);
expect(surfaceRect.height, 231);
expect(previewRect.size, const Size(316, 88));
expect(previewImageRect.left - previewRect.left, 8);
expect(previewImageRect.top - previewRect.top, 8);
expect(previewRect.right - previewImageRect.right, 8);
expect(previewRect.bottom - previewImageRect.bottom, 8);
expect(previewRect.top - trayRect.bottom, Grid.twelve);
expect(surfaceRect.center.dx, closeTo(previewRect.center.dx, 0.01));
expect(surfaceRect.center.dx, closeTo(trayRect.center.dx, 0.01));
expect(surfaceRect.width, lessThan(previewRect.width));
await _dismissIosPopover(tester);
});
testWidgets('reaction-only tray matches the composed message spacing', (
tester,
) async {
const anchorRect = Rect.fromLTWH(32, 260, 300, 72);
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(isSystem: true),
prefs: prefs,
anchorRect: anchorRect,
);
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
expect(anchorRect.top - trayRect.bottom, Grid.twelve);
});
testWidgets(
'iOS reaction-only messages lift the preview without an action surface',
(tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(isSystem: true),
prefs: prefs,
platform: TargetPlatform.iOS,
anchorRect: const Rect.fromLTWH(32, 260, 300, 72),
);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsNothing,
);
expect(find.byType(BottomSheet), findsNothing);
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
final previewRect = tester.getRect(
find.byKey(const ValueKey('message-action-preview-container')),
);
expect(previewRect.top - trayRect.bottom, Grid.twelve);
expect(trayRect.center.dx, closeTo(previewRect.center.dx, 0.01));
expect(
(trayRect.top + previewRect.bottom) / 2,
closeTo(
tester.view.physicalSize.height / tester.view.devicePixelRatio / 2,
0.01,
),
);
Navigator.of(
tester.element(
find.byKey(const ValueKey('message-action-preview-container')),
),
).pop();
await tester.pumpAndSettle();
},
);
testWidgets('popover uses contiguous transparent reaction hit targets', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpIosPopover(tester, message: _message(), prefs: prefs);
final targets = find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
);
final targetKeys = [
for (final element in targets.evaluate()) element.widget.key!,
];
final targetRects = [
for (final key in targetKeys) tester.getRect(find.byKey(key)),
];
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
expect(targetRects, hasLength(6));
for (final rect in targetRects) {
expect(rect.size.width, greaterThanOrEqualTo(44));
expect(rect.size.height, greaterThanOrEqualTo(44));
}
for (var index = 0; index < targetRects.length - 1; index++) {
expect(
targetRects[index + 1].left - targetRects[index].right,
closeTo(0, 0.01),
);
}
expect(targetRects.first.left - trayRect.left, Grid.xxs);
expect(trayRect.right - targetRects.last.right, Grid.xxs);
expect(
trayRect.width,
targetRects.last.right - targetRects.first.left + (Grid.xxs * 2),
);
expect(
tester
.widget<NativeEmojiGlyph>(
find.descendant(
of: find.byKey(const ValueKey('quick-reaction-\u{1F44D}')),
matching: find.byType(NativeEmojiGlyph),
),
)
.size,
28,
);
final addReactionIcon = tester.widget<Icon>(
find.descendant(
of: find.byKey(const ValueKey('quick-reaction-more')),
matching: find.byType(Icon),
),
);
expect(addReactionIcon.icon, LucideIcons.smilePlus);
expect(addReactionIcon.size, 24);
for (final key in targetKeys) {
final target = find.byKey(key);
expect(
tester
.widget<Material>(
find.descendant(of: target, matching: find.byType(Material)),
)
.color,
Colors.transparent,
);
final inkWell = tester.widget<InkWell>(
find.descendant(of: target, matching: find.byType(InkWell)),
);
expect(inkWell.highlightColor, isNot(Colors.transparent));
expect(inkWell.splashColor, isNot(Colors.transparent));
}
await _dismissIosPopover(tester);
});
testWidgets('popover does not persist selected reaction feedback', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpIosPopover(
tester,
message: _message(
reactions: const [
TimelineReaction(
emoji: '\u{1F44D}',
count: 1,
reactedByCurrentUser: true,
userPubkeys: ['self'],
currentUserReactionId: 'reaction-1',
),
],
),
prefs: prefs,
);
final selectedTarget = find.byKey(
const ValueKey('quick-reaction-\u{1F44D}'),
);
expect(
tester
.widget<Material>(
find.descendant(
of: selectedTarget,
matching: find.byType(Material),
),
)
.color,
Colors.transparent,
);
await _dismissIosPopover(tester);
});
testWidgets('popover reactions do not reorder the quick-reaction ranking', (
tester,
) async {
final prefs = await _mockPrefs();
final harness = await _pumpIosPopover(
tester,
message: _message(),
prefs: prefs,
);
expect(harness.container.read(recentEmojiProvider), isEmpty);
await tester.tap(find.byKey(const ValueKey('quick-reaction-\u{1F44D}')));
await tester.pumpAndSettle();
expect(harness.container.read(recentEmojiProvider), isEmpty);
expect(
(harness.container.read(channelActionsProvider)
as _RecordingChannelActions)
.addedReactions,
const [('msg-1', '\u{1F44D}')],
);
});
testWidgets('restores its source after the composed popover dismisses', (
tester,
) async {
final prefs = await _mockPrefs();
final harness = await _pumpIosPopover(
tester,
message: _message(),
prefs: prefs,
allMessages: [_message()],
);
expect(harness.popoverPresented.value, isTrue);
await _dismissIosPopover(tester);
expect(harness.popoverPresented.value, isFalse);
});
testWidgets('combined reaction tray expands right from a fixed left edge', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpIosPopover(
tester,
message: _message(),
prefs: prefs,
settlePresentation: false,
);
await tester.pump(const Duration(milliseconds: 60));
final tray = find.byKey(const ValueKey('reaction-popover-tray'));
final firstLeft = tester.getTopLeft(tray).dx;
final firstWidth = tester.getSize(tray).width;
await tester.pump(const Duration(milliseconds: 80));
final secondLeft = tester.getTopLeft(tray).dx;
final secondWidth = tester.getSize(tray).width;
expect(secondLeft, closeTo(firstLeft, 0.01));
expect(secondWidth, greaterThan(firstWidth));
await tester.pumpAndSettle();
await _dismissIosPopover(tester);
});
testWidgets('runs a selected native action after the popover dismisses', (
tester,
) async {
final prefs = await _mockPrefs();
final harness = await _pumpIosPopover(
tester,
message: _message(rootId: 'root-9'),
prefs: prefs,
);
await tester.tap(
find.byKey(const ValueKey('ios-native-action-followThread')),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
expect(harness.container.read(threadFollowsProvider).followedRootIds, {
'root-9',
});
});
testWidgets('can reopen the composed iOS message actions', (tester) async {
final prefs = await _mockPrefs();
await _pumpIosPopover(tester, message: _message(), prefs: prefs);
await _dismissIosPopover(tester);
await tester.tap(find.byKey(const ValueKey('open-ios-popover')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('ios-native-message-action-surface')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
await _dismissIosPopover(tester);
});
testWidgets('falls back to the Flutter sheet on older iOS versions', (
tester,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
(call) async => call.method == 'isSupported' ? false : null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
_nativeMessageActionSurfaceChannel,
null,
),
);
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
platform: TargetPlatform.iOS,
anchorRect: const Rect.fromLTWH(32, 260, 300, 72),
);
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.text('Copy text'), findsOneWidget);
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
});
testWidgets('shows parity actions for a regular message', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
@@ -212,6 +844,37 @@ void main() {
tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))),
const Size.square(52),
);
expect(
tester
.widget<Material>(
find.descendant(
of: find.byKey(const ValueKey('quick-reaction-\u{1F44D}')),
matching: find.byType(Material),
),
)
.color,
isNot(Colors.transparent),
);
});
testWidgets('existing sheet still updates the quick-reaction ranking', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
expect(container.read(recentEmojiProvider), isEmpty);
await tester.tap(
find.byKey(const ValueKey('quick-reaction-\u{2764}\u{FE0F}')),
);
await tester.pumpAndSettle();
expect(container.read(recentEmojiProvider).map((entry) => entry.emoji), [
'\u{2764}\u{FE0F}',
]);
});
testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '