Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
22 changes: 21 additions & 1 deletion mobile/lib/features/channels/compose_bar/suggestions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions mobile/lib/features/channels/mentions/mention_display_name.dart
Original file line number Diff line number Diff line change
@@ -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)';
}
59 changes: 58 additions & 1 deletion mobile/test/features/channels/compose_bar_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'),
),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
}