fix(mobile): disambiguate same-named agents

Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
2026-07-16 21:01:56 -07:00
parent 43bd104de5
commit 941c50d04c
5 changed files with 174 additions and 2 deletions
@@ -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';
@@ -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,
@@ -0,0 +1,37 @@
/// Case-insensitive display-name counts for the currently visible suggestions.
Map<String, int> countVisibleMentionDisplayNames(
Iterable<String> displayNames,
) {
final counts = <String, int>{};
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<String, int> 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)';
}
@@ -116,6 +116,7 @@ Widget _buildComposeBar({
List<ChannelMember> members = const <ChannelMember>[],
Future<List<ChannelMember>>? membersFuture,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
Map<String, String> agentOwners = const <String, String>{},
List<Channel> channels = const <Channel>[],
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 <String, String>{}),
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 <List<String>>[],
}) 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<TextField>(find.byType(TextField)).controller?.text,
'@Bumble ',
);
},
);
testWidgets('adds a selected non-member agent as a bot before sending', (
tester,
) async {
@@ -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);
});
}