diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 7ccf13bae..16af9db3c 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; }; 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; + 4A71C00A2F40500100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 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 */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 6ab55c359..d1024bcfb 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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( diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift new file mode 100644 index 000000000..fb070b7da --- /dev/null +++ b/mobile/ios/Runner/NativeMessageActionSurface.swift @@ -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) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e1c2ce00f..d9c8efa66 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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) diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 87673b3cb..df8016766 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -1,6 +1,6 @@ part of '../channel_detail_page.dart'; -class _MessageBubble extends ConsumerWidget { +class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; final Map 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( - 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( + 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, + ), + ), + ), ], ), ), diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index e09f9478a..970c1452d 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -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 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( context: context, isScrollControlled: true, diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart new file mode 100644 index 000000000..c045aebef --- /dev/null +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -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 _supportsIosNativeMessageActionSurface() async { + if (!Platform.isIOS) return false; + final cached = _iosNativeMessageActionSurfaceSupported; + if (cached != null) return cached; + + try { + final supported = + await _iosNativeMessageActionSurfaceChannel.invokeMethod( + '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? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect? anchorRect, + required Future 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 _showMessageActionsPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect anchorRect, + required Future 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( + 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.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? 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( + 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 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 toPlatformArguments() => { + 'id': id, + 'title': title, + 'symbol': iosSymbol, + 'group': group.name, + 'destructive': destructive, + }; +} + +class _IosNativeMessageActionSurface extends HookWidget { + final List<_PopoverMessageAction> actions; + final ValueChanged onSelected; + + const _IosNativeMessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final viewId = useState(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: { + '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 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 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 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 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); +} diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart index dfbc12786..692f2dc5a 100644 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ b/mobile/lib/features/channels/message_actions/reaction_popover.dart @@ -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 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 { final Rect anchorRect; final EdgeInsets spotlightPadding; diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart index d894e4a03..fa68d7c85 100644 --- a/mobile/lib/features/channels/message_long_press_region.dart +++ b/mobile/lib/features/channels/message_long_press_region.dart @@ -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 Function() captureSnapshot; + final ValueChanged 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 onLongPress; + final ValueChanged? onLongPress; + final ValueChanged? 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 onLongPress; + final VoidCallback? onTap; + final ValueChanged? onLongPress; + final ValueChanged? 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(null); - final origin = useRef(null); - final timer = useRef(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 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( + 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, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 6ab3ddd9b..326ffcefa 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -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. /// diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart new file mode 100644 index 000000000..502d6cffa --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -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, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 248ccc968..80c7bf616 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -1,6 +1,6 @@ part of '../thread_detail_page.dart'; -class _ThreadMessage extends ConsumerWidget { +class _ThreadMessage extends HookConsumerWidget { final TimelineMessage message; final Map 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( - 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( + 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, - ), - ), - ); - } -} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e385452..9145dd132 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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, diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 842a97211..02fa00913 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -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 _pumpImageSheet( await tester.pumpAndSettle(); } +Future _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 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? 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 _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( + find.byKey(const ValueKey('message-action-reaction-tray')), + ); + final actionMaterial = tester.widget( + 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);