From 17e694caedcdfd9cd15dbc02b739cbf51e9d4f32 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:34:48 -0500 Subject: [PATCH] fix(#1079): scroll long menus and keep links tappable beside mentions #1075: the contact, channel, message and channel-sender menus were plain Columns in a sheet capped at 9/16 of the screen, so items past the cap were clipped. A room server's Delete Contact was unreachable on an iPhone. They now scroll, and the sheet can grow to the safe area. #1074: a message with an @mention or contact card was built as rich text whose plain segments were never linkified. Those segments now go through LinkifySpan with the same options, style and confirm dialog as Linkify. Three new widget tests fail without the fix and pass with it. Epic #1078. Co-Authored-By: Claude Opus 5.5 --- lib/helpers/link_handler.dart | 34 +- lib/screens/channel_chat_screen.dart | 284 ++++----- lib/screens/channels_screen.dart | 140 ++--- lib/screens/chat_screen.dart | 156 ++--- lib/screens/contacts_screen.dart | 548 +++++++++--------- lib/widgets/translated_message_content.dart | 15 +- test/widgets/contact_card_chip_test.dart | 10 + .../translated_message_content_test.dart | 38 ++ 8 files changed, 662 insertions(+), 563 deletions(-) diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index c2eae294..58e76cca 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -14,6 +14,12 @@ class LinkHandler { return base.copyWith(color: orange, decoration: TextDecoration.underline); } + static const _options = LinkifyOptions( + humanize: false, + defaultToHttps: false, + ); + static const _linkifiers = [UrlLinkifier(), EmailLinkifier()]; + /// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile. static Widget buildLinkifyText({ required BuildContext context, @@ -22,8 +28,6 @@ class LinkHandler { TextStyle? linkStyle, }) { final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style); - const options = LinkifyOptions(humanize: false, defaultToHttps: false); - const linkifiers = [UrlLinkifier(), EmailLinkifier()]; void onOpen(LinkableElement link) => handleLinkTap(context, link.url); if (PlatformInfo.isDesktop) { @@ -31,8 +35,8 @@ class LinkHandler { text: text, style: style, linkStyle: effectiveLinkStyle, - options: options, - linkifiers: linkifiers, + options: _options, + linkifiers: _linkifiers, onOpen: onOpen, ); } @@ -40,12 +44,30 @@ class LinkHandler { text: text, style: style, linkStyle: effectiveLinkStyle, - options: options, - linkifiers: linkifiers, + options: _options, + linkifiers: _linkifiers, onOpen: onOpen, ); } + /// A span with the same links, style and confirm-on-tap as + /// [buildLinkifyText], for text that sits between widget spans such as + /// mention chips. (#1074) + static InlineSpan buildLinkifySpan({ + required BuildContext context, + required String text, + required TextStyle style, + }) { + return LinkifySpan( + text: text, + style: style, + linkStyle: defaultLinkStyle(context, style), + options: _options, + linkifiers: _linkifiers, + onOpen: (link) => handleLinkTap(context, link.url), + ); + } + static Future handleLinkTap(BuildContext context, String url) async { // Show confirmation dialog final shouldOpen = await showDialog( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 3491f646..f3c0c05e 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -769,37 +769,41 @@ class _ChannelChatScreenState extends State { showModalBottomSheet( context: context, showDragHandle: true, + isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) { final l10n = sheetContext.l10n; return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: _buildAvatar(message.senderName), - title: Text(message.senderName), - subtitle: ambiguous - ? Text( - l10n.channel_senderMultipleMatches(candidates.length), - ) - : null, - ), - const Divider(height: 1), - ..._buildSenderContactEntries(sheetContext, message), - ListTile( - leading: Icon(Icons.block, color: Colors.red.shade700), - title: Text(l10n.block_sender), - onTap: () { - Navigator.pop(sheetContext); - _blockChannelSender(message); - }, - ), - ListTile( - leading: const Icon(Icons.close), - title: Text(l10n.common_cancel), - onTap: () => Navigator.pop(sheetContext), - ), - ], + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: _buildAvatar(message.senderName), + title: Text(message.senderName), + subtitle: ambiguous + ? Text( + l10n.channel_senderMultipleMatches(candidates.length), + ) + : null, + ), + const Divider(height: 1), + ..._buildSenderContactEntries(sheetContext, message), + ListTile( + leading: Icon(Icons.block, color: Colors.red.shade700), + title: Text(l10n.block_sender), + onTap: () { + Navigator.pop(sheetContext); + _blockChannelSender(message); + }, + ), + ListTile( + leading: const Icon(Icons.close), + title: Text(l10n.common_cancel), + onTap: () => Navigator.pop(sheetContext), + ), + ], + ), ), ); }, @@ -2092,134 +2096,138 @@ class _ChannelChatScreenState extends State { showModalBottomSheet( context: context, + isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.reply), - title: Text(context.l10n.chat_reply), - onTap: () { - Navigator.pop(sheetContext); - _setReplyingTo(message); - }, - ), - if (message.pathBytes.isNotEmpty) - ListTile( - leading: const Icon(Icons.alt_route), - title: Text(context.l10n.chat_replyWithRoute), - onTap: () { - Navigator.pop(sheetContext); - _sendRouteReply(message); - }, - ), - if (PlatformInfo.isDesktop) - ListTile( - leading: const Icon(Icons.route), - title: Text(context.l10n.chat_path), - onTap: () { - Navigator.pop(sheetContext); - _showMessagePathInfo(message); - }, - ), - // Offer resend on an outgoing channel message until a repeater - // repeats it back (repeatCount > 0). The radio-ack checkmark - // (status == sent) does NOT mean a repeat was heard, so gate on the - // repeat-back, not status. Channel sends aren't auto-retried, so - // this is the only recovery path. (#256, #555) - if (message.isOutgoing && message.repeatCount == 0) - ListTile( - leading: const Icon(Icons.refresh), - title: Text(context.l10n.message_sendAgain), - onTap: () { - Navigator.pop(sheetContext); - _retryChannelMessage(message); - }, - ), - // CoreScope observer count refresh (#524), owner feature. A large, - // reliable tap target vs the small inline badge. - if (message.isOutgoing && message.onAirHash != null) + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - leading: const Icon(Icons.cloud_outlined), - title: Text(context.l10n.channel_coreScopeRefresh), - subtitle: message.coreScopeObserverCount != null - ? Text(_coreScopeLabel(message)) - : null, + leading: const Icon(Icons.reply), + title: Text(context.l10n.chat_reply), onTap: () { Navigator.pop(sheetContext); - _refreshCoreScope(message); + _setReplyingTo(message); }, ), - // Can't react to your own messages - if (!message.isOutgoing) + if (message.pathBytes.isNotEmpty) + ListTile( + leading: const Icon(Icons.alt_route), + title: Text(context.l10n.chat_replyWithRoute), + onTap: () { + Navigator.pop(sheetContext); + _sendRouteReply(message); + }, + ), + if (PlatformInfo.isDesktop) + ListTile( + leading: const Icon(Icons.route), + title: Text(context.l10n.chat_path), + onTap: () { + Navigator.pop(sheetContext); + _showMessagePathInfo(message); + }, + ), + // Offer resend on an outgoing channel message until a repeater + // repeats it back (repeatCount > 0). The radio-ack checkmark + // (status == sent) does NOT mean a repeat was heard, so gate on the + // repeat-back, not status. Channel sends aren't auto-retried, so + // this is the only recovery path. (#256, #555) + if (message.isOutgoing && message.repeatCount == 0) + ListTile( + leading: const Icon(Icons.refresh), + title: Text(context.l10n.message_sendAgain), + onTap: () { + Navigator.pop(sheetContext); + _retryChannelMessage(message); + }, + ), + // CoreScope observer count refresh (#524), owner feature. A large, + // reliable tap target vs the small inline badge. + if (message.isOutgoing && message.onAirHash != null) + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: Text(context.l10n.channel_coreScopeRefresh), + subtitle: message.coreScopeObserverCount != null + ? Text(_coreScopeLabel(message)) + : null, + onTap: () { + Navigator.pop(sheetContext); + _refreshCoreScope(message); + }, + ), + // Can't react to your own messages + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: Text(context.l10n.chat_addReaction), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: Text(context.l10n.chat_addReaction), + leading: const Icon(Icons.copy), + title: Text(context.l10n.common_copy), onTap: () { Navigator.pop(sheetContext); - _showEmojiPicker(message); + _copyMessageText(message.text); }, ), - ListTile( - leading: const Icon(Icons.copy), - title: Text(context.l10n.common_copy), - onTap: () { - Navigator.pop(sheetContext); - _copyMessageText(message.text); - }, - ), - if (canTranslateMessage) - ListTile( - leading: const Icon(Icons.translate), - title: Text(context.l10n.translation_translateMessage), - onTap: () { - Navigator.pop(sheetContext); - unawaited( - context.read().translateChannelMessage( - _currentChannel.index, - message, - manualTranslation: true, - ), - ); - }, - ), - if (!message.isOutgoing) + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateChannelMessage( + _currentChannel.index, + message, + manualTranslation: true, + ), + ); + }, + ), + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: Text(context.l10n.chat_markAsUnread), + onTap: () { + Navigator.pop(sheetContext); + _markAsUnread(message); + }, + ), ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: Text(context.l10n.chat_markAsUnread), - onTap: () { + leading: const Icon(Icons.delete_outline), + title: Text(context.l10n.common_delete), + onTap: () async { Navigator.pop(sheetContext); - _markAsUnread(message); + await _deleteMessage(message); }, ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: Text(context.l10n.common_delete), - onTap: () async { - Navigator.pop(sheetContext); - await _deleteMessage(message); - }, - ), - // Same sender contact actions the avatar tap offers (#468), so - // long-press remains a superset of every gesture on the message. - if (!message.isOutgoing) ...[ - const Divider(height: 1), - ..._buildSenderContactEntries(sheetContext, message), + // Same sender contact actions the avatar tap offers (#468), so + // long-press remains a superset of every gesture on the message. + if (!message.isOutgoing) ...[ + const Divider(height: 1), + ..._buildSenderContactEntries(sheetContext, message), + ListTile( + leading: Icon(Icons.block, color: Colors.red.shade700), + title: Text(context.l10n.block_sender), + onTap: () { + Navigator.pop(sheetContext); + _blockChannelSender(message); + }, + ), + ], ListTile( - leading: Icon(Icons.block, color: Colors.red.shade700), - title: Text(context.l10n.block_sender), - onTap: () { - Navigator.pop(sheetContext); - _blockChannelSender(message); - }, + leading: const Icon(Icons.close), + title: Text(context.l10n.common_cancel), + onTap: () => Navigator.pop(sheetContext), ), ], - ListTile( - leading: const Icon(Icons.close), - title: Text(context.l10n.common_cancel), - onTap: () => Navigator.pop(sheetContext), - ), - ], + ), ), ), ); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 1b2ec301..bf2036f6 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -497,76 +497,80 @@ class _ChannelsScreenState extends State showModalBottomSheet( context: parentContext, + isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit_outlined), - title: Text(context.l10n.channels_editChannel), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(const Duration(milliseconds: 100)); - if (parentContext.mounted) { - _showEditChannelDialog(parentContext, connector, channel); - } - }, - ), - ListTile( - leading: const Icon(Icons.qr_code_2), - title: Text(context.l10n.channels_shareQr), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(const Duration(milliseconds: 100)); - if (parentContext.mounted) { - _showChannelQrDialog(parentContext, channel); - } - }, - ), - ListTile( - leading: Icon(channelNotifyModeIcon(notifyMode)), - title: Text(context.l10n.channels_notifications), - subtitle: Text(channelNotifyModeLabel(context, notifyMode)), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(const Duration(milliseconds: 100)); - if (parentContext.mounted) { - showChannelNotifyModeDialog(parentContext, channel); - } - }, - ), - // The radio's own tone for this channel (#971), named apart from - // Notifications, which is the phone's. Gated on caps byte 2 0x10. - ChannelRadioToneTile( - channel: channel, - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(const Duration(milliseconds: 100)); - if (parentContext.mounted) { - showChannelRadioToneDialog(parentContext, channel); - } - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline, color: Colors.red), - title: Text( - context.l10n.channels_deleteChannel, - style: const TextStyle(color: Colors.red), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.channels_editChannel), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + _showEditChannelDialog(parentContext, connector, channel); + } + }, ), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(const Duration(milliseconds: 100)); - if (parentContext.mounted) { - _confirmDeleteChannel( - context, - connector, - channelMessageStore, - channel, - ); - } - }, - ), - ], + ListTile( + leading: const Icon(Icons.qr_code_2), + title: Text(context.l10n.channels_shareQr), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + _showChannelQrDialog(parentContext, channel); + } + }, + ), + ListTile( + leading: Icon(channelNotifyModeIcon(notifyMode)), + title: Text(context.l10n.channels_notifications), + subtitle: Text(channelNotifyModeLabel(context, notifyMode)), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + showChannelNotifyModeDialog(parentContext, channel); + } + }, + ), + // The radio's own tone for this channel (#971), named apart from + // Notifications, which is the phone's. Gated on caps byte 2 0x10. + ChannelRadioToneTile( + channel: channel, + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + showChannelRadioToneDialog(parentContext, channel); + } + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline, color: Colors.red), + title: Text( + context.l10n.channels_deleteChannel, + style: const TextStyle(color: Colors.red), + ), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + _confirmDeleteChannel( + context, + connector, + channelMessageStore, + channel, + ); + } + }, + ), + ], + ), ), ), ); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index b83f5402..8f4787da 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1576,94 +1576,98 @@ class _ChatScreenState extends State { showModalBottomSheet( context: context, + isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Can't react to your own messages - if (!message.isOutgoing) - ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: Text(context.l10n.chat_addReaction), - onTap: () { - Navigator.pop(sheetContext); - _showEmojiPicker(message, contact); - }, - ), - if (PlatformInfo.isDesktop) - ListTile( - leading: const Icon(Icons.route), - title: Text(context.l10n.chat_path), - onTap: () { - Navigator.pop(sheetContext); - _openMessagePath(message, contact); - }, - ), - ListTile( - leading: const Icon(Icons.copy), - title: Text(context.l10n.common_copy), - onTap: () { - Navigator.pop(sheetContext); - _copyMessageText(message.text); - }, - ), - if (canTranslateMessage) - ListTile( - leading: const Icon(Icons.translate), - title: Text(context.l10n.translation_translateMessage), - onTap: () { - Navigator.pop(sheetContext); - unawaited( - context.read().translateContactMessage( - widget.contact.publicKeyHex, - message, - manualTranslation: true, - ), - ); - }, - ), - if (!message.isOutgoing) + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Can't react to your own messages + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: Text(context.l10n.chat_addReaction), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message, contact); + }, + ), + if (PlatformInfo.isDesktop) + ListTile( + leading: const Icon(Icons.route), + title: Text(context.l10n.chat_path), + onTap: () { + Navigator.pop(sheetContext); + _openMessagePath(message, contact); + }, + ), ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: Text(context.l10n.chat_markAsUnread), + leading: const Icon(Icons.copy), + title: Text(context.l10n.common_copy), onTap: () { Navigator.pop(sheetContext); - _markAsUnread(message); + _copyMessageText(message.text); }, ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: Text(context.l10n.common_delete), - onTap: () async { - Navigator.pop(sheetContext); - await _deleteMessage(message); - }, - ), - if (message.isOutgoing && message.status == MessageStatus.failed) + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateContactMessage( + widget.contact.publicKeyHex, + message, + manualTranslation: true, + ), + ); + }, + ), + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: Text(context.l10n.chat_markAsUnread), + onTap: () { + Navigator.pop(sheetContext); + _markAsUnread(message); + }, + ), ListTile( - leading: const Icon(Icons.refresh), - title: Text(context.l10n.message_sendAgain), - onTap: () { + leading: const Icon(Icons.delete_outline), + title: Text(context.l10n.common_delete), + onTap: () async { Navigator.pop(sheetContext); - _retryMessage(message); + await _deleteMessage(message); }, ), - if (_resolveContact(context.read()).type == - advTypeRoom) + if (message.isOutgoing && message.status == MessageStatus.failed) + ListTile( + leading: const Icon(Icons.refresh), + title: Text(context.l10n.message_sendAgain), + onTap: () { + Navigator.pop(sheetContext); + _retryMessage(message); + }, + ), + if (_resolveContact(context.read()).type == + advTypeRoom) + ListTile( + leading: const Icon(Icons.chat), + title: Text(context.l10n.contacts_openChat), + onTap: () { + Navigator.pop(sheetContext); + _openChat(context, contact); + }, + ), ListTile( - leading: const Icon(Icons.chat), - title: Text(context.l10n.contacts_openChat), - onTap: () { - Navigator.pop(sheetContext); - _openChat(context, contact); - }, + leading: const Icon(Icons.close), + title: Text(context.l10n.common_cancel), + onTap: () => Navigator.pop(sheetContext), ), - ListTile( - leading: const Icon(Icons.close), - title: Text(context.l10n.common_cancel), - onTap: () => Navigator.pop(sheetContext), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index fa5d05b7..8ef35cac 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1305,246 +1305,183 @@ class _ContactsScreenState extends State final isBlocked = blockService.isBlocked(contact.publicKeyHex); final isSelf = blockService.isSelf(contact.publicKeyHex); + // Scrolls rather than clipping: a room server's 10 items outgrow the + // sheet's height on a small phone or at a large text size. (#1075) showModalBottomSheet( context: context, + isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Stock parity: any node can carry a custom name, and the node's - // own name stays underneath where the user can still see it. (#750) - ListTile( - leading: const Icon(Icons.drive_file_rename_outline), - title: Text(context.l10n.contact_rename), - subtitle: contact.customName == null - ? null - : Text( - context.l10n.contact_nodeCallsItself( - contact.receivedName, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - showContactRenameDialog(context, contact); - }, - ), - // Same destination as the chat ellipsis -> Contact settings (#351). - // Kept above the destructive Block tile, which stays below. - ListTile( - leading: const Icon(Icons.settings_outlined), - title: Text(context.l10n.contact_settings), - onTap: () { - Navigator.pop(sheetContext); - showContactSettingsDialog(context, contact); - }, - ), - // Blocking your own node is never meaningful (#250). - if (!isSelf) - ListTile( - leading: Icon( - isBlocked ? Icons.check_circle_outline : Icons.block, - color: isBlocked ? null : Colors.red.shade700, - ), - title: Text( - isBlocked - ? context.l10n.block_unblock - : context.l10n.block_block, - ), - onTap: () async { - Navigator.pop(sheetContext); - if (isBlocked) { - await blockService.unblock(contact.publicKeyHex); - } else { - await blockService.block(contact.publicKeyHex); - } - }, - ), - if (isRepeater) ...[ - ListTile( - leading: const Icon(Icons.radar, color: Colors.green), - title: Text(context.l10n.contacts_pathTrace), - onTap: () async { - final connector = context.read(); - final navigator = Navigator.of(context); - final l10n = context.l10n; - final hw = connector.pathHashByteWidth; - final hopBytes = PathHelper.traceHopBytes(hw); - - String hopsToHex(List hops) => hops - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); - - // A committed route is stored non-zero; a flood route is - // all-zero = "no real route". (#150) - final committed = contact.pathBytesForDisplay; - final hasRoute = - committed.isNotEmpty && committed.any((b) => b != 0); - - Uint8List? path; - var flip = false; - var title = l10n.contacts_repeaterPing; - - if (hasRoute) { - // Firmware already committed a route through the mesh. - path = committed; - flip = true; - title = l10n.contacts_repeaterPathTrace; - } else { - // No committed route. Ask the passive topology oracle for a - // route inferred from traffic we've already heard. (#186) - final inferred = connector.topology?.inferRoute( - contact.publicKey, - ); - final direct = - (inferred?.isEmpty ?? false) || - connector.directRepeaters.any( - (r) => r.matchesPathStart(contact.publicKey), - ); - if (direct) { - // A one-hop ping reaches a direct neighbour. - path = Uint8List.fromList( - contact.publicKey.sublist(0, hopBytes), - ); - } else { - // Confirm-first: pre-fill the route builder with the - // inferred route when we have one, else an empty builder. - // The user always confirms, we never fire a silent guess. - // (#186; replaces the old strongest-repeater guess.) - final suggested = - (inferred != null && inferred.isNotEmpty) - ? inferred.map(hopsToHex).join(',') - : null; - String? suggestedLabel; - if (inferred != null && inferred.isNotEmpty) { - // Resolve the inferred hops to repeater/contact NAMES so - // the user can read + verify the route instead of hex. - // (#186) - final names = PathHelper.resolvePathNames( - inferred.expand((h) => h).toList(), - connector.allContacts, - hw, - ); - suggestedLabel = - '${l10n.pathTrace_you} → $names → ${contact.name}'; - } - final picked = await PathSelectionDialog.show( - context, - availableContacts: connector.allContacts - .where( - (c) => c.publicKeyHex != contact.publicKeyHex, - ) - .toList(), - pathHashByteWidth: hw, - initialPath: suggested, - suggestedRouteLabel: suggestedLabel, - title: l10n.contacts_repeaterPathTrace, - ); - if (picked == null || picked.isEmpty) return; // cancelled - path = picked; - flip = true; - final fh = picked.length >= hopBytes - ? picked.sublist(0, hopBytes) - : picked; - title = l10n.contacts_repeaterPathTraceVia( - hopsToHex(fh).toUpperCase(), - ); - } - } - - navigator.push( - MaterialPageRoute( - builder: (context) => PathTraceMapScreen( - title: title, - path: path!, - flipPathAround: flip, - targetContact: contact, - pathHashByteWidth: hw, - ), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.cell_tower, color: Colors.orange), - title: Text(context.l10n.contacts_manageRepeater), - onTap: () { - Navigator.pop(sheetContext); - _showRepeaterLogin(context, contact); - }, - ), - ] else if (isRoom) ...[ + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Stock parity: any node can carry a custom name, and the node's + // own name stays underneath where the user can still see it. (#750) ListTile( - leading: const Icon(Icons.radar, color: Colors.green), - title: Text(context.l10n.contacts_pathTrace), - onTap: () { - final hw = context - .read() - .pathHashByteWidth; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PathTraceMapScreen( - title: contact.pathBytesForDisplay.isNotEmpty - ? context.l10n.contacts_roomPathTrace - : context.l10n.contacts_roomPing, - path: contact.pathBytesForDisplay.isNotEmpty - ? contact.pathBytesForDisplay - : Uint8List.fromList( - contact.publicKey.sublist( - 0, - PathHelper.traceHopBytes(hw), - ), - ), - flipPathAround: contact.pathBytesForDisplay.isNotEmpty, - targetContact: contact, - pathHashByteWidth: hw, + leading: const Icon(Icons.drive_file_rename_outline), + title: Text(context.l10n.contact_rename), + subtitle: contact.customName == null + ? null + : Text( + context.l10n.contact_nodeCallsItself( + contact.receivedName, + ), ), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.room, color: Colors.blue), - title: Text(context.l10n.contacts_roomLogin), onTap: () { Navigator.pop(sheetContext); - _showRoomLogin(context, contact, RoomLoginDestination.chat); + showContactRenameDialog(context, contact); }, ), + // Same destination as the chat ellipsis -> Contact settings (#351). + // Kept above the destructive Block tile, which stays below. ListTile( - leading: const Icon( - Icons.room_preferences, - color: Colors.orange, - ), - title: Text(context.l10n.room_management), + leading: const Icon(Icons.settings_outlined), + title: Text(context.l10n.contact_settings), onTap: () { Navigator.pop(sheetContext); - _showRoomLogin( - context, - contact, - RoomLoginDestination.management, - ); + showContactSettingsDialog(context, contact); }, ), - ] else ...[ - // Sensors run the same admin login and CLI as repeaters - // (stock SensorMesh), so they share the repeater admin hub (#745). - if (contact.type == advTypeSensor) + // Blocking your own node is never meaningful (#250). + if (!isSelf) ListTile( - leading: const Icon( - Icons.sensors_outlined, - color: Colors.orange, + leading: Icon( + isBlocked ? Icons.check_circle_outline : Icons.block, + color: isBlocked ? null : Colors.red.shade700, + ), + title: Text( + isBlocked + ? context.l10n.block_unblock + : context.l10n.block_block, ), - title: Text(context.l10n.contacts_manageSensor), + onTap: () async { + Navigator.pop(sheetContext); + if (isBlocked) { + await blockService.unblock(contact.publicKeyHex); + } else { + await blockService.block(contact.publicKeyHex); + } + }, + ), + if (isRepeater) ...[ + ListTile( + leading: const Icon(Icons.radar, color: Colors.green), + title: Text(context.l10n.contacts_pathTrace), + onTap: () async { + final connector = context.read(); + final navigator = Navigator.of(context); + final l10n = context.l10n; + final hw = connector.pathHashByteWidth; + final hopBytes = PathHelper.traceHopBytes(hw); + + String hopsToHex(List hops) => hops + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + // A committed route is stored non-zero; a flood route is + // all-zero = "no real route". (#150) + final committed = contact.pathBytesForDisplay; + final hasRoute = + committed.isNotEmpty && committed.any((b) => b != 0); + + Uint8List? path; + var flip = false; + var title = l10n.contacts_repeaterPing; + + if (hasRoute) { + // Firmware already committed a route through the mesh. + path = committed; + flip = true; + title = l10n.contacts_repeaterPathTrace; + } else { + // No committed route. Ask the passive topology oracle for a + // route inferred from traffic we've already heard. (#186) + final inferred = connector.topology?.inferRoute( + contact.publicKey, + ); + final direct = + (inferred?.isEmpty ?? false) || + connector.directRepeaters.any( + (r) => r.matchesPathStart(contact.publicKey), + ); + if (direct) { + // A one-hop ping reaches a direct neighbour. + path = Uint8List.fromList( + contact.publicKey.sublist(0, hopBytes), + ); + } else { + // Confirm-first: pre-fill the route builder with the + // inferred route when we have one, else an empty builder. + // The user always confirms, we never fire a silent guess. + // (#186; replaces the old strongest-repeater guess.) + final suggested = + (inferred != null && inferred.isNotEmpty) + ? inferred.map(hopsToHex).join(',') + : null; + String? suggestedLabel; + if (inferred != null && inferred.isNotEmpty) { + // Resolve the inferred hops to repeater/contact NAMES so + // the user can read + verify the route instead of hex. + // (#186) + final names = PathHelper.resolvePathNames( + inferred.expand((h) => h).toList(), + connector.allContacts, + hw, + ); + suggestedLabel = + '${l10n.pathTrace_you} → $names → ${contact.name}'; + } + final picked = await PathSelectionDialog.show( + context, + availableContacts: connector.allContacts + .where( + (c) => c.publicKeyHex != contact.publicKeyHex, + ) + .toList(), + pathHashByteWidth: hw, + initialPath: suggested, + suggestedRouteLabel: suggestedLabel, + title: l10n.contacts_repeaterPathTrace, + ); + if (picked == null || picked.isEmpty) + return; // cancelled + path = picked; + flip = true; + final fh = picked.length >= hopBytes + ? picked.sublist(0, hopBytes) + : picked; + title = l10n.contacts_repeaterPathTraceVia( + hopsToHex(fh).toUpperCase(), + ); + } + } + + navigator.push( + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: title, + path: path!, + flipPathAround: flip, + targetContact: contact, + pathHashByteWidth: hw, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.cell_tower, color: Colors.orange), + title: Text(context.l10n.contacts_manageRepeater), onTap: () { Navigator.pop(sheetContext); _showRepeaterLogin(context, contact); }, ), - if (contact.pathLength > 0) + ] else if (isRoom) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), - title: Text(context.l10n.contacts_chatTraceRoute), + title: Text(context.l10n.contacts_pathTrace), onTap: () { final hw = context .read() @@ -1553,11 +1490,19 @@ class _ContactsScreenState extends State context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( - title: context.l10n.contacts_pathTraceTo( - contact.name, - ), - path: contact.pathBytesForDisplay, - flipPathAround: true, + title: contact.pathBytesForDisplay.isNotEmpty + ? context.l10n.contacts_roomPathTrace + : context.l10n.contacts_roomPing, + path: contact.pathBytesForDisplay.isNotEmpty + ? contact.pathBytesForDisplay + : Uint8List.fromList( + contact.publicKey.sublist( + 0, + PathHelper.traceHopBytes(hw), + ), + ), + flipPathAround: + contact.pathBytesForDisplay.isNotEmpty, targetContact: contact, pathHashByteWidth: hw, ), @@ -1565,61 +1510,124 @@ class _ContactsScreenState extends State ); }, ), + ListTile( + leading: const Icon(Icons.room, color: Colors.blue), + title: Text(context.l10n.contacts_roomLogin), + onTap: () { + Navigator.pop(sheetContext); + _showRoomLogin(context, contact, RoomLoginDestination.chat); + }, + ), + ListTile( + leading: const Icon( + Icons.room_preferences, + color: Colors.orange, + ), + title: Text(context.l10n.room_management), + onTap: () { + Navigator.pop(sheetContext); + _showRoomLogin( + context, + contact, + RoomLoginDestination.management, + ); + }, + ), + ] else ...[ + // Sensors run the same admin login and CLI as repeaters + // (stock SensorMesh), so they share the repeater admin hub (#745). + if (contact.type == advTypeSensor) + ListTile( + leading: const Icon( + Icons.sensors_outlined, + color: Colors.orange, + ), + title: Text(context.l10n.contacts_manageSensor), + onTap: () { + Navigator.pop(sheetContext); + _showRepeaterLogin(context, contact); + }, + ), + if (contact.pathLength > 0) + ListTile( + leading: const Icon(Icons.radar, color: Colors.green), + title: Text(context.l10n.contacts_chatTraceRoute), + onTap: () { + final hw = context + .read() + .pathHashByteWidth; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: context.l10n.contacts_pathTraceTo( + contact.name, + ), + path: contact.pathBytesForDisplay, + flipPathAround: true, + targetContact: contact, + pathHashByteWidth: hw, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.chat), + title: Text(context.l10n.contacts_openChat), + onTap: () { + Navigator.pop(sheetContext); + _openChat(context, contact); + }, + ), + ], ListTile( - leading: const Icon(Icons.chat), - title: Text(context.l10n.contacts_openChat), - onTap: () { + leading: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: Colors.amber[700], + ), + title: Text( + isFavorite + ? context.l10n.listFilter_removeFromFavorites + : context.l10n.listFilter_addToFavorites, + ), + onTap: () async { Navigator.pop(sheetContext); - _openChat(context, contact); + await connector.setContactFlags( + contact, + isFavorite: !isFavorite, + ); }, ), - ], - ListTile( - leading: Icon( - isFavorite ? Icons.star : Icons.star_border, - color: Colors.amber[700], + ListTile( + leading: const Icon(Icons.copy), + title: Text(context.l10n.contacts_ShareContact), + onTap: () { + Navigator.pop(sheetContext); + _contactExport(contact.publicKey); + }, ), - title: Text( - isFavorite - ? context.l10n.listFilter_removeFromFavorites - : context.l10n.listFilter_addToFavorites, + ListTile( + leading: const Icon(Icons.connect_without_contact), + title: Text(context.l10n.contacts_ShareContactZeroHop), + onTap: () { + Navigator.pop(sheetContext); + _contactZeroHop(contact.publicKey); + }, ), - onTap: () async { - Navigator.pop(sheetContext); - await connector.setContactFlags( - contact, - isFavorite: !isFavorite, - ); - }, - ), - ListTile( - leading: const Icon(Icons.copy), - title: Text(context.l10n.contacts_ShareContact), - onTap: () { - Navigator.pop(sheetContext); - _contactExport(contact.publicKey); - }, - ), - ListTile( - leading: const Icon(Icons.connect_without_contact), - title: Text(context.l10n.contacts_ShareContactZeroHop), - onTap: () { - Navigator.pop(sheetContext); - _contactZeroHop(contact.publicKey); - }, - ), - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: Text( - context.l10n.contacts_deleteContact, - style: const TextStyle(color: Colors.red), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: Text( + context.l10n.contacts_deleteContact, + style: const TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(sheetContext); + _confirmDelete(context, connector, contact); + }, ), - onTap: () { - Navigator.pop(sheetContext); - _confirmDelete(context, connector, contact); - }, - ), - ], + ], + ), ), ), ); diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart index d61f6cf0..b0ee0b4f 100644 --- a/lib/widgets/translated_message_content.dart +++ b/lib/widgets/translated_message_content.dart @@ -70,9 +70,8 @@ class TranslatedMessageContent extends StatelessWidget { ); } // Neither chip can be interleaved with the Linkify widget, so a message - // containing one renders as rich text with chip spans (links inside such a - // message are not tappable, same as the prior leading-mention path). - // Messages with neither keep full link support above. + // containing one renders as rich text with chip spans, and the text between + // the chips is linkified span by span so its links stay tappable. (#1074) // // Both patterns are collected and sorted by position, so a message // carrying a mention AND a contact card renders both in the right order. @@ -85,12 +84,18 @@ class TranslatedMessageContent extends StatelessWidget { ..._contactCard.allMatches(text).map((m) => (m, true)), ]..sort((a, b) => a.$1.start.compareTo(b.$1.start)); + InlineSpan plain(String segment) => LinkHandler.buildLinkifySpan( + context: context, + text: segment, + style: textStyle, + ); + final spans = []; var last = 0; for (final (m, isCard) in matches) { if (m.start < last) continue; if (m.start > last) { - spans.add(TextSpan(text: text.substring(last, m.start))); + spans.add(plain(text.substring(last, m.start))); } spans.add( WidgetSpan( @@ -103,7 +108,7 @@ class TranslatedMessageContent extends StatelessWidget { last = m.end; } if (last < text.length) { - spans.add(TextSpan(text: text.substring(last))); + spans.add(plain(text.substring(last))); } return Text.rich(TextSpan(children: spans), style: textStyle); } diff --git a/test/widgets/contact_card_chip_test.dart b/test/widgets/contact_card_chip_test.dart index 4693fbef..2e37f168 100644 --- a/test/widgets/contact_card_chip_test.dart +++ b/test/widgets/contact_card_chip_test.dart @@ -94,6 +94,16 @@ void main() { expect(find.textContaining(_key), findsNothing); }); + testWidgets('a link beside a card stays tappable (#1074)', (tester) async { + const url = 'https://example.org'; + await _pump(tester, '$_card see $url', _Conn()); + + await tester.tapOnText(find.textRange.ofSubstring(url)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AlertDialog, url), findsOneWidget); + }); + testWidgets('a plain message is untouched and keeps link support', ( tester, ) async { diff --git a/test/widgets/translated_message_content_test.dart b/test/widgets/translated_message_content_test.dart index 491792c8..c122a439 100644 --- a/test/widgets/translated_message_content_test.dart +++ b/test/widgets/translated_message_content_test.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; import 'package:meshcore_open/widgets/translated_message_content.dart'; Future _pump(WidgetTester tester, String text) async { await tester.pumpWidget( MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: TranslatedMessageContent( displayText: text, @@ -37,6 +40,41 @@ void main() { }); }); + group('TranslatedMessageContent links beside a mention (#1074)', () { + const url = 'https://example.org'; + + testWidgets('a link in a reply opens the confirm dialog', (tester) async { + await _pump(tester, '@[Bob] $url'); + expect(find.text('@Bob'), findsOneWidget); + + await tester.tapOnText(find.textRange.ofSubstring(url)); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.widgetWithText(AlertDialog, url), findsOneWidget); + }); + + testWidgets('a link between two mentions opens the confirm dialog', ( + tester, + ) async { + await _pump(tester, '@[Alice] see $url @[Bob]'); + + await tester.tapOnText(find.textRange.ofSubstring(url)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AlertDialog, url), findsOneWidget); + }); + + testWidgets('plain text beside a mention opens nothing', (tester) async { + await _pump(tester, '@[Bob] no link here'); + + await tester.tapOnText(find.textRange.ofSubstring('no link here')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + }); + }); + group('TranslatedMessageContent.leadingReplyName (#232)', () { test('extracts a space-terminated leading reply name', () { expect(TranslatedMessageContent.leadingReplyName('@[Bob] hi'), 'Bob');