From 93ffc37cbdb7ced6a07eca11744931cdffb82e20 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 15 Oct 2026 20:33:15 -0400 Subject: [PATCH] feat(mobile): find people by name through the relay's name-only profile search (#8333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary In the mobile app, searching for "wes" did not find Wes. People search matched text anywhere in a profile, including the image data of a shared default avatar. Many agent profiles use that avatar, so they filled the results and the real Wes never appeared. Now every people search in the app uses the relay's new name search: the Search page, adding channel members, new messages, and @mention suggestions. It matches only a profile's name and display name and puts exact names first. The desktop app (block/buzz-app#1084), agent tools (block/buzz-app#1124) and the CLI (#8331) make the same change. Compatibility: a relay without the new search answers 404 or 405. That search uses the old search, and so do later searches on the same relay for five minutes; then the app tries the new search again, so a relay that is upgraded while the app runs is used soon after. A query or result count that the new search cannot take also uses the old search. Other errors are shown as before. Order: search results keep the relay's ranking, so "Wes" comes before "Abe Wes". Before, the Search page and the member and new-message pickers sorted results by name. The list with no query is still alphabetical. Each screen still applies its own filters. Not in scope: the community invite screen accepts only an npub and has no name search, so this PR does not change it. ### Related issue Relay endpoint: #8301. This PR does not depend on it; either can merge first. ### Testing - Relay session tests: the name query goes to the new search with a signed request; a 404 falls back and stays on the old search until the retry time; a 405 falls back; other errors do not fall back; an unsupported result count uses the old search. - Updated tests for add members and @mention search, and a test that search results keep the relay's order while the browse list stays alphabetical. - `just mobile-check` and the relay, channel, mention, invite and search tests (1,962) pass. - No screenshots: results look the same; only which people are found changes. --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../channels/channel_management_actions.dart | 8 +- .../channels/channel_management_provider.dart | 38 ++-- .../mentions/mention_candidates_provider.dart | 4 +- mobile/lib/shared/relay/relay_session.dart | 74 +++++++- .../channel_management_provider_test.dart | 36 +++- .../mention_search_community_test.dart | 7 + .../test/shared/relay/relay_session_test.dart | 166 ++++++++++++++++++ 7 files changed, 304 insertions(+), 29 deletions(-) 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(