import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/crypto/nip_oa.dart'; import '../../../shared/mentions/agent_identity_provider.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'; import 'mention_candidates.dart'; import 'mention_ranking.dart'; /// 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, String>((ref, query) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; var disposed = false; ref.onDispose(() => disposed = true); await Future.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 = {}; 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, then global /// search results; ordering matches desktop's `rankMentionCandidates`. final mentionCandidatesProvider = Provider.family .autoDispose, ({String channelId, String query})>(( ref, args, ) { final members = ref.watch(channelMembersProvider(args.channelId)).asData?.value ?? const []; final relayAgents = ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; final channels = ref.watch(channelsProvider).asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); final currentPubkey = ref.watch(currentPubkeyProvider); final searchResults = ref.watch(mentionUserSearchProvider(args.query)).asData?.value ?? const []; final sharedChannelIds = { for (final channel in channels) if (channel.isMember && !channel.isArchived) channel.id, }; final candidates = buildMentionCandidates( members: members, relayAgents: relayAgents, sharedChannelIds: sharedChannelIds, userCache: userCache, ownerByAgentPubkey: owners, searchResults: searchResults, currentPubkey: currentPubkey, ); return rankMentionCandidates(candidates, args.query); });