From 22ce0f6338f92cb2f8592ee134e2eb10972c4afd Mon Sep 17 00:00:00 2001 From: Strycher Date: Tue, 11 Aug 2026 19:44:14 -0400 Subject: [PATCH] feat(#565): add/view a channel sender from avatar tap and long-press Channel frames carry no key, so the sender name is resolved against known and discovered contacts. Adds resolveContactsByName (returning Contacts, which importDiscoveredContact needs) and rebuilds resolveContactKeysByName on top of it so one matching rule serves both. The avatar is the shortcut; the sender name stays inert because it sits too close to the message body to hit reliably on a phone. Long-press keeps every action it had and gains the same contact rows. A name several nodes claim lists all of them, and an unheard name says so instead of failing silently. --- lib/connector/meshcore_connector.dart | 28 ++- lib/l10n/app_en.arb | 21 +++ lib/l10n/app_localizations.dart | 24 +++ lib/l10n/app_localizations_bg.dart | 16 ++ lib/l10n/app_localizations_de.dart | 16 ++ lib/l10n/app_localizations_en.dart | 16 ++ lib/l10n/app_localizations_es.dart | 16 ++ lib/l10n/app_localizations_fr.dart | 16 ++ lib/l10n/app_localizations_hu.dart | 16 ++ lib/l10n/app_localizations_it.dart | 16 ++ lib/l10n/app_localizations_ja.dart | 16 ++ lib/l10n/app_localizations_ko.dart | 16 ++ lib/l10n/app_localizations_nl.dart | 16 ++ lib/l10n/app_localizations_pl.dart | 16 ++ lib/l10n/app_localizations_pt.dart | 16 ++ lib/l10n/app_localizations_ru.dart | 16 ++ lib/l10n/app_localizations_sk.dart | 16 ++ lib/l10n/app_localizations_sl.dart | 16 ++ lib/l10n/app_localizations_sv.dart | 16 ++ lib/l10n/app_localizations_uk.dart | 16 ++ lib/l10n/app_localizations_zh.dart | 16 ++ lib/screens/channel_chat_screen.dart | 178 +++++++++++++++++- .../channel_sender_resolution_test.dart | 102 ++++++++++ 23 files changed, 631 insertions(+), 10 deletions(-) create mode 100644 test/connector/channel_sender_resolution_test.dart 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); + }, + ); + }); +}