Polish mobile message actions

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