diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 92bfb2e079..2fbefd21b0 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -22,6 +22,7 @@ import 'channels_provider.dart'; import 'emoji_picker.dart'; import 'mentions/mention_candidates.dart'; import 'mentions/mention_candidates_provider.dart'; +import 'mentions/mention_display_name.dart'; import 'mentions/mention_ranking.dart'; part 'compose_bar/helpers.dart'; diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 9677c7cfe4..92b826ea1d 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -17,6 +17,10 @@ class _MentionSuggestions extends StatelessWidget { @override Widget build(BuildContext context) { + final nameCounts = countVisibleMentionDisplayNames( + suggestions.map((candidate) => candidate.label), + ); + return Container( constraints: const BoxConstraints(maxHeight: 240), clipBehavior: Clip.hardEdge, @@ -41,6 +45,22 @@ class _MentionSuggestions extends StatelessWidget { itemBuilder: (context, index) { final candidate = suggestions[index]; final name = candidate.label; + final ownerLabel = candidate.isAgent + ? formatOwnerLabel( + candidate.ownerPubkey, + currentPubkey, + userCache, + ) + : null; + final displayName = formatDisambiguatedMentionDisplayName( + displayName: name, + hasNameCollision: hasVisibleMentionDisplayNameCollision( + name, + nameCounts, + ), + isAgent: candidate.isAgent, + ownerLabel: ownerLabel, + ); final avatarUrl = candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; @@ -58,7 +78,7 @@ class _MentionSuggestions extends StatelessWidget { ), ), ), - title: Text(name, style: context.textTheme.bodyMedium), + title: Text(displayName, style: context.textTheme.bodyMedium), subtitle: _MentionSuggestionInfo.build( context, candidate: candidate, diff --git a/mobile/lib/features/channels/mentions/mention_display_name.dart b/mobile/lib/features/channels/mentions/mention_display_name.dart new file mode 100644 index 0000000000..20c52a039e --- /dev/null +++ b/mobile/lib/features/channels/mentions/mention_display_name.dart @@ -0,0 +1,37 @@ +/// Case-insensitive display-name counts for the currently visible suggestions. +Map countVisibleMentionDisplayNames( + Iterable displayNames, +) { + final counts = {}; + for (final displayName in displayNames) { + final normalizedName = displayName.trim().toLowerCase(); + if (normalizedName.isEmpty) continue; + counts[normalizedName] = (counts[normalizedName] ?? 0) + 1; + } + return counts; +} + +bool hasVisibleMentionDisplayNameCollision( + String displayName, + Map counts, +) { + return (counts[displayName.trim().toLowerCase()] ?? 0) > 1; +} + +/// Adds an agent's owner only when its name collides with another visible +/// suggestion. The original candidate label remains unchanged for insertion. +String formatDisambiguatedMentionDisplayName({ + required String displayName, + required bool hasNameCollision, + required bool isAgent, + required String? ownerLabel, +}) { + final normalizedOwnerLabel = ownerLabel?.trim(); + if (!hasNameCollision || + !isAgent || + normalizedOwnerLabel == null || + normalizedOwnerLabel.isEmpty) { + return displayName; + } + return '$displayName ($normalizedOwnerLabel)'; +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 1b0fd60c2c..8e1afaabbc 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -116,6 +116,7 @@ Widget _buildComposeBar({ List members = const [], Future>? membersFuture, List relayAgents = const [], + Map agentOwners = const {}, List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, @@ -128,7 +129,7 @@ Widget _buildComposeBar({ 'channel-1', ).overrideWith((ref) => membersFuture ?? Future.value(members)), agentDirectoryProvider.overrideWith((ref) async => relayAgents), - agentOwnersProvider.overrideWith((ref) async => const {}), + agentOwnersProvider.overrideWith((ref) async => agentOwners), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), @@ -844,6 +845,62 @@ void main() { ); }); + testWidgets( + 'disambiguates colliding agent titles without changing selection text', + (tester) async { + final currentPubkey = 'a' * 64; + final ownedAgentPubkey = 'b' * 64; + final remoteAgentPubkey = 'c' * 64; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + currentPubkey: currentPubkey, + relayAgents: [ + AgentDirectoryEntry( + pubkey: ownedAgentPubkey, + displayName: 'Bumble', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + AgentDirectoryEntry( + pubkey: remoteAgentPubkey, + displayName: 'bUmBlE', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + agentOwners: { + ownedAgentPubkey: currentPubkey, + remoteAgentPubkey: 'd' * 64, + }, + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.enterText(find.byType(TextField), '@bum'); + await tester.pumpAndSettle(); + + expect(find.text('Bumble (you)'), findsOneWidget); + expect(find.text('bUmBlE (dddddddd…)'), findsOneWidget); + expect(find.text('owned by you · not in channel'), findsOneWidget); + + await tester.tap(find.text('Bumble (you)')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + '@Bumble ', + ); + }, + ); + testWidgets('adds a selected non-member agent as a bot before sending', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_display_name_test.dart b/mobile/test/features/channels/mentions/mention_display_name_test.dart new file mode 100644 index 0000000000..4655446613 --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_display_name_test.dart @@ -0,0 +1,57 @@ +import 'package:buzz/features/channels/mentions/mention_display_name.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('appends owner labels to colliding agents', () { + expect( + formatDisambiguatedMentionDisplayName( + displayName: 'Bumble', + hasNameCollision: true, + isAgent: true, + ownerLabel: 'sergior', + ), + 'Bumble (sergior)', + ); + expect( + formatDisambiguatedMentionDisplayName( + displayName: 'Bumble', + hasNameCollision: true, + isAgent: true, + ownerLabel: 'you', + ), + 'Bumble (you)', + ); + }); + + test('leaves humans and unique agents unchanged', () { + expect( + formatDisambiguatedMentionDisplayName( + displayName: 'Bumble', + hasNameCollision: true, + isAgent: false, + ownerLabel: null, + ), + 'Bumble', + ); + expect( + formatDisambiguatedMentionDisplayName( + displayName: 'Bumble', + hasNameCollision: false, + isAgent: true, + ownerLabel: 'sergior', + ), + 'Bumble', + ); + }); + + test('detects visible display-name collisions case-insensitively', () { + final counts = countVisibleMentionDisplayNames([ + 'Bumble', + ' bUmBlE ', + 'Fizz', + ]); + + expect(hasVisibleMentionDisplayNameCollision('bumble', counts), isTrue); + expect(hasVisibleMentionDisplayNameCollision('Fizz', counts), isFalse); + }); +}