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');