mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv
parent
1742dce7b2
commit
54f4165942
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user