From b5223d2525defa2a53dcd29ddcc69f609b8711ab Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 13 Oct 2026 12:55:07 -0400 Subject: [PATCH] Use grouped message action sheets and fix date layering (#8280) ## Summary Replace message long-press popovers with the shared grouped bottom sheet: an emoji row, Reply / Copy link / Remind me tiles, and remaining actions in regular sections. Match emoji backgrounds to the sections and use the theme primary color for the selected reaction. Keep the scrolling date above the navigation blur in channels and threads. Restore composer focus when the sheet is canceled, keep section gaps consistent, and soften shared dividers to 7% opacity. The reaction details sheet shows all reactors by default. Emoji count chips toggle your own reaction, show your selected state, and update live without dismissing the sheet. Unselected reactions have visible outlines; adding a supported emoji plays the chat-style burst and respects reduced motion. ### Related issue No matching issue found. Related sheet styling: #4911. ### Testing - Full mobile checks and tests passed, including 23 reaction tests and composer-focus regression coverage. - Analysis and file-size checks passed; signed iOS build installed and launched on a physical iPhone. - Full `just ci` stopped during Rust compilation due to low disk space. - On-device visual capture unavailable. --------- Signed-off-by: kenny lopez --- ...NativeMessagePresentationCoordinator.swift | 14 + mobile/ios/Runner/NativeReactionDetails.swift | 117 +- .../channels/channel_detail_page.dart | 30 + .../channel_detail_page/message_list.dart | 27 +- .../features/channels/message_actions.dart | 302 ++-- .../message_action_popover.dart | 777 ---------- .../message_action_popover_widgets.dart | 223 --- .../message_reaction_tray.dart | 36 - .../message_actions/native_actions.dart | 242 --- .../message_actions/quick_reaction_row.dart | 56 +- .../message_actions/reaction_popover.dart | 345 ----- .../lib/features/channels/reaction_row.dart | 341 ++--- .../features/channels/thread_detail_page.dart | 25 +- mobile/lib/shared/widgets/app_list_card.dart | 2 +- .../lib/shared/widgets/frosted_scaffold.dart | 13 +- .../widgets/native_message_presentation.dart | 19 +- .../channels/channel_detail_page_test.dart | 443 +----- .../action_row_tests.dart | 4 +- .../channels/message_actions_test.dart | 1304 +++-------------- .../features/channels/reaction_row_test.dart | 240 ++- mobile/test/shared/widgets/app_list_test.dart | 2 +- .../shared/widgets/frosted_scaffold_test.dart | 37 + 22 files changed, 959 insertions(+), 3640 deletions(-) delete mode 100644 mobile/lib/features/channels/message_actions/message_action_popover.dart delete mode 100644 mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart delete mode 100644 mobile/lib/features/channels/message_actions/message_reaction_tray.dart delete mode 100644 mobile/lib/features/channels/message_actions/native_actions.dart delete mode 100644 mobile/lib/features/channels/message_actions/reaction_popover.dart diff --git a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift index 4a6d846c1f..4b7821dcd3 100644 --- a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift +++ b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift @@ -25,6 +25,15 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati result(["supported": true]) return } + if call.method == "updateReactions" { + if data["requestId"] as? String == requestID, + let sheet = presented as? NativeReactionDetailsViewController, + let reactions = data["reactions"] as? [[String: Any]] { + sheet.updateReactions(reactions) + } + result([:]) + return + } if call.method == "updateProfiles" { if data["requestId"] as? String == requestID, let sheet = presented as? NativeReactionDetailsViewController, @@ -65,6 +74,11 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } else { let sheet = NativeReactionDetailsViewController(data: data) sheet.onClose = { [weak self] in self?.dismiss([:]) } + sheet.onToggle = { [weak self] emoji in + self?.channel.invokeMethod("toggleReaction", arguments: [ + "requestId": data["requestId"] as? String ?? "", "emoji": emoji, + ]) + } sheet.modalPresentationStyle = .pageSheet if let presentation = sheet.sheetPresentationController { presentation.overrideTraitCollection = UITraitCollection(userInterfaceStyle: sheet.overrideUserInterfaceStyle) diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 00657f133d..8d6361e92c 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -1,22 +1,23 @@ import UIKit import ImageIO -/// Native resizable sheet: selected emoji first, with All and per-emoji filters. +/// Native reaction toggles above the complete list of reactors. final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource { var onClose: (() -> Void)? + var onToggle: ((String) -> Void)? private let sheetColor: UIColor private let foregroundColor: UIColor - private let reactions: [[String: Any]] + private let outlineColor: UIColor + private var reactions: [[String: Any]] private var profiles: [String: [String: Any]] - private var selectedEmoji: String? private let table = UITableView(frame: .zero, style: .plain) private let filters = UIStackView() private var filterHeight: NSLayoutConstraint? private var rows: [(String, [String: Any])] = [] + private var burstGlyphs: [UILabel] = [] private func rebuildRows() { - rows = reactions.filter { selectedEmoji == nil || $0["emoji"] as? String == selectedEmoji } - .flatMap { reaction in + rows = reactions.flatMap { reaction in (reaction["users"] as? [String] ?? []).map { ($0, reaction) } } } @@ -31,10 +32,9 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } sheetColor = color("sheetColor", fallback: .systemGroupedBackground) foregroundColor = color("foregroundColor", fallback: .label) + outlineColor = color("outlineColor", fallback: .separator) reactions = data["reactions"] as? [[String: Any]] ?? [] profiles = data["profiles"] as? [String: [String: Any]] ?? [:] - let initial = data["initialEmoji"] as? String - selectedEmoji = reactions.contains { $0["emoji"] as? String == initial } ? initial : nil super.init(nibName: nil, bundle: nil) rebuildRows() overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light @@ -132,8 +132,6 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa private func rebuildFilters() { for child in filters.arrangedSubviews { child.removeFromSuperview() } - let count = reactions.reduce(0) { $0 + ($1["count"] as? Int ?? 0) } - addFilter(title: "All \(count)", emoji: nil, reaction: nil) for reaction in reactions { guard let emoji = reaction["emoji"] as? String else { continue } addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction) @@ -144,20 +142,25 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa var config = UIButton.Configuration.tinted() config.cornerStyle = .capsule config.title = reaction == nil ? title : " \(title)" - config.baseBackgroundColor = selectedEmoji == emoji ? .systemBlue : .tertiarySystemFill - config.baseForegroundColor = selectedEmoji == emoji ? .systemBlue : .label - let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] _ in + let selected = reaction?["selected"] as? Bool == true + config.baseBackgroundColor = selected ? foregroundColor : .tertiarySystemFill + config.baseForegroundColor = selected ? foregroundColor : .label + config.background.strokeWidth = 1 + let stroke = selected ? foregroundColor.withAlphaComponent(0.40) : outlineColor + config.background.strokeColor = stroke + config.background.strokeColorTransformer = UIConfigurationColorTransformer { _ in stroke } + let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] action in guard let self else { return } - guard self.selectedEmoji != emoji else { return } - self.selectedEmoji = emoji - self.rebuildRows() - self.rebuildFilters() - self.table.reloadData() - self.table.setContentOffset(.zero, animated: false) + guard let emoji else { return } + if !selected, reaction?["burst"] as? Bool == true, + let button = action.sender as? UIButton { + self.burst(emoji, from: button) + } + self.onToggle?(emoji) UISelectionFeedbackGenerator().selectionChanged() }) button.accessibilityLabel = reaction.map { "\($0["label"] as? String ?? emoji ?? "") \(title)" } ?? title - if selectedEmoji == emoji { button.accessibilityTraits.insert(.selected) } + if selected { button.accessibilityTraits.insert(.selected) } if let reaction { let glyph = NativeMessageGlyph(data: reaction, size: 22) glyph.isUserInteractionEnabled = false @@ -173,6 +176,82 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa filters.addArrangedSubview(button) } + /// Mirrors Flutter EmojiBurstController's five-particle, fixed-60Hz physics. + /// UIKit owns this sheet, so Flutter's underlying overlay cannot paint above it. + private func burst(_ emoji: String, from button: UIView) { + guard !UIAccessibility.isReduceMotionEnabled, + burstGlyphs.count + 5 <= 160, let window = view.window else { return } + let origin = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window) + for _ in 0..<5 { + let glyph = UILabel() + glyph.text = emoji + glyph.font = .systemFont(ofSize: 18 + ceil(CGFloat.random(in: 0..<24))) + glyph.sizeToFit() + glyph.center = origin + glyph.isUserInteractionEnabled = false + glyph.isAccessibilityElement = false + window.addSubview(glyph) + burstGlyphs.append(glyph) + var x = origin.x, y = origin.y + var xv = CGFloat.random(in: -2.2..<2.2) + var yv = -CGFloat.random(in: 2.1..<4.45) + var rotation = CGFloat.random(in: -11..<11) + let spin = CGFloat.random(in: -2.6..<2.6) + let gravity = -CGFloat.random(in: 0.018..<0.036) + var scale: CGFloat = 0.25 + var positions = [NSValue(cgPoint: origin)] + var rotations = [rotation * .pi / 180] + var scales = [scale] + var opacities: [CGFloat] = [1] + for frame in 1...108 { + rotation += spin + yv = (yv + gravity) * 0.998 + xv *= 0.965 + x += xv + y += yv + scale += (1 - scale) * 0.28 + positions.append(NSValue(cgPoint: CGPoint(x: x, y: y))) + rotations.append(rotation * .pi / 180) + scales.append(scale) + opacities.append(min(1, CGFloat(108 - frame) / (108 * 0.24))) + } + let values: [(String, [Any])] = [ + ("position", positions), ("transform.rotation.z", rotations), + ("transform.scale", scales), ("opacity", opacities), + ] + let group = CAAnimationGroup() + group.duration = 108.0 / 60.0 + group.animations = values.map { key, frames in + let animation = CAKeyframeAnimation(keyPath: key) + animation.values = frames + animation.duration = group.duration + animation.calculationMode = .linear + return animation + } + glyph.layer.opacity = 0 + glyph.layer.add(group, forKey: "reaction-burst") + DispatchQueue.main.asyncAfter(deadline: .now() + group.duration) { [weak self, weak glyph] in + glyph?.removeFromSuperview() + self?.burstGlyphs.removeAll { $0 === glyph } + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + burstGlyphs.forEach { $0.removeFromSuperview() } + burstGlyphs.removeAll() + } + + func updateReactions(_ reactions: [[String: Any]]) { + guard !reactions.isEmpty else { onClose?(); return } + self.reactions = reactions + rebuildRows() + guard isViewLoaded else { return } + rebuildFilters() + table.reloadData() + } + func updateProfiles(_ profiles: [String: [String: Any]]) { self.profiles.merge(profiles) { _, new in new } guard isViewLoaded else { return } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 5549d6af63..b25edaa883 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -268,6 +268,10 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + [channel.id], + ); final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); final restoreComposerFocus = useRef(null); @@ -610,6 +614,31 @@ class ChannelDetailPage extends HookConsumerWidget { ); return FrostedScaffold( + foreground: + !resolvedChannel.isForum && + timelineEntries.isNotEmpty && + !showInitialConnectionSkeleton && + !showConnectionSkeleton.value && + !messagesState.isLoading + ? Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('channel-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), + ], + ) + : null, resizeToAvoidBottomInset: !usesFixedAndroidImeViewport || resolvedChannel.isForum, appBar: FrostedAppBar( @@ -746,6 +775,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), data: (events) { return _MessageList( + stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, allMessages: timelineMessages, initialMessageId: initialMessageId, diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 1a2c3c8211..2c18dcf786 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -1,6 +1,7 @@ part of '../channel_detail_page.dart'; class _MessageList extends HookConsumerWidget { + final ValueNotifier stickyDateHeaderState; final List entries; final List allMessages; final String? initialMessageId; @@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget { const _MessageList({ required this.entries, + required this.stickyDateHeaderState, required this.allMessages, required this.initialMessageId, required this.initialThreadRootId, @@ -48,9 +50,6 @@ class _MessageList extends HookConsumerWidget { final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); - final stickyDateHeaderState = useValueNotifier( - StickyDateHeaderState.hidden, - ); final stickyDayTimestamp = useValueNotifier(null); final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); @@ -234,7 +233,9 @@ class _MessageList extends HookConsumerWidget { StickyDateHeaderState state, { int? activeDayTimestamp, }) { - stickyDateHeaderState.value = state; + stickyDateHeaderState.value = showUnreadNavigation + ? StickyDateHeaderState.hidden + : state; stickyDayTimestamp.value = activeDayTimestamp; } @@ -536,11 +537,12 @@ class _MessageList extends HookConsumerWidget { itemPositionsListener, appBarTitleContentHeight, composerBottomInset, + showUnreadNavigation, + stickyDateHeaderState, ], ); useEffect(() { - stickyDateHeaderState.value = StickyDateHeaderState.hidden; stickyDayTimestamp.value = null; return null; }, [channelId]); @@ -880,21 +882,6 @@ class _MessageList extends HookConsumerWidget { ), ), ), - if (!showUnreadNavigation) - Positioned( - left: 0, - right: 0, - top: - frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, - ) + - Grid.twelve, - child: StickyDateHeader( - key: const ValueKey('channel-sticky-date-header'), - state: stickyDateHeaderState, - ), - ), if (showUnreadNavigation) Positioned( left: 0, diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 883f456078..6aac44bfa6 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,13 +1,9 @@ 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'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -23,17 +19,14 @@ import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/modal_presentation.dart'; -import '../../shared/widgets/native_message_presentation.dart'; -import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; import 'emoji_picker.dart'; -import 'message_action_backdrop_state.dart'; -import 'message_actions/native_message_action_selection.dart'; import 'reaction_row.dart'; import 'recent_emoji_provider.dart'; import '../../shared/read_state/message_read_state.dart'; @@ -44,38 +37,30 @@ import 'thread_follows/thread_follows_provider.dart'; import 'timeline_message.dart'; import 'unread_badge/is_high_priority_event.dart'; -part 'message_actions/reaction_popover.dart'; part 'message_actions/quick_reaction_row.dart'; -part 'message_actions/message_action_popover.dart'; -part 'message_actions/message_action_popover_widgets.dart'; -part 'message_actions/message_reaction_tray.dart'; -part 'message_actions/native_actions.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. const _reminderPreviewLength = 100; -const _messageActionBackdropBlurSigma = 20.0; -const _messageActionBackdropTintOpacity = 0.10; -final _messageActionBackdropFilter = ImageFilter.blur( - sigmaX: _messageActionBackdropBlurSigma, - sigmaY: _messageActionBackdropBlurSigma, -); -/// Presents the actions for [message] as an anchored popover when both -/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet. -/// -/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid -/// until its future completes, and the returned image becomes this function's -/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports -/// whether the constrained layout actually renders the lifted preview; -/// [onPopoverDismissed] runs after the route completes while [context] is still -/// mounted. Neither callback runs for sheet fallback or a failed capture. -/// -/// [composerFocusNode] remains caller-owned and must outlive the popover. If it -/// has focus when this function is called, the popover unfocuses it and invokes -/// [restoreComposerFocus] only after a dismissal with no selected action. The -/// restorer must remain callable for the same lifetime and no-op if its composer -/// is later disposed or replaced. +// A weak, route-owned latch shared by every action, including pointers that +// were already down when another selection started dismissing the sheet. +final _selectedMessageActionRoutes = Expando(); + +bool _selectMessageAction(BuildContext context) { + final route = ModalRoute.of(context); + if (route == null || + !route.isCurrent || + _selectedMessageActionRoutes[route] == true) { + return false; + } + _selectedMessageActionRoutes[route] = true; + Navigator.of(context).pop(true); + return true; +} + +/// Presents message reactions and grouped actions in the shared bottom sheet. +/// Snapshot and preview callbacks are not used by the sheet presentation. Future showMessageActions({ required BuildContext context, required WidgetRef ref, @@ -94,66 +79,22 @@ Future showMessageActions({ bool isArchived = false, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), }) async { - if (NativeMessagePresentation.isSupportedPlatform && - anchorRect != null && - await _showNativeMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - )) { - return; - } if (!context.mounted) return; - final hasReactionOnlyActions = message.isSystem && !canManageMessage; - if (anchorRect != null && hasReactionOnlyActions) { - _showMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: popoverSpotlightPadding, - ); - return; - } + unawaited(HapticFeedback.mediumImpact()); + final restoreFocusOnCancel = composerFocusNode?.hasFocus ?? false; + composerFocusNode?.unfocus(); - if (_tryShowMessageActionsPopover( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - )) { - return; - } - - showBuzzModalBottomSheet( + final selectedAction = await showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, showCloseButton: false, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), builder: (sheetContext) => SafeArea( + top: false, child: IconTheme.merge( data: const IconThemeData(size: 22), child: ConstrainedBox( @@ -177,10 +118,10 @@ Future showMessageActions({ pageContext: context, pageRef: ref, ), - const SizedBox(height: Grid.xs), + SizedBox(height: message.isSystem ? Grid.xxs : Grid.xs), if (!message.isSystem) ...[ // Fast actions: respond now, hand off context, defer. - _FastActionsRow( + _MessageQuickActionsRow( message: message, channelId: channelId, allMessages: allMessages, @@ -189,7 +130,8 @@ Future showMessageActions({ isArchived: isArchived, pageContext: context, ), - const SizedBox(height: Grid.xs), + // Section cards supply the other half of the 16pt gap. + const SizedBox(height: Grid.xxs), // Triage: come back to this message later. SheetActionSection( children: [ @@ -208,7 +150,7 @@ Future showMessageActions({ leading: const Icon(BuzzIcons.copy), title: const Text('Copy text'), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; // Copy to clipboard final data = ClipboardData(text: message.content); Clipboard.setData(data); @@ -224,7 +166,7 @@ Future showMessageActions({ leading: const Icon(BuzzIcons.pencil), title: const Text('Edit message'), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; _showEditSheet( context: context, ref: ref, @@ -243,7 +185,7 @@ Future showMessageActions({ style: TextStyle(color: sheetContext.colors.error), ), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; _confirmDelete( context: context, ref: ref, @@ -262,6 +204,9 @@ Future showMessageActions({ ), ), ); + if (selectedAction != true && restoreFocusOnCancel && context.mounted) { + restoreComposerFocus?.call(); + } } /// Image-focused actions shown from the full-screen viewer. @@ -567,7 +512,7 @@ class _MarkReadUnreadTile extends ConsumerWidget { leading: Icon(unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen), title: Text(unread ? 'Mark read' : 'Mark unread'), onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; final notifier = ref.read(readStateProvider.notifier); if (unread) { // Advances the `msg:` marker and clears this message's own forced @@ -608,7 +553,7 @@ class _FollowThreadTile extends ConsumerWidget { leading: Icon(following ? BuzzIcons.bellOff : BuzzIcons.bellRing), title: Text(following ? 'Unfollow thread' : 'Follow thread'), onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; final notifier = ref.read(threadFollowsProvider.notifier); if (following) { notifier.unfollowThread(rootId); @@ -620,7 +565,7 @@ class _FollowThreadTile extends ConsumerWidget { } } -class _FastActionsRow extends ConsumerWidget { +class _MessageQuickActionsRow extends ConsumerWidget { final TimelineMessage message; final String channelId; final List? allMessages; @@ -632,7 +577,7 @@ class _FastActionsRow extends ConsumerWidget { /// for the thread push and the copy-link snackbar. final BuildContext pageContext; - const _FastActionsRow({ + const _MessageQuickActionsRow({ required this.message, required this.channelId, required this.allMessages, @@ -649,11 +594,11 @@ class _FastActionsRow extends ConsumerWidget { final tiles = [ if (messages != null) - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.messageSquareReply, label: 'Reply', onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; Navigator.of(pageContext).push( MaterialPageRoute( builder: (_) => ThreadDetailPage( @@ -668,11 +613,11 @@ class _FastActionsRow extends ConsumerWidget { ); }, ), - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.link2, label: 'Copy link', onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; copyToClipboard( pageContext, messageLinkFor(message: message, channelId: channelId), @@ -681,7 +626,7 @@ class _FastActionsRow extends ConsumerWidget { }, ), if (canRemind) - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.clock, label: 'Remind me', onTap: () { @@ -689,7 +634,7 @@ class _FastActionsRow extends ConsumerWidget { context, rootNavigator: true, ).context; - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; showRemindMeLaterSheet( context: rootContext, ref: ref, @@ -706,57 +651,56 @@ class _FastActionsRow extends ConsumerWidget { ), ]; - return Row( - children: [ - for (var index = 0; index < tiles.length; index++) ...[ - Expanded(child: tiles[index]), - if (index < tiles.length - 1) const SizedBox(width: Grid.twelve), + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < tiles.length; index++) ...[ + Expanded(child: tiles[index]), + if (index < tiles.length - 1) const SizedBox(width: Grid.twelve), + ], ], - ], + ), ); } } -class _FastActionTile extends StatelessWidget { +class _MessageQuickActionTile extends StatelessWidget { final IconData icon; final String label; final VoidCallback onTap; - const _FastActionTile({ + const _MessageQuickActionTile({ required this.icon, required this.label, required this.onTap, }); @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // The full-width tiles deliberately contrast with the compact emoji - // reactions immediately above them. - Container( - height: 68 + (Grid.xxs * 2), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - ), + Widget build(BuildContext context) => Semantics( + button: true, + child: Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)), + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), child: Column( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 22, color: context.colors.onSurface), const SizedBox(height: Grid.xxs), Text( label, - maxLines: 1, - overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurface, ), @@ -764,51 +708,10 @@ class _FastActionTile extends StatelessWidget { ], ), ), - ], + ), ), - ); - } -} - -/// The emoji shown are the user's own frequently-used set (desktop's -/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so -/// the row is full on a fresh install. -class _ReactionItemReveal extends StatelessWidget { - final Animation? animation; - final int index; - final Widget child; - - const _ReactionItemReveal({ - super.key, - required this.animation, - required this.index, - required this.child, - }); - - @override - Widget build(BuildContext context) { - final parent = animation; - if (parent == null) return child; - - final start = 0.06 + (index * 0.11); - final opacityEnd = math.min(1.0, start + 0.20); - final scaleEnd = math.min(1.0, start + 0.30); - final opacity = CurvedAnimation( - parent: parent, - curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic), - ); - final scale = Tween(begin: 0.86, end: 1).animate( - CurvedAnimation( - parent: parent, - curve: Interval(start, scaleEnd, curve: _reactionSpringCurve), - ), - ); - - return FadeTransition( - opacity: opacity, - child: ScaleTransition(scale: scale, child: child), - ); - } + ), + ); } /// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as @@ -845,33 +748,48 @@ class _QuickReactionCircle extends StatelessWidget { final VoidCallback onTap; final Widget child; final double size; + final String label; + final bool? selected; const _QuickReactionCircle({ required this.onTap, required this.child, required this.size, + required this.label, + this.selected, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Color.lerp( - context.colors.surface, - context.colors.primaryContainer, - 0.78, + return Semantics( + button: true, + label: label, + selected: selected, + excludeSemantics: true, + onTap: onTap, + child: GestureDetector( + excludeFromSemantics: true, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected == true + ? context.colors.primary.withValues(alpha: 0.10) + : context.colors.surfaceContainerHighest, + border: selected == true + ? Border.all( + color: context.colors.primary.withValues(alpha: 0.40), + ) + : null, + shape: BoxShape.circle, ), - shape: BoxShape.circle, + child: child, ), - child: child, ), ); } diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart deleted file mode 100644 index 7a41f72a0c..0000000000 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ /dev/null @@ -1,777 +0,0 @@ -part of '../message_actions.dart'; - -const _messageActionRowHeight = 48.0; -const _messageActionRowVerticalPadding = Grid.xxs; -const _messageActionSeparatorHeight = 0.5; -const _messageActionVerticalInset = Grid.half; -const _messageActionMenuMaxWidth = 288.0; -const _messageActionPreviewMaxWidth = 358.0; -const _messageActionPreviewInset = Grid.xxs; -const _messageActionGap = Grid.twelve; -const _messageActionReactionSelection = '__reaction__'; -const _messageActionTransitionDuration = _reactionPopoverDuration; -const _iosMessageActionTransitionDuration = Duration(milliseconds: 220); -const _iosNativeMessageActionSurfaceChannel = MethodChannel( - 'buzz/native_message_action_surface', -); - -bool _messageActionPresentationInFlight = 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 ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, - required FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, -}) { - if (anchorRect == null || captureAnchorSnapshot == null) return false; - final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false; - unawaited( - _showMessageActionsPopover( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - shouldRestoreComposerFocus: shouldRestoreComposerFocus, - ).then((shown) { - if (shown || !context.mounted) return; - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); - }), - ); - return true; -} - -Future _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 ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, - required FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, - required bool shouldRestoreComposerFocus, -}) async { - if (_messageActionPresentationInFlight) return true; - _messageActionPresentationInFlight = true; - - try { - final actions = _buildPopoverMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); - if (actions.isEmpty) return false; - final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface(); - final isIos = defaultTargetPlatform == TargetPlatform.iOS; - - unawaited(HapticFeedback.mediumImpact()); - - final ui.Image snapshot; - try { - snapshot = await captureAnchorSnapshot(); - } catch (_) { - return false; - } - if (!context.mounted) { - snapshot.dispose(); - return false; - } - final useIosNativeActionSurface = await nativeActionSurfaceSupport; - if (!context.mounted) { - snapshot.dispose(); - return false; - } - - final reduceMotion = MediaQuery.disableAnimationsOf(context); - if (shouldRestoreComposerFocus) composerFocusNode!.unfocus(); - - messageActionBackdropActive.value = true; - // Give the timeline one frame to replace UIKit glass platform views with - // composable Flutter stand-ins before the full-screen blur is presented. - await WidgetsBinding.instance.endOfFrame; - if (!context.mounted) { - messageActionBackdropActive.value = false; - snapshot.dispose(); - return false; - } - - String? selectedActionId; - final dialogRoute = RawDialogRoute( - barrierDismissible: true, - barrierLabel: 'Dismiss message actions', - barrierColor: Colors.transparent, - transitionDuration: reduceMotion - ? Duration.zero - : isIos - ? _iosMessageActionTransitionDuration - : _messageActionTransitionDuration, - transitionBuilder: (context, animation, secondaryAnimation, child) => - child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageActionsPopover( - anchorRect: anchorRect, - anchorSnapshot: snapshot, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - actions: actions, - useIosNativeActionSurface: useIosNativeActionSurface, - onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - ), - ); - var routePushed = false; - try { - final popResult = Navigator.of( - context, - rootNavigator: true, - ).push(dialogRoute); - routePushed = true; - selectedActionId = await popResult; - } finally { - if (routePushed) await dialogRoute.completed; - messageActionBackdropActive.value = false; - snapshot.dispose(); - if (context.mounted) onPopoverDismissed?.call(); - } - - for (final action in actions) { - if (action.id != selectedActionId) continue; - await Future.sync(action.onSelected); - break; - } - if (selectedActionId == null && - shouldRestoreComposerFocus && - context.mounted) { - restoreComposerFocus?.call(); - } - return true; - } finally { - _messageActionPresentationInFlight = 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: BuzzIcons.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: BuzzIcons.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: BuzzIcons.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 = messageActionShowsUnread( - ref, - readState, - channelId: channelId, - message: message, - ); - actions.add( - _PopoverMessageAction( - id: unread ? 'markRead' : 'markUnread', - title: unread ? 'Mark read' : 'Mark unread', - icon: unread ? BuzzIcons.mailCheck : BuzzIcons.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 ? BuzzIcons.bellOff : BuzzIcons.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: BuzzIcons.copy, - group: _PopoverMessageActionGroup.utility, - onSelected: () => - Clipboard.setData(ClipboardData(text: message.content)), - ), - ); - } - - if (canManageMessage) { - actions.add( - _PopoverMessageAction( - id: 'edit', - title: 'Edit message', - icon: BuzzIcons.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: BuzzIcons.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 double rowHeight; - final ValueChanged onSelected; - - const _IosNativeMessageActionSurface({ - required this.actions, - required this.rowHeight, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final viewId = useState(null); - useNativeMessageActionSelection(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(), - 'interfaceStyle': context.colors.brightness.name, - 'rowHeight': rowHeight, - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: (id) => viewId.value = id, - ); - } -} - -class _MessageActionsPopover extends HookWidget { - final Rect anchorRect; - final ui.Image anchorSnapshot; - final Animation animation; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - final List<_PopoverMessageAction> actions; - final bool useIosNativeActionSurface; - final ValueChanged? onPreviewVisibilityChanged; - - const _MessageActionsPopover({ - required this.anchorRect, - required this.anchorSnapshot, - required this.animation, - required this.message, - required this.pageContext, - required this.pageRef, - required this.actions, - required this.useIosNativeActionSurface, - required this.onPreviewVisibilityChanged, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final selectionStarted = useRef(false); - - void select(Object? result, [VoidCallback? effect]) { - if (selectionStarted.value) return; - selectionStarted.value = true; - Navigator.of(context).pop(result); - effect?.call(); - } - - void selectAction(String actionId) => select(actionId); - return LayoutBuilder( - builder: (context, constraints) { - final safeLeft = mediaQuery.padding.left + Grid.xxs; - final safeRight = - constraints.maxWidth - mediaQuery.padding.right - Grid.xxs; - final safeTop = mediaQuery.padding.top + Grid.xxs; - final safeBottom = - constraints.maxHeight - - mediaQuery.padding.bottom - - mediaQuery.viewInsets.bottom - - Grid.xxs; - final availableWidth = math.max(1.0, safeRight - safeLeft); - final availableHeight = math.max(1.0, safeBottom - safeTop); - final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); - final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth); - final menuLayout = _MessageActionSurfaceLayout.from(context, actions); - final preferredMenuHeight = menuLayout.preferredHeight; - final minimumMenuHeight = math.min( - menuLayout.rowHeight, - availableHeight, - ); - final showReactionTray = - availableHeight >= - _reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight; - final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0; - final trayGap = showReactionTray ? _messageActionGap : 0.0; - final heightAfterTray = availableHeight - trayHeight - trayGap; - final showPreview = - showReactionTray && - heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0; - final previewMinimumHeight = showPreview ? 48.0 : 0.0; - final previewGap = showPreview ? _messageActionGap : 0.0; - final menuBudget = math.max( - minimumMenuHeight, - availableHeight - - trayHeight - - trayGap - - previewMinimumHeight - - previewGap, - ); - final menuHeight = math.min(preferredMenuHeight, menuBudget); - final previewMaximumHeight = showPreview - ? math.max( - previewMinimumHeight, - availableHeight - - trayHeight - - trayGap - - menuHeight - - previewGap, - ) - : 0.0; - final previewInsetExtent = _messageActionPreviewInset * 2; - final previewSize = showPreview - ? () { - final previewWidthRatio = - (math.min(_messageActionPreviewMaxWidth, availableWidth) - - previewInsetExtent) / - math.max(anchorRect.width, 1); - final previewHeightRatio = - (previewMaximumHeight - previewInsetExtent) / - math.max(anchorRect.height, 1); - final previewScale = math.min( - 1.0, - math.max( - 0.0, - math.min(previewWidthRatio, previewHeightRatio), - ), - ); - return Size( - (anchorRect.width * previewScale) + previewInsetExtent, - (anchorRect.height * previewScale) + previewInsetExtent, - ); - }() - : Size.zero; - final totalHeight = - trayHeight + trayGap + previewSize.height + previewGap + menuHeight; - final contentTop = math.max(safeTop, safeBottom - totalHeight); - final surfaceLeft = - safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2); - final trayRect = Rect.fromLTWH( - surfaceLeft, - contentTop, - trayWidth, - trayHeight, - ); - final trayHostWidth = math.min( - trayWidth + _reactionTraySpringAllowance, - safeRight - surfaceLeft, - ); - final previewRect = Rect.fromLTWH( - surfaceLeft, - trayRect.bottom + trayGap, - previewSize.width, - previewSize.height, - ); - final menuRect = Rect.fromLTWH( - surfaceLeft, - previewRect.bottom + previewGap, - menuWidth, - menuHeight, - ); - return _MessageActionPreviewVisibility( - visible: showPreview, - onChanged: onPreviewVisibilityChanged, - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - key: const ValueKey('message-actions-backdrop-filter'), - filter: _messageActionBackdropFilter, - 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: _messageActionBackdropTintOpacity * opacity, - ), - ); - }, - ), - ), - ), - Positioned.fill( - child: GestureDetector( - key: const ValueKey('message-actions-backdrop'), - behavior: HitTestBehavior.opaque, - onTap: () => select(null), - ), - ), - if (showPreview) - Positioned.fromRect( - rect: previewRect, - child: AnimatedBuilder( - animation: animation, - child: RepaintBoundary( - child: _LiftedMessagePreview( - anchorSnapshot: anchorSnapshot, - ), - ), - builder: (context, child) { - final movement = - (defaultTargetPlatform == TargetPlatform.iOS - ? Curves.easeOutCubic - : Curves.easeInOutCubic) - .transform(animation.value); - final sourceRect = anchorRect.inflate( - _messageActionPreviewInset, - ); - final translation = Offset( - ui.lerpDouble( - sourceRect.left - previewRect.left, - 0, - movement, - )!, - ui.lerpDouble( - sourceRect.top - previewRect.top, - 0, - movement, - )!, - ); - final scaleX = ui.lerpDouble( - sourceRect.width / previewRect.width, - 1, - movement, - )!; - final scaleY = ui.lerpDouble( - sourceRect.height / previewRect.height, - 1, - movement, - )!; - return Transform.translate( - offset: translation, - child: Transform( - alignment: Alignment.topLeft, - transform: Matrix4.diagonal3Values(scaleX, scaleY, 1), - child: child, - ), - ); - }, - ), - ), - if (showReactionTray) - Positioned( - left: trayRect.left, - top: trayRect.top, - width: trayHostWidth, - height: trayRect.height, - child: _MessageReactionTray( - animation: animation, - trayWidth: trayWidth, - message: message, - pageContext: pageContext, - pageRef: pageRef, - popResult: _messageActionReactionSelection, - onSelected: (result, effect) => select(result, effect), - ), - ), - Positioned.fromRect( - rect: menuRect, - child: AnimatedBuilder( - animation: animation, - child: useIosNativeActionSurface - ? _IosNativeMessageActionSurface( - actions: actions, - rowHeight: menuLayout.rowHeight, - onSelected: selectAction, - ) - : _MessageActionSurface( - actions: actions, - onSelected: selectAction, - ), - builder: (context, child) { - final appearance = const Interval( - 0.08, - 0.82, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final fadedChild = Opacity( - opacity: appearance, - child: child, - ); - if (defaultTargetPlatform == TargetPlatform.iOS) { - return fadedChild; - } - return Transform.scale( - alignment: Alignment.topLeft, - scale: ui.lerpDouble(0.96, 1, appearance)!, - child: fadedChild, - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart b/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart deleted file mode 100644 index 8c1bb7a5d5..0000000000 --- a/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart +++ /dev/null @@ -1,223 +0,0 @@ -part of '../message_actions.dart'; - -class _MessageActionPreviewVisibility extends HookWidget { - final bool visible; - final ValueChanged? onChanged; - final Widget child; - - const _MessageActionPreviewVisibility({ - required this.visible, - required this.onChanged, - required this.child, - }); - - @override - Widget build(BuildContext context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) onChanged?.call(visible); - }); - return null; - }, [visible, onChanged]); - return child; - } -} - -class _LiftedMessagePreview extends StatelessWidget { - final ui.Image anchorSnapshot; - - const _LiftedMessagePreview({required this.anchorSnapshot}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - key: const ValueKey('message-action-preview'), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.7), - width: 0.5, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(_messageActionPreviewInset), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.xs), - child: RawImage( - image: anchorSnapshot, - fit: BoxFit.fill, - filterQuality: FilterQuality.medium, - ), - ), - ), - ); - } -} - -class _MessageActionSurface extends StatelessWidget { - final List<_PopoverMessageAction> actions; - final ValueChanged onSelected; - - const _MessageActionSurface({ - required this.actions, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final menuLayout = _MessageActionSurfaceLayout.from(context, actions); - return Material( - key: const ValueKey('message-action-surface'), - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 10, - shadowColor: Colors.black.withValues(alpha: 0.22), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.popover), - side: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.55), - width: 0.5, - ), - ), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: _messageActionVerticalInset, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < actions.length; index++) ...[ - if (index > 0 && - actions[index - 1].group != actions[index].group) - Divider( - key: ValueKey( - 'message-action-divider-${actions[index].group.name}', - ), - height: _messageActionSeparatorHeight, - thickness: _messageActionSeparatorHeight, - indent: Grid.xs, - endIndent: Grid.xs, - ), - _MessageActionRow( - action: actions[index], - height: menuLayout.rowHeight, - onSelected: onSelected, - ), - ], - ], - ), - ), - ), - ); - } -} - -class _MessageActionRow extends StatelessWidget { - final _PopoverMessageAction action; - final double height; - final ValueChanged onSelected; - - const _MessageActionRow({ - required this.action, - required this.height, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final foreground = action.destructive - ? context.colors.error - : context.colors.onSurface; - return Semantics( - button: true, - label: action.title, - excludeSemantics: true, - child: InkWell( - key: ValueKey('message-action-${action.id}'), - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onSelected(action.id); - }, - child: SizedBox( - height: height, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Row( - children: [ - SizedBox( - width: 32, - child: Center( - child: Icon(action.icon, size: 22, color: foreground), - ), - ), - const SizedBox(width: Grid.twelve), - Expanded( - child: Text( - action.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( - color: foreground, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _MessageActionSurfaceLayout { - final double rowHeight, preferredHeight; - - const _MessageActionSurfaceLayout({ - required this.rowHeight, - required this.preferredHeight, - }); - - factory _MessageActionSurfaceLayout.from( - BuildContext context, - List<_PopoverMessageAction> actions, - ) { - final textPainter = TextPainter( - text: TextSpan( - text: 'Message action', - style: context.textTheme.bodyLarge, - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - maxLines: 1, - )..layout(); - final rowHeight = math.max( - _messageActionRowHeight, - textPainter.height + (_messageActionRowVerticalPadding * 2), - ); - textPainter.dispose(); - - var separatorCount = 0; - for (var index = 1; index < actions.length; index++) { - if (actions[index - 1].group != actions[index].group) separatorCount += 1; - } - final preferredHeight = - (_messageActionVerticalInset * 2) + - (actions.length * rowHeight) + - (separatorCount * _messageActionSeparatorHeight); - return _MessageActionSurfaceLayout( - rowHeight: rowHeight, - preferredHeight: preferredHeight, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart deleted file mode 100644 index 2128178904..0000000000 --- a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart +++ /dev/null @@ -1,36 +0,0 @@ -part of '../message_actions.dart'; - -class _MessageReactionTray extends StatelessWidget { - final Animation animation; - final double trayWidth; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - final Object popResult; - final void Function(Object? result, VoidCallback effect) onSelected; - - const _MessageReactionTray({ - required this.animation, - required this.trayWidth, - required this.message, - required this.pageContext, - required this.pageRef, - required this.popResult, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return _AnimatedReactionTray( - trayKey: const ValueKey('message-action-reaction-tray'), - animation: animation, - trayWidth: trayWidth, - scaleAlignment: Alignment.bottomLeft, - message: message, - pageContext: pageContext, - pageRef: pageRef, - popResult: popResult, - onSelected: onSelected, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/native_actions.dart b/mobile/lib/features/channels/message_actions/native_actions.dart deleted file mode 100644 index f64e658fec..0000000000 --- a/mobile/lib/features/channels/message_actions/native_actions.dart +++ /dev/null @@ -1,242 +0,0 @@ -part of '../message_actions.dart'; - -Future _showNativeMessageActions({ - 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 FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, - required ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, -}) async { - if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return false; - return NativeMessagePresentation.withLease(() async { - if (captureAnchorSnapshot == null) return false; - final hadComposerFocus = composerFocusNode?.hasFocus ?? false; - final support = await NativeMessagePresentation.present( - 'supportsMessage', - {}, - ); - if (support?['supported'] != true) return false; - if (!context.mounted) return true; - final community = ref.read(relayConfigProvider); - Uint8List? previewBytes; - try { - final snapshot = await captureAnchorSnapshot(); - try { - final bytes = await snapshot.toByteData(format: ui.ImageByteFormat.png); - previewBytes = bytes?.buffer.asUint8List(); - } finally { - snapshot.dispose(); - } - } catch (_) { - return false; - } - if (!context.mounted || ref.read(relayConfigProvider) != community) { - return true; - } - if (previewBytes == null) return false; - final callbacks = {}; - final actions = >[]; - void action( - String id, - String title, - String symbol, - VoidCallback callback, { - bool destructive = false, - }) { - callbacks[id] = callback; - actions.add({ - 'id': id, - 'title': title, - 'symbol': symbol, - 'destructive': destructive, - }); - } - - if (!message.isSystem) { - if (allMessages != null) { - action('reply', 'Reply', 'arrowshape.turn.up.left', () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }); - } - action('copyLink', 'Copy link', 'link', () { - copyToClipboard( - context, - messageLinkFor(message: message, channelId: channelId), - message: 'Message link copied', - ); - }); - if (ref.read(reminderServiceProvider) != null) { - action('remind', 'Remind me', 'clock', () { - showRemindMeLaterSheet( - context: 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 = messageActionShowsUnread( - ref, - readState, - channelId: channelId, - message: message, - ); - action('read', unread ? 'Mark read' : 'Mark unread', 'envelope', () { - 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); - action( - 'follow', - following ? 'Unfollow thread' : 'Follow thread', - following ? 'bell.slash' : 'bell', - () { - final notifier = ref.read(threadFollowsProvider.notifier); - if (following) { - notifier.unfollowThread(rootId); - } else { - notifier.followThread(rootId); - } - }, - ); - action('copy', 'Copy text', 'doc.on.doc', () { - Clipboard.setData(ClipboardData(text: message.content)); - }); - } - if (canManageMessage) { - action('edit', 'Edit message', 'pencil', () { - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }); - action('delete', 'Delete message', 'trash', () { - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - ); - }, destructive: true); - } - final palette = ref.read(customEmojiListProvider); - final emojis = quickReactionEmoji( - ref.read(recentEmojiProvider), - customShortcodes: {for (final e in palette) e.shortcode.toLowerCase()}, - ); - final dataset = ref.read(emojiDatasetOrEmptyProvider); - Map? response; - var ownsPreview = false; - if (hadComposerFocus) composerFocusNode!.unfocus(); - try { - response = await NativeMessagePresentation.present( - 'message', - { - 'x': anchorRect.left, - 'y': anchorRect.top, - 'width': anchorRect.width, - 'height': anchorRect.height, - 'dark': Theme.of(context).brightness == Brightness.dark, - 'previewLabel': message.content, - 'previewBytes': previewBytes, - 'actions': actions, - 'reactions': [ - for (final emoji in emojis) - { - 'emoji': emoji, - 'label': dataset.displayName(emoji), - 'selected': message.reactions.any( - (r) => r.emoji == emoji && r.reactedByCurrentUser, - ), - if (reactionEmojiUrl(emoji, palette) case final String url) ...{ - 'url': url, - 'headers': mediaGetHeadersFor(ref, url), - }, - }, - ], - }, - onPresented: () { - if (!context.mounted || ownsPreview) return; - ownsPreview = true; - onPopoverPreviewVisibilityChanged?.call(true); - }, - ); - } finally { - if (ownsPreview) { - onPopoverPreviewVisibilityChanged?.call(false); - if (context.mounted) onPopoverDismissed?.call(); - } - if (hadComposerFocus && - response?['busy'] != true && - response?['action'] == null && - context.mounted) { - restoreComposerFocus?.call(); - } - } - if (response == null) return false; - // A native controller can outlive the Flutter page that opened it. - if (!context.mounted || ref.read(relayConfigProvider) != community) { - return true; - } - final selected = response['action']; - if (selected == 'reaction' && emojis.contains(response['emoji'])) { - final emoji = response['emoji']! as String; - final existing = message.reactions.where((r) => r.emoji == emoji); - if (existing.isNotEmpty && existing.first.reactedByCurrentUser) { - toggleReaction(ref, message, emoji); - } else { - ref.read(recentEmojiProvider.notifier).record(emoji); - armReactionBurst(ref, message, emoji); - ref.read(channelActionsProvider).addReaction(message.id, emoji); - } - } else if (selected == 'more') { - showAddReactionPicker(context: context, ref: ref, message: message); - } else { - callbacks[selected]?.call(); - } - return true; - }); -} diff --git a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart index 18dacae27d..1673086e73 100644 --- a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart +++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart @@ -14,28 +14,16 @@ class _QuickReactionRow extends ConsumerWidget { /// bottom sheet, so it must not read through the sheet's disposed ref. final WidgetRef pageRef; - /// Drives the staged glyph reveal when this row is shown in the popover. - /// The bottom sheet leaves this null and retains its existing static row. - final Animation? presentationAnimation; - - final Object? popResult; - - /// Selects exactly one popover result and side effect. Bottom sheets leave - /// this null and retain their existing local dismissal behavior. - final void Function(Object? result, VoidCallback effect)? onSelected; - const _QuickReactionRow({ required this.message, required this.sheetContext, required this.pageContext, required this.pageRef, - this.presentationAnimation, - this.popResult, - this.onSelected, }); @override Widget build(BuildContext context, WidgetRef ref) { + final dataset = ref.watch(emojiDatasetOrEmptyProvider); final customEmoji = ref.watch(customEmojiListProvider); final emoji = quickReactionEmoji( ref.watch(recentEmojiProvider), @@ -48,6 +36,12 @@ class _QuickReactionRow extends ConsumerWidget { }; void react(String value) { + if (message.reactions.any( + (r) => r.emoji == value && r.reactedByCurrentUser, + )) { + toggleReaction(pageRef, message, value); + return; + } // The generic picker is also used for composing and statuses. Record // recency here, at the reaction call site, so only reactions drive the // quick-reaction row. @@ -74,22 +68,17 @@ class _QuickReactionRow extends ConsumerWidget { .toDouble(); final circles = [ for (var index = 0; index < emoji.length; index++) - _ReactionItemReveal( + KeyedSubtree( key: ValueKey('quick-reaction-${emoji[index]}'), - animation: presentationAnimation, - index: index, child: _QuickReactionCircle( size: circleSize, + label: dataset.displayName(emoji[index]), + selected: message.reactions.any( + (r) => r.emoji == emoji[index] && r.reactedByCurrentUser, + ), onTap: () { - void effect() => react(emoji[index]); - - final select = onSelected; - if (select != null) { - select(popResult, effect); - } else { - Navigator.of(sheetContext).pop(popResult); - effect(); - } + if (!_selectMessageAction(context)) return; + react(emoji[index]); }, child: _QuickReactionGlyph( value: emoji[index], @@ -97,23 +86,14 @@ class _QuickReactionRow extends ConsumerWidget { ), ), ), - _ReactionItemReveal( + KeyedSubtree( key: const ValueKey('quick-reaction-more'), - animation: presentationAnimation, - index: emoji.length, child: _QuickReactionCircle( size: circleSize, + label: 'Add reaction', onTap: () { - void effect() => - showEmojiPicker(context: pageContext, onSelect: react); - - final select = onSelected; - if (select != null) { - select(popResult, effect); - } else { - Navigator.of(sheetContext).pop(popResult); - effect(); - } + if (!_selectMessageAction(context)) return; + showEmojiPicker(context: pageContext, onSelect: react); }, child: Icon( BuzzIcons.plus, diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart deleted file mode 100644 index b5b3e5a0c9..0000000000 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ /dev/null @@ -1,345 +0,0 @@ -part of '../message_actions.dart'; - -const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2); -const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2); -const _reactionTraySpringAllowance = 16.0; -const _reactionPopoverDuration = Duration(milliseconds: 320); -final _reactionSpringCurve = _SpringEaseOutCurve( - duration: const Duration(milliseconds: 260), - bounce: 0.22, -); - -/// Presents the frequently-used reaction tray over a long-pressed message. -/// -/// The conversation is frosted around [anchorRect] so the selected message -/// remains visually connected to the tray. -void _showMessageReactionPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required Rect anchorRect, - required EdgeInsets spotlightPadding, -}) { - unawaited( - _presentMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - ), - ); -} - -Future _presentMessageReactionPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required Rect anchorRect, - required EdgeInsets spotlightPadding, -}) async { - if (_messageActionPresentationInFlight) return; - _messageActionPresentationInFlight = true; - - try { - unawaited(HapticFeedback.mediumImpact()); - final reduceMotion = MediaQuery.disableAnimationsOf(context); - final dialogRoute = RawDialogRoute( - barrierDismissible: true, - barrierLabel: 'Dismiss reaction picker', - barrierColor: Colors.transparent, - transitionDuration: reduceMotion - ? Duration.zero - : _reactionPopoverDuration, - transitionBuilder: (context, animation, secondaryAnimation, child) => - child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageReactionPopover( - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - ), - ); - var routePushed = false; - messageActionBackdropActive.value = true; - try { - // Remove UIKit glass views from Flutter's platform-view overlay before the - // backdrop filter paints, otherwise they leave sharp rectangular holes. - await WidgetsBinding.instance.endOfFrame; - if (!context.mounted) return; - final popResult = Navigator.of( - context, - rootNavigator: true, - ).push(dialogRoute); - routePushed = true; - await popResult; - } finally { - if (routePushed) await dialogRoute.completed; - messageActionBackdropActive.value = false; - } - } finally { - _messageActionPresentationInFlight = false; - } -} - -class _MessageReactionPopover extends HookWidget { - final Rect anchorRect; - final EdgeInsets spotlightPadding; - final Animation animation; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - - const _MessageReactionPopover({ - required this.anchorRect, - required this.spotlightPadding, - required this.animation, - required this.message, - required this.pageContext, - required this.pageRef, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final selectionStarted = useRef(false); - - void select(Object? result, [VoidCallback? effect]) { - if (selectionStarted.value) return; - selectionStarted.value = true; - Navigator.of(context).pop(result); - effect?.call(); - } - - return LayoutBuilder( - builder: (context, constraints) { - final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top; - final safeBottom = - constraints.maxHeight - - mediaQuery.padding.bottom - - mediaQuery.viewInsets.bottom; - final availableAbove = anchorRect.top - Grid.xxs - safeTop; - final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs; - final showAbove = - availableAbove >= _reactionTrayMaxHeight || - (availableBelow < _reactionTrayMaxHeight && - availableAbove >= availableBelow); - final proposedTop = showAbove - ? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs - : anchorRect.bottom + Grid.xxs; - final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight); - final top = proposedTop.clamp(safeTop, maxTop).toDouble(); - final trayScaleAlignment = showAbove - ? Alignment.bottomLeft - : Alignment.topLeft; - final trayWidth = math.min( - _reactionTrayMaxWidth, - constraints.maxWidth - (Grid.xxs * 2), - ); - final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs; - final left = (anchorRect.center.dx - (trayWidth / 2)) - .clamp(Grid.xxs, maxLeft) - .toDouble(); - - return Stack( - children: [ - Positioned.fill( - child: ClipPath( - key: const ValueKey('reaction-popover-background'), - clipper: _OutsideAnchorClipper(anchorRect, spotlightPadding), - child: BackdropFilter( - key: const ValueKey('reaction-popover-backdrop-filter'), - filter: _messageActionBackdropFilter, - child: AnimatedBuilder( - animation: animation, - builder: (context, child) { - final opacity = const Interval( - 0, - 0.30, - curve: Curves.easeOutCubic, - ).transform(animation.value); - return ColoredBox( - key: const ValueKey('reaction-popover-background-tint'), - color: context.colors.inverseSurface.withValues( - alpha: _messageActionBackdropTintOpacity * opacity, - ), - ); - }, - ), - ), - ), - ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => select(null), - ), - ), - Positioned( - top: top, - left: left, - width: trayWidth + _reactionTraySpringAllowance, - height: _reactionTrayMaxHeight, - child: _AnimatedReactionTray( - trayKey: const ValueKey('reaction-popover-tray'), - animation: animation, - trayWidth: trayWidth, - scaleAlignment: trayScaleAlignment, - message: message, - pageContext: pageContext, - pageRef: pageRef, - onSelected: (result, effect) => select(result, effect), - ), - ), - ], - ); - }, - ); - } -} - -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; - final Object? popResult; - final void Function(Object? result, VoidCallback effect)? onSelected; - - const _AnimatedReactionTray({ - required this.trayKey, - required this.animation, - required this.trayWidth, - required this.scaleAlignment, - required this.message, - required this.pageContext, - required this.pageRef, - this.popResult, - this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: animation, - child: SizedBox( - width: trayWidth, - height: _reactionTrayMaxHeight, - child: Padding( - padding: const EdgeInsets.all(Grid.xxs), - child: _QuickReactionRow( - message: message, - sheetContext: context, - pageContext: pageContext, - pageRef: pageRef, - presentationAnimation: animation, - popResult: popResult, - onSelected: onSelected, - ), - ), - ), - builder: (context, child) { - final appearance = const Interval( - 0.04, - 0.23, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final expansion = const Interval(0.16, 0.92).transform(animation.value); - final springExpansion = _reactionSpringCurve.transform(expansion); - final width = lerpDouble( - _reactionTrayMaxHeight, - trayWidth, - springExpansion, - )!; - - return Opacity( - opacity: appearance, - child: Transform.scale( - alignment: scaleAlignment, - scale: lerpDouble(0.95, 1, appearance)!, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: width, - height: _reactionTrayMaxHeight, - child: Material( - key: trayKey, - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.2), - shape: const StadiumBorder(), - clipBehavior: Clip.antiAlias, - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: trayWidth, - maxWidth: trayWidth, - minHeight: _reactionTrayMaxHeight, - maxHeight: _reactionTrayMaxHeight, - child: child, - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -class _OutsideAnchorClipper extends CustomClipper { - final Rect anchorRect; - final EdgeInsets spotlightPadding; - - const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding); - - @override - Path getClip(Size size) { - final spotlightRect = Rect.fromLTRB( - anchorRect.left - spotlightPadding.left, - anchorRect.top - spotlightPadding.top, - anchorRect.right + spotlightPadding.right, - anchorRect.bottom + spotlightPadding.bottom, - ); - return Path() - ..fillType = PathFillType.evenOdd - ..addRect(Offset.zero & size) - ..addRRect( - RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)), - ); - } - - @override - bool shouldReclip(_OutsideAnchorClipper oldClipper) => - oldClipper.anchorRect != anchorRect || - oldClipper.spotlightPadding != spotlightPadding; -} - -class _SpringEaseOutCurve extends Curve { - final double _durationSeconds; - final SpringSimulation _simulation; - - _SpringEaseOutCurve({required Duration duration, required double bounce}) - : _durationSeconds = - duration.inMicroseconds / Duration.microsecondsPerSecond, - _simulation = SpringSimulation( - SpringDescription.withDurationAndBounce( - duration: duration, - bounce: bounce, - ), - 0, - 1, - 0, - snapToEnd: true, - ); - - @override - double transformInternal(double t) => _simulation.x(t * _durationSeconds); -} diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 929dd5bfc0..367a92e455 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -49,8 +49,9 @@ const _pillGlyphSize = 16.0; /// Used by channel detail, thread detail, and system message rows to avoid /// duplicating the toggle wiring. void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) { + final reaction = message.reactions.where((r) => r.emoji == emoji).firstOrNull; + if (reaction == null) return; final actions = ref.read(channelActionsProvider); - final reaction = message.reactions.firstWhere((r) => r.emoji == emoji); if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) { actions.removeReaction(reaction.currentUserReactionId!, emoji); } else { @@ -95,7 +96,7 @@ void showAddReactionPicker({ ); } -class ReactionRow extends StatelessWidget { +class ReactionRow extends HookWidget { /// The message these reactions belong to. Used to match a pending burst to /// the right pill — the same emoji can be pending on a different message. final String messageId; @@ -126,6 +127,23 @@ class ReactionRow extends StatelessWidget { @override Widget build(BuildContext context) { + final liveReactions = useMemoized(() => ValueNotifier(reactions)); + useEffect( + () => () { + scheduleMicrotask(() { + liveReactions.value = []; + liveReactions.dispose(); + }); + }, + [liveReactions], + ); + final latestToggle = useRef(onToggle)..value = onToggle; + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) liveReactions.value = reactions; + }); + return null; + }, [reactions]); // With the add button on, an empty row still renders — that affordance is // the point. Without it, an empty row would be dead space. if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink(); @@ -144,8 +162,10 @@ class ReactionRow extends StatelessWidget { onLongPress: () => showReactionDetailSheet( context: context, channelId: channelId, - reactions: reactions, - initialEmoji: reaction.emoji, + reactions: liveReactions, + onToggle: (emoji) { + if (context.mounted) latestToggle.value(emoji); + }, ), ), if (showAddButton && onAddReaction != null) @@ -344,199 +364,66 @@ class _ReactionEmoji extends StatelessWidget { Future showReactionDetailSheet({ required BuildContext context, required String channelId, - required List reactions, - required String initialEmoji, + required ValueListenable> reactions, + required ValueChanged onToggle, }) async { - if (reactions.isEmpty) return; + if (reactions.value.isEmpty) return; if (await _showNativeReactionDetails( context, channelId, reactions, - initialEmoji, + onToggle, )) { return; } if (!context.mounted) return; - if (defaultTargetPlatform == TargetPlatform.android) { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - backgroundColor: context.colors.surfaceContainerHighest, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(Radii.dialog)), - ), - builder: (_) => _AndroidReactionDetailSheet( - channelId: channelId, - reactions: reactions, - initialEmoji: initialEmoji, - ), - ); - return; - } - showBuzzModalBottomSheet( + await showBuzzModalBottomSheet( context: context, title: 'Reactions', isScrollControlled: true, showDragHandle: true, - builder: (sheetContext) => _ReactionDetailSheet( - channelId: channelId, - reactions: reactions, - initialEmoji: initialEmoji, + builder: (_) => ValueListenableBuilder>( + valueListenable: reactions, + builder: (sheetContext, value, _) { + if (value.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (sheetContext.mounted && + ModalRoute.of(sheetContext)?.isCurrent == true) { + Navigator.of(sheetContext).pop(); + } + }); + return const SizedBox.shrink(); + } + return _ReactionDetailSheet( + channelId: channelId, + reactions: value, + onToggle: onToggle, + ); + }, ), ); } -class _AndroidReactionDetailSheet extends HookConsumerWidget { - const _AndroidReactionDetailSheet({ - required this.channelId, - required this.reactions, - required this.initialEmoji, - }); - - final String channelId; - final List reactions; - final String initialEmoji; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final cache = ref.watch(userCacheProvider); - final names = ref.watch(channelIdentityNamesProvider(channelId)); - final dataset = ref.watch(emojiDatasetOrEmptyProvider); - final pubkeys = reactions - .expand((reaction) => reaction.userPubkeys) - .toSet() - .toList(); - useEffect(() { - if (pubkeys.isNotEmpty) { - ref.read(userCacheProvider.notifier).preload(pubkeys); - } - return null; - }, [pubkeys.join(',')]); - final total = reactions.fold( - 0, - (sum, reaction) => sum + reaction.count, - ); - final maxHeight = MediaQuery.sizeOf(context).height * 0.65; - final desiredHeight = - 48.0 + pubkeys.length * 56.0 + MediaQuery.viewPaddingOf(context).bottom; - return DefaultTabController( - length: reactions.length + 1, - initialIndex: reactions.indexWhere((r) => r.emoji == initialEmoji) + 1, - child: SizedBox( - key: const ValueKey('reaction-details-sheet'), - height: desiredHeight.clamp( - maxHeight < 340 ? maxHeight : 340, - maxHeight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TabBar( - isScrollable: true, - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - tabAlignment: TabAlignment.start, - dividerHeight: 0, - indicatorSize: TabBarIndicatorSize.tab, - indicatorPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), - indicator: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.full), - ), - labelColor: context.colors.onSurface, - unselectedLabelColor: context.colors.onSurfaceVariant, - tabs: [ - Tab( - key: const ValueKey('reaction-filter-all'), - text: 'All $total', - ), - for (final reaction in reactions) - Tab( - key: ValueKey('reaction-filter-${reaction.emoji}'), - child: Tooltip( - message: dataset.displayName(reaction.emoji), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReactionEmoji(reaction: reaction, size: 20), - const SizedBox(width: Grid.quarter), - Text('${reaction.count}'), - ], - ), - ), - ), - ], - ), - Expanded( - child: TabBarView( - children: [ - for (var tab = 0; tab <= reactions.length; tab++) - Builder( - builder: (context) { - final filtered = tab == 0 - ? reactions - : [reactions[tab - 1]]; - final rows = [ - for (final reaction in filtered) - for (final pubkey in reaction.userPubkeys.toSet()) - (pubkey: pubkey, reaction: reaction), - ]; - return ListView.builder( - key: PageStorageKey('reaction-page-$tab'), - padding: EdgeInsets.only( - bottom: MediaQuery.viewPaddingOf(context).bottom, - ), - itemCount: rows.length, - itemBuilder: (_, index) { - final row = rows[index]; - return _ReactorTile( - profile: cache[row.pubkey.toLowerCase()], - pubkey: row.pubkey, - displayName: names.labelFor(row.pubkey), - reaction: row.reaction, - reactionLabel: dataset.displayName( - row.reaction.emoji, - ), - compact: true, - ); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - class _ReactionDetailSheet extends HookConsumerWidget { final String channelId; final List reactions; - final String initialEmoji; + final ValueChanged onToggle; const _ReactionDetailSheet({ required this.channelId, required this.reactions, - required this.initialEmoji, + required this.onToggle, }); @override Widget build(BuildContext context, WidgetRef ref) { - final selectedEmoji = useState(initialEmoji); final userCache = ref.watch(userCacheProvider); final identityNames = ref.watch(channelIdentityNamesProvider(channelId)); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final rows = [ for (final reaction in reactions) - if (selectedEmoji.value == null || - reaction.emoji == selectedEmoji.value) - for (final pubkey in reaction.userPubkeys.toSet()) - (pubkey: pubkey, reaction: reaction), + for (final pubkey in reaction.userPubkeys.toSet()) + (pubkey: pubkey, reaction: reaction), ]; final pubkeys = rows.map((row) => row.pubkey).toSet().toList(); useEffect(() { @@ -545,11 +432,6 @@ class _ReactionDetailSheet extends HookConsumerWidget { } return null; }, [pubkeys.join(',')]); - final total = reactions.fold( - 0, - (sum, reaction) => sum + reaction.count, - ); - return ConstrainedBox( key: const ValueKey('reaction-details-sheet'), constraints: BoxConstraints( @@ -564,31 +446,40 @@ class _ReactionDetailSheet extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - Padding( - padding: const EdgeInsets.only(right: Grid.half), - child: ChoiceChip( - key: const ValueKey('reaction-filter-all'), - label: Text('All $total'), - selected: selectedEmoji.value == null, - onSelected: (_) => selectedEmoji.value = null, - ), - ), for (final reaction in reactions) Padding( padding: const EdgeInsets.only(right: Grid.half), - child: ChoiceChip( - key: ValueKey('reaction-filter-${reaction.emoji}'), - tooltip: dataset.displayName(reaction.emoji), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReactionEmoji(reaction: reaction, size: 20), - const SizedBox(width: Grid.quarter), - Text('${reaction.count}'), - ], + child: Builder( + builder: (chipContext) => FilterChip( + key: ValueKey('reaction-toggle-${reaction.emoji}'), + tooltip: dataset.displayName(reaction.emoji), + side: BorderSide( + color: reaction.reactedByCurrentUser + ? context.colors.primary.withValues(alpha: 0.40) + : context.colors.onSurface.withValues( + alpha: 0.25, + ), + ), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ReactionEmoji(reaction: reaction, size: 20), + const SizedBox(width: Grid.quarter), + Text('${reaction.count}'), + ], + ), + selected: reaction.reactedByCurrentUser, + onSelected: (_) { + if (!reaction.reactedByCurrentUser) { + burstEmojiFromContext( + ref, + chipContext, + reaction.emoji, + ); + } + onToggle(reaction.emoji); + }, ), - selected: reaction.emoji == selectedEmoji.value, - onSelected: (_) => selectedEmoji.value = reaction.emoji, ), ), ], @@ -618,6 +509,7 @@ class _ReactionDetailSheet extends HookConsumerWidget { displayName: identityNames.labelFor(row.pubkey), reaction: row.reaction, reactionLabel: dataset.displayName(row.reaction.emoji), + compact: defaultTargetPlatform == TargetPlatform.android, ), ); }, @@ -719,20 +611,22 @@ class _ReactorAvatar extends StatelessWidget { Future _showNativeReactionDetails( BuildContext context, String channelId, - List reactions, - String initialEmoji, + ValueListenable> reactions, + ValueChanged onToggle, ) async { if (!NativeMessagePresentation.isSupportedPlatform) return false; return NativeMessagePresentation.withLease(() async { final container = ProviderScope.containerOf(context, listen: false); final requestId = DateTime.now().microsecondsSinceEpoch.toString(); - final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList(); + List pubkeys() => + reactions.value.expand((r) => r.userPubkeys).toSet().toList(); final sent = {}; Map profiles({bool onlyChanges = false}) { + if (reactions.value.isEmpty) return {}; final cache = container.read(userCacheProvider); final names = container.read(channelIdentityNamesProvider(channelId)); final result = {}; - for (final pubkey in pubkeys) { + for (final pubkey in pubkeys()) { final value = ( name: names.labelFor(pubkey), avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl, @@ -804,38 +698,59 @@ Future _showNativeReactionDetails( channelIdentityNamesProvider(channelId), (_, _) => scheduleProfiles(), ); + final dataset = container.read(emojiDatasetOrEmptyProvider); + List> reactionData() => [ + for (final reaction in reactions.value) + { + 'emoji': reaction.emoji, + 'label': dataset.displayName(reaction.emoji), + 'count': reaction.count, + 'selected': reaction.reactedByCurrentUser, + 'burst': isPositiveEmojiParticle(reaction.emoji), + 'users': reaction.userPubkeys.toSet().toList(), + if (reaction.emojiUrl case final String url) ...{ + 'url': url, + 'headers': container + .read(mediaGetAuthServiceProvider) + .headersFor(url), + }, + }, + ]; + void updateReactions() { + if (disposed) return; + unawaited( + NativeMessagePresentation.present('updateReactions', { + 'requestId': requestId, + 'reactions': reactionData(), + }), + ); + if (reactions.value.isNotEmpty) { + container.read(userCacheProvider.notifier).preload(pubkeys()); + scheduleProfiles(); + } + } + + reactions.addListener(updateReactions); try { - final dataset = container.read(emojiDatasetOrEmptyProvider); final sheetColors = utilitySurfaceThemeData( Theme.of(context), ).colorScheme; final pending = NativeMessagePresentation.present('reactions', { 'requestId': requestId, - 'initialEmoji': initialEmoji, 'sheetColor': sheetColors.surface.toARGB32(), 'foregroundColor': sheetColors.primary.toARGB32(), + 'outlineColor': Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(), 'dark': Theme.of(context).brightness == Brightness.dark, 'profiles': profiles(), - 'reactions': [ - for (final reaction in reactions) - { - 'emoji': reaction.emoji, - 'label': dataset.displayName(reaction.emoji), - 'count': reaction.count, - 'users': reaction.userPubkeys.toSet().toList(), - if (reaction.emojiUrl case final String url) ...{ - 'url': url, - 'headers': container - .read(mediaGetAuthServiceProvider) - .headersFor(url), - }, - }, - ], - }); - container.read(userCacheProvider.notifier).preload(pubkeys); + 'reactions': reactionData(), + }, onReaction: onToggle); + container.read(userCacheProvider.notifier).preload(pubkeys()); return await pending != null; } finally { disposed = true; + reactions.removeListener(updateReactions); timer?.cancel(); subscription.close(); namesSubscription.close(); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index eaaa8cdac6..e3b6f10c62 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -832,6 +832,21 @@ class ThreadDetailPage extends HookConsumerWidget { }); return FrostedScaffold( + foreground: threadViewportVisible + ? Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: frostedAppBarHeight(context) + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('thread-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), + ], + ) + : null, resizeToAvoidBottomInset: !usesFixedAndroidImeViewport, appBar: _threadAppBar(context, ref, channel, currentPubkey), body: Stack( @@ -913,16 +928,6 @@ class ThreadDetailPage extends HookConsumerWidget { ), ], ), - if (threadViewportVisible) - Positioned( - left: 0, - right: 0, - top: frostedAppBarHeight(context) + Grid.twelve, - child: StickyDateHeader( - key: const ValueKey('thread-sticky-date-header'), - state: stickyDateHeaderState, - ), - ), if (isMember && !isArchived) AndroidImeLift( child: Align( diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart index eb4b72a0cf..44e1aac2c2 100644 --- a/mobile/lib/shared/widgets/app_list_card.dart +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -56,7 +56,7 @@ class AppListCard extends StatelessWidget { // which lands them within a few levels of the card fill — invisible. // Tinting with the text color instead keeps the hairline readable on // the card in both brightnesses. - color: context.colors.onSurface.withValues(alpha: 0.12), + color: context.colors.onSurface.withValues(alpha: 0.07), ), ); } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 98996f0cf7..160d9ab127 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -21,6 +21,10 @@ class FrostedScaffold extends HookWidget { /// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding. final Widget body; + /// Non-scrolling content painted above the navigation bar and its blur. + /// Position this child within the scaffold's full-screen stack. + final Widget? foreground; + /// Optional floating action button, passed through to [Scaffold]. final Widget? floatingActionButton; @@ -45,6 +49,7 @@ class FrostedScaffold extends HookWidget { super.key, required this.appBar, required this.body, + this.foreground, this.floatingActionButton, this.resizeToAvoidBottomInset, this.backgroundColor, @@ -248,7 +253,13 @@ class FrostedScaffold extends HookWidget { // only samples pixels that were already painted behind it. This is the // same composition as channel navigation, so top-level headers blur their // content rather than only the fixed gradient. - return [...backdrop, bodyMotion, appBar]; + return [ + ...backdrop, + bodyMotion, + appBar, + if (foreground != null) + Positioned.fill(child: DirectionalTransitionMotion(child: foreground!)), + ]; } } diff --git a/mobile/lib/shared/widgets/native_message_presentation.dart b/mobile/lib/shared/widgets/native_message_presentation.dart index 5c76d3f789..3d8bbc2858 100644 --- a/mobile/lib/shared/widgets/native_message_presentation.dart +++ b/mobile/lib/shared/widgets/native_message_presentation.dart @@ -32,12 +32,20 @@ class NativeMessagePresentation { String method, Map arguments, { VoidCallback? onPresented, + ValueChanged? onReaction, }) async { if (!isSupportedPlatform) return null; - final requestId = 'message-${_nextRequest++}'; - if (onPresented != null) { - _presentedCallbacks[requestId] = onPresented; + final requestId = + arguments['requestId'] as String? ?? 'message-${_nextRequest++}'; + if (onPresented != null || onReaction != null) { + if (onPresented != null) _presentedCallbacks[requestId] = onPresented; channel.setMethodCallHandler((call) async { + if (call.method == 'toggleReaction') { + final data = call.arguments as Map; + if (data['requestId'] == requestId && data['emoji'] is String) { + onReaction?.call(data['emoji'] as String); + } + } if (call.method == 'messagePresented') { _presentedCallbacks[call.arguments]?.call(); } @@ -46,7 +54,7 @@ class NativeMessagePresentation { try { return await channel.invokeMapMethod(method, { ...arguments, - if (onPresented != null) 'requestId': requestId, + 'requestId': requestId, }); } on MissingPluginException { return null; @@ -54,6 +62,9 @@ class NativeMessagePresentation { return null; } finally { _presentedCallbacks.remove(requestId); + if (onPresented != null || onReaction != null) { + channel.setMethodCallHandler(null); + } } } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ab6bc9e50d..f768564391 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -45,7 +45,6 @@ import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; -import 'package:buzz/features/channels/message_actions.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/features/channels/reaction_row.dart'; @@ -3296,9 +3295,7 @@ void main() { ); }); - testWidgets('long press opens the anchored reaction popover', ( - tester, - ) async { + testWidgets('long press opens the reaction sheet', (tester) async { final hapticCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, @@ -3349,376 +3346,13 @@ void main() { ), findsNWidgets(6), ); - expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Copy text'), findsNothing); expect(findRichText('joined the channel'), findsOneWidget); expect(hapticCalls, hasLength(1)); expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction popover leaves existing reactions in the blur', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'reacted-system-message', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - _reaction( - id: 'existing-reaction', - targetId: 'reacted-system-message', - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - final messageCenter = tester.getCenter( - findRichText('joined the channel'), - ); - final reactionRect = tester.getRect(find.byType(ReactionRow)); - final reactionCenter = reactionRect.center; - final reactionPillTop = Offset( - reactionRect.center.dx, - reactionRect.top + Grid.half + Grid.quarter, - ); - await tester.longPress( - find.byKey(const ValueKey('system-message-row-reacted-system-message')), - ); - await tester.pumpAndSettle(); - - final backgroundFinder = find.byKey( - const ValueKey('reaction-popover-background'), - ); - final background = tester.widget(backgroundFinder); - final backgroundOrigin = tester.getTopLeft(backgroundFinder); - final blurPath = background.clipper!.getClip( - tester.getSize(backgroundFinder), - ); - - expect(blurPath.contains(messageCenter - backgroundOrigin), isFalse); - expect(blurPath.contains(reactionPillTop - backgroundOrigin), isTrue); - expect(blurPath.contains(reactionCenter - backgroundOrigin), isTrue); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - for (final thread in [false, true]) { - for (final reactionOnly in [false, true]) { - testWidgets( - 'native header restores after backdrop thread=$thread reactionOnly=$reactionOnly', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() { - debugDefaultTargetPlatformOverride = null; - messageActionBackdropActive.value = false; - }); - // Exercise the composable fallback when native menus are unavailable. - const presentation = MethodChannel( - 'buzz/native_message_presentation', - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - presentation, - (_) async => {'supported': false}, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(presentation, null), - ); - final event = reactionOnly - ? _systemMsg( - id: 'native-backdrop', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ) - : _textMsg( - id: 'native-backdrop', - pubkey: 'alice', - content: 'Native backdrop target', - ); - final timeline = formatTimeline([event]); - await tester.pumpWidget( - _buildTestable( - messages: [event], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - home: thread - ? ThreadDetailPage( - threadHead: timeline.single, - allMessages: timeline, - channelId: _testChannel.id, - currentPubkey: null, - isMember: true, - isArchived: false, - ) - : null, - ), - ); - await tester.pumpAndSettle(); - final header = find.byWidgetPredicate( - (widget) => - widget is UiKitView && - widget.viewType == 'buzz/ios_navigation_bar', - ); - expect(header, findsOneWidget); - final key = thread - ? 'thread-message-row-native-backdrop' - : reactionOnly - ? 'system-message-row-native-backdrop' - : 'message-row-native-backdrop'; - await tester.longPress(find.byKey(ValueKey(key))); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - expect(header, findsNothing); - expect( - find.descendant( - of: find.byType(FrostedAppBar), - matching: find.byType(UiKitView), - ), - findsNothing, - reason: 'Every header control must participate in Flutter blur', - ); - final overlay = find.byKey( - ValueKey( - reactionOnly - ? 'reaction-popover-tray' - : 'message-action-surface', - ), - ); - expect(overlay, findsOneWidget); - Navigator.of(tester.element(overlay)).pop(); - await tester.pump(); - expect( - header, - findsNothing, - reason: - 'Keep native view suppressed through the dismissal transition', - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - expect(header, findsOneWidget); - await tester.pumpWidget(const SizedBox()); - debugDefaultTargetPlatformOverride = null; - }, - ); - } - } - - testWidgets('reaction-only and full actions share the stronger backdrop', ( - tester, - ) async { - addTearDown(() => messageActionBackdropActive.value = false); - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'reaction-only-blur', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - _textMsg( - id: 'full-action-blur', - pubkey: 'alice', - content: 'Full action target', - createdAt: 1100, - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.longPress( - find.byKey(const ValueKey('system-message-row-reaction-only-blur')), - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - final reactionBackdrop = tester.widget( - find.byKey(const ValueKey('reaction-popover-backdrop-filter')), - ); - final reactionTint = tester.widget( - find.byKey(const ValueKey('reaction-popover-background-tint')), - ); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - - await tester.longPress( - find.byKey(const ValueKey('message-row-full-action-blur')), - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - final fullBackdrop = tester.widget( - find.byKey(const ValueKey('message-actions-backdrop-filter')), - ); - final fullTint = tester.widget( - find.byKey(const ValueKey('message-actions-background')), - ); - - expect(fullBackdrop.filter, same(reactionBackdrop.filter)); - expect(fullTint.color, reactionTint.color); - - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction-only popovers serialize concurrent requests', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: const [], - home: Scaffold( - body: Consumer( - builder: (context, ref, _) => TextButton( - key: const ValueKey('concurrent-reaction-launcher'), - onPressed: () { - final message = formatTimeline([ - _systemMsg( - id: 'concurrent-reaction', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - ]).single; - const anchorRect = Rect.fromLTWH(32, 260, 300, 72); - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ); - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ); - }, - child: const Text('Open concurrent reactions'), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap( - find.byKey(const ValueKey('concurrent-reaction-launcher')), - ); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - ); - expect(messageActionBackdropActive.value, isTrue); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - - await tester.tap( - find.byKey(const ValueKey('concurrent-reaction-launcher')), - ); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - reason: 'The presentation latch must release after dismissal.', - ); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction popover grows right from a fixed left edge', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'left-growth-target', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.longPress( - find.byKey(const ValueKey('system-message-row-left-growth-target')), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - final earlyRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), - ); - await tester.pump(const Duration(milliseconds: 80)); - final laterRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), - ); - - expect(laterRect.left, moreOrLessEquals(earlyRect.left)); - expect(laterRect.width, greaterThan(earlyRect.width)); - await tester.pumpAndSettle(); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -3759,14 +3393,9 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - ); + expect(find.byType(BottomSheet), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -3809,24 +3438,15 @@ void main() { ); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsOneWidget, - ); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); expect( find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, + findsNothing, ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Copy text'), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -5377,6 +4997,30 @@ void main() { final stickyHeader = find.byKey( const ValueKey('channel-sticky-date-header'), ); + final scaffold = tester.widget( + find + .ancestor( + of: stickyHeader, + matching: find.byType(FrostedScaffold), + ) + .first, + ); + expect(scaffold.foreground, isNotNull); + expect( + find.descendant( + of: find.byWidget(scaffold.foreground!), + matching: stickyHeader, + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byWidget(scaffold.body), + matching: stickyHeader, + ), + findsNothing, + ); + final stickySurface = find.byKey( const ValueKey('channel-sticky-date-header-surface'), ); @@ -12281,6 +11925,27 @@ void main() { final stickyHeader = find.byKey( const ValueKey('thread-sticky-date-header'), ); + final scaffold = tester.widget( + find + .ancestor(of: stickyHeader, matching: find.byType(FrostedScaffold)) + .first, + ); + expect(scaffold.foreground, isNotNull); + expect( + find.descendant( + of: find.byWidget(scaffold.foreground!), + matching: stickyHeader, + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byWidget(scaffold.body), + matching: stickyHeader, + ), + findsNothing, + ); + expect( find.descendant( of: stickyHeader, diff --git a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart index 0364ff6f0b..3e5919cec4 100644 --- a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart @@ -175,9 +175,7 @@ void actionRowTests() { ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); }); diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index cd5bad0821..ef9a9a01e9 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,6 +1,5 @@ import 'package:buzz/shared/widgets/sheet_action_section.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; -import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/services.dart'; @@ -8,7 +7,6 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_actions.dart'; -import 'package:buzz/features/channels/reaction_row.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'; @@ -18,7 +16,6 @@ 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:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -33,6 +30,7 @@ TimelineMessage _message({ bool isSystem = false, String? rootId, List> tags = const [], + List reactions = const [], }) => TimelineMessage( id: id, pubkey: pubkey, @@ -41,8 +39,22 @@ TimelineMessage _message({ isSystem: isSystem, rootId: rootId, tags: tags, + reactions: reactions, ); +class _RecordingReactionActions implements ChannelActions { + final added = []; + final removed = []; + @override + Future addReaction(String eventId, String emoji) async => + added.add(emoji); + @override + Future removeReaction(String eventId, String emoji) async => + removed.add(eventId); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + final _listedChannel = Channel( id: _channelId, name: 'general', @@ -381,34 +393,74 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover( ); } -Future _dismissMessageActionsPopover(WidgetTester tester) async { - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pumpAndSettle(); -} - -class _FakeChannelActions extends ChannelActions { - final reactions = <({String eventId, String emoji})>[]; - - _FakeChannelActions(Ref ref) - : super( - ref: ref, - session: ref.read(relaySessionProvider.notifier), - signedEventRelay: SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: null, - ), - currentPubkey: 'self', - ); - - @override - Future addReaction(String eventId, String emoji) async { - reactions.add((eventId: eventId, emoji: emoji)); - } -} - void main() { + testWidgets( + 'quick reactions show selection, label controls, and remove our reaction', + (tester) async { + final semantics = tester.ensureSemantics(); + final actions = _RecordingReactionActions(); + await _pumpMessageActionsPopover( + tester, + message: _message( + reactions: [ + const TimelineReaction( + emoji: '👍', + count: 1, + reactedByCurrentUser: true, + userPubkeys: ['self'], + currentUserReactionId: 'our-reaction', + ), + ], + ), + prefs: await _mockPrefs(), + createChannelActions: (_) => actions, + ); + final emoji = find.byKey(const ValueKey('quick-reaction-👍')); + final owner = tester.widget( + find.descendant(of: emoji, matching: find.byType(Semantics)).first, + ); + expect(owner.properties.button, isTrue); + expect(owner.properties.selected, isTrue); + expect(owner.properties.label, isNotEmpty); + expect(owner.properties.onTap, isNotNull); + expect(find.bySemanticsLabel('Add reaction'), findsOneWidget); + await tester.tap(emoji); + await tester.pumpAndSettle(); + expect(actions.removed, ['our-reaction']); + expect(actions.added, isEmpty); + semantics.dispose(); + }, + ); + + testWidgets('two active pointers select only one sheet action', ( + tester, + ) async { + final actions = _RecordingReactionActions(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + launcherOnNestedRoute: true, + createChannelActions: (_) => actions, + ); + final first = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('quick-reaction-👍'))), + pointer: 1, + ); + final second = await tester.startGesture( + tester.getCenter(find.text('Copy text')), + pointer: 2, + ); + await first.up(); + await second.up(); + await tester.pumpAndSettle(); + expect(actions.added, ['👍']); + expect( + find.byKey(const ValueKey('message-actions-underlying-page')), + findsOneWidget, + ); + }); + // Some sheets build the app lifecycle, which listens for network changes. // That listen is asynchronous, so a missing plugin fails whichever test // happens to be running. Flutter documents mock handlers as cleared after @@ -652,983 +704,70 @@ void main() { expect(snapshot.height, 80); }); - testWidgets( - 'native menu respects ownership and dispatches the target read action', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {'action': 'read'}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - final notifier = _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), - ); - await _pumpSheet( + group('showMessageActions', () { + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('uses sectioned sheet on $platform even with a snapshot', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var captures = 0; + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + canManageMessage: true, + allMessages: [_message()], + reminderService: _stubReminderService(), + captureAnchorSnapshot: () async { + captures++; + return _testMessageSnapshot(); + }, + ); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(SheetActionSection), findsNWidgets(3)); + expect(find.byType(AppListCard), findsNWidgets(3)); + expect(captures, 0); + expect(harness.sourceHidden.value, isFalse); + for (final label in [ + 'Mark unread', + 'Follow thread', + 'Copy text', + 'Edit message', + 'Delete message', + ]) { + expect( + find.ancestor( + of: find.text(label), + matching: find.byType(SheetActionSection), + ), + findsOneWidget, + ); + } + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }); + } + + testWidgets('sheet actions grow with large text and remain scrollable', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 667); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await _pumpMessageActionsPopover( tester, message: _message(), prefs: await _mockPrefs(), - readStateOverride: () => notifier, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - ); - expect(payload?['previewBytes'], isA()); - expect(notifier.markedUnread, ['msg:msg-1']); - final actions = (payload!['actions'] as List) - .cast>(); - expect(actions.map((a) => a['id']), isNot(contains('edit'))); - expect(actions.map((a) => a['id']), isNot(contains('delete'))); - expect(find.text('Copy text'), findsNothing); - debugDefaultTargetPlatformOverride = null; - }, - ); - - testWidgets('the native menu uses channel catch-up without a profile', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(createdAt: 900), - prefs: await _mockPrefs(), - readStateOverride: () => _FakeReadStateNotifier( - _readState(const {_channelId: 500, 'activity:$_channelId': 2000}), - ), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - currentPubkey: null, - listChannel: true, - ); - final actions = (payload!['actions'] as List).cast>(); - expect( - actions.firstWhere((a) => a['id'] == 'read')['title'], - 'Mark unread', - ); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('system messages use the native tray without action rows', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(isSystem: true), - prefs: await _mockPrefs(), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - ); - expect(payload?['actions'], isEmpty); - expect(payload?['reactions'], isNotEmpty); - expect(payload?['previewBytes'], isA()); - expect(find.byType(BottomSheet), findsNothing); - debugDefaultTargetPlatformOverride = null; - }); - - for (final selectedAction in [ - null, - 'follow', - 'unmount', - 'unmountFailure', - 'overlap', - ]) { - testWidgets('native focus and source lifecycle for $selectedAction', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final focus = FocusNode(); - late Completer ready; - late Completer> result; - await tester.runAsync(() async { - ready = Completer(); - result = Completer>(); - }); - final visibility = []; - var dismissed = 0; - var focusRestores = 0; - late BuildContext pageContext; - late WidgetRef pageRef; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - expect(visibility, isEmpty); - final args = call.arguments as Map; - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - NativeMessagePresentation.channel.name, - const StandardMethodCodec().encodeMethodCall( - MethodCall('messagePresented', args['requestId']), - ), - (_) {}, - ); - ready.complete(); - return result.future; - }, - ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(await _mockPrefs()), - myPubkeyProvider.overrideWithValue('self'), - readStateProvider.overrideWith( - () => _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), - ), - ), - reminderServiceProvider.overrideWithValue(null), - ], - child: MaterialApp( - home: Consumer( - builder: (context, ref, _) { - pageContext = context; - pageRef = ref; - return Scaffold(body: TextField(focusNode: focus)); - }, - ), - ), - ), - ); - focus.requestFocus(); - await tester.pump(); - late Future presentation; - Future openMenu() => showMessageActions( - context: pageContext, - ref: pageRef, - message: _message(), - channelId: _channelId, - canManageMessage: false, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - captureAnchorSnapshot: _testMessageSnapshot, - composerFocusNode: focus, - restoreComposerFocus: () { - focusRestores++; - focus.requestFocus(); - }, - onPopoverPreviewVisibilityChanged: visibility.add, - onPopoverDismissed: () => dismissed++, - ); - await tester.runAsync(() async { - presentation = openMenu(); - if (selectedAction == 'overlap') { - // A is awaiting preflight/capture, so both gestures could see focus. - await openMenu(); - expect(focusRestores, 0); - } - await ready.future; - }); - await tester.pump(); - expect(focus.hasFocus, isFalse); - expect(visibility, [true]); - if (selectedAction == 'overlap') { - await tester.runAsync(openMenu); - await tester.pump(); - expect(focus.hasFocus, isFalse); - expect(focusRestores, 0); - } - if (selectedAction?.startsWith('unmount') == true) { - await tester.pumpWidget(const SizedBox.shrink()); - } - await tester.runAsync(() async { - if (selectedAction == 'unmountFailure') { - result.completeError(PlatformException(code: 'presentation-failed')); - } else { - result.complete({ - if (selectedAction == 'follow') 'action': selectedAction, - }); - } - await presentation; - }); - await tester.pump(); - final restores = selectedAction == null || selectedAction == 'overlap'; - expect(focus.hasFocus, restores); - expect(focusRestores, restores ? 1 : 0); - expect(visibility, [true, false]); - expect(dismissed, selectedAction?.startsWith('unmount') == true ? 0 : 1); - await tester.pumpWidget(const SizedBox.shrink()); - focus.dispose(); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ); - debugDefaultTargetPlatformOverride = null; - }); - } - - for (final (reactionFirst, missingSnapshot) in [ - (false, false), - (true, false), - (true, true), - ]) { - testWidgets( - 'cross-surface native ownership reactionFirst=$reactionFirst missingSnapshot=$missingSnapshot', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final focus = FocusNode(); - late Completer> result; - late Completer> support; - late Completer entered; - late Completer ready; - await tester.runAsync(() async { - result = Completer(); - support = Completer(); - entered = Completer(); - ready = Completer(); - }); - var messages = 0; - var reactions = 0; - var restores = 0; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') { - entered.complete(); - return support.future; - } - if (call.method == 'message') messages++; - if (call.method == 'reactions') reactions++; - ready.complete(); - return result.future; - }, - ); - late BuildContext pageContext; - late WidgetRef pageRef; - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(await _mockPrefs()), - myPubkeyProvider.overrideWithValue('self'), - readStateProvider.overrideWith( - () => _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), - ), - ), - reminderServiceProvider.overrideWithValue(null), - ], - child: MaterialApp( - home: Consumer( - builder: (context, ref, _) { - pageContext = context; - pageRef = ref; - return Scaffold( - body: TextField(focusNode: focus, showCursor: false), - ); - }, - ), - ), - ), - ); - focus.requestFocus(); - await tester.pump(); - Future message() => showMessageActions( - context: pageContext, - ref: pageRef, - message: _message(), - channelId: _channelId, - canManageMessage: false, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - captureAnchorSnapshot: missingSnapshot ? null : _testMessageSnapshot, - composerFocusNode: focus, - restoreComposerFocus: () { - restores++; - focus.requestFocus(); - }, - ); - Future reaction() => showReactionDetailSheet( - context: pageContext, - channelId: _channelId, - reactions: const [ - TimelineReaction( - emoji: '❤️', - count: 1, - reactedByCurrentUser: false, - userPubkeys: [], - ), - ], - initialEmoji: '❤️', - ); - late Future owner; - await tester.runAsync(() async { - if (reactionFirst) { - owner = reaction(); - await ready.future; - await message(); - } else { - owner = message(); - await entered.future; - await reaction(); - expect(reactions, 0); - support.complete({'supported': true}); - await ready.future; - } - }); - await tester.pump(); - expect(messages, reactionFirst ? 0 : 1); - expect(reactions, reactionFirst ? 1 : 0); - expect(restores, 0); - expect(focus.hasFocus, reactionFirst); - expect(find.byType(BottomSheet), findsNothing); - await tester.runAsync(() async { - result.complete({}); - await owner; - }); - await tester.pumpAndSettle(); - expect(focus.hasFocus, isTrue); - expect(restores, reactionFirst ? 0 : 1); - await tester.pumpWidget(const SizedBox.shrink()); - focus.dispose(); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ); - debugDefaultTargetPlatformOverride = null; - }, - ); - } - - testWidgets('native dismissal does not open the Flutter sheet', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async => call.method == 'supportsMessage' - ? {'supported': true} - : {}, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(), - prefs: await _mockPrefs(), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - ); - expect(find.text('Copy text'), findsNothing); - expect(find.text('open'), findsOneWidget); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('unavailable native presentation falls back to message actions', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(), - prefs: await _mockPrefs(), - canManageMessage: true, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - ); - expect(find.text('Edit message'), findsOneWidget); - expect(find.text('Delete message'), findsOneWidget); - debugDefaultTargetPlatformOverride = null; - }); - - 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; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - try { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); - - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1)); - - await _dismissMessageActionsPopover(tester); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }, - ); - } - - testWidgets('keeps the action menu above the software keyboard', ( - tester, - ) async { - const keyboardInset = 300.0; - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - ); - - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - expect( - actionRect.bottom, - closeTo(logicalHeight - keyboardInset - Grid.xxs, 0.1), - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('collapses fixed sections in a short keyboard viewport', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 220); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - const keyboardInset = 100.0; - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - ); - - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('message-action-preview')), - findsNothing, - ); - expect(harness.sourceHidden.value, isFalse); - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - expect(actionRect.top, greaterThanOrEqualTo(Grid.xxs)); - expect( - actionRect.bottom, - lessThanOrEqualTo(220 - keyboardInset - Grid.xxs), - ); - expect(tester.takeException(), isNull); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('keeps tall message previews within the visible viewport', ( - tester, - ) async { - const keyboardInset = 300.0; - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(pubkey: 'self'), - prefs: prefs, - canManageMessage: true, - allMessages: [_message(pubkey: 'self')], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - anchorRect: const Rect.fromLTWH(32, 40, 300, 2000), - ); - - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - final visibleBottom = logicalHeight - keyboardInset - Grid.xxs; - expect( - tester - .getRect(find.byKey(const ValueKey('message-action-preview'))) - .top, - greaterThanOrEqualTo(Grid.xxs), - ); - expect( - tester - .getRect(find.byKey(const ValueKey('message-action-surface'))) - .bottom, - lessThanOrEqualTo(visibleBottom), - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('restores composer focus only after a dismissed popover', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - expect(focusNode.hasFocus, isFalse); - await _dismissMessageActionsPopover(tester); - expect(focusNode.hasFocus, isTrue); - }); - - testWidgets('leaves an initially unfocused composer unfocused', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - ); - - expect(focusNode.hasFocus, isFalse); - await _dismissMessageActionsPopover(tester); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('does not restore composer focus after opening reactions', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - await tester.tap(find.byKey(const ValueKey('quick-reaction-more'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('does not restore composer focus after selecting an action', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - await tester.tap( - find.byKey(const ValueKey('message-action-followThread')), - ); - await tester.pumpAndSettle(); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('runs an action after dismissal and can reopen', ( - tester, - ) async { - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - ); - - await tester.tap( - find.byKey(const ValueKey('message-action-followThread')), - ); - await tester.pumpAndSettle(); - - expect(harness.sourceHidden.value, isFalse); - expect(harness.container.read(threadFollowsProvider).followedRootIds, { - 'root-9', - }); - - await tester.tap( - find.byKey(const ValueKey('open-message-actions-popover')), - ); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('ignores repeat action taps once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - launcherOnNestedRoute: true, - ); - final action = find.byKey(const ValueKey('message-action-followThread')); - final actionWidget = tester.widget(action); - - actionWidget.onTap!.call(); - actionWidget.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(harness.container.read(threadFollowsProvider).followedRootIds, { - 'root-9', - }); - expect(tester.takeException(), isNull); - }); - - testWidgets('ignores repeat backdrop taps once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - launcherOnNestedRoute: true, - ); - final backdrop = tester.widget( - find.byKey(const ValueKey('message-actions-backdrop')), - ); - - backdrop.onTap!.call(); - backdrop.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('ignores repeat quick reactions once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - late _FakeChannelActions actions; - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - launcherOnNestedRoute: true, - createChannelActions: (ref) => actions = _FakeChannelActions(ref), - ); - final reaction = find.byKey(const ValueKey('quick-reaction-\u{1F44D}')); - final detector = tester.widget( - find.descendant(of: reaction, matching: find.byType(GestureDetector)), - ); - - detector.onTap!.call(); - detector.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(actions.reactions, [(eventId: 'msg-1', emoji: '\u{1F44D}')]); - expect(tester.takeException(), isNull); - }); - - testWidgets('fallback action rows grow with accessibility text', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - textScaler: const TextScaler.linear(3), - ); - - final rowFinder = find.byKey( - const ValueKey('message-action-followThread'), - ); - expect(tester.getSize(rowFinder).height, greaterThan(48)); - expect(tester.takeException(), isNull); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('orders primary, utility, and destructive action groups', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, canManageMessage: true, allMessages: [_message()], reminderService: _stubReminderService(), + textScaler: TextScaler.linear(2), ); - - const actionIds = [ - 'reply', - 'markUnread', - 'edit', - 'copyText', - 'copyLink', - 'remind', - 'followThread', - 'delete', - ]; - final actionTops = [ - for (final actionId in actionIds) - tester - .getTopLeft(find.byKey(ValueKey('message-action-$actionId'))) - .dy, - ]; - expect(actionTops, orderedEquals([...actionTops]..sort())); - expect( - find.byKey(const ValueKey('message-action-divider-utility')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-divider-destructive')), - findsOneWidget, - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('reduced motion presents the complete surface immediately', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - disableAnimations: true, - ); - - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('keeps the snapshot alive through the reverse transition', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - ui.Image? snapshot; - try { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - captureAnchorSnapshot: () async { - snapshot = await _testMessageSnapshot(); - return snapshot!; - }, - ); - - final image = snapshot!; - expect(image.debugDisposed, isFalse); - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pump(); - - expect( - find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect(image.debugDisposed, isFalse); - await tester.pump(const Duration(milliseconds: 110)); - expect(image.debugDisposed, isFalse); - - await tester.pumpAndSettle(); - expect(image.debugDisposed, isTrue); - expect(tester.takeException(), isNull); - } finally { - final image = snapshot; - if (image != null && !image.debugDisposed) image.dispose(); - debugDefaultTargetPlatformOverride = null; - } + await tester.ensureVisible(find.text('Delete message')); + expect(find.text('Delete message').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); }); testWidgets('shows parity actions for a regular message', (tester) async { @@ -1663,24 +802,82 @@ void main() { ); }); - testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions ' - 'row', (tester) async { - final prefs = await _mockPrefs(); - await _pumpSheet( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); + testWidgets( + 'places three quick action tiles between reactions and sections', + (tester) async { + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + ); - expect(find.text('Reply'), findsOneWidget); - expect(find.text('Copy link'), findsOneWidget); - expect(find.text('Remind me'), findsOneWidget); - // Promoted actions no longer appear under their old list-row labels. - expect(find.text('Reply in thread'), findsNothing); - expect(find.text('Remind me later'), findsNothing); - }); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Remind me'), findsOneWidget); + final reply = tester.getCenter(find.text('Reply')); + final link = tester.getCenter(find.text('Copy link')); + final remind = tester.getCenter(find.text('Remind me')); + expect(reply.dy, link.dy); + expect(link.dy, remind.dy); + expect(reply.dx, lessThan(link.dx)); + expect(link.dx, lessThan(remind.dx)); + expect( + tester + .getBottomLeft(find.byKey(const ValueKey('quick-reaction-more'))) + .dy, + lessThan(reply.dy), + ); + expect( + remind.dy, + lessThan(tester.getTopLeft(find.byType(SheetActionSection).first).dy), + ); + for (final label in ['Reply', 'Copy link', 'Remind me']) { + expect( + find.ancestor( + of: find.text(label), + matching: find.byType(SheetActionSection), + ), + findsNothing, + ); + } + // Keep the existing labels. + expect(find.text('Reply in thread'), findsNothing); + expect(find.text('Remind me later'), findsNothing); + }, + ); + + for (final action in [ + null, + 'Copy link', + 'Copy text', + 'Follow thread', + 'Mark unread', + ]) { + testWidgets('composer focus after sheet dismissal: $action', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + composerFocusNode: focusNode, + composerInitiallyFocused: true, + ); + expect(focusNode.hasFocus, isFalse); + if (action == null) { + await tester.tapAt(const Offset(10, 10)); + } else { + await tester.tap(find.text(action)); + } + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, action == null); + }); + } testWidgets('hides utility actions for system messages', (tester) async { final prefs = await _mockPrefs(); @@ -1735,71 +932,20 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('keeps the reaction popover on-screen when neither side fits', ( + testWidgets('system messages use a dismissible reaction sheet', ( tester, ) async { - tester.view.physicalSize = const Size(320, 240); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - const anchorRect = Rect.fromLTWH(40, 60, 240, 100); - const safeTop = 24.0; - const visibleBottom = 200.0; - const trayHeight = 68.0; - const trayGap = Grid.xxs; - final prefs = await _mockPrefs(); - expect(anchorRect.top - trayGap - trayHeight, lessThan(safeTop)); - expect( - anchorRect.bottom + trayGap + trayHeight, - greaterThan(visibleBottom), + await _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: await _mockPrefs(), + anchorRect: const Rect.fromLTWH(20, 20, 100, 100), ); - - await tester.pumpWidget( - ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: MaterialApp( - theme: AppTheme.light(), - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: const EdgeInsets.only(top: safeTop), - viewInsets: const EdgeInsets.only(bottom: 40), - ), - child: child!, - ), - home: Scaffold( - body: Consumer( - builder: (context, ref, _) => TextButton( - onPressed: () => showMessageActions( - context: context, - ref: ref, - message: _message(isSystem: true), - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ), - child: const Text('open popover'), - ), - ), - ), - ), - ), - ); - await tester.tap(find.text('open popover')); - await tester.pumpAndSettle(); - - final trayRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), - ); - expect(trayRect.top, greaterThanOrEqualTo(safeTop)); - expect(trayRect.bottom, lessThanOrEqualTo(visibleBottom)); - - // Close the popover so its presentation guard does not block later - // tests. - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); + await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); }); testWidgets('shows Edit/Delete only with manage rights', (tester) async { @@ -1959,7 +1105,7 @@ void main() { }); } - testWidgets('the popover uses channel catch-up without a profile', ( + testWidgets('the anchored sheet uses channel catch-up without a profile', ( tester, ) async { await _pumpMessageActionsPopover( diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 1670268a06..3fc3257730 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -111,6 +112,130 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { } void main() { + testWidgets('removing the sole reaction closes the sheet', (tester) async { + final reactions = ValueNotifier>([ + _reaction(reactedByCurrentUser: true), + ]); + addTearDown(reactions.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => showReactionDetailSheet( + context: context, + channelId: 'channel', + reactions: reactions, + onToggle: (_) => reactions.value = [], + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('reaction-details-sheet')), findsNothing); + expect(find.text('Open'), findsOneWidget); + }); + + testWidgets('a stale toggle for an absent emoji does nothing', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => toggleReaction( + ref, + TimelineMessage( + id: 'message', + pubkey: _alice, + createdAt: 1, + content: '', + ), + _fire, + ), + child: const Text('Toggle'), + ), + ), + ), + ); + await tester.tap(find.text('Toggle')); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'native toggles use the current callback and stream updated counts', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final completion = Completer>(); + Map? payload; + Map? updated; + final toggles = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + if (call.method == 'updateReactions') updated = call.arguments as Map; + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpRow( + tester, + reactions: [_reaction()], + onToggle: (emoji) => toggles.add('old:$emoji'), + ); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pumpAndSettle(); + await _pumpRow( + tester, + reactions: [_reaction(count: 2, reactedByCurrentUser: true)], + onToggle: (emoji) => toggles.add('new:$emoji'), + ); + expect((updated!['reactions'] as List).single, containsPair('count', 2)); + expect( + (updated!['reactions'] as List).single, + containsPair('selected', true), + ); + expect(updated!['requestId'], payload!['requestId']); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + NativeMessagePresentation.channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('toggleReaction', { + 'requestId': payload!['requestId'], + 'emoji': _fire, + }), + ), + (_) {}, + ); + await tester.pump(); + expect(toggles, ['new:$_fire']); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + expect(updated!['reactions'], isEmpty); + completion.complete({}); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets( 'native reactor labels retain channel qualifications and live updates', (tester) async { @@ -304,7 +429,7 @@ void main() { ); testWidgets( - 'holding a pill opens native membership with the selected emoji without toggling', + 'holding a pill opens native membership with all reactions without toggling', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -341,8 +466,14 @@ void main() { await tester.pumpAndSettle(); expect(toggles, 0); expect(messageHolds, 0); - expect(payload!['initialEmoji'], _eyes); + expect(payload!.containsKey('initialEmoji'), isFalse); + expect( + ((payload!['reactions'] as List).first as Map)['selected'], + isFalse, + ); expect((payload!['reactions'] as List).length, 2); + expect(((payload!['reactions'] as List).first as Map)['burst'], isTrue); + expect(((payload!['reactions'] as List).last as Map)['burst'], isFalse); final profiles = payload!['profiles'] as Map; expect((profiles[_alice] as Map)['name'], 'Alice'); expect(find.byType(BottomSheet), findsNothing); @@ -371,54 +502,39 @@ void main() { find.descendant(of: pill, matching: find.text('1')), ); await tester.pumpAndSettle(); - expect(find.text('Reactions'), findsNothing); - expect(find.text('All 2'), findsOneWidget); - final controller = DefaultTabController.of( - tester.element(find.byType(TabBar)), - ); - expect(controller.index, 2); + expect(find.text('Reactions'), findsOneWidget); + expect(find.text('All 2'), findsNothing); + for (final emoji in [_fire, _eyes]) { + expect( + find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(), + findsOneWidget, + ); + } expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_fire')), + ) + .selected, + isTrue, ); expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsNothing, + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_eyes')), + ) + .selected, + isFalse, ); - await tester.tap(find.byKey(const ValueKey('reaction-filter-all'))); + await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire'))); await tester.pumpAndSettle(); - expect(controller.index, 0); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsOneWidget, - ); - await tester.tap(find.byKey(const ValueKey('reaction-filter-$_fire'))); - await tester.pumpAndSettle(); - expect(controller.index, 1); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsNothing, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsOneWidget, - ); - await tester.drag(find.byType(TabBarView), const Offset(-500, 0)); - await tester.pumpAndSettle(); - expect(controller.index, 2); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsNothing, - ); - expect(toggles, 0); + expect(toggles, 1); + for (final emoji in [_fire, _eyes]) { + expect( + find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(), + findsOneWidget, + ); + } expect(messageHolds, 0); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); @@ -533,12 +649,42 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip(':fire:'), findsOneWidget); - expect(find.byType(TabBar), findsOneWidget); - expect(find.text('All 1'), findsOneWidget); + expect(find.byType(FilterChip), findsOneWidget); + expect(find.text('All 1'), findsNothing); expect(find.text('Alice'), findsOneWidget); }); }); + for (final entry in [ + (emoji: _fire, selected: false, bursts: true), + (emoji: _fire, selected: true, bursts: false), + (emoji: _eyes, selected: false, bursts: false), + ]) { + testWidgets('sheet burst for ${entry.emoji}, selected=${entry.selected}', ( + tester, + ) async { + final container = await _pumpRow( + tester, + reactions: [ + _reaction(emoji: entry.emoji, reactedByCurrentUser: entry.selected), + ], + ); + final controller = container.read(emojiBurstControllerProvider); + await tester.longPress( + find.byKey(ValueKey('reaction-pill-${entry.emoji}')), + ); + await tester.pumpAndSettle(); + final chip = find.byKey(ValueKey('reaction-toggle-${entry.emoji}')); + expect(tester.widget(chip).side!.width, 1); + await tester.tap(chip); + await tester.pump(); + expect(controller.hasParticles, entry.bursts); + if (entry.bursts) { + expect(controller.debugLastBurstOrigin, tester.getCenter(chip)); + } + }); + } + group('reaction bursts', () { testWidgets('adding a positive reaction bursts from the pill', ( tester, diff --git a/mobile/test/shared/widgets/app_list_test.dart b/mobile/test/shared/widgets/app_list_test.dart index db493a3f9b..d0a2b7a4a7 100644 --- a/mobile/test/shared/widgets/app_list_test.dart +++ b/mobile/test/shared/widgets/app_list_test.dart @@ -93,7 +93,7 @@ void main() { final scheme = AppTheme.light().colorScheme; // Derived from the text color, not the scheme's border tokens — those sit // within a few levels of the card fill and vanish. - expect(divider.color, scheme.onSurface.withValues(alpha: 0.12)); + expect(divider.color, scheme.onSurface.withValues(alpha: 0.07)); // The indent is painted inside the divider's box, so the line starts at // the box's left edge plus the indent — level with the row titles. diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index 998febfdec..c77322ff95 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -12,6 +12,43 @@ Border? _appBarBorder(WidgetTester tester) { } void main() { + testWidgets('foreground paints above header and leaves body interactive', ( + tester, + ) async { + var taps = 0; + const marker = Text('Foreground date'); + const bar = FrostedAppBar(title: Text('Header')); + await tester.pumpWidget( + MaterialApp( + home: FrostedScaffold( + appBar: bar, + body: Center( + child: TextButton( + onPressed: () => taps++, + child: const Text('Body action'), + ), + ), + foreground: const Stack( + children: [Positioned(top: 80, left: 0, child: marker)], + ), + ), + ), + ); + final stack = tester.widget( + find.ancestor(of: find.byWidget(bar), matching: find.byType(Stack)).first, + ); + expect(stack.children.indexOf(bar), lessThan(stack.children.length - 1)); + expect( + find.descendant( + of: find.byWidget(stack.children.last), + matching: find.byWidget(marker), + ), + findsOneWidget, + ); + await tester.tap(find.text('Body action')); + expect(taps, 1); + }); + testWidgets('utility pages expose the inverted surface hierarchy', ( tester, ) async {