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 <noreply@anthropic.com>
feat/1014-ios-app-review
diffwireauto 1 week ago
parent fa8d3b2c51
commit 17e694caed

@ -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<void> handleLinkTap(BuildContext context, String url) async {
// Show confirmation dialog
final shouldOpen = await showDialog<bool>(

@ -769,37 +769,41 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<ChannelChatScreen> {
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<MeshCoreConnector>().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<MeshCoreConnector>().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),
),
],
),
),
),
);

@ -497,76 +497,80 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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,
);
}
},
),
],
),
),
),
);

@ -1576,94 +1576,98 @@ class _ChatScreenState extends State<ChatScreen> {
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<MeshCoreConnector>().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<MeshCoreConnector>().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<MeshCoreConnector>()).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<MeshCoreConnector>()).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),
),
],
],
),
),
),
);

@ -1305,246 +1305,183 @@ class _ContactsScreenState extends State<ContactsScreen>
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<MeshCoreConnector>();
final navigator = Navigator.of(context);
final l10n = context.l10n;
final hw = connector.pathHashByteWidth;
final hopBytes = PathHelper.traceHopBytes(hw);
String hopsToHex(List<int> 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<MeshCoreConnector>()
.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<MeshCoreConnector>();
final navigator = Navigator.of(context);
final l10n = context.l10n;
final hw = connector.pathHashByteWidth;
final hopBytes = PathHelper.traceHopBytes(hw);
String hopsToHex(List<int> 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<MeshCoreConnector>()
@ -1553,11 +1490,19 @@ class _ContactsScreenState extends State<ContactsScreen>
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<ContactsScreen>
);
},
),
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<MeshCoreConnector>()
.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);
},
),
],
],
),
),
),
);

@ -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 = <InlineSpan>[];
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);
}

@ -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 {

@ -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<void> _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');

Loading…
Cancel
Save

Powered by TurnKey Linux.