From f97872c20a012d050ae3380dc1c9f26916ab9459 Mon Sep 17 00:00:00 2001 From: liferidge Date: Thu, 20 Aug 2026 10:48:36 +0000 Subject: [PATCH] feat(mobile): allow selecting and copying message text Signed-off-by: liferidge --- .../channel_detail_page/message_bubble.dart | 18 +- .../features/channels/message_actions.dart | 42 ++++ .../message_action_popover.dart | 29 ++- .../features/channels/message_content.dart | 15 +- .../channels/message_long_press_region.dart | 12 + .../thread_detail_page/thread_message.dart | 16 ++ .../lib/features/forum/forum_post_card.dart | 46 ++-- .../lib/features/forum/forum_thread_page.dart | 207 ++++++++++++------ .../channels/channel_detail_page_test.dart | 57 +++++ .../channels/message_actions_test.dart | 53 +++++ .../channels/message_content_test.dart | 24 ++ .../features/forum/forum_widgets_test.dart | 22 ++ 12 files changed, 456 insertions(+), 85 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 8c953b7211b..132f2d251f9 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -77,6 +77,8 @@ class _MessageBubble extends HookConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); + final selecting = ref.watch(messageTextSelectionIdProvider) == message.id; + void openMessageActions(MessageLongPressDetails details) { showMessageActions( context: context, @@ -94,6 +96,9 @@ class _MessageBubble extends HookConsumerWidget { onPopoverDismissed: () => details.setSourceHidden(false), composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, + onSelectText: () { + ref.read(messageTextSelectionIdProvider.notifier).state = message.id; + }, ); } @@ -108,13 +113,14 @@ class _MessageBubble extends HookConsumerWidget { clipBehavior: Clip.none, child: MessageLongPressInkWell( key: ValueKey('message-row-${message.id}'), + enabled: !selecting, onLongPressDetails: openMessageActions, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), snapshotKey: messageSnapshotKey, // Tap opens the thread; long-press still opens the action sheet. // MessageContent handles mention, channel-link, and media taps. - onTap: allMessages == null + onTap: selecting || allMessages == null ? null : () => Navigator.of(context).push( MaterialPageRoute( @@ -209,11 +215,21 @@ class _MessageBubble extends HookConsumerWidget { ), ), ], + if (selecting) ...[ + const SizedBox(width: Grid.half), + const MessageTextSelectionDoneButton(), + ], ], ), + ) + else if (selecting) + const Align( + alignment: Alignment.centerRight, + child: MessageTextSelectionDoneButton(), ), MessageContent( content: message.content, + selectable: selecting, mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 53dd4c30f65..eb10c4fb823 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -55,6 +55,9 @@ final _messageActionBackdropFilter = ImageFilter.blur( sigmaY: _messageActionBackdropBlurSigma, ); +/// The message or forum post currently in substring-selection mode, if any. +final messageTextSelectionIdProvider = StateProvider((ref) => null); + /// Presents the actions for [message] as an anchored popover when both /// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet. /// @@ -87,6 +90,7 @@ void showMessageActions({ VoidCallback? restoreComposerFocus, bool isArchived = false, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), + VoidCallback? onSelectText, }) { final hasReactionOnlyActions = message.isSystem && !canManageMessage; if (anchorRect != null && hasReactionOnlyActions) { @@ -116,6 +120,7 @@ void showMessageActions({ onPopoverDismissed: onPopoverDismissed, composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, + onSelectText: onSelectText, )) { return; } @@ -178,6 +183,15 @@ void showMessageActions({ Clipboard.setData(data); }, ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.textCursorInput), + title: const Text('Select text'), + onTap: () { + Navigator.of(sheetContext).pop(); + onSelectText?.call(); + }, + ), ], if (canManageMessage) ...[ if (!message.isSystem) const SheetDivider(), @@ -460,6 +474,34 @@ String messageLinkFor({ ); } +/// Exits [messageTextSelectionIdProvider] for the message currently selecting. +class MessageTextSelectionDoneButton extends ConsumerWidget { + const MessageTextSelectionDoneButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return TextButton( + key: const ValueKey('message-select-text-done'), + onPressed: () { + ref.read(messageTextSelectionIdProvider.notifier).state = null; + }, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Done', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + class _MarkReadUnreadTile extends ConsumerWidget { final TimelineMessage message; final String channelId; diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart index f56fb7d3acb..18342ed19b7 100644 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -56,6 +56,7 @@ bool _tryShowMessageActionsPopover({ required VoidCallback? onPopoverDismissed, required FocusNode? composerFocusNode, required VoidCallback? restoreComposerFocus, + required VoidCallback? onSelectText, }) { if (anchorRect == null || captureAnchorSnapshot == null) return false; final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false; @@ -77,6 +78,7 @@ bool _tryShowMessageActionsPopover({ composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, shouldRestoreComposerFocus: shouldRestoreComposerFocus, + onSelectText: onSelectText, ).then((shown) { if (shown || !context.mounted) return; showMessageActions( @@ -89,6 +91,7 @@ bool _tryShowMessageActionsPopover({ currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, + onSelectText: onSelectText, ); }), ); @@ -112,6 +115,7 @@ Future _showMessageActionsPopover({ required FocusNode? composerFocusNode, required VoidCallback? restoreComposerFocus, required bool shouldRestoreComposerFocus, + required VoidCallback? onSelectText, }) async { if (_messageActionPresentationInFlight) return true; _messageActionPresentationInFlight = true; @@ -127,6 +131,7 @@ Future _showMessageActionsPopover({ currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, + onSelectText: onSelectText, ); if (actions.isEmpty) return false; final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface(); @@ -229,6 +234,7 @@ List<_PopoverMessageAction> _buildPopoverMessageActions({ required String? currentPubkey, required bool isMember, required bool isArchived, + required VoidCallback? onSelectText, }) { final actions = <_PopoverMessageAction>[]; final messages = allMessages; @@ -363,6 +369,17 @@ List<_PopoverMessageAction> _buildPopoverMessageActions({ Clipboard.setData(ClipboardData(text: message.content)), ), ); + if (onSelectText != null) { + actions.add( + _PopoverMessageAction( + id: 'selectText', + title: 'Select text', + icon: LucideIcons.textCursorInput, + group: _PopoverMessageActionGroup.utility, + onSelected: onSelectText, + ), + ); + } } if (canManageMessage) { @@ -409,11 +426,12 @@ List<_PopoverMessageAction> _buildPopoverMessageActions({ 'markUnread': 1, 'edit': 2, 'copyText': 3, - 'copyLink': 4, - 'remind': 5, - 'followThread': 6, - 'unfollowThread': 6, - 'delete': 7, + 'selectText': 4, + 'copyLink': 5, + 'remind': 6, + 'followThread': 7, + 'unfollowThread': 7, + 'delete': 8, }; actions.sort( (left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!), @@ -446,6 +464,7 @@ class _PopoverMessageAction { 'markUnread' => 'envelope.badge', 'edit' => 'pencil', 'copyText' => 'doc.on.doc', + 'selectText' => 'text.cursor', 'copyLink' => 'link', 'remind' => 'clock', 'followThread' => 'bell', diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 64938b5c879..e668acf2520 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -133,6 +133,10 @@ class MessageContent extends HookConsumerWidget { /// gutter while keeping its first image and count aligned with the body. final double mediaCarouselTrailingOverflow; + /// When true, wrap the markdown body in a [SelectionArea] so the user can + /// highlight a substring and use the platform copy menu. + final bool selectable; + const MessageContent({ super.key, required this.content, @@ -150,6 +154,7 @@ class MessageContent extends HookConsumerWidget { this.scaleEmojiOnly = false, this.mediaCarouselLeadingOverflow = 0, this.mediaCarouselTrailingOverflow = 0, + this.selectable = false, }); @override @@ -296,13 +301,19 @@ class MessageContent extends HookConsumerWidget { ], ), ); - if (trailingGallery == null) return markdown; + final body = selectable + ? SelectionArea( + key: const ValueKey('message-content-selection-area'), + child: markdown, + ) + : markdown; + if (trailingGallery == null) return body; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (trailingGallery.content.trim().isNotEmpty) markdown, + if (trailingGallery.content.trim().isNotEmpty) body, _MessageImageCarousel( key: ValueKey( trailingGallery.items.map((item) => item.url).join('\u0000'), diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart index caf84815a35..e9f75e82f97 100644 --- a/mobile/lib/features/channels/message_long_press_region.dart +++ b/mobile/lib/features/channels/message_long_press_region.dart @@ -60,6 +60,10 @@ class MessageLongPressInkWell extends StatelessWidget { final GlobalKey? snapshotKey; final Widget child; + /// When false, the long-press interceptor is omitted so platform text + /// selection can take the pointer. + final bool enabled; + const MessageLongPressInkWell({ super.key, this.onTap, @@ -68,11 +72,19 @@ class MessageLongPressInkWell extends StatelessWidget { this.borderRadius, this.highlightColor, this.snapshotKey, + this.enabled = true, required this.child, }) : assert(onLongPress != null || onLongPressDetails != null); @override Widget build(BuildContext context) { + final inkWell = InkWell( + onTap: enabled ? onTap : null, + borderRadius: borderRadius, + highlightColor: highlightColor, + child: child, + ); + if (!enabled) return inkWell; return _MessageLongPressRegion( onTap: onTap, onLongPress: onLongPress, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 89bdd40998f..1fcb3de6a86 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -72,6 +72,8 @@ class _ThreadMessage extends HookConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); + final selecting = ref.watch(messageTextSelectionIdProvider) == message.id; + void openMessageActions(MessageLongPressDetails details) { showMessageActions( context: context, @@ -89,6 +91,9 @@ class _ThreadMessage extends HookConsumerWidget { onPopoverDismissed: () => details.setSourceHidden(false), composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, + onSelectText: () { + ref.read(messageTextSelectionIdProvider.notifier).state = message.id; + }, ); } @@ -134,6 +139,7 @@ class _ThreadMessage extends HookConsumerWidget { clipBehavior: Clip.none, child: MessageLongPressInkWell( key: ValueKey('thread-message-row-${message.id}'), + enabled: !selecting, onLongPressDetails: openMessageActions, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), @@ -219,11 +225,21 @@ class _ThreadMessage extends HookConsumerWidget { ), ), ], + if (selecting) ...[ + const SizedBox(width: Grid.half), + const MessageTextSelectionDoneButton(), + ], ], ), + ) + else if (selecting) + const Align( + alignment: Alignment.centerRight, + child: MessageTextSelectionDoneButton(), ), MessageContent( content: message.content, + selectable: selecting, mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index b3ae953ef69..944753e89b6 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -8,6 +8,7 @@ import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../channels/message_actions.dart'; import '../channels/message_content.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../profile/user_profile_sheet.dart'; @@ -89,10 +90,11 @@ class ForumPostCard extends HookConsumerWidget { ? '${post.content.substring(0, 200)}...' : post.content; final summary = post.threadSummary; + final selecting = ref.watch(messageTextSelectionIdProvider) == post.eventId; return GestureDetector( - onTap: onTap, - onLongPress: () => _showActions(context), + onTap: selecting ? null : onTap, + onLongPress: selecting ? null : () => _showActions(context, ref), child: Container( width: double.infinity, padding: const EdgeInsets.all(Grid.twelve), @@ -140,20 +142,23 @@ class ForumPostCard extends HookConsumerWidget { ), ), const SizedBox(width: Grid.half), - SizedBox( - width: 24, - height: 24, - child: IconButton( - onPressed: () => _showActions(context), - icon: Icon( - LucideIcons.ellipsis, - size: 16, - color: context.colors.onSurfaceVariant, + if (selecting) + const MessageTextSelectionDoneButton() + else + SizedBox( + width: 24, + height: 24, + child: IconButton( + onPressed: () => _showActions(context, ref), + icon: Icon( + LucideIcons.ellipsis, + size: 16, + color: context.colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, ), - ), ], ), const SizedBox(height: Grid.xxs), @@ -169,6 +174,7 @@ class ForumPostCard extends HookConsumerWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 120), child: IgnorePointer( + ignoring: !selecting, child: MessageContent( content: preview, mentionNames: mentionNames, @@ -177,6 +183,7 @@ class ForumPostCard extends HookConsumerWidget { baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, ), + selectable: selecting, ), ), ), @@ -226,7 +233,7 @@ class ForumPostCard extends HookConsumerWidget { ); } - void _showActions(BuildContext context) { + void _showActions(BuildContext context, WidgetRef ref) { final isOwn = currentPubkey != null && post.pubkey.toLowerCase() == currentPubkey!.toLowerCase(); @@ -255,6 +262,15 @@ class ForumPostCard extends HookConsumerWidget { Clipboard.setData(ClipboardData(text: post.content)); }, ), + ListTile( + leading: const Icon(LucideIcons.textCursorInput), + title: const Text('Select text'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref.read(messageTextSelectionIdProvider.notifier).state = + post.eventId; + }, + ), if (isOwn && onDelete != null) ListTile( leading: Icon( diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 7c1f4c3cea0..8dbcb056688 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -14,6 +14,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/compose_bar.dart'; +import '../channels/message_actions.dart'; import '../channels/message_content.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; @@ -138,6 +139,15 @@ class ForumThreadPage extends HookConsumerWidget { Clipboard.setData(ClipboardData(text: thread.post.content)); }, ), + ListTile( + leading: const Icon(LucideIcons.textCursorInput), + title: const Text('Select text'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref.read(messageTextSelectionIdProvider.notifier).state = + thread.post.eventId; + }, + ), ListTile( leading: Icon( LucideIcons.trash2, @@ -349,65 +359,120 @@ class _OriginalPost extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); - return Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - GestureDetector( - onTap: () => showUserProfileSheet(context, post.pubkey), - child: _Avatar( - profile: profile, - pubkey: post.pubkey, - radius: 16, + final selecting = ref.watch(messageTextSelectionIdProvider) == post.eventId; + + return GestureDetector( + onLongPress: selecting ? null : () => _showActions(context, ref), + child: Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => showUserProfileSheet(context, post.pubkey), + child: _Avatar( + profile: profile, + pubkey: post.pubkey, + radius: 16, + ), ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => showUserProfileSheet(context, post.pubkey), + const SizedBox(width: Grid.xxs), + Expanded( + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => + showUserProfileSheet(context, post.pubkey), + child: Text( + displayName, + maxLines: 1, + style: messageUsernameTextStyle, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), child: Text( - displayName, + formatRelativeTime(post.createdAt), maxLines: 1, - style: messageUsernameTextStyle, overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), - ), - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: Grid.xxl), - child: Text( - formatRelativeTime(post.createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: messageTimestampTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], + if (selecting) ...[ + const SizedBox(width: Grid.half), + const MessageTextSelectionDoneButton(), + ], + ], + ), ), + ], + ), + const SizedBox(height: Grid.xxs), + MessageContent( + content: post.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + tags: post.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, ), - ], - ), - const SizedBox(height: Grid.xxs), - MessageContent( - content: post.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - tags: post.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, + onMentionTap: selecting + ? null + : (pubkey) => showUserProfileSheet(context, pubkey), + selectable: selecting, + ), + ], + ), + ), + ); + } + + void _showActions(BuildContext context, WidgetRef ref) { + showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: post.content)); + }, + ), + ListTile( + leading: const Icon(LucideIcons.textCursorInput), + title: const Text('Select text'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref.read(messageTextSelectionIdProvider.notifier).state = + post.eventId; + }, + ), + ], ), - onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), ), - ], + ), ), ); } @@ -449,6 +514,9 @@ class _ReplyRow extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); + final selecting = + ref.watch(messageTextSelectionIdProvider) == reply.eventId; + return Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, @@ -498,20 +566,23 @@ class _ReplyRow extends ConsumerWidget { ], ), ), - SizedBox( - width: 28, - height: 28, - child: IconButton( - onPressed: () => _showActions(context, ref), - icon: Icon( - LucideIcons.ellipsis, - size: 16, - color: context.colors.onSurfaceVariant, + if (selecting) + const MessageTextSelectionDoneButton() + else + SizedBox( + width: 28, + height: 28, + child: IconButton( + onPressed: () => _showActions(context, ref), + icon: Icon( + LucideIcons.ellipsis, + size: 16, + color: context.colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, ), - ), ], ), Padding( @@ -524,7 +595,10 @@ class _ReplyRow extends ConsumerWidget { baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, ), - onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), + onMentionTap: selecting + ? null + : (pubkey) => showUserProfileSheet(context, pubkey), + selectable: selecting, ), ), ], @@ -561,6 +635,15 @@ class _ReplyRow extends ConsumerWidget { Clipboard.setData(ClipboardData(text: reply.content)); }, ), + ListTile( + leading: const Icon(LucideIcons.textCursorInput), + title: const Text('Select text'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref.read(messageTextSelectionIdProvider.notifier).state = + reply.eventId; + }, + ), if (isOwn) ListTile( leading: Icon( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index facf466daab..a4a9ccff0b7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -22,6 +22,7 @@ import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; +import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/message_actions.dart'; import 'package:buzz/features/channels/reaction_row.dart'; @@ -2286,6 +2287,62 @@ void main() { expect(messageActionBackdropActive.value, isFalse); }); + testWidgets('Select text enters selection mode for that message', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'hello selectable world', + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SelectionArea), findsNothing); + + await tester.longPress(find.byKey(const ValueKey('message-row-msg1'))); + await tester.pumpAndSettle(); + + expect(find.text('Select text'), findsOneWidget); + expect(find.text('Copy text'), findsOneWidget); + + await tester.tap(find.text('Select text')); + await tester.pumpAndSettle(); + + expect(find.text('Select text'), findsNothing); + expect(find.byType(SelectionArea), findsOneWidget); + expect(find.text('Done'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('message-row-msg1')), + ) + .enabled, + isFalse, + ); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.byType(SelectionArea), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('message-row-msg1')), + ) + .enabled, + isTrue, + ); + }); + testWidgets( 'keeps image galleries body-aligned and flush with the trailing edge', (tester) async { diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index a8c44e34923..4180acae814 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -112,6 +113,7 @@ Future _pumpSheet( bool canManageMessage = false, List? allMessages, ReminderService? reminderService, + VoidCallback? onSelectText, }) async { await tester.pumpWidget( ProviderScope( @@ -142,6 +144,7 @@ Future _pumpSheet( allMessages: allMessages, currentPubkey: 'self', isMember: true, + onSelectText: onSelectText, ), child: const Text('open'), ), @@ -1112,6 +1115,7 @@ void main() { await _pumpSheet(tester, message: _message(), prefs: prefs); expect(find.text('Copy text'), findsOneWidget); + expect(find.text('Select text'), findsOneWidget); expect(find.text('Copy link'), findsOneWidget); expect(find.text('Mark unread'), findsOneWidget); expect(find.text('Follow thread'), findsOneWidget); @@ -1139,6 +1143,54 @@ void main() { ); }); + testWidgets('Copy text copies the full message content', (tester) async { + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final args = call.arguments; + if (args is Map) { + clipboardText = args['text'] as String?; + } + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + final prefs = await _mockPrefs(); + await _pumpSheet(tester, message: _message(), prefs: prefs); + await tester.tap(find.text('Copy text')); + await tester.pumpAndSettle(); + + expect(clipboardText, 'hello world'); + }); + + testWidgets('Select text pops the sheet and invokes onSelectText', ( + tester, + ) async { + var selected = false; + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, + message: _message(), + prefs: prefs, + onSelectText: () => selected = true, + ); + + await tester.tap(find.text('Select text')); + await tester.pumpAndSettle(); + + expect(find.text('Select text'), findsNothing); + expect(selected, isTrue); + }); + testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions ' 'row', (tester) async { final prefs = await _mockPrefs(); @@ -1163,6 +1215,7 @@ void main() { await _pumpSheet(tester, message: _message(isSystem: true), prefs: prefs); expect(find.text('Copy text'), findsNothing); + expect(find.text('Select text'), findsNothing); expect(find.text('Copy link'), findsNothing); expect(find.text('Mark unread'), findsNothing); expect(find.text('Follow thread'), findsNothing); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index c4d624b22d7..b108380d76d 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -190,6 +190,30 @@ void main() { ); }); + testWidgets('does not wrap markdown in a SelectionArea by default', ( + tester, + ) async { + await tester.pumpWidget( + _testable(const MessageContent(content: 'hello world')), + ); + + expect(find.byType(SelectionArea), findsNothing); + }); + + testWidgets('selectable wraps markdown in a SelectionArea', (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent(content: 'hello world', selectable: true), + ), + ); + + expect(find.byType(SelectionArea), findsOneWidget); + expect( + find.byKey(const ValueKey('message-content-selection-area')), + findsOneWidget, + ); + }); + testWidgets('opens local file links through an authenticated download', ( tester, ) async { diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 939a25e1397..0cb8ab81bcf 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -380,6 +380,28 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Copy text'), findsOneWidget); + expect(find.text('Select text'), findsOneWidget); + }); + + testWidgets('Select text puts the post body into selection mode', ( + tester, + ) async { + await tester.pumpWidget(_buildPostCard(post: _makePost())); + await tester.pumpAndSettle(); + + expect(find.byType(SelectionArea), findsNothing); + + await tester.longPress(find.byType(ForumPostCard)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Select text')); + await tester.pumpAndSettle(); + + expect(find.byType(SelectionArea), findsOneWidget); + expect(find.text('Done'), findsOneWidget); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(find.byType(SelectionArea), findsNothing); }); testWidgets('long press shows Delete only for own posts', (tester) async {