fix(mobile): surface non-member people and owned agents in @mention autocomplete (#1877)

Signed-off-by: npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv <0028755dd8f83d29f0d5af546e7cf3be55cca85628aa5cdc5340a73deed4b769@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv <0028755dd8f83d29f0d5af546e7cf3be55cca85628aa5cdc5340a73deed4b769@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-14 20:14:35 -04:00
committed by GitHub
co-authored by npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv
parent 1742dce7b2
commit 54f4165942
7 changed files with 362 additions and 21 deletions
+61 -12
View File
@@ -320,13 +320,9 @@ class ComposeBar extends HookConsumerWidget {
final pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
final selectedAgentPubkeys = LinkedHashSet<String>.from(
selectedMentions
.where((candidate) => candidate.isAgent)
.map((candidate) => candidate.pubkey.toLowerCase()),
);
final nonMemberAgentPubkeys = <String>[];
if (selectedAgentPubkeys.isNotEmpty) {
final nonMemberHumans = <MentionCandidate>[];
if (selectedMentions.isNotEmpty) {
final currentChannel = (await ref.read(
channelsProvider.future,
)).firstWhere((channel) => channel.id == channelId);
@@ -334,11 +330,55 @@ class ComposeBar extends HookConsumerWidget {
final memberPubkeys = (await ref.read(
channelMembersProvider(channelId).future,
)).map((member) => member.pubkey.toLowerCase()).toSet();
nonMemberAgentPubkeys.addAll(
selectedAgentPubkeys.where(
(pubkey) => !memberPubkeys.contains(pubkey),
),
);
final seenNonMembers = <String>{};
for (final candidate in selectedMentions) {
final pk = candidate.pubkey.toLowerCase();
if (memberPubkeys.contains(pk)) continue;
if (!seenNonMembers.add(pk)) continue;
if (candidate.isAgent) {
nonMemberAgentPubkeys.add(pk);
} else {
nonMemberHumans.add(candidate);
}
}
}
}
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
var mentionPubkeys = pubkeys;
final referenceMentionTags = <List<String>>[];
var inviteHumanPubkeys = const <String>[];
if (nonMemberHumans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in nonMemberHumans) candidate.label],
);
switch (choice) {
case null:
return; // Dismissed — keep the draft, send nothing.
case _NonMemberMentionChoice.invite:
inviteHumanPubkeys = [
for (final candidate in nonMemberHumans)
candidate.pubkey.toLowerCase(),
];
case _NonMemberMentionChoice.sendWithoutInviting:
// Strip their p-tags (no channel notification) but keep a
// `mention` reference tag so their name still renders —
// mirrors desktop's mergeOutgoingTagsWithReferenceMentions.
final excluded = {
for (final candidate in nonMemberHumans)
candidate.pubkey.toLowerCase(),
};
mentionPubkeys = [
for (final pk in pubkeys)
if (!excluded.contains(pk)) pk,
];
referenceMentionTags.addAll([
for (final pk in excluded) ['mention', pk],
]);
}
}
@@ -359,7 +399,16 @@ class ComposeBar extends HookConsumerWidget {
role: 'bot',
);
}
await onSend(payload.content, pubkeys, mediaTags: payload.mediaTags);
if (inviteHumanPubkeys.isNotEmpty) {
await ref
.read(channelActionsProvider)
.addMembers(channelId: channelId, pubkeys: inviteHumanPubkeys);
}
await onSend(
payload.content,
mentionPubkeys,
mediaTags: [...payload.mediaTags, ...referenceMentionTags],
);
if (context.mounted) {
clearComposer();
}
@@ -92,6 +92,42 @@ bool hasMention(String text, String name) {
return pattern.hasMatch(text);
}
/// Outcome of the non-member mention prompt. `null` (dialog dismissed)
/// cancels the send and keeps the draft.
enum _NonMemberMentionChoice { invite, sendWithoutInviting }
/// Ask whether to invite mentioned humans who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
Future<_NonMemberMentionChoice?> _promptNonMemberMention(
BuildContext context, {
required List<String> names,
}) {
final verb = names.length == 1 ? 'is' : 'are';
return showDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Mention people outside this channel?'),
content: Text(
'${names.join(', ')} $verb not in this channel. Invite them to '
'the channel, or send without inviting them.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
child: const Text('Do nothing'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
child: const Text('Invite'),
),
],
),
);
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
@@ -90,15 +90,18 @@ String? formatOwnerLabel(
}
/// Assemble the full mention candidate list: channel members first-class,
/// then eligible non-member relay agents. Mirrors desktop's `useMentions`
/// candidate assembly (minus personas and global people search, which are
/// desktop-only surfaces).
/// then eligible non-member relay agents, then global user-search results.
/// Mirrors desktop's `useMentions` candidate assembly (minus personas and
/// managed agents, which live in the desktop app's local store; the
/// owner-check on search results below covers their mention-eligibility
/// semantics).
List<MentionCandidate> buildMentionCandidates({
required List<ChannelMember> members,
required List<AgentDirectoryEntry> relayAgents,
required Set<String> sharedChannelIds,
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
String? currentPubkey,
}) {
final candidates = <MentionCandidate>[];
@@ -126,12 +129,19 @@ List<MentionCandidate> buildMentionCandidates({
);
}
final directoryPubkeys = <String>{};
final sharedAgentPubkeys = <String>{};
for (final agent in relayAgents) {
directoryPubkeys.add(agent.pubkey);
if (agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
sharedAgentPubkeys.add(agent.pubkey);
}
}
for (final agent in relayAgents) {
final pk = agent.pubkey;
if (seen.contains(pk)) continue;
if (!agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
continue;
}
if (!sharedAgentPubkeys.contains(pk)) continue;
seen.add(pk);
final profile = userCache[pk];
candidates.add(
@@ -149,5 +159,38 @@ List<MentionCandidate> buildMentionCandidates({
);
}
final currentLower = currentPubkey?.toLowerCase();
for (final profile in searchResults) {
final pk = profile.pubkey.toLowerCase();
if (seen.contains(pk)) continue;
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey;
final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk);
if (isAgent) {
// Mirrors desktop's `shouldHideAgentFromMentions` for non-member
// agents: show only when invocable. Invocable = owned by the current
// user (desktop's managed-agent semantics, derived here from the
// verified NIP-OA owner) or shared via the relay agent directory.
final ownedByCurrentUser =
currentLower != null && ownerPubkey?.toLowerCase() == currentLower;
if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) {
continue;
}
}
seen.add(pk);
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: null,
secondaryLabel: profile.nip05Handle,
avatarUrl: profile.avatarUrl,
isAgent: isAgent,
isMember: false,
ownerPubkey: ownerPubkey,
),
);
}
return candidates;
}
@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
import '../channel.dart';
import '../channel_management_provider.dart';
import '../channels_provider.dart';
@@ -40,9 +41,63 @@ final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
return owners;
});
/// Debounce before a mention query hits the relay search endpoint.
const _mentionSearchDebounce = Duration(milliseconds: 250);
/// Global user search for mention autocomplete — kind:0 prefix search via
/// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery`
/// feeding `useMentions` (source 4: people and agents outside the channel).
///
/// Debounced: the provider waits [_mentionSearchDebounce] before querying;
/// keystrokes dispose the stale family member so its request never fires.
final mentionUserSearchProvider = FutureProvider.autoDispose
.family<List<UserProfile>, String>((ref, query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
var disposed = false;
ref.onDispose(() => disposed = true);
await Future<void>.delayed(_mentionSearchDebounce);
if (disposed) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed),
]);
// Keep only the latest kind:0 event per pubkey (the bridge does not
// honor the `kinds` filter under search, and may return several
// profile revisions — mirrors desktop's `list_user_search_results`).
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pk = event.pubkey.toLowerCase();
final current = latestByPubkey[pk];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pk] = event;
}
}
return [
for (final event in latestByPubkey.values) _profileFromEvent(event),
];
});
UserProfile _profileFromEvent(NostrEvent event) {
final data = ProfileData.fromEvent(event);
return UserProfile(
pubkey: event.pubkey.toLowerCase(),
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
);
}
/// Ranked mention candidates for a channel + query. Channel members first,
/// then non-member relay agents the user can actually reach; ordering
/// matches desktop's `rankMentionCandidates`.
/// then non-member relay agents the user can actually reach, then global
/// search results; ordering matches desktop's `rankMentionCandidates`.
final mentionCandidatesProvider = Provider.family
.autoDispose<List<MentionCandidate>, ({String channelId, String query})>((
ref,
@@ -59,6 +114,9 @@ final mentionCandidatesProvider = Provider.family
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final searchResults =
ref.watch(mentionUserSearchProvider(args.query)).asData?.value ??
const <UserProfile>[];
final sharedChannelIds = {
for (final channel in channels)
@@ -71,6 +129,7 @@ final mentionCandidatesProvider = Provider.family
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
searchResults: searchResults,
currentPubkey: currentPubkey,
);
@@ -343,9 +343,12 @@ List<TimelineMessage> formatTimeline(
event.kind == EventKind.streamMessageDiff) {
final edit = edits[event.id];
final effectiveTags = edit?.tags ?? event.tags;
// Include both notify (`p`) and reference-only (`mention`) tags —
// mirrors desktop's resolveMentionNames, so names in messages sent
// "without inviting" still render as mentions.
final mentions = <String>[
for (final tag in effectiveTags)
if (tag.length >= 2 && tag[0] == 'p') tag[1],
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) tag[1],
];
final threadRef = event.threadReference;
@@ -132,6 +132,20 @@ abstract final class NostrFilters {
limit: limit,
);
/// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge).
///
/// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is
/// a typeahead surface, so a partially typed name must match ("rac" →
/// "raccoon"). Mirrors desktop's `build_user_search_filter`
/// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through
/// `queryRelay`, not a WebSocket REQ.
static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter(
kinds: [0],
search: query,
limit: limit,
extensions: const {'search_mode': 'prefix'},
);
/// Deletions (kind:5) targeting event IDs.
static NostrFilter deletionsByTargetIds(
List<String> ids, {
@@ -128,5 +128,142 @@ void main() {
expect(candidates.single.isMember, isTrue);
expect(candidates.single.isAgent, isTrue);
});
test('search results add non-member humans, ungated', () {
final humanPubkey = '1' * 64;
final candidates = buildMentionCandidates(
members: [member(memberPubkey)],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: humanPubkey, displayName: 'Wes Outside'),
],
currentPubkey: userPubkey,
);
expect(candidates.map((c) => c.pubkey), [memberPubkey, humanPubkey]);
final human = candidates.last;
expect(human.isAgent, isFalse);
expect(human.isMember, isFalse);
expect(human.displayName, 'Wes Outside');
});
test('search results show agents owned by the current user', () {
final ownedAgent = '2' * 64;
final candidates = buildMentionCandidates(
members: const [],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: ownedAgent,
displayName: 'raccoon',
ownerPubkey: userPubkey,
),
],
currentPubkey: userPubkey,
);
expect(candidates, hasLength(1));
expect(candidates.single.isAgent, isTrue);
expect(candidates.single.ownerPubkey, userPubkey);
});
test('search results hide non-shared agents owned by someone else', () {
final foreignAgent = '3' * 64;
final candidates = buildMentionCandidates(
members: const [],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: foreignAgent,
displayName: 'stranger-bot',
ownerPubkey: ownerPubkey,
),
],
currentPubkey: userPubkey,
);
expect(candidates, isEmpty);
});
test('search results show non-owned agents shared via the directory', () {
final candidates = buildMentionCandidates(
members: const [],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-1'],
),
],
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: agentPubkey,
displayName: 'Helper',
ownerPubkey: ownerPubkey,
),
],
currentPubkey: userPubkey,
);
// Already surfaced by the directory pass; the search pass must not
// duplicate it.
expect(candidates, hasLength(1));
expect(candidates.single.pubkey, agentPubkey);
expect(candidates.single.isAgent, isTrue);
});
test('directory-listed agents in search results are agents even without '
'a verified owner', () {
final candidates = buildMentionCandidates(
members: const [],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-9'],
),
],
// Not shared with the user → directory pass skips it; the search
// pass must still classify it as an agent and hide it.
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: agentPubkey, displayName: 'Helper'),
],
currentPubkey: userPubkey,
);
expect(candidates, isEmpty);
});
test('search results never duplicate channel members', () {
final candidates = buildMentionCandidates(
members: [member(memberPubkey)],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: memberPubkey, displayName: 'Member Dup'),
],
currentPubkey: userPubkey,
);
expect(candidates, hasLength(1));
expect(candidates.single.isMember, isTrue);
});
});
}