diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index e0879eb022..46e161d2af 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -225,15 +225,13 @@ class ChannelActions { _ref.invalidate(channelCanvasProvider(channelId)); } - /// User search via NIP-50 over kind:0 profile events. + /// User search by profile name ([RelaySessionNotifier.searchProfiles]). Future> searchUsers(String query, {int limit = 8}) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; - final events = await _session.queryRelay([ - NostrFilters.searchUsers(trimmed, limit: limit), - ]); - return directoryUsersFromProfileEvents(events) + final events = await _session.searchProfiles(trimmed, limit: limit); + return directoryUsersFromProfileEvents(events, alphabetize: false) .where( (user) => _currentPubkey == null || diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index c2b065274f..1eb8e25776 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -312,9 +312,16 @@ List relayMemberPubkeysFromEvents(List events) { return pubkeys.toList(); } -/// Converts kind:0 events into a deduplicated, alphabetized people directory. +/// Converts kind:0 events into a deduplicated people directory. +/// +/// The directory listing is alphabetized. Search results pass +/// [alphabetize] false to keep the relay's order, which ranks the best name +/// match first; each person keeps the place of their first result. @visibleForTesting -List directoryUsersFromProfileEvents(List events) { +List directoryUsersFromProfileEvents( + List events, { + bool alphabetize = true, +}) { final latestByPubkey = {}; for (final event in events) { if (event.kind != 0) { @@ -327,7 +334,7 @@ List directoryUsersFromProfileEvents(List events) { } } - return [ + final users = [ for (final event in latestByPubkey.values) if (ProfileData.fromEvent(event) case final profile) DirectoryUser( @@ -337,14 +344,18 @@ List directoryUsersFromProfileEvents(List events) { nip05Handle: profile.nip05, isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, ), - ]..sort((a, b) { - final labelComparison = a.label.toLowerCase().compareTo( - b.label.toLowerCase(), - ); - return labelComparison != 0 - ? labelComparison - : a.pubkey.compareTo(b.pubkey); - }); + ]; + if (alphabetize) { + users.sort((a, b) { + final labelComparison = a.label.toLowerCase().compareTo( + b.label.toLowerCase(), + ); + return labelComparison != 0 + ? labelComparison + : a.pubkey.compareTo(b.pubkey); + }); + } + return users; } /// People and agents discoverable on the active relay. @@ -455,11 +466,10 @@ final relayDirectorySearchProvider = FutureProvider.autoDispose ref.watch(relayConfigProvider); final session = ref.watch(relaySessionProvider.notifier); final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); - final events = await session.queryRelay([ - NostrFilters.searchUsers(trimmed, limit: 50), - ]); + final events = await session.searchProfiles(trimmed, limit: 50); return directoryUsersFromProfileEvents( events, + alphabetize: false, ).where((user) => user.pubkey.toLowerCase() != currentPubkey).toList(); }); diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index c09fedfe68..86bab7a801 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -41,9 +41,7 @@ final mentionUserSearchProvider = FutureProvider.autoDispose if (disposed) return const []; final session = ref.read(relaySessionProvider.notifier); - final events = await session.queryRelay([ - NostrFilters.searchUsers(trimmed), - ]); + final events = await session.searchProfiles(trimmed); // Keep only the latest kind:0 event per pubkey (the bridge does not // honor the `kinds` filter under search, and may return several diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index ece4b9266b..70b175fad7 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -12,6 +12,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/age_gate/age_signal_provider.dart'; import '../auth/auth.dart'; +import 'nostr_filters.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_closed_policy.dart'; @@ -157,16 +158,81 @@ class RelaySessionNotifier extends Notifier { Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), + }) => _postForEvents( + '/query', + filters.map((filter) => filter.toJson()).toList(), + timeout: timeout, + ); + + /// Finds people by profile name, for typeahead surfaces. + /// + /// NIP-50 search over kind:0 matches the whole profile JSON, so text in + /// avatar data URLs, `about` or `nip05` can fill every result page and hide + /// a real name match. The relay's `POST /profiles/search` applies the same + /// prefix rule to just `name` and `display_name` and puts exact and prefix + /// name matches first. + /// + /// A relay without that endpoint answers 404 or 405. The search then uses + /// NIP-50 ([NostrFilters.searchUsers]), and so do later searches on that + /// relay for [profileSearchRetry]; after that the endpoint is tried again. + /// A query or limit the endpoint does not accept also uses NIP-50. + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), + }) async { + final text = query.trim(); + final relay = ref.read(relayConfigProvider).baseUrl; + final fallback = _profileSearchFallback; + if (text.isNotEmpty && + text.runes.length <= _profileSearchQueryMax && + limit >= 1 && + limit <= _profileSearchLimitMax && + (fallback == null || + fallback.relay != relay || + !_now().isBefore(fallback.until))) { + try { + final events = await _postForEvents('/profiles/search', { + 'query': text, + 'limit': limit, + }, timeout: timeout); + return [ + for (final event in events) + if (event.kind == 0) event, + ]; + } on RelayException catch (error) { + if (error.statusCode != 404 && error.statusCode != 405) rethrow; + _profileSearchFallback = ( + relay: relay, + until: _now().add(profileSearchRetry), + ); + } + } + return queryRelay([ + NostrFilters.searchUsers(text, limit: limit), + ], timeout: timeout); + } + + /// How long a relay's 404 or 405 keeps [searchProfiles] on NIP-50. + static const profileSearchRetry = Duration(minutes: 5); + static const _profileSearchQueryMax = 100; + static const _profileSearchLimitMax = 100; + ({String relay, DateTime until})? _profileSearchFallback; + + /// POSTs [payload] as JSON to relay [path] with NIP-98 auth and decodes + /// the event array it answers. + Future> _postForEvents( + String path, + Object payload, { + required Duration timeout, }) async { if (_disposed || _ageRestricted) { throw StateError('Relay session is unavailable'); } final generation = _contextGeneration; final config = ref.read(relayConfigProvider); - final url = Uri.parse(config.baseUrl).resolve('/query').toString(); - final bodyBytes = utf8.encode( - jsonEncode(filters.map((filter) => filter.toJson()).toList()), - ); + final url = Uri.parse(config.baseUrl).resolve(path).toString(); + final bodyBytes = utf8.encode(jsonEncode(payload)); // Reuse the session transport on success. A timeout rotates immediately // for new queries, then closes the retired client after its peers finish. final response = await _httpQueryClient.post( diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 69f588622b..f12ca104ae 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -723,6 +723,27 @@ void main() { expect(session.searchQueryCount, 2); }); + test('search results keep the relay ranking', () async { + // The relay ranks the exact name first; alphabetizing would move + // "Abe Wes" above "Wes". + final session = _DirectoryFakeRelaySession( + profileEvents: [profile('wes', 'Wes'), profile('abe', 'Abe Wes')], + ); + final container = buildContainer(session); + addTearDown(container.dispose); + + final results = await container.read( + relayDirectorySearchProvider('wes').future, + ); + expect(results.map((user) => user.label), ['Wes', 'Abe Wes']); + final found = await container + .read(channelActionsProvider) + .searchUsers('wes'); + expect(found.map((user) => user.label), ['Wes', 'Abe Wes']); + final browsed = await container.read(relayDirectoryUsersProvider.future); + expect(browsed.map((user) => user.label), ['Abe Wes', 'Wes']); + }); + test('cached search families are released once unlistened', () async { final session = _DirectoryFakeRelaySession( profileEvents: [profile('alice', 'Alice')], @@ -850,10 +871,19 @@ class _DirectoryFakeRelaySession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { if (filters.any((filter) => filter.search != null)) { - searchQueryCount++; - } else { - directoryQueryCount++; + throw StateError('name search must use searchProfiles'); } + directoryQueryCount++; + return profileEvents; + } + + @override + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), + }) async { + searchQueryCount++; return profileEvents; } diff --git a/mobile/test/features/channels/mentions/mention_search_community_test.dart b/mobile/test/features/channels/mentions/mention_search_community_test.dart index d74d983674..19f7e974be 100644 --- a/mobile/test/features/channels/mentions/mention_search_community_test.dart +++ b/mobile/test/features/channels/mentions/mention_search_community_test.dart @@ -58,6 +58,13 @@ class _HeldSearchSession extends RelaySessionNotifier { Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), + }) => throw StateError('name search must use searchProfiles'); + + @override + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), }) { final search = Completer>(); searches.add(search); diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 50bdb5201d..3d57dca120 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -508,6 +508,172 @@ void main() { }, ); + group('searchProfiles', () { + Map profileJson(int kind, String name) => { + 'id': name.padRight(64, '0'), + 'pubkey': 'a' * 64, + 'created_at': 1, + 'kind': kind, + 'tags': >[], + 'content': jsonEncode({'name': name}), + 'sig': 'b' * 128, + }; + + ({ + ProviderContainer container, + List requests, + void Function(Duration) advance, + }) + harness(http.Response Function(http.Request request) respond) { + final requests = []; + var now = DateTime(2026); + final session = RelaySessionNotifier( + httpClient: http_testing.MockClient((request) async { + requests.add(request); + return respond(request); + }), + now: () => now, + ); + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + addTearDown(container.dispose); + return ( + container: container, + requests: requests, + advance: (duration) => now = now.add(duration), + ); + } + + test('posts the name query to /profiles/search with NIP-98 auth', () async { + final h = harness( + (_) => http.Response( + jsonEncode([profileJson(0, 'wes'), profileJson(1, 'note')]), + 200, + ), + ); + + final events = await h.container + .read(relaySessionProvider.notifier) + .searchProfiles(' wes ', limit: 8); + + expect(events.map((event) => event.kind), [0]); + expect(h.requests, hasLength(1)); + final request = h.requests.single; + expect(request.method, 'POST'); + expect(request.url.toString(), 'https://relay.example/profiles/search'); + expect(jsonDecode(request.body), {'query': 'wes', 'limit': 8}); + final auth = request.headers['Authorization']!; + final authEvent = + jsonDecode( + utf8.decode( + base64Url.decode( + base64Url.normalize(auth.substring('Nostr '.length)), + ), + ), + ) + as Map; + expect( + authEvent['tags'], + anyElement(equals(['u', 'https://relay.example/profiles/search'])), + ); + }); + + test( + 'falls back to NIP-50 on 404 and retries the endpoint later', + () async { + final h = harness( + (request) => request.url.path == '/profiles/search' + ? http.Response('not found', 404) + : http.Response(jsonEncode([profileJson(0, 'wes')]), 200), + ); + final session = h.container.read(relaySessionProvider.notifier); + + final events = await session.searchProfiles('wes', limit: 8); + expect(events.map((event) => event.content), [ + jsonEncode({'name': 'wes'}), + ]); + expect(h.requests.map((request) => request.url.path), [ + '/profiles/search', + '/query', + ]); + expect(jsonDecode(h.requests.last.body), [ + NostrFilters.searchUsers('wes', limit: 8).toJson(), + ]); + + // Inside the retry window the relay is known to lack the endpoint. + h.requests.clear(); + h.advance( + RelaySessionNotifier.profileSearchRetry - const Duration(seconds: 1), + ); + await session.searchProfiles('wes', limit: 8); + expect(h.requests.map((request) => request.url.path), ['/query']); + + h.requests.clear(); + h.advance(const Duration(seconds: 1)); + await session.searchProfiles('wes', limit: 8); + expect(h.requests.map((request) => request.url.path), [ + '/profiles/search', + '/query', + ]); + }, + ); + + test('falls back to NIP-50 on 405', () async { + final h = harness( + (request) => request.url.path == '/profiles/search' + ? http.Response('', 405) + : http.Response('[]', 200), + ); + + await h.container + .read(relaySessionProvider.notifier) + .searchProfiles('wes'); + + expect(h.requests.map((request) => request.url.path), [ + '/profiles/search', + '/query', + ]); + }); + + test('surfaces other endpoint errors without a fallback', () async { + final h = harness((_) => http.Response('{"error":"boom"}', 500)); + + await expectLater( + h.container.read(relaySessionProvider.notifier).searchProfiles('wes'), + throwsA( + isA().having( + (error) => error.statusCode, + 'statusCode', + 500, + ), + ), + ); + expect(h.requests.map((request) => request.url.path), [ + '/profiles/search', + ]); + }); + + test('uses NIP-50 for a limit the endpoint does not accept', () async { + final h = harness((_) => http.Response('[]', 200)); + + await h.container + .read(relaySessionProvider.notifier) + .searchProfiles('wes', limit: 101); + + expect(h.requests.map((request) => request.url.path), ['/query']); + }); + }); + test('background disconnect rejects in-flight history', () async { final session = RelaySessionNotifier(); final container = ProviderContainer(