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 {