Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class _MessageBubble extends HookConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);

final selecting = ref.watch(messageTextSelectionIdProvider) == message.id;

void openMessageActions(MessageLongPressDetails details) {
showMessageActions(
context: context,
Expand All @@ -97,6 +99,9 @@ class _MessageBubble extends HookConsumerWidget {
onPopoverDismissed: () => details.setSourceHidden(false),
composerFocusNode: composerFocusNode,
restoreComposerFocus: restoreComposerFocus,
onSelectText: () {
ref.read(messageTextSelectionIdProvider.notifier).state = message.id;
},
);
}

Expand All @@ -111,13 +116,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<void>(
Expand Down Expand Up @@ -213,11 +219,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,
Expand Down
42 changes: 42 additions & 0 deletions mobile/lib/features/channels/message_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>((ref) => null);

/// Presents the actions for [message] as an anchored popover when both
/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet.
///
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -116,6 +120,7 @@ void showMessageActions({
onPopoverDismissed: onPopoverDismissed,
composerFocusNode: composerFocusNode,
restoreComposerFocus: restoreComposerFocus,
onSelectText: onSelectText,
)) {
return;
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -77,6 +78,7 @@ bool _tryShowMessageActionsPopover({
composerFocusNode: composerFocusNode,
restoreComposerFocus: restoreComposerFocus,
shouldRestoreComposerFocus: shouldRestoreComposerFocus,
onSelectText: onSelectText,
).then((shown) {
if (shown || !context.mounted) return;
showMessageActions(
Expand All @@ -89,6 +91,7 @@ bool _tryShowMessageActionsPopover({
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
onSelectText: onSelectText,
);
}),
);
Expand All @@ -112,6 +115,7 @@ Future<bool> _showMessageActionsPopover({
required FocusNode? composerFocusNode,
required VoidCallback? restoreComposerFocus,
required bool shouldRestoreComposerFocus,
required VoidCallback? onSelectText,
}) async {
if (_messageActionPresentationInFlight) return true;
_messageActionPresentationInFlight = true;
Expand All @@ -127,6 +131,7 @@ Future<bool> _showMessageActionsPopover({
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
onSelectText: onSelectText,
);
if (actions.isEmpty) return false;
final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]!),
Expand Down Expand Up @@ -446,6 +464,7 @@ class _PopoverMessageAction {
'markUnread' => 'envelope.badge',
'edit' => 'pencil',
'copyText' => 'doc.on.doc',
'selectText' => 'text.cursor',
'copyLink' => 'link',
'remind' => 'clock',
'followThread' => 'bell',
Expand Down
15 changes: 13 additions & 2 deletions mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -150,6 +154,7 @@ class MessageContent extends HookConsumerWidget {
this.scaleEmojiOnly = false,
this.mediaCarouselLeadingOverflow = 0,
this.mediaCarouselTrailingOverflow = 0,
this.selectable = false,
});

@override
Expand Down Expand Up @@ -304,13 +309,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'),
Expand Down
12 changes: 12 additions & 0 deletions mobile/lib/features/channels/message_long_press_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class _ThreadMessage extends HookConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);

final selecting = ref.watch(messageTextSelectionIdProvider) == message.id;

void openMessageActions(MessageLongPressDetails details) {
showMessageActions(
context: context,
Expand All @@ -92,6 +94,9 @@ class _ThreadMessage extends HookConsumerWidget {
onPopoverDismissed: () => details.setSourceHidden(false),
composerFocusNode: composerFocusNode,
restoreComposerFocus: restoreComposerFocus,
onSelectText: () {
ref.read(messageTextSelectionIdProvider.notifier).state = message.id;
},
);
}

Expand Down Expand Up @@ -137,6 +142,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),
Expand Down Expand Up @@ -223,11 +229,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,
Expand Down
Loading