feat(mobile): find people by name through the relay's name-only profile search (#8333)

🤖
## 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 <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-15 20:33:15 -04:00
committed by GitHub
parent 46159fcf91
commit 93ffc37cbd
7 changed files with 304 additions and 29 deletions

View File

@@ -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<List<DirectoryUser>> 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 ||

View File

@@ -312,9 +312,16 @@ List<String> relayMemberPubkeysFromEvents(List<NostrEvent> 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<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
List<DirectoryUser> directoryUsersFromProfileEvents(
List<NostrEvent> events, {
bool alphabetize = true,
}) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
@@ -327,7 +334,7 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
}
}
return [
final users = [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
DirectoryUser(
@@ -337,14 +344,18 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> 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();
});

View File

@@ -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

View File

@@ -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<SessionState> {
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> 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<List<NostrEvent>> 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<List<NostrEvent>> _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(

View File

@@ -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<List<NostrEvent>> searchProfiles(
String query, {
int limit = 50,
Duration timeout = const Duration(seconds: 8),
}) async {
searchQueryCount++;
return profileEvents;
}

View File

@@ -58,6 +58,13 @@ class _HeldSearchSession extends RelaySessionNotifier {
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) => throw StateError('name search must use searchProfiles');
@override
Future<List<NostrEvent>> searchProfiles(
String query, {
int limit = 50,
Duration timeout = const Duration(seconds: 8),
}) {
final search = Completer<List<NostrEvent>>();
searches.add(search);

View File

@@ -508,6 +508,172 @@ void main() {
},
);
group('searchProfiles', () {
Map<String, dynamic> profileJson(int kind, String name) => {
'id': name.padRight(64, '0'),
'pubkey': 'a' * 64,
'created_at': 1,
'kind': kind,
'tags': <List<String>>[],
'content': jsonEncode({'name': name}),
'sig': 'b' * 128,
};
({
ProviderContainer container,
List<http.Request> requests,
void Function(Duration) advance,
})
harness(http.Response Function(http.Request request) respond) {
final requests = <http.Request>[];
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<String, dynamic>;
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<RelayException>().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(