diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 12b0371..b0d3b5e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1118,19 +1118,25 @@ class MeshCoreConnector extends ChangeNotifier { /// display name matches [name] (case-insensitive). Resolves an anonymous /// channel sender (name-only, no pubkey) back to identities for block /// matching. Multiple keys => several devices/people share the name. - List resolveContactKeysByName(String name) { + /// Resolve a claimed name to the identities behind it, known contacts first. + /// + /// Channel messages carry no key (#468), so a claimed name is all a channel + /// sender gives us. A name several nodes share resolves to every one of them: + /// callers surface the ambiguity, they never pick a winner. + List resolveContactsByName(String name) { final target = name.trim().toLowerCase(); if (target.isEmpty) return const []; - final keys = {}; - for (final c in contacts) { - if (c.name.trim().toLowerCase() == target) keys.add(c.publicKeyHex); - } - for (final c in discoveredContacts) { - if (c.name.trim().toLowerCase() == target) keys.add(c.publicKeyHex); + final byKey = {}; + for (final c in [...contacts, ...discoveredContacts]) { + if (c.name.trim().toLowerCase() != target) continue; + byKey.putIfAbsent(c.publicKeyHex, () => c); } - return keys.toList(); + return byKey.values.toList(); } + List resolveContactKeysByName(String name) => + resolveContactsByName(name).map((c) => c.publicKeyHex).toList(); + Future deleteChannelMessage(ChannelMessage message) async { final channelIndex = message.channelIndex; if (channelIndex == null) return; @@ -2930,6 +2936,12 @@ class MeshCoreConnector extends ChangeNotifier { @visibleForTesting Map> get channelMessagesForTest => _channelMessages; + @visibleForTesting + List get contactsForTest => _contacts; + + @visibleForTesting + List get discoveredContactsForTest => _discoveredContacts; + @visibleForTesting Map> get conversationsForTest => _conversations; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5f28d62..36fe038 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -902,6 +902,27 @@ } } }, + "channel_senderNotHeard": "Haven't heard this node's advert yet", + "channel_senderAlreadyContact": "Already in contacts", + "channel_senderMultipleMatches": "{count} nodes use this name", + "@channel_senderMultipleMatches": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "channel_senderCandidate": "{keyPrefix} · {type}", + "@channel_senderCandidate": { + "placeholders": { + "keyPrefix": { + "type": "String" + }, + "type": { + "type": "String" + } + } + }, "chat_successes": "successes", "chat_score": "Score", "chat_removePath": "Remove path", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05661a2..2ed4bcb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3136,6 +3136,30 @@ abstract class AppLocalizations { /// **'{observers, plural, =1{1 observer} other{{observers} observers}} · {observations} observations'** String channel_coreScopeCounts(int observers, int observations); + /// No description provided for @channel_senderNotHeard. + /// + /// In en, this message translates to: + /// **'Haven\'t heard this node\'s advert yet'** + String get channel_senderNotHeard; + + /// No description provided for @channel_senderAlreadyContact. + /// + /// In en, this message translates to: + /// **'Already in contacts'** + String get channel_senderAlreadyContact; + + /// No description provided for @channel_senderMultipleMatches. + /// + /// In en, this message translates to: + /// **'{count} nodes use this name'** + String channel_senderMultipleMatches(int count); + + /// No description provided for @channel_senderCandidate. + /// + /// In en, this message translates to: + /// **'{keyPrefix} · {type}'** + String channel_senderCandidate(String keyPrefix, String type); + /// No description provided for @chat_successes. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 9a60600..3c5d115 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1745,6 +1745,22 @@ class AppLocalizationsBg extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Успехи'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 43a3de2..232a62e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1743,6 +1743,22 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Erfolgreich'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9426ce1..c0b7280 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1714,6 +1714,22 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'successes'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c3127f0..e361423 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1740,6 +1740,22 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Éxitos'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7d06f98..0e4025b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1747,6 +1747,22 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Succès'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 3c72de8..43a468d 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1752,6 +1752,22 @@ class AppLocalizationsHu extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'sikerek'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index e8ba848..cf87d9d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1742,6 +1742,22 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'successi'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 6350be7..4d19a9c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1677,6 +1677,22 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => '成功事例'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 3cb99bb..4a6e481 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1673,6 +1673,22 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => '성공 사례'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 10e2bb6..95678f7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1730,6 +1730,22 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Succesvol'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index b2a33a7..ade060e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1755,6 +1755,22 @@ class AppLocalizationsPl extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Sukcesy'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8fbf97f..f9805c6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1739,6 +1739,22 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Sucessos'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 95040df..29c3886 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1743,6 +1743,22 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'успешно'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d896b0b..9bbacb1 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1732,6 +1732,22 @@ class AppLocalizationsSk extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Úspechy'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index b1996fd..61c4e0e 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1728,6 +1728,22 @@ class AppLocalizationsSl extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Uspešni'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index f118c64..6785860 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1723,6 +1723,22 @@ class AppLocalizationsSv extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'framgångar'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 2043089..e684a8e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1738,6 +1738,22 @@ class AppLocalizationsUk extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => 'Успішно'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 610a991..b945956 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1652,6 +1652,22 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0 · $observations observations'; } + @override + String get channel_senderNotHeard => 'Haven\'t heard this node\'s advert yet'; + + @override + String get channel_senderAlreadyContact => 'Already in contacts'; + + @override + String channel_senderMultipleMatches(int count) { + return '$count nodes use this name'; + } + + @override + String channel_senderCandidate(String keyPrefix, String type) { + return '$keyPrefix · $type'; + } + @override String get chat_successes => '成功'; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 047f874..33485c7 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -19,8 +19,10 @@ import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/snack_bar_builder.dart'; import '../l10n/l10n.dart'; +import '../l10n/contact_localization.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; +import '../models/contact.dart'; import '../models/translation_support.dart'; import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; @@ -30,6 +32,7 @@ import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; +import 'chat_screen.dart'; import 'settings_screen.dart'; import '../utils/dialog_utils.dart'; import '../widgets/app_shell.dart'; @@ -658,6 +661,166 @@ class _ChannelChatScreenState extends State { } } + /// Contact actions for the sender of a channel post (#468). Channel frames + /// carry no key, so the claimed name is resolved against known + discovered + /// contacts. Every node claiming that name gets its own row: an ambiguous + /// namesake is never resolved for the user. + void _showSenderActions(ChannelMessage message) { + if (message.isOutgoing) return; + final connector = context.read(); + final candidates = connector.resolveContactsByName(message.senderName); + final ambiguous = candidates.length > 1; + + showModalBottomSheet( + context: context, + showDragHandle: 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), + ), + ], + ), + ); + }, + ); + } + + /// Sender contact rows, shared by the avatar-tap sheet and the long-press + /// menu so both offer exactly the same actions. An unresolved name says so + /// outright instead of silently offering nothing. + List _buildSenderContactEntries( + BuildContext sheetContext, + ChannelMessage message, + ) { + final connector = sheetContext.read(); + final candidates = connector.resolveContactsByName(message.senderName); + if (candidates.isEmpty) { + return [ + ListTile( + leading: const Icon(Icons.help_outline), + title: Text(sheetContext.l10n.channel_senderNotHeard), + ), + ]; + } + return [ + for (final candidate in candidates) + ..._buildSenderCandidateTiles( + sheetContext, + candidate, + showIdentity: candidates.length > 1, + ), + ]; + } + + /// Add/view rows for one resolved sender identity. [showIdentity] prefixes an + /// identifying row (pubkey prefix + type) so colliding namesakes stay apart. + List _buildSenderCandidateTiles( + BuildContext sheetContext, + Contact candidate, { + required bool showIdentity, + }) { + final l10n = sheetContext.l10n; + final connector = sheetContext.read(); + final isKnown = connector.contacts.any( + (c) => c.publicKeyHex == candidate.publicKeyHex, + ); + + return [ + if (showIdentity) + ListTile( + dense: true, + leading: const Icon(Icons.fingerprint), + title: Text( + l10n.channel_senderCandidate( + candidate.publicKeyHex.substring( + 0, + math.min(8, candidate.publicKeyHex.length), + ), + candidate.typeLabel(l10n), + ), + ), + ), + if (!isKnown) + ListTile( + leading: const Icon(Icons.add_reaction_sharp), + title: Text(l10n.discoveredContacts_addContact), + onTap: () { + Navigator.pop(sheetContext); + _addSenderContact(candidate); + }, + ) + // A repeater or room server reached through a channel post still needs + // its login flow, which lives on the Contacts screen. Say it is already + // known rather than dropping the user into a chat that cannot work. + else if (candidate.type == advTypeChat) + ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: Text(l10n.contacts_openChat), + onTap: () { + Navigator.pop(sheetContext); + _openSenderChat(candidate); + }, + ) + else + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(l10n.channel_senderAlreadyContact), + ), + ]; + } + + Future _addSenderContact(Contact candidate) async { + final connector = context.read(); + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + await connector.importDiscoveredContact(candidate); + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.discoveredContacts_contactAdded)), + ); + } + + void _openSenderChat(Contact candidate) { + final connector = context.read(); + final unread = connector.getUnreadCountForContactKey( + candidate.publicKeyHex, + ); + connector.markContactRead(candidate.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ChatScreen(contact: candidate, initialUnreadCount: unread), + ), + ); + } + /// A channel post is hidden only when its claimed name resolves to at least /// one identity and *every* matching pubkey is blocked (an ambiguous namesake /// with any unblocked match is still shown), or the name is in blockedNames. @@ -712,7 +875,13 @@ class _ChannelChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isOutgoing) ...[ - _buildAvatar(message.senderName), + // The avatar is the contact-action shortcut (#468). The sender + // name text is deliberately NOT a tap target: on a phone it sits + // too close to the message body to hit reliably. + GestureDetector( + onTap: () => _showSenderActions(message), + child: _buildAvatar(message.senderName), + ), const SizedBox(width: 8), ], Flexible( @@ -1819,7 +1988,11 @@ class _ChannelChatScreenState extends State { await _deleteMessage(message); }, ), - if (!message.isOutgoing) + // 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), @@ -1828,6 +2001,7 @@ class _ChannelChatScreenState extends State { _blockChannelSender(message); }, ), + ], ListTile( leading: const Icon(Icons.close), title: Text(context.l10n.common_cancel), diff --git a/test/connector/channel_sender_resolution_test.dart b/test/connector/channel_sender_resolution_test.dart new file mode 100644 index 0000000..b5213a7 --- /dev/null +++ b/test/connector/channel_sender_resolution_test.dart @@ -0,0 +1,102 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/contact.dart'; + +/// Channel posts carry a claimed NAME and no key (#468), so adding a channel +/// sender to contacts hangs entirely on resolving that name against what the +/// radio has already heard. These pin the resolution rules, including the +/// namesake case the UI must never resolve on the user's behalf. +Contact _contact(String name, int keyByte, {int type = advTypeChat}) => Contact( + publicKey: Uint8List.fromList(List.filled(32, keyByte)), + name: name, + type: type, + pathLength: -1, + path: Uint8List(0), + lastSeen: DateTime.utc(2026, 8, 11), +); + +void main() { + late MeshCoreConnector connector; + + setUp(() { + connector = MeshCoreConnector(); + connector.contactsForTest.clear(); + connector.discoveredContactsForTest.clear(); + }); + + group('resolveContactsByName', () { + test( + 'matches a known contact ignoring case and surrounding whitespace', + () { + connector.contactsForTest.add(_contact('Ben', 0x11)); + + expect(connector.resolveContactsByName(' bEn ').single.name, 'Ben'); + }, + ); + + test('matches a discovered node that is not a contact yet', () { + connector.discoveredContactsForTest.add(_contact('Rover', 0x22)); + + final resolved = connector.resolveContactsByName('Rover'); + expect( + resolved.single.publicKeyHex, + _contact('Rover', 0x22).publicKeyHex, + ); + }); + + test('returns every node claiming the name, known ones first', () { + connector.contactsForTest.add(_contact('Twin', 0x33)); + connector.discoveredContactsForTest.add(_contact('Twin', 0x44)); + + final resolved = connector.resolveContactsByName('Twin'); + expect(resolved.length, 2); + expect(resolved.first.publicKeyHex, _contact('x', 0x33).publicKeyHex); + }); + + test('a node in both lists resolves once, as the known contact', () { + connector.contactsForTest.add(_contact('Dup', 0x55)); + connector.discoveredContactsForTest.add(_contact('Dup', 0x55)); + + expect(connector.resolveContactsByName('Dup').length, 1); + }); + + test('a partial name is not a match', () { + connector.contactsForTest.add(_contact('Benjamin', 0x66)); + + expect(connector.resolveContactsByName('Ben'), isEmpty); + }); + + test('an unheard name resolves to nothing', () { + expect(connector.resolveContactsByName('Ghost'), isEmpty); + }); + + test('an empty or whitespace-only name resolves to nothing', () { + connector.contactsForTest.add(_contact('Ben', 0x77)); + + expect(connector.resolveContactsByName(''), isEmpty); + expect(connector.resolveContactsByName(' '), isEmpty); + }); + }); + + group('resolveContactKeysByName', () { + test('returns the keys of the same nodes, in the same order', () { + connector.contactsForTest.add(_contact('Twin', 0x33)); + connector.discoveredContactsForTest.add(_contact('Twin', 0x44)); + + expect( + connector.resolveContactKeysByName('Twin'), + connector.resolveContactsByName('Twin').map((c) => c.publicKeyHex), + ); + }); + + test( + 'stays empty for an unheard name, so blocking falls back to the name', + () { + expect(connector.resolveContactKeysByName('Ghost'), isEmpty); + }, + ); + }); +}