Skip to content
Open
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
15 changes: 10 additions & 5 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -432,11 +432,16 @@ class ComposeBar extends HookConsumerWidget {
return;
}

// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
// Mention candidates present in the final text — picked from the
// suggestion list or hand-typed member names (see
// _selectedMentionCandidates in compose_bar/helpers.dart).
final selectedMentions = _selectedMentionCandidates(
text: text,
picked: mentionMap.value,
members: ref.read(
mentionCandidatesProvider((channelId: channelId, query: '')),
),
);
final pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
Expand Down
32 changes: 32 additions & 0 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,38 @@ void _insertTriggerAtCursor(
focusNode.requestFocus();
}

/// Mention candidates referenced by [text]: suggestion-picked entries first,
/// then hand-typed channel-member names that were never picked — mirrors
/// desktop's `extractMentionPubkeys` member scan, so a typed `@Name` still
/// notifies its target. Picked entries keep precedence for a name, and the
/// typed scan is members-only, so it can never trigger the non-member invite
/// prompt on its own.
List<MentionCandidate> _selectedMentionCandidates({
required String text,
required Map<String, MentionCandidate> picked,
required Iterable<MentionCandidate> members,
}) {
final selected = [
for (final entry in picked.entries)
if (hasMention(text, entry.key)) entry.value,
];
final pickedNames = {
for (final name in picked.keys) name.trim().toLowerCase(),
};
final pickedPubkeys = {
for (final candidate in picked.values) candidate.pubkey.toLowerCase(),
};
for (final candidate in members) {
if (!candidate.isMember) continue;
if (pickedPubkeys.contains(candidate.pubkey.toLowerCase())) continue;
final name = candidate.displayName?.trim();
if (name == null || name.isEmpty) continue;
if (pickedNames.contains(name.toLowerCase())) continue;
if (hasMention(text, name)) selected.add(candidate);
}
return selected;
}

bool hasMention(String text, String name) {
final pattern = RegExp(
'(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)',
Expand Down
48 changes: 48 additions & 0 deletions mobile/test/features/channels/compose_bar_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,54 @@ void main() {
]);
});

testWidgets('send resolves a hand-typed member mention without a '
'suggestion tap', (tester) async {
final signer = nostr.Keys.generate();
const memberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
String? sentContent;
List<String> sentMentionPubkeys = const <String>[];

await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
members: [
ChannelMember(
pubkey: memberPubkey,
role: 'member',
joinedAt: DateTime(2024),
displayName: 'Fable',
),
],
channels: [_makeCurrentChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sentContent = content;
sentMentionPubkeys = mentionPubkeys;
},
),
);

await _expandComposer(tester);
// Type the whole message in one edit — the mention never goes through
// the suggestion list, mirroring a user who types the name by hand.
await tester.enterText(
find.byType(TextField),
'hey @Fable are you there',
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.arrowUp));
await tester.pumpAndSettle();

expect(sentContent, 'hey @Fable are you there');
expect(sentMentionPubkeys, [memberPubkey]);
});

testWidgets(
'renders chips only for selected agents outside code and composition',
(tester) async {
Expand Down