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.
pull/585/head
Strycher 2 months ago
parent a57ec8f902
commit 22ce0f6338

@ -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<String> 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<Contact> resolveContactsByName(String name) {
final target = name.trim().toLowerCase();
if (target.isEmpty) return const [];
final keys = <String>{};
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 = <String, Contact>{};
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<String> resolveContactKeysByName(String name) =>
resolveContactsByName(name).map((c) => c.publicKeyHex).toList();
Future<void> deleteChannelMessage(ChannelMessage message) async {
final channelIndex = message.channelIndex;
if (channelIndex == null) return;
@ -2930,6 +2936,12 @@ class MeshCoreConnector extends ChangeNotifier {
@visibleForTesting
Map<int, List<ChannelMessage>> get channelMessagesForTest => _channelMessages;
@visibleForTesting
List<Contact> get contactsForTest => _contacts;
@visibleForTesting
List<Contact> get discoveredContactsForTest => _discoveredContacts;
@visibleForTesting
Map<String, List<Message>> get conversationsForTest => _conversations;

@ -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",

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

@ -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 => 'Успехи';

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

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

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

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

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

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

@ -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 => '成功事例';

@ -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 => '성공 사례';

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

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

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

@ -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 => 'успешно';

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

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

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

@ -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 => 'Успішно';

@ -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 => '成功';

@ -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<ChannelChatScreen> {
}
}
/// 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<MeshCoreConnector>();
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<Widget> _buildSenderContactEntries(
BuildContext sheetContext,
ChannelMessage message,
) {
final connector = sheetContext.read<MeshCoreConnector>();
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<Widget> _buildSenderCandidateTiles(
BuildContext sheetContext,
Contact candidate, {
required bool showIdentity,
}) {
final l10n = sheetContext.l10n;
final connector = sheetContext.read<MeshCoreConnector>();
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<void> _addSenderContact(Contact candidate) async {
final connector = context.read<MeshCoreConnector>();
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<MeshCoreConnector>();
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<ChannelChatScreen> {
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<ChannelChatScreen> {
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<ChannelChatScreen> {
_blockChannelSender(message);
},
),
],
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),

@ -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<int>.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);
},
);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.