diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index d95bd0c431..dfa42bbe8f 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -11,6 +11,7 @@ import UserNotifications private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? + private var nativeMenuButtonSupportChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -116,6 +117,27 @@ import UserNotifications result(true) } } + + if #available(iOS 16.0, *), + let nativeMenuButtonRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeMenuButton" + ) { + nativeMenuButtonRegistrar.register( + NativeMenuButtonFactory(messenger: messenger), + withId: "buzz/native_menu_button" + ) + nativeMenuButtonSupportChannel = FlutterMethodChannel( + name: "buzz/native_menu_button", + binaryMessenger: messenger + ) + nativeMenuButtonSupportChannel?.setMethodCallHandler { call, result in + guard call.method == "isSupported" else { + result(FlutterMethodNotImplemented) + return + } + result(true) + } + } } private static func handleQrScannerMethodCall( diff --git a/mobile/ios/Runner/NativeMessageActionsCoordinator.swift b/mobile/ios/Runner/NativeMessageActionsCoordinator.swift index a35e0f399c..d9e668ff2a 100644 --- a/mobile/ios/Runner/NativeMessageActionsCoordinator.swift +++ b/mobile/ios/Runner/NativeMessageActionsCoordinator.swift @@ -430,3 +430,179 @@ final class NativeMessageActionSurfacePlatformView: NSObject, channel.invokeMethod("selected", arguments: ["id": definition.id]) } } + +struct NativeMenuButtonItemDefinition { + let id: String + let title: String + let systemImage: String? + let group: String + let checked: Bool + let enabled: Bool + let destructive: Bool + + init?(arguments: [String: Any]) { + guard + let id = arguments["id"] as? String, + let title = arguments["title"] as? String + else { + return nil + } + self.id = id + self.title = title + systemImage = arguments["systemImage"] as? String + group = arguments["group"] as? String ?? "primary" + checked = arguments["checked"] as? Bool ?? false + enabled = arguments["enabled"] as? Bool ?? true + destructive = arguments["destructive"] as? Bool ?? false + } +} + +@available(iOS 16.0, *) +final class NativeMenuButtonFactory: 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 { + NativeMenuButtonPlatformView( + frame: frame, + viewIdentifier: viewId, + messenger: messenger, + arguments: args + ) + } +} + +@available(iOS 16.0, *) +final class NativeMenuButtonPlatformView: NSObject, FlutterPlatformView { + private let button = UIButton(type: .system) + private let channel: FlutterMethodChannel + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger, + arguments args: Any? + ) { + channel = FlutterMethodChannel( + name: "buzz/native_menu_button/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + button.frame = frame + button.autoresizingMask = [.flexibleWidth, .flexibleHeight] + button.showsMenuAsPrimaryAction = true + button.changesSelectionAsPrimaryAction = false + update(arguments: args) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "update" else { + result(FlutterMethodNotImplemented) + return + } + DispatchQueue.main.async { + self?.update(arguments: call.arguments) + result(nil) + } + } + } + + func view() -> UIView { + button + } + + private func update(arguments args: Any?) { + guard let arguments = args as? [String: Any] else { return } + + let title = arguments["title"] as? String + let systemImage = arguments["systemImage"] as? String + let accessibilityLabel = arguments["accessibilityLabel"] as? String + let foregroundColor = color(arguments["foregroundColor"] as? NSNumber) + let itemArguments = arguments["items"] as? [[String: Any]] ?? [] + let items = itemArguments.compactMap( + NativeMenuButtonItemDefinition.init(arguments:) + ) + + var configuration = UIButton.Configuration.plain() + configuration.title = title + configuration.image = systemImage.flatMap(UIImage.init(systemName:)) + configuration.imagePlacement = title == nil ? .leading : .trailing + configuration.imagePadding = title == nil ? 0 : 4 + configuration.baseForegroundColor = foregroundColor + configuration.contentInsets = .zero + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = UIFont.preferredFont(forTextStyle: .subheadline) + .withWeight(.semibold) + return outgoing + } + button.configuration = configuration + button.accessibilityLabel = accessibilityLabel + button.menu = makeMenu(items: items) + } + + private func makeMenu(items: [NativeMenuButtonItemDefinition]) -> UIMenu { + var groupOrder: [String] = [] + for item in items where !groupOrder.contains(item.group) { + groupOrder.append(item.group) + } + + let groups = groupOrder.map { group -> UIMenu in + let actions = items + .filter { $0.group == group } + .map { definition in + var attributes = UIMenuElement.Attributes() + if !definition.enabled { attributes.insert(.disabled) } + if definition.destructive { attributes.insert(.destructive) } + let image = definition.systemImage.flatMap(UIImage.init(systemName:)) + return UIAction( + title: definition.title, + image: image, + attributes: attributes, + state: definition.checked ? .on : .off + ) { [weak self] _ in + self?.channel.invokeMethod( + "selected", + arguments: ["id": definition.id] + ) + } + } + return UIMenu(options: .displayInline, children: actions) + } + return UIMenu(children: groups) + } + + private func color(_ number: NSNumber?) -> UIColor { + guard let number else { return .label } + let argb = UInt32(truncating: number) + return UIColor( + red: CGFloat((argb >> 16) & 0xFF) / 255, + green: CGFloat((argb >> 8) & 0xFF) / 255, + blue: CGFloat(argb & 0xFF) / 255, + alpha: CGFloat((argb >> 24) & 0xFF) / 255 + ) + } +} + +private extension UIFont { + func withWeight(_ weight: UIFont.Weight) -> UIFont { + let traits = [UIFontDescriptor.TraitKey.weight: weight] + let descriptor = fontDescriptor.addingAttributes([ + UIFontDescriptor.AttributeName.traits: traits + ]) + return UIFont(descriptor: descriptor, size: pointSize) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 88532fb019..356abee891 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -160,7 +160,7 @@ class RunnerTests: XCTestCase { @available(iOS 16.0, *) @MainActor - func testNativeMessageActionSeparatorsShareGeometry() { + func testNativeMessageActionSeparatorsShareGeometry() throws { let separators = [ NativeMessageActionSeparatorView(), NativeMessageActionSeparatorView(), @@ -188,10 +188,12 @@ class RunnerTests: XCTestCase { NativeMessageActionSurfaceLayout.surfaceWidth - (NativeMessageActionSurfaceLayout.separatorHorizontalInset * 2) ) - XCTAssertEqual( - lineFrames.first?.height, + let renderedHeight = try XCTUnwrap(lineFrames.first?.height) + XCTAssertGreaterThanOrEqual( + renderedHeight, NativeMessageActionSurfaceLayout.separatorHeight ) + XCTAssertLessThanOrEqual(renderedHeight, 1) } @available(iOS 16.0, *) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 99d0bbc031..eee71200f8 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -18,6 +21,7 @@ import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/ios_native_menu_button.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/channel.dart'; @@ -38,6 +42,7 @@ import 'reminders_provider.dart'; part 'activity_page/header_actions.dart'; part 'activity_page/inbox_row.dart'; +part 'activity_page/row_actions_popover.dart'; part 'activity_page/lists.dart'; part 'activity_page/status_views.dart'; diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 08384b69e1..2d04b244b5 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -11,7 +11,7 @@ const _filterLabels = { InboxFilter.drafts: 'Drafts', }; -class _ActivityActionsPill extends StatelessWidget { +class _ActivityActionsPill extends HookWidget { final InboxFilter filter; final int dueReminderCount; final int draftCount; @@ -33,35 +33,176 @@ class _ActivityActionsPill extends StatelessWidget { }); @override - Widget build(BuildContext context) => ClipRRect( - borderRadius: BorderRadius.circular(Radii.full), - child: DecoratedBox( - decoration: BoxDecoration( - color: context.colors.primaryContainer, - borderRadius: BorderRadius.circular(Radii.full), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _FilterMenuButton( - filter: filter, - dueReminderCount: dueReminderCount, - draftCount: draftCount, - onChanged: onFilterChanged, - ), - _InboxOptionsButton( - unreadOnly: unreadOnly, - unreadCount: unreadCount, - onUnreadOnlyChanged: onUnreadOnlyChanged, - onMarkAllRead: onMarkAllRead, - ), - ], + Widget build(BuildContext context) { + final nativeMenuSupported = + useFuture(useMemoized(IosNativeMenuButton.isSupported)).data ?? false; + final rowActionOverlayPresented = useValueListenable( + _activityRowActionOverlayPresented, + ); + final showNativeMenu = nativeMenuSupported && !rowActionOverlayPresented; + + return ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: BorderRadius.circular(Radii.full), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showNativeMenu) + _NativeFilterMenuButton( + filter: filter, + dueReminderCount: dueReminderCount, + draftCount: draftCount, + onChanged: onFilterChanged, + ) + else + _FilterMenuButton( + filter: filter, + dueReminderCount: dueReminderCount, + draftCount: draftCount, + onChanged: onFilterChanged, + ), + if (showNativeMenu) + _NativeInboxOptionsButton( + unreadOnly: unreadOnly, + unreadCount: unreadCount, + onUnreadOnlyChanged: onUnreadOnlyChanged, + onMarkAllRead: onMarkAllRead, + ) + else + _InboxOptionsButton( + unreadOnly: unreadOnly, + unreadCount: unreadCount, + onUnreadOnlyChanged: onUnreadOnlyChanged, + onMarkAllRead: onMarkAllRead, + ), + ], + ), ), ), - ), - ); + ); + } +} + +class _NativeFilterMenuButton extends StatelessWidget { + final InboxFilter filter; + final int dueReminderCount; + final int draftCount; + final ValueChanged onChanged; + + const _NativeFilterMenuButton({ + required this.filter, + required this.dueReminderCount, + required this.draftCount, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final label = _filterLabels[filter]!; + final textPainter = TextPainter( + text: TextSpan( + text: label, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + maxLines: 1, + )..layout(); + final width = math.max(Grid.xl, textPainter.width + Grid.lg); + + return SizedBox( + key: const ValueKey('activity-filter-menu'), + width: width, + height: Grid.xl, + child: IosNativeMenuButton( + key: const ValueKey('activity-native-filter'), + title: label, + systemImage: 'chevron.down', + accessibilityLabel: 'Filter activity: $label', + foregroundColor: navigationPrimaryForeground(context), + items: [ + for (final entry in _filterLabels.entries) + IosNativeMenuItem( + id: entry.key.name, + title: switch (entry.key) { + InboxFilter.reminders when dueReminderCount > 0 => + '${entry.value} ($dueReminderCount)', + InboxFilter.drafts when draftCount > 0 => + '${entry.value} ($draftCount)', + _ => entry.value, + }, + group: + entry.key == InboxFilter.reminders || + entry.key == InboxFilter.drafts + ? 'saved' + : 'inbox', + checked: entry.key == filter, + ), + ], + onSelected: (id) { + final selected = InboxFilter.values + .where((candidate) => candidate.name == id) + .firstOrNull; + if (selected != null) onChanged(selected); + }, + ), + ); + } +} + +class _NativeInboxOptionsButton extends StatelessWidget { + final bool unreadOnly; + final int unreadCount; + final ValueChanged onUnreadOnlyChanged; + final VoidCallback onMarkAllRead; + + const _NativeInboxOptionsButton({ + required this.unreadOnly, + required this.unreadCount, + required this.onUnreadOnlyChanged, + required this.onMarkAllRead, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('activity-options-menu'), + width: Grid.xl, + height: Grid.xl, + child: IosNativeMenuButton( + key: const ValueKey('activity-native-options'), + systemImage: 'ellipsis', + accessibilityLabel: 'Activity options', + foregroundColor: navigationPrimaryForeground(context), + items: [ + IosNativeMenuItem( + id: 'unread-only', + title: unreadOnly ? 'Show all' : 'Show unread', + systemImage: unreadOnly ? 'tray.full' : 'envelope.badge', + checked: unreadOnly, + ), + IosNativeMenuItem( + id: 'mark-all-read', + title: 'Mark all as read', + systemImage: 'envelope.open', + enabled: unreadCount > 0, + ), + ], + onSelected: (id) { + if (id == 'unread-only') onUnreadOnlyChanged(!unreadOnly); + if (id == 'mark-all-read') onMarkAllRead(); + }, + ), + ); + } } /// Compact filter dropdown replacing the old chip rail — mirrors desktop's diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fe7869299d..4465005017 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -69,6 +69,9 @@ class _InboxRow extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { const restingRevealWidth = 132.0; const commitThreshold = 0.58; + final actionSnapshotKey = useMemoized(GlobalKey.new, const []); + final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context); + final actionPopoverPresented = useState(false); final revealAmount = useState(0.0); final isDragging = useState(false); final labelHapticFired = useRef(false); @@ -124,6 +127,56 @@ class _InboxRow extends HookConsumerWidget { isDone ? onMarkUnread() : onMarkRead(); } + Future captureActionSnapshot() async { + await WidgetsBinding.instance.endOfFrame; + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + throw StateError('Activity row snapshot is unavailable'); + } + return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio); + } + + Rect? actionSnapshotRect() { + final renderObject = actionSnapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + + Future openRowActions() async { + if (isDragging.value) return; + final anchorRect = actionSnapshotRect(); + if (anchorRect == null) { + _showRowActionsSheet(context); + return; + } + final presented = await _showActivityRowActionsPopover( + context: context, + anchorRect: anchorRect, + captureAnchorSnapshot: captureActionSnapshot, + actions: [ + _ActivityRowAction( + id: isDone ? 'markUnread' : 'markRead', + title: isDone ? 'Mark unread' : 'Mark as read', + symbol: isDone ? 'envelope.badge' : 'envelope.open', + icon: isDone ? LucideIcons.mailOpen : LucideIcons.mailCheck, + onSelected: isDone ? onMarkUnread : onMarkRead, + ), + _ActivityRowAction( + id: 'openConversation', + title: 'Open conversation', + symbol: 'arrow.up.right.square', + icon: LucideIcons.externalLink, + onSelected: onTap, + ), + ], + onPopoverPresented: () => actionPopoverPresented.value = true, + onPopoverDismissed: () => actionPopoverPresented.value = false, + ); + if (!presented && context.mounted) _showRowActionsSheet(context); + } + return LayoutBuilder( builder: (context, constraints) { final actionExtent = constraints.maxWidth; @@ -164,168 +217,186 @@ class _InboxRow extends HookConsumerWidget { ? Duration.zero : const Duration(milliseconds: 160), curve: Curves.easeOutCubic, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) { - isDragging.value = true; - labelHapticFired.value = false; - }, - onHorizontalDragUpdate: (details) { - isDragging.value = true; - final previous = revealAmount.value; - final next = - (previous - (details.delta.dx * swipeDirection)) - .clamp(0, actionExtent) - .toDouble(); - if (!labelHapticFired.value && - previous < _inboxSwipeLabelRevealWidth && - next >= _inboxSwipeLabelRevealWidth) { - labelHapticFired.value = true; - unawaited(HapticFeedback.selectionClick()); - } - revealAmount.value = next; - }, - onHorizontalDragEnd: (details) { - isDragging.value = false; - final velocity = details.primaryVelocity ?? 0; - final shouldCommit = - (velocity * swipeDirection) < -900 || - revealAmount.value >= actionExtent * commitThreshold; - if (shouldCommit) { - toggleReadState(); - } else { - revealAmount.value = - (velocity * swipeDirection) < -200 || - revealAmount.value >= restingRevealWidth / 2 - ? restingRevealWidth - : 0; - } - }, - child: Material( - color: context.colors.surface, - child: InkWell( - key: ValueKey('inbox-row-${item.id}'), - onTap: onTap, - onLongPress: () => _showRowActions(context), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _RowAvatar( - pubkey: item.item.pubkey, - profile: profile, + child: Opacity( + key: ValueKey('activity-row-action-source-${item.id}'), + opacity: actionPopoverPresented.value ? 0 : 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + isDragging.value = true; + labelHapticFired.value = false; + }, + onHorizontalDragUpdate: (details) { + isDragging.value = true; + final previous = revealAmount.value; + final next = + (previous - (details.delta.dx * swipeDirection)) + .clamp(0, actionExtent) + .toDouble(); + if (!labelHapticFired.value && + previous < _inboxSwipeLabelRevealWidth && + next >= _inboxSwipeLabelRevealWidth) { + labelHapticFired.value = true; + unawaited(HapticFeedback.selectionClick()); + } + revealAmount.value = next; + }, + onHorizontalDragEnd: (details) { + isDragging.value = false; + final velocity = details.primaryVelocity ?? 0; + final shouldCommit = + (velocity * swipeDirection) < -900 || + revealAmount.value >= actionExtent * commitThreshold; + if (shouldCommit) { + toggleReadState(); + } else { + revealAmount.value = + (velocity * swipeDirection) < -200 || + revealAmount.value >= restingRevealWidth / 2 + ? restingRevealWidth + : 0; + } + }, + child: RepaintBoundary( + key: actionSnapshotKey, + child: Material( + color: context.colors.surface, + child: _ActivityLongPressInkWell( + key: ValueKey('inbox-row-${item.id}'), + onTap: onTap, + onLongPress: () => unawaited(openRowActions()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.twelve, ), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Sender + unread dot + timestamp. - Row( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _RowAvatar( + pubkey: item.item.pubkey, + profile: profile, + ), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, children: [ - Expanded( - child: MessageAuthorMeta( - displayName: senderLabel, - username: messageUsernameLabel( - profile, - ), - timestamp: _inboxTimestamp( - item.latestActivityAt, - ), - nameColor: context.colors.onSurface, - metadataColor: mutedColor, - nameStyle: activityUsernameTextStyle, - metadataStyle: - activityTimestampTextStyle, - displayNameKey: ValueKey( - 'activity-author-${item.id}', - ), - usernameKey: ValueKey( - 'activity-username-${item.id}', - ), - timestampKey: ValueKey( - 'activity-timestamp-${item.id}', - ), - ), - ), - if (!isDone) ...[ - const SizedBox(width: Grid.xxs), - Container( - key: ValueKey( - 'inbox-unread-dot-${item.id}', - ), - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.colors.primary, - ), - ), - ], - ], - ), - const SizedBox(height: Grid.quarter), - // Contextual label: "Mentioned in #channel" etc. - Row( - children: [ - Flexible( - child: Text( - label.text, - style: activityContextTextStyle - .copyWith(color: labelColor), - overflow: TextOverflow.ellipsis, - ), - ), - if (label.channelLabel != null) ...[ - const SizedBox(width: Grid.half), - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: - Grid.half + Grid.quarter, - vertical: Grid.quarter / 2, + // Sender + unread dot + timestamp. + Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: senderLabel, + username: messageUsernameLabel( + profile, + ), + timestamp: _inboxTimestamp( + item.latestActivityAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: mutedColor, + nameStyle: + activityUsernameTextStyle, + metadataStyle: + activityTimestampTextStyle, + displayNameKey: ValueKey( + 'activity-author-${item.id}', + ), + usernameKey: ValueKey( + 'activity-username-${item.id}', + ), + timestampKey: ValueKey( + 'activity-timestamp-${item.id}', + ), ), - decoration: BoxDecoration( - color: context - .colors - .surfaceContainerHighest, - borderRadius: - BorderRadius.circular( - Grid.half, - ), + ), + if (!isDone) ...[ + const SizedBox(width: Grid.xxs), + Container( + key: ValueKey( + 'inbox-unread-dot-${item.id}', + ), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + ), ), + ], + ], + ), + const SizedBox(height: Grid.quarter), + // Contextual label: "Mentioned in #channel" etc. + Row( + children: [ + Flexible( child: Text( - '#${label.channelLabel}', + label.text, style: activityContextTextStyle - .copyWith(color: mutedColor), + .copyWith(color: labelColor), overflow: TextOverflow.ellipsis, ), ), - ), - ], + if (label.channelLabel != null) ...[ + const SizedBox(width: Grid.half), + Flexible( + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: + Grid.half + + Grid.quarter, + vertical: + Grid.quarter / 2, + ), + decoration: BoxDecoration( + color: context + .colors + .surfaceContainerHighest, + borderRadius: + BorderRadius.circular( + Grid.half, + ), + ), + child: Text( + '#${label.channelLabel}', + style: + activityContextTextStyle + .copyWith( + color: mutedColor, + ), + overflow: + TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ), + const SizedBox(height: Grid.half), + // Message preview. + MessageContent( + content: item.item.displayContent, + mentionNames: mentionNames, + agentMentionPubkeys: + agentMentionPubkeys, + tags: item.item.tags, + maxLines: 2, + baseStyle: activityPreviewTextStyle + .copyWith( + color: context.colors.onSurface, + ), + ), ], ), - const SizedBox(height: Grid.half), - // Message preview. - MessageContent( - content: item.item.displayContent, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - tags: item.item.tags, - maxLines: 2, - baseStyle: activityPreviewTextStyle - .copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), + ), + ], ), - ], + ), ), ), ), @@ -339,7 +410,7 @@ class _InboxRow extends HookConsumerWidget { ); } - void _showRowActions(BuildContext context) { + void _showRowActionsSheet(BuildContext context) { showBuzzModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -382,6 +453,31 @@ class _InboxRow extends HookConsumerWidget { } } +class _ActivityLongPressInkWell extends StatelessWidget { + final VoidCallback onTap; + final VoidCallback onLongPress; + final Widget child; + + const _ActivityLongPressInkWell({ + super.key, + required this.onTap, + required this.onLongPress, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + onLongPress: onLongPress, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (_) => onLongPress(), + child: InkWell(onTap: onTap, child: child), + ), + ); + } +} + class _InboxSwipeAction extends StatelessWidget { final Color color; final Color foregroundColor; diff --git a/mobile/lib/features/activity/activity_page/row_actions_popover.dart b/mobile/lib/features/activity/activity_page/row_actions_popover.dart new file mode 100644 index 0000000000..5e4f1cb3c4 --- /dev/null +++ b/mobile/lib/features/activity/activity_page/row_actions_popover.dart @@ -0,0 +1,468 @@ +part of '../activity_page.dart'; + +const _activityActionRowHeight = 50.0; +const _activityActionVerticalInset = Grid.half; +const _activityContextGap = Grid.twelve; +const _activityContextMenuMaxWidth = 288.0; +const _activityContextPreviewMaxWidth = 358.0; +const _activityContextPreviewInset = Grid.xxs; +const _activityContextTransitionDuration = Duration(milliseconds: 240); +const _activityActionSurfaceRadius = 26.0; +const _activityActionIconSize = 22.0; +const _activityActionIconSlotWidth = 32.0; +const _activityNativeActionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', +); + +bool _activityRowActionsPresentationInFlight = false; +final _activityRowActionOverlayPresented = ValueNotifier(false); + +enum _ActivityActionSurfaceKind { iosNative, androidFlutter } + +class _ActivityRowAction { + final String id; + final String title; + final String symbol; + final IconData icon; + final FutureOr Function() onSelected; + + const _ActivityRowAction({ + required this.id, + required this.title, + required this.symbol, + required this.icon, + required this.onSelected, + }); + + Map toPlatformArguments() => { + 'id': id, + 'title': title, + 'symbol': symbol, + 'group': 'triage', + 'destructive': false, + }; +} + +Future _showActivityRowActionsPopover({ + required BuildContext context, + required Rect anchorRect, + required Future Function() captureAnchorSnapshot, + required List<_ActivityRowAction> actions, + required VoidCallback onPopoverPresented, + required VoidCallback onPopoverDismissed, +}) async { + if (_activityRowActionsPresentationInFlight) return true; + _activityRowActionsPresentationInFlight = true; + try { + final surfaceKind = await _activityActionSurfaceKind(); + if (surfaceKind == null || !context.mounted) return false; + + final ui.Image snapshot; + try { + snapshot = await captureAnchorSnapshot(); + } catch (_) { + return false; + } + if (!context.mounted) { + snapshot.dispose(); + return false; + } + + unawaited(HapticFeedback.mediumImpact()); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + _activityRowActionOverlayPresented.value = true; + onPopoverPresented(); + String? selectedActionId; + var overlayBuilt = false; + var dismissalCompleted = false; + + void completeDismissal() { + if (dismissalCompleted) return; + dismissalCompleted = true; + snapshot.dispose(); + _activityRowActionOverlayPresented.value = false; + if (context.mounted) onPopoverDismissed(); + } + + try { + selectedActionId = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Dismiss activity actions', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : _activityContextTransitionDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) { + overlayBuilt = true; + return _ActivityRowActionsPopover( + anchorRect: anchorRect, + anchorSnapshot: snapshot, + animation: animation, + actions: actions, + surfaceKind: surfaceKind, + onTransitionDismissed: completeDismissal, + ); + }, + ); + } finally { + if (!overlayBuilt) completeDismissal(); + } + + final selectedAction = actions + .where((action) => action.id == selectedActionId) + .firstOrNull; + if (selectedAction != null) await selectedAction.onSelected(); + return true; + } finally { + _activityRowActionsPresentationInFlight = false; + } +} + +Future<_ActivityActionSurfaceKind?> _activityActionSurfaceKind() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return _ActivityActionSurfaceKind.androidFlutter; + } + try { + final supported = + await _activityNativeActionSurfaceChannel.invokeMethod( + 'isSupported', + ) ?? + false; + return supported ? _ActivityActionSurfaceKind.iosNative : null; + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } +} + +class _ActivityRowActionsPopover extends HookWidget { + final Rect anchorRect; + final ui.Image anchorSnapshot; + final Animation animation; + final List<_ActivityRowAction> actions; + final _ActivityActionSurfaceKind surfaceKind; + final VoidCallback onTransitionDismissed; + + const _ActivityRowActionsPopover({ + required this.anchorRect, + required this.anchorSnapshot, + required this.animation, + required this.actions, + required this.surfaceKind, + required this.onTransitionDismissed, + }); + + @override + Widget build(BuildContext context) { + final transitionBegan = useRef( + animation.status != AnimationStatus.dismissed, + ); + useEffect(() { + var completed = false; + + void finish() { + if (completed) return; + completed = true; + onTransitionDismissed(); + } + + void handleStatus(AnimationStatus status) { + if (status != AnimationStatus.dismissed) transitionBegan.value = true; + if (transitionBegan.value && status == AnimationStatus.dismissed) { + finish(); + } + } + + animation.addStatusListener(handleStatus); + handleStatus(animation.status); + return () { + animation.removeStatusListener(handleStatus); + finish(); + }; + }, [animation, onTransitionDismissed]); + + final mediaQuery = MediaQuery.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final safeLeft = mediaQuery.padding.left + Grid.xxs; + final safeRight = + constraints.maxWidth - mediaQuery.padding.right - Grid.xxs; + final safeTop = mediaQuery.padding.top + Grid.xxs; + final safeBottom = + constraints.maxHeight - mediaQuery.padding.bottom - Grid.xxs; + final availableWidth = safeRight - safeLeft; + final availableHeight = safeBottom - safeTop; + final menuWidth = math.min( + _activityContextMenuMaxWidth, + availableWidth, + ); + final preferredMenuHeight = + (_activityActionVerticalInset * 2) + + (actions.length * _activityActionRowHeight); + final menuHeight = math.min( + preferredMenuHeight, + math.max( + _activityActionRowHeight, + availableHeight - _activityContextGap - 56, + ), + ); + final previewMaximumHeight = math.max( + 56, + availableHeight - menuHeight - _activityContextGap, + ); + final previewInsetExtent = _activityContextPreviewInset * 2; + final previewScale = math.min( + 1, + math.min( + (math.min(_activityContextPreviewMaxWidth, availableWidth) - + previewInsetExtent) / + anchorRect.width, + (previewMaximumHeight - previewInsetExtent) / anchorRect.height, + ), + ); + final previewContentSize = Size( + anchorRect.width * previewScale, + anchorRect.height * previewScale, + ); + final previewSize = Size( + previewContentSize.width + previewInsetExtent, + previewContentSize.height + previewInsetExtent, + ); + final previewLeft = + safeLeft + ((availableWidth - previewSize.width) / 2); + final totalHeight = + previewSize.height + _activityContextGap + menuHeight; + final contentTop = safeTop + ((availableHeight - totalHeight) / 2); + final targetPreviewRect = Rect.fromLTWH( + previewLeft, + contentTop, + previewSize.width, + previewSize.height, + ); + final menuRect = Rect.fromLTWH( + safeLeft + ((availableWidth - menuWidth) / 2), + targetPreviewRect.bottom + _activityContextGap, + menuWidth, + menuHeight, + ); + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final progress = Curves.easeOutCubic.transform( + animation.value, + ); + return BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 14 * progress, + sigmaY: 14 * progress, + ), + child: ColoredBox( + color: context.colors.inverseSurface.withValues( + alpha: 0.12 * progress, + ), + ), + ); + }, + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + ), + ), + AnimatedBuilder( + animation: animation, + builder: (context, child) { + final movement = Curves.easeInOutCubic.transform( + animation.value, + ); + final displayRect = Rect.lerp( + anchorRect.inflate(_activityContextPreviewInset), + targetPreviewRect, + movement, + )!; + return Positioned.fromRect(rect: displayRect, child: child!); + }, + child: DecoratedBox( + key: const ValueKey('activity-row-action-preview-container'), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.7), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(_activityContextPreviewInset), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.xs), + child: RawImage( + key: const ValueKey('activity-row-action-preview'), + image: anchorSnapshot, + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ), + Positioned.fromRect( + rect: menuRect, + child: AnimatedBuilder( + animation: animation, + child: surfaceKind == _ActivityActionSurfaceKind.iosNative + ? _IosNativeActivityActionSurface(actions: actions) + : _AndroidActivityActionSurface(actions: actions), + builder: (context, child) { + final appearance = const Interval( + 0.12, + 0.72, + curve: Curves.easeOutCubic, + ).transform(animation.value); + return Opacity( + opacity: appearance, + child: Transform.scale( + alignment: Alignment.topCenter, + scale: ui.lerpDouble(0.96, 1, appearance)!, + child: child, + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _IosNativeActivityActionSurface extends HookWidget { + final List<_ActivityRowAction> actions; + + const _IosNativeActivityActionSurface({required this.actions}); + + @override + Widget build(BuildContext context) { + final viewId = useState(null); + final navigator = Navigator.of(context); + final navigatorRef = useRef(navigator)..value = navigator; + + 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) navigatorRef.value.pop(actionId); + }); + return () => channel.setMethodCallHandler(null); + }, [viewId.value]); + + return ClipRRect( + key: const ValueKey('activity-native-action-surface'), + borderRadius: BorderRadius.circular(_activityActionSurfaceRadius), + child: UiKitView( + viewType: 'buzz/native_message_action_surface', + creationParams: { + 'actions': [ + for (final action in actions) action.toPlatformArguments(), + ], + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) => viewId.value = id, + ), + ); + } +} + +class _AndroidActivityActionSurface extends StatelessWidget { + final List<_ActivityRowAction> actions; + + const _AndroidActivityActionSurface({required this.actions}); + + @override + Widget build(BuildContext context) { + return Material( + key: const ValueKey('activity-android-action-surface'), + color: context.colors.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_activityActionSurfaceRadius), + side: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.55), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: _activityActionVerticalInset, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in actions) + Semantics( + button: true, + label: action.title, + excludeSemantics: true, + child: InkWell( + key: ValueKey('activity-row-action-${action.id}'), + onTap: () => Navigator.of(context).pop(action.id), + child: SizedBox( + height: _activityActionRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Row( + children: [ + SizedBox( + width: _activityActionIconSlotWidth, + child: Center( + child: Icon( + action.icon, + size: _activityActionIconSize, + color: context.colors.onSurface, + ), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Text( + action.title, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 3bea3a6c7f..e28fc00334 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -40,115 +40,14 @@ import 'timeline_message.dart'; part 'message_actions/reaction_popover.dart'; part 'message_actions/ios_native_message_actions.dart'; part 'message_actions/ios_message_actions_popover.dart'; +part 'message_actions/android_message_actions_popover.dart'; +part 'message_actions/show_message_actions.dart'; part 'message_actions/message_actions_sheet.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. const _reminderPreviewLength = 100; -void showMessageActions({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool canManageMessage, - List? allMessages, - String? currentPubkey, - bool isMember = false, - bool isArchived = false, - Rect? anchorRect, - Future Function()? captureAnchorSnapshot, - EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), - VoidCallback? onPopoverPresented, - VoidCallback? onPopoverDismissed, -}) { - final hasReactionOnlyActions = message.isSystem && !canManageMessage; - if (anchorRect != null && hasReactionOnlyActions) { - if (Theme.of(context).platform == TargetPlatform.iOS && - captureAnchorSnapshot != null) { - unawaited( - _showIosReactionOnlyPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - spotlightPadding: popoverSpotlightPadding, - onPopoverPresented: onPopoverPresented, - onPopoverDismissed: onPopoverDismissed, - ).then((shown) { - if (shown || !context.mounted) return; - _showMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: popoverSpotlightPadding, - ); - }), - ); - return; - } - _showMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: popoverSpotlightPadding, - ); - return; - } - - if (anchorRect != null && - Theme.of(context).platform == TargetPlatform.iOS && - captureAnchorSnapshot != null) { - unawaited( - _showIosMessageActionsPopover( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - spotlightPadding: popoverSpotlightPadding, - onPopoverPresented: onPopoverPresented, - onPopoverDismissed: onPopoverDismissed, - ).then((shown) { - if (shown || !context.mounted) return; - _showMessageActionsSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); - }), - ); - return; - } - - _showMessageActionsSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); -} - /// Image-focused actions shown from the full-screen viewer. void showImageActions({ required BuildContext context, diff --git a/mobile/lib/features/channels/message_actions/android_message_actions_popover.dart b/mobile/lib/features/channels/message_actions/android_message_actions_popover.dart new file mode 100644 index 0000000000..03ed517dfe --- /dev/null +++ b/mobile/lib/features/channels/message_actions/android_message_actions_popover.dart @@ -0,0 +1,225 @@ +part of '../message_actions.dart'; + +const _androidActionRowMinHeight = 52.0; +const _androidActionSeparatorHeight = 0.5; +const _androidActionVerticalInset = Grid.half; +const _androidContextMenuMaxWidth = 288.0; +const _androidContextMenuRadius = 26.0; +const _androidActionIconSize = 22.0; +const _androidActionIconSlotWidth = 32.0; +bool _androidMessageActionsPresentationInFlight = false; + +Future _showAndroidMessageActionsPopover({ + 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 EdgeInsets spotlightPadding, + required VoidCallback? onPopoverPresented, + required VoidCallback? onPopoverDismissed, +}) async { + if (_androidMessageActionsPresentationInFlight) return true; + _androidMessageActionsPresentationInFlight = true; + try { + final actions = _buildMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + if (actions.isEmpty) return false; + + final ui.Image snapshot; + try { + snapshot = await captureAnchorSnapshot(); + } catch (_) { + return false; + } + if (!context.mounted) { + snapshot.dispose(); + return false; + } + + unawaited(HapticFeedback.mediumImpact()); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + onPopoverPresented?.call(); + String? selectedActionId; + try { + selectedActionId = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Dismiss message actions', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : _iosContextTransitionDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) => + _MessageActionsPopover( + anchorRect: anchorRect, + anchorSnapshot: snapshot, + spotlightPadding: spotlightPadding, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + actions: actions, + actionSurfaceKind: _MessageActionSurfaceKind.androidFlutter, + ), + ); + } finally { + snapshot.dispose(); + if (context.mounted) onPopoverDismissed?.call(); + } + + final selectedAction = actions + .where((action) => action.id == selectedActionId) + .firstOrNull; + if (selectedAction != null) await selectedAction.onSelected(); + return true; + } finally { + _androidMessageActionsPresentationInFlight = false; + } +} + +class _AndroidMessageActionSurface extends StatelessWidget { + final List<_MessageAction> actions; + final ValueChanged onSelected; + + const _AndroidMessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Material( + key: const ValueKey('android-message-action-surface'), + color: context.colors.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_androidContextMenuRadius), + 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: _androidActionVerticalInset, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0 && + actions[index - 1].group != actions[index].group) + Container( + key: ValueKey( + 'android-message-action-divider-${actions[index].group.name}', + ), + height: _androidActionSeparatorHeight, + margin: const EdgeInsets.symmetric(horizontal: Grid.xs), + color: context.colors.outlineVariant.withValues(alpha: 0.7), + ), + _AndroidMessageActionRow( + action: actions[index], + onSelected: onSelected, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _AndroidMessageActionRow extends StatelessWidget { + final _MessageAction action; + final ValueChanged onSelected; + + const _AndroidMessageActionRow({ + 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('android-message-action-${action.id}'), + onTap: () => onSelected(action.id), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _androidActionRowMinHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Row( + children: [ + SizedBox( + width: _androidActionIconSlotWidth, + child: Center( + child: Icon( + action.flutterIcon, + key: ValueKey('android-message-action-icon-${action.id}'), + size: _androidActionIconSize, + color: foreground, + ), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Text( + action.title, + key: ValueKey('android-message-action-label-${action.id}'), + style: context.textTheme.bodyLarge?.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +double _androidActionSurfacePreferredHeight(List<_MessageAction> actions) { + var separatorCount = 0; + for (var index = 1; index < actions.length; index++) { + if (actions[index - 1].group != actions[index].group) separatorCount += 1; + } + return (_androidActionVerticalInset * 2) + + (actions.length * _androidActionRowMinHeight) + + (separatorCount * _androidActionSeparatorHeight); +} diff --git a/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart b/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart index 56784ba867..25d2d1544a 100644 --- a/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart +++ b/mobile/lib/features/channels/message_actions/ios_message_actions_popover.dart @@ -50,7 +50,7 @@ Future _showIosReactionOnlyPopover({ transitionBuilder: (context, animation, secondaryAnimation, child) => child, pageBuilder: (dialogContext, animation, secondaryAnimation) => - _IosMessageActionsPopover( + _MessageActionsPopover( anchorRect: anchorRect, anchorSnapshot: snapshot, spotlightPadding: spotlightPadding, @@ -59,6 +59,7 @@ Future _showIosReactionOnlyPopover({ pageContext: context, pageRef: ref, actions: const [], + actionSurfaceKind: _MessageActionSurfaceKind.iosNative, ), ); } finally { @@ -131,7 +132,7 @@ Future _presentIosMessageActionsPopover({ return false; } - final actions = _buildIosNativeMessageActions( + final actions = _buildMessageActions( context: context, ref: ref, message: message, @@ -171,7 +172,7 @@ Future _presentIosMessageActionsPopover({ transitionBuilder: (context, animation, secondaryAnimation, child) => child, pageBuilder: (dialogContext, animation, secondaryAnimation) => - _IosMessageActionsPopover( + _MessageActionsPopover( anchorRect: anchorRect, anchorSnapshot: snapshot, spotlightPadding: spotlightPadding, @@ -180,6 +181,7 @@ Future _presentIosMessageActionsPopover({ pageContext: context, pageRef: ref, actions: actions, + actionSurfaceKind: _MessageActionSurfaceKind.iosNative, ), ); } finally { @@ -194,7 +196,9 @@ Future _presentIosMessageActionsPopover({ return true; } -class _IosMessageActionsPopover extends StatelessWidget { +enum _MessageActionSurfaceKind { iosNative, androidFlutter } + +class _MessageActionsPopover extends StatelessWidget { final Rect anchorRect; final ui.Image anchorSnapshot; final EdgeInsets spotlightPadding; @@ -202,9 +206,10 @@ class _IosMessageActionsPopover extends StatelessWidget { final TimelineMessage message; final BuildContext pageContext; final WidgetRef pageRef; - final List<_IosNativeMessageAction> actions; + final List<_MessageAction> actions; + final _MessageActionSurfaceKind actionSurfaceKind; - const _IosMessageActionsPopover({ + const _MessageActionsPopover({ required this.anchorRect, required this.anchorSnapshot, required this.spotlightPadding, @@ -213,6 +218,7 @@ class _IosMessageActionsPopover extends StatelessWidget { required this.pageContext, required this.pageRef, required this.actions, + required this.actionSurfaceKind, }); @override @@ -231,13 +237,22 @@ class _IosMessageActionsPopover extends StatelessWidget { final hasActionMenu = actions.isNotEmpty; final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); final menuWidth = hasActionMenu - ? math.min(_iosContextMenuMaxWidth, availableWidth) + ? math.min( + actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter + ? _androidContextMenuMaxWidth + : _iosContextMenuMaxWidth, + availableWidth, + ) : 0.0; final menuHeight = hasActionMenu ? math.min( - _iosActionSurfacePreferredHeight(actions), + actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter + ? _androidActionSurfacePreferredHeight(actions) + : _iosActionSurfacePreferredHeight(actions), math.max( - _iosActionRowHeight * 3, + actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter + ? _androidActionRowMinHeight * 3 + : _iosActionRowHeight * 3, availableHeight - _reactionTrayMaxHeight - (_iosContextGap * 2) - @@ -384,11 +399,19 @@ class _IosMessageActionsPopover extends StatelessWidget { rect: menuRect, child: AnimatedBuilder( animation: animation, - child: _IosNativeMessageActionSurface( - actions: actions, - onSelected: (actionId) => - Navigator.of(context).pop(actionId), - ), + child: + actionSurfaceKind == + _MessageActionSurfaceKind.androidFlutter + ? _AndroidMessageActionSurface( + actions: actions, + onSelected: (actionId) => + Navigator.of(context).pop(actionId), + ) + : _IosNativeMessageActionSurface( + actions: actions, + onSelected: (actionId) => + Navigator.of(context).pop(actionId), + ), builder: (context, child) { final appearance = const Interval( 0.12, @@ -398,7 +421,11 @@ class _IosMessageActionsPopover extends StatelessWidget { return Opacity( opacity: appearance, child: Transform.scale( - alignment: Alignment.topLeft, + alignment: + actionSurfaceKind == + _MessageActionSurfaceKind.androidFlutter + ? Alignment.topCenter + : Alignment.topLeft, scale: ui.lerpDouble(0.96, 1, appearance)!, child: child, ), @@ -492,7 +519,7 @@ class _IosReactionTray extends StatelessWidget { } class _IosNativeMessageActionSurface extends HookWidget { - final List<_IosNativeMessageAction> actions; + final List<_MessageAction> actions; final ValueChanged onSelected; const _IosNativeMessageActionSurface({ @@ -540,7 +567,7 @@ class _IosNativeMessageActionSurface extends HookWidget { } class _TestIosNativeMessageActionSurface extends StatelessWidget { - final List<_IosNativeMessageAction> actions; + final List<_MessageAction> actions; final ValueChanged onSelected; const _TestIosNativeMessageActionSurface({ @@ -561,7 +588,7 @@ class _TestIosNativeMessageActionSurface extends StatelessWidget { children: [ for (final action in actions) SizedBox( - height: action.group == _IosNativeMessageActionGroup.promoted + height: action.group == _MessageActionGroup.promoted ? _iosActionPromotedHeight : _iosActionRowHeight, child: ListTile( @@ -577,11 +604,11 @@ class _TestIosNativeMessageActionSurface extends StatelessWidget { } } -double _iosActionSurfacePreferredHeight(List<_IosNativeMessageAction> actions) { +double _iosActionSurfacePreferredHeight(List<_MessageAction> actions) { final groups = {for (final action in actions) action.group}; - final promoted = groups.contains(_IosNativeMessageActionGroup.promoted); + final promoted = groups.contains(_MessageActionGroup.promoted); final rowCount = actions - .where((action) => action.group != _IosNativeMessageActionGroup.promoted) + .where((action) => action.group != _MessageActionGroup.promoted) .length; return (_iosActionVerticalInset * 2) + (promoted ? _iosActionPromotedHeight : 0) + diff --git a/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart b/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart index 9dbb6524b2..1040fc9d14 100644 --- a/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart +++ b/mobile/lib/features/channels/message_actions/ios_native_message_actions.dart @@ -4,7 +4,7 @@ const _nativeMessageActionSurfaceChannel = MethodChannel( 'buzz/native_message_action_surface', ); -List<_IosNativeMessageAction> _buildIosNativeMessageActions({ +List<_MessageAction> _buildMessageActions({ required BuildContext context, required WidgetRef ref, required TimelineMessage message, @@ -15,18 +15,18 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ required bool isMember, required bool isArchived, }) { - final actions = <_IosNativeMessageAction>[]; + final actions = <_MessageAction>[]; final messages = allMessages; final canRemind = ref.read(reminderServiceProvider) != null; if (!message.isSystem) { if (messages != null) { actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'reply', title: 'Reply', symbol: 'arrowshape.turn.up.left', - group: _IosNativeMessageActionGroup.promoted, + group: _MessageActionGroup.promoted, onSelected: () { if (!context.mounted) return; Navigator.of(context).push( @@ -46,11 +46,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ ); } actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'copyLink', title: 'Copy link', symbol: 'link', - group: _IosNativeMessageActionGroup.promoted, + group: _MessageActionGroup.promoted, onSelected: () { if (!context.mounted) return; copyToClipboard( @@ -63,11 +63,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ ); if (canRemind) { actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'remind', title: 'Remind me', symbol: 'clock', - group: _IosNativeMessageActionGroup.promoted, + group: _MessageActionGroup.promoted, onSelected: () { if (!context.mounted) return; showRemindMeLaterSheet( @@ -97,11 +97,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ threadRootId: message.rootId, ); actions.add( - _IosNativeMessageAction( + _MessageAction( id: unread ? 'markRead' : 'markUnread', title: unread ? 'Mark read' : 'Mark unread', symbol: unread ? 'envelope.open' : 'envelope.badge', - group: _IosNativeMessageActionGroup.triage, + group: _MessageActionGroup.triage, onSelected: () { final notifier = ref.read(readStateProvider.notifier); if (unread) { @@ -123,11 +123,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ final rootId = message.rootId ?? message.id; final following = ref.read(threadFollowsProvider).isFollowing(rootId); actions.add( - _IosNativeMessageAction( + _MessageAction( id: following ? 'unfollowThread' : 'followThread', title: following ? 'Unfollow thread' : 'Follow thread', symbol: following ? 'bell.slash' : 'bell', - group: _IosNativeMessageActionGroup.triage, + group: _MessageActionGroup.triage, onSelected: () { final notifier = ref.read(threadFollowsProvider.notifier); if (following) { @@ -139,11 +139,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ ), ); actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'copyText', title: 'Copy text', symbol: 'doc.on.doc', - group: _IosNativeMessageActionGroup.export, + group: _MessageActionGroup.export, onSelected: () => Clipboard.setData(ClipboardData(text: message.content)), ), @@ -152,11 +152,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ if (canManageMessage) { actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'edit', title: 'Edit message', symbol: 'pencil', - group: _IosNativeMessageActionGroup.manage, + group: _MessageActionGroup.manage, onSelected: () { if (!context.mounted) return; _showEditSheet( @@ -169,11 +169,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ ), ); actions.add( - _IosNativeMessageAction( + _MessageAction( id: 'delete', title: 'Delete message', symbol: 'trash', - group: _IosNativeMessageActionGroup.manage, + group: _MessageActionGroup.manage, destructive: true, onSelected: () { if (!context.mounted) return; @@ -191,17 +191,17 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({ return actions; } -enum _IosNativeMessageActionGroup { promoted, triage, export, manage } +enum _MessageActionGroup { promoted, triage, export, manage } -class _IosNativeMessageAction { +class _MessageAction { final String id; final String title; final String symbol; - final _IosNativeMessageActionGroup group; + final _MessageActionGroup group; final bool destructive; final FutureOr Function() onSelected; - const _IosNativeMessageAction({ + const _MessageAction({ required this.id, required this.title, required this.symbol, @@ -210,6 +210,20 @@ class _IosNativeMessageAction { this.destructive = false, }); + IconData get flutterIcon => switch (id) { + 'reply' => LucideIcons.messageSquareReply, + 'copyLink' => LucideIcons.link2, + 'remind' => LucideIcons.clock, + 'markRead' => LucideIcons.mailCheck, + 'markUnread' => LucideIcons.mailOpen, + 'followThread' => LucideIcons.bellRing, + 'unfollowThread' => LucideIcons.bellOff, + 'copyText' => LucideIcons.copy, + 'edit' => LucideIcons.pencil, + 'delete' => LucideIcons.trash2, + _ => LucideIcons.ellipsis, + }; + Map toPlatformArguments() => { 'id': id, 'title': title, diff --git a/mobile/lib/features/channels/message_actions/show_message_actions.dart b/mobile/lib/features/channels/message_actions/show_message_actions.dart new file mode 100644 index 0000000000..ea170b8453 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/show_message_actions.dart @@ -0,0 +1,141 @@ +part of '../message_actions.dart'; + +void showMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + List? allMessages, + String? currentPubkey, + bool isMember = false, + bool isArchived = false, + Rect? anchorRect, + Future Function()? captureAnchorSnapshot, + EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), + VoidCallback? onPopoverPresented, + VoidCallback? onPopoverDismissed, +}) { + final hasReactionOnlyActions = message.isSystem && !canManageMessage; + if (anchorRect != null && hasReactionOnlyActions) { + if (Theme.of(context).platform == TargetPlatform.iOS && + captureAnchorSnapshot != null) { + unawaited( + _showIosReactionOnlyPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: popoverSpotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ).then((shown) { + if (shown || !context.mounted) return; + _showMessageReactionPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + spotlightPadding: popoverSpotlightPadding, + ); + }), + ); + return; + } + _showMessageReactionPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + spotlightPadding: popoverSpotlightPadding, + ); + return; + } + + if (anchorRect != null && + Theme.of(context).platform == TargetPlatform.iOS && + captureAnchorSnapshot != null) { + unawaited( + _showIosMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: popoverSpotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ).then((shown) { + if (shown || !context.mounted) return; + _showMessageActionsSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + }), + ); + return; + } + + if (anchorRect != null && + Theme.of(context).platform == TargetPlatform.android && + captureAnchorSnapshot != null) { + unawaited( + _showAndroidMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + spotlightPadding: popoverSpotlightPadding, + onPopoverPresented: onPopoverPresented, + onPopoverDismissed: onPopoverDismissed, + ).then((shown) { + if (shown || !context.mounted) return; + _showMessageActionsSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + }), + ); + return; + } + + _showMessageActionsSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); +} diff --git a/mobile/lib/shared/widgets/ios_native_menu_button.dart b/mobile/lib/shared/widgets/ios_native_menu_button.dart new file mode 100644 index 0000000000..4204277981 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_menu_button.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +const _supportChannel = MethodChannel('buzz/native_menu_button'); + +/// A single action displayed by an iOS system menu. +class IosNativeMenuItem { + final String id; + final String title; + final String? systemImage; + final String group; + final bool checked; + final bool enabled; + final bool destructive; + + const IosNativeMenuItem({ + required this.id, + required this.title, + this.systemImage, + this.group = 'primary', + this.checked = false, + this.enabled = true, + this.destructive = false, + }); + + Map toPlatformArguments() => { + 'id': id, + 'title': title, + if (systemImage != null) 'systemImage': systemImage, + 'group': group, + 'checked': checked, + 'enabled': enabled, + 'destructive': destructive, + }; +} + +/// A native UIButton whose primary action opens a UIKit UIMenu. +/// +/// Call [isSupported] before building this widget. Android and older iOS +/// versions should retain their existing Flutter menu trigger. +class IosNativeMenuButton extends HookWidget { + final String? title; + final String? systemImage; + final String accessibilityLabel; + final Color foregroundColor; + final List items; + final ValueChanged onSelected; + + const IosNativeMenuButton({ + super.key, + this.title, + this.systemImage, + required this.accessibilityLabel, + required this.foregroundColor, + required this.items, + required this.onSelected, + }); + + static Future isSupported() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + try { + return await _supportChannel.invokeMethod('isSupported') ?? false; + } on MissingPluginException { + return false; + } on PlatformException { + return false; + } + } + + @override + Widget build(BuildContext context) { + final viewId = useState(null); + final onSelectedRef = useRef(onSelected)..value = onSelected; + final arguments = { + if (title != null) 'title': title, + if (systemImage != null) 'systemImage': systemImage, + 'accessibilityLabel': accessibilityLabel, + 'foregroundColor': foregroundColor.toARGB32(), + 'items': [for (final item in items) item.toPlatformArguments()], + }; + final itemSignature = Object.hashAll([ + for (final item in items) + Object.hash( + item.id, + item.title, + item.systemImage, + item.group, + item.checked, + item.enabled, + item.destructive, + ), + ]); + + useEffect(() { + final id = viewId.value; + if (id == null) return null; + final channel = MethodChannel('buzz/native_menu_button/$id'); + channel.setMethodCallHandler((call) async { + if (call.method != 'selected' || call.arguments is! Map) return; + final selectedId = (call.arguments as Map)['id']; + if (selectedId is String) onSelectedRef.value(selectedId); + }); + return () => channel.setMethodCallHandler(null); + }, [viewId.value]); + + useEffect( + () { + final id = viewId.value; + if (id == null) return null; + final channel = MethodChannel('buzz/native_menu_button/$id'); + unawaited(channel.invokeMethod('update', arguments)); + return null; + }, + [ + viewId.value, + title, + systemImage, + accessibilityLabel, + foregroundColor.toARGB32(), + itemSignature, + ], + ); + + return UiKitView( + viewType: 'buzz/native_menu_button', + creationParams: arguments, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) => viewId.value = id, + ); + } +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 8619a513d9..992a818953 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -683,17 +683,122 @@ void main() { await tester.longPress(find.byKey(const ValueKey('inbox-row-m1'))); await tester.pumpAndSettle(); + final preview = find.byKey( + const ValueKey('activity-row-action-preview-container'), + ); + final menu = find.byKey(const ValueKey('activity-android-action-surface')); + expect(preview, findsOneWidget); + expect(menu, findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing); + expect( + tester.getTopLeft(menu).dy, + greaterThan(tester.getBottomLeft(preview).dy), + ); + + final source = tester.widget( + find.byKey(const ValueKey('activity-row-action-source-m1')), + ); + expect(source.opacity, 0); + await tester.tap( - find.descendant( - of: find.byType(BottomSheet), - matching: find.text('Mark unread'), - ), + find.byKey(const ValueKey('activity-row-action-markUnread')), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget); }); + testWidgets('iOS long press removes native header views above the overlay', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const menuButtonChannel = MethodChannel('buzz/native_menu_button'); + const actionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + menuButtonChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + actionSurfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + + try { + await tester.pumpWidget(await buildTestable()); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('activity-native-filter')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('activity-native-options')), + findsOneWidget, + ); + + await tester.longPress(find.byKey(const ValueKey('inbox-row-m1'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('activity-native-action-surface')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('activity-native-filter')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('activity-native-options')), + findsNothing, + ); + expect(find.byType(UiKitView), findsOneWidget); + + await tester.tapAt(const Offset(10, 300)); + await tester.pump(); + expect( + find.byKey(const ValueKey('activity-native-filter')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('activity-native-options')), + findsNothing, + ); + await tester.pump(const Duration(milliseconds: 120)); + expect( + find.byKey(const ValueKey('activity-native-filter')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('activity-native-options')), + findsNothing, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-native-filter')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('activity-native-options')), + findsOneWidget, + ); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + menuButtonChannel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + actionSurfaceChannel, + null, + ); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('swiping an inbox row reveals and runs its row actions', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d5c3d76252..9c8a4c559b 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1625,8 +1625,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('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('android-message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); expect(find.text('Copy text'), findsOneWidget); }); diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 239d54d46b..4a9deb6512 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -417,6 +417,161 @@ void main() { ); group('showMessageActions', () { + testWidgets( + 'Android composes the existing tray, lifted preview, and curved actions', + (tester) async { + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(), + prefs: prefs, + canManageMessage: true, + allMessages: [_message()], + reminderService: _stubReminderService(), + anchorRect: const Rect.fromLTWH(32, 260, 300, 72), + ); + + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview-container')), + findsOneWidget, + ); + final surface = find.byKey( + const ValueKey('android-message-action-surface'), + ); + expect(surface, findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + + const actionIds = [ + 'reply', + 'copyLink', + 'remind', + 'markUnread', + 'followThread', + 'copyText', + 'edit', + 'delete', + ]; + const actionTitles = [ + 'Reply', + 'Copy link', + 'Remind me', + 'Mark unread', + 'Follow thread', + 'Copy text', + 'Edit message', + 'Delete message', + ]; + for (var index = 0; index < actionIds.length; index++) { + expect( + find.byKey(ValueKey('android-message-action-${actionIds[index]}')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey( + ValueKey( + 'android-message-action-label-${actionIds[index]}', + ), + ), + ) + .data, + actionTitles[index], + ); + } + + final surfaceRect = tester.getRect(surface); + final previewRect = tester.getRect( + find.byKey(const ValueKey('message-action-preview-container')), + ); + final trayRect = tester.getRect( + find.byKey(const ValueKey('reaction-popover-tray')), + ); + expect(surfaceRect.width, 288); + expect(surfaceRect.center.dx, closeTo(previewRect.center.dx, 0.01)); + expect(surfaceRect.center.dx, closeTo(trayRect.center.dx, 0.01)); + expect( + tester.widget(surface).shape, + isA().having( + (shape) => shape.borderRadius, + 'border radius', + BorderRadius.circular(26), + ), + ); + expect( + find.byKey(const ValueKey('android-message-action-divider-triage')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('android-message-action-divider-export')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('android-message-action-divider-manage')), + findsOneWidget, + ); + + final iconCenters = [ + for (final id in actionIds) + tester + .getRect( + find.byKey(ValueKey('android-message-action-icon-$id')), + ) + .center + .dx, + ]; + final labelLefts = [ + for (final id in actionIds) + tester + .getRect( + find.byKey(ValueKey('android-message-action-label-$id')), + ) + .left, + ]; + for (final center in iconCenters.skip(1)) { + expect(center, closeTo(iconCenters.first, 0.01)); + } + for (final left in labelLefts.skip(1)) { + expect(left, closeTo(labelLefts.first, 0.01)); + } + + Navigator.of(tester.element(surface)).pop(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Android runs its selected action after dismissing the menu', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(rootId: 'android-root'), + prefs: prefs, + anchorRect: const Rect.fromLTWH(32, 260, 300, 72), + ); + final container = ProviderScope.containerOf( + tester.element(find.text('open')), + ); + + await tester.tap( + find.byKey(const ValueKey('android-message-action-followThread')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('android-message-action-surface')), + findsNothing, + ); + expect(container.read(threadFollowsProvider).followedRootIds, { + 'android-root', + }); + }); + testWidgets( 'composes the Flutter tray, lifted preview, and native actions', (tester) async { diff --git a/mobile/test/shared/widgets/ios_native_menu_button_test.dart b/mobile/test/shared/widgets/ios_native_menu_button_test.dart new file mode 100644 index 0000000000..1e0030a283 --- /dev/null +++ b/mobile/test/shared/widgets/ios_native_menu_button_test.dart @@ -0,0 +1,93 @@ +import 'package:buzz/shared/widgets/ios_native_menu_button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('serializes native iOS menu item state and grouping', () { + const item = IosNativeMenuItem( + id: 'mentions', + title: 'Mentions', + systemImage: 'at', + group: 'inbox', + checked: true, + enabled: false, + ); + + expect(item.toPlatformArguments(), { + 'id': 'mentions', + 'title': 'Mentions', + 'systemImage': 'at', + 'group': 'inbox', + 'checked': true, + 'enabled': false, + 'destructive': false, + }); + }); + + testWidgets('keeps its selection callback while menu content updates', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const viewId = 74; + final channel = MethodChannel('buzz/native_menu_button/$viewId'); + final selected = []; + final checked = ValueNotifier(false); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + + try { + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: checked, + builder: (context, value, child) => SizedBox( + width: 120, + height: 48, + child: IosNativeMenuButton( + title: value ? 'Mentions' : 'All', + accessibilityLabel: 'Filter activity', + foregroundColor: Colors.black, + items: [ + IosNativeMenuItem( + id: 'mention', + title: 'Mentions', + checked: value, + ), + ], + onSelected: selected.add, + ), + ), + ), + ), + ); + final platformView = tester.widget(find.byType(UiKitView)); + platformView.onPlatformViewCreated?.call(viewId); + await tester.pump(); + + checked.value = true; + await tester.pump(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('selected', {'id': 'mention'}), + ), + null, + ); + await tester.pump(); + + expect(selected, ['mention']); + } finally { + checked.dispose(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); +}