From c39ee507b27354b34baec635c8835c7ebe6f1de0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 5 Nov 2026 18:53:40 -0500 Subject: [PATCH] fix(mobile): find verified people who have no profile (#9086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes Mobile now finds a person who has a relay-verified profile (NIP-FI, kind 30382) but never published a kind:0 profile. This happens, for example, when someone skips profile setup. The relay's `POST /profiles/search` already finds such people by their verified name. Each one comes back as a kind:30382 head with no kind:0. Mobile kept only kind:0 results, so these people did not appear. - `RelaySessionNotifier.searchProfiles` keeps kind:30382 heads from the endpoint. - `VerifiedProfilesNotifier.searched` turns a search page into people: - If the relay authority is not known yet, it first waits for NIP-11 discovery. Otherwise, a search that finished first would drop these people for that query. - It checks each head's signature with the directory's off-isolate proof checker. - Proven heads go into the verified directory, so the rows show verified names right away. - `searchedPeople` (in `verified_profile.dart`) builds one row per person, in the relay's order: - If the person has a kind:0, the newest kind:0 is used, as before. - If not, it uses the proven head that the authority signed, provided it parses as an active verified profile. - These cases add no row: - a head signed by another key; - a head that names the relay key but has a bad signature; - a retraction; - a malformed subject. - **Behavior change for all mobile mentions:** every verified person now matches in mention ranking by their verified full name and username, as well as by display name, including people who have a kind:0. The relay's search matches these same three names. Without this, a person whose display name is their username (`glilienfeld`, full name "Gabi Lilienfeld") came back from the relay for "gabi", and then the local filter dropped them. - Callers: - Mention search, which also serves Add members and New DM. - The user rows in global search, through `directoryUsersFromSearch`. The desktop app has the same fix for Manage members: https://github.com/block/buzz-app/pull/2554 ## Screenshots These come from the real Add members sheet on an Android emulator. Only the relay session, verified-profile discovery and the member actions are faked. The names are made up. Jordan Park has a kind:0. jrivera has only a verified profile, with the full name "Jordan Rivera". The search is "jordan". The row shows the verified display name, which here is the same as the username. | Search finds jrivera | After tapping + on jrivera | | --- | --- | | jrivera is found next to Jordan Park | jrivera is selected to add | ## Tests - `add_members_sheet_names_test.dart`: a journey in which Gabi has `display_name` = `glilienfeld` and `full_name` = "Gabi Lilienfeld", and NIP-11 discovery is slow. - While discovery is pending, typing "gabi" shows nothing yet. - When discovery finishes, Gabi appears with no further keystroke. - A head from another key does not appear, and neither does a head that names the relay key but has a bad signature. - **Add member** adds Gabi's key. - I removed each fix in turn: the full-name alias, the signature proof, and the wait for discovery. The journey fails each time, for the expected reason. - `verified_profile_test.dart`: a table of `searchedPeople` cases. - A kind:0 wins over a head, and the newest kind:0 counts. - These find nobody: another signer, a retraction, an uppercase subject, a non-profile kind, and any head when the authority is unknown. - `relay_session_test.dart` and `channel_management_provider_test.dart`: rows for the kept heads and the directory row. - `flutter analyze` is clean, and the full `flutter test` passes on 6efef79cc: 4,462 tests. **Note:** mobile on `main` does not compile at e558f15fa. `account_identity_store.dart` calls the removed `_keys`. That fix is https://github.com/block/buzz/pull/9085. The local runs above had that one-line fix applied but not committed. Until it lands, mobile CI on this PR will fail. ## Not covered - **Search during a reconnect:** discovery starts only when the relay session is connected. If an HTTP search succeeds while the session is reconnecting, that search leaves these people out. - **Discovery failure:** if NIP-11 discovery fails, the search also leaves these people out. - In both cases the mention cache keeps that partial page for the query, and the next search tries discovery again. People who have a kind:0 show as before. Showing the left-out people when the authority arrives later would need the cached results to be rebuilt; that is a possible follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .../channels/channel_management_actions.dart | 5 +- .../channels/channel_management_provider.dart | 42 +++---- .../mentions/mention_candidates_provider.dart | 33 +++--- .../channels/mentions/mention_ranking.dart | 14 ++- mobile/lib/shared/relay/relay_session.dart | 7 +- .../verified_profiles/verified_profile.dart | 52 ++++++++ .../verified_profile_directory.dart | 43 +++++++ .../add_members_sheet_names_test.dart | 112 ++++++++++++++++++ .../channel_management_provider_test.dart | 96 ++++++++------- .../test/shared/relay/relay_session_test.dart | 9 +- .../verified_profile_test.dart | 48 ++++++++ 11 files changed, 373 insertions(+), 88 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index f57a651a00..ae115d29da 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -320,7 +320,10 @@ class ChannelActions { if (trimmed.isEmpty) return const []; final events = await _session.searchProfiles(trimmed, limit: limit); - return directoryUsersFromProfileEvents(events, alphabetize: false) + final people = await _ref + .read(verifiedProfilesProvider.notifier) + .searched(events); + return directoryUsersFromSearch(people, 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 d74830ff02..aceaec21f7 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -12,6 +12,8 @@ import '../../shared/crypto/nip_oa.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; +import '../../shared/verified_profiles/verified_profile.dart'; +import '../../shared/verified_profiles/verified_profile_directory.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channel_metadata_updates.dart'; @@ -271,37 +273,35 @@ List relayMemberPubkeysFromEvents(List events) { return pubkeys.toList(); } -/// Converts kind:0 events into a deduplicated people directory. +/// Converts the people a profile search found into a 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. +/// match first. @visibleForTesting -List directoryUsersFromProfileEvents( - List events, { +List directoryUsersFromSearch( + List people, { bool alphabetize = true, }) { - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) { - continue; - } - final pubkey = event.pubkey.toLowerCase(); - final current = latestByPubkey[pubkey]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pubkey] = event; - } + DirectoryUser fromProfile(String pubkey, NostrEvent event) { + final profile = ProfileData.fromEvent(event); + return DirectoryUser( + pubkey: pubkey, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + nip05Handle: profile.nip05, + isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + ); } final users = [ - for (final event in latestByPubkey.values) - if (ProfileData.fromEvent(event) case final profile) + for (final person in people) + if (person.profile case final event?) + fromProfile(person.pubkey, event) + else DirectoryUser( - pubkey: profile.pubkey.toLowerCase(), - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - nip05Handle: profile.nip05, - isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + pubkey: person.pubkey, + displayName: person.verified?.displayName, ), ]; if (alphabetize) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index ab840014bb..521b155ea2 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -22,7 +22,7 @@ import '../../profile/presence_cache_provider.dart'; /// Typing pause before a mention query hits the relay search endpoint. const mentionSearchDebounce = Duration(milliseconds: 200); -/// Global user search for mention autocomplete — kind:0 prefix search via +/// Global user search for mention autocomplete — profile name search via /// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery` /// feeding `useMentions` (source 4: people and agents outside the channel). /// @@ -47,21 +47,20 @@ final mentionUserSearchProvider = FutureProvider.autoDispose final session = ref.read(relaySessionProvider.notifier); 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 - // 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; - } - } - + // Each person once, by their newest kind:0; someone with none, by + // their verified head. + final found = await ref + .read(verifiedProfilesProvider.notifier) + .searched(events); final people = [ - for (final event in latestByPubkey.values) _profileFromEvent(event), + for (final person in found) + if (person.profile case final profile?) + _profileFromEvent(profile) + else + UserProfile( + pubkey: person.pubkey, + displayName: person.verified?.displayName, + ), ]; // A search that outlived its query or community is not kept. if (!disposed) ref.read(mentionSearchCacheProvider).put(trimmed, people); @@ -317,7 +316,7 @@ List _chooserCandidates( candidates = [ for (final candidate in candidates) switch (verified[candidate.pubkey.toLowerCase()]) { - final profile? => candidate.withVerifiedName(profile.displayName), + final profile? => candidate.withVerifiedName(profile), null => candidate, }, ]; @@ -360,7 +359,7 @@ List verifiedMentionMatches( pubkey: key, name: value.displayName, label: value.displayName, - aliases: [value.displayName], + aliases: [value.displayName, ...verifiedAliases(value)], member: false, ), ], query).isNotEmpty) diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart index 2f1bae6f4e..6f9252ae1a 100644 --- a/mobile/lib/features/channels/mentions/mention_ranking.dart +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import '../../../shared/mentions/mention_rules.dart'; import '../../../shared/utils/string_utils.dart'; +import '../../../shared/verified_profiles/verified_profile.dart'; /// A mention autocomplete candidate. Mirrors the desktop's /// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts). @@ -58,9 +59,12 @@ class MentionCandidate { aliases: aliases, ); - /// This candidate named by its NIP-FI verified [name], which it is then - /// matched, ranked and inserted as. The self-declared name stays searchable. - MentionCandidate withVerifiedName(String name) { + /// This candidate named by its NIP-FI [verified] display name, which it is + /// then matched, ranked and inserted as. The self-declared name stays + /// searchable, and so do the verified full name and username, which the + /// relay's profile search matches too. + MentionCandidate withVerifiedName(VerifiedProfile verified) { + final name = verified.displayName; final declared = displayName?.trim(); return MentionCandidate( pubkey: pubkey, @@ -75,8 +79,8 @@ class MentionCandidate { contextLabel: contextLabel, aliases: [ ...aliases, - if (declared != null && declared.isNotEmpty && declared != name) - declared, + for (final alias in [declared, ...verifiedAliases(verified)]) + if (alias != null && alias.isNotEmpty && alias != name) alias, ], ); } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 84f760701a..0285df5eb0 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -24,6 +24,7 @@ import 'relay_query_response.dart'; import 'relay_rate_limit_gate.dart'; import 'relay_reconnect_policy.dart'; import 'read_state_snapshot.dart'; +import '../verified_profiles/verified_profile.dart'; import 'relay_session_types.dart'; import 'relay_socket.dart'; import 'package:buzz/shared/crypto/event_signing.dart'; @@ -168,7 +169,9 @@ class RelaySessionNotifier extends Notifier { /// 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. + /// A query or limit the endpoint does not accept also uses NIP-50. The + /// endpoint's verified heads (kind:30382) are kept: [searchedPeople] finds + /// people with no kind:0 by them. Future> searchProfiles( String query, { int limit = 50, @@ -191,7 +194,7 @@ class RelaySessionNotifier extends Notifier { }, timeout: timeout); return [ for (final event in events) - if (event.kind == 0) event, + if (event.kind == 0 || event.kind == verifiedProfileKind) event, ]; } on RelayException catch (error) { if (error.statusCode != 404 && error.statusCode != 405) rethrow; diff --git a/mobile/lib/shared/verified_profiles/verified_profile.dart b/mobile/lib/shared/verified_profiles/verified_profile.dart index 6da2e98f14..28ff273719 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile.dart @@ -293,3 +293,55 @@ VerifiedProfile? parseVerifiedProfile( multipleIssuers: authority.multipleIssuers, ); } + +/// The verified names besides the display name that the relay's profile +/// search matches: the full name, when there is one, and the username. +List verifiedAliases(VerifiedProfile profile) => [ + ?profile.fullName, + profile.username, +]; + +/// One person a profile search found: their newest kind:0, or, for someone +/// with none, their verified profile. +typedef SearchedPerson = ({ + String pubkey, + NostrEvent? profile, + VerifiedProfile? verified, +}); + +/// The people a profile search page found, in the relay's order: each +/// person's newest kind:0, and for a person with none, the head [authority] +/// signed, when it is an active verified profile. Any other event finds +/// nobody. Callers must have verified each head's id and signature +/// ([VerifiedProfilesNotifier.searched] does). +List searchedPeople( + List events, + VerifiedProfileAuthority? authority, +) { + final order = {}; + final profiles = {}; + final heads = {}; + for (final event in events) { + if (event.kind == 0) { + final pubkey = event.pubkey.toLowerCase(); + order.add(pubkey); + final current = profiles[pubkey]; + if (current == null || event.createdAt > current.createdAt) { + profiles[pubkey] = event; + } + } else if (authority != null) { + final subject = verifiedProfileSubject(event, authority); + if (subject == null) continue; + order.add(subject); + if (supersedes(event, heads[subject])) heads[subject] = event; + } + } + return [ + for (final pubkey in order) + if (profiles[pubkey] case final profile?) + (pubkey: pubkey, profile: profile, verified: null) + else if (parseVerifiedProfile(heads[pubkey]!, authority!, checked: false) + case final verified?) + (pubkey: pubkey, profile: null, verified: verified), + ]; +} diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index 6aa83ec060..2f38fd17f7 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -75,6 +75,7 @@ class VerifiedProfilesNotifier extends Notifier> { bool _discoveryFresh = false; bool _connected = false; bool _discoveryInFlight = false; + Completer? _discoveryDone; bool _scheduled = false; int _generation = 0; int _interest = 0; @@ -128,6 +129,45 @@ class VerifiedProfilesNotifier extends Notifier> { _checked.clear(); } + /// The people a profile search found ([searchedPeople]). Waits for + /// discovery when the relay's authority is not known yet, and keeps only + /// heads with a valid signature. A kept head also names its subject here, + /// so the search's rows show their verified names at once. + Future> searched(List events) { + final heads = [ + for (final event in events) + if (event.kind == verifiedProfileKind) event, + ]; + // Without heads there is nothing to wait for or prove: answer at once. + if (heads.isEmpty) return SynchronousFuture(searchedPeople(events, null)); + return _searchedWithHeads(events, heads); + } + + Future> _searchedWithHeads( + List events, + List heads, + ) async { + final generation = _generation; + if (_authority == null && !_discoveryFresh) { + unawaited(_refreshDiscovery(generation)); + await _discoveryDone?.future; + } + final authority = _enabled ? _authority : null; + if (authority == null) return searchedPeople(events, null); + final proven = Set.identity()..addAll(await _prove(heads)); + if (generation == _generation && authority == _authority && ref.mounted) { + track([ + for (final head in proven) ?verifiedProfileSubject(head, authority), + ]); + _accept(proven.toList(), authority); + _publish(); + } + return searchedPeople([ + for (final event in events) + if (event.kind != verifiedProfileKind || proven.contains(event)) event, + ], authority); + } + /// Marks [pubkeys] relevant. Repeated calls coalesce into one scheduling pass. void track(Iterable pubkeys) { final valid = RegExp(r'^[0-9a-f]{64}$'); @@ -190,6 +230,7 @@ class VerifiedProfilesNotifier extends Notifier> { Future _refreshDiscovery(int generation) async { if (_discoveryInFlight || generation != _generation || !_connected) return; _discoveryInFlight = true; + final done = _discoveryDone = Completer(); try { final result = await ref.read(verifiedProfileDiscoveryFetchProvider)(); if (generation != _generation) return; @@ -214,6 +255,8 @@ class VerifiedProfilesNotifier extends Notifier> { if (generation == _generation) _discoveryFresh = false; debugPrint('[VerifiedProfiles] discovery failed: $error'); } finally { + if (identical(_discoveryDone, done)) _discoveryDone = null; + done.complete(); if (generation == _generation) { _discoveryInFlight = false; _armRefresh(generation); diff --git a/mobile/test/features/channels/add_members_sheet_names_test.dart b/mobile/test/features/channels/add_members_sheet_names_test.dart index 59c956798e..1a7e90e683 100644 --- a/mobile/test/features/channels/add_members_sheet_names_test.dart +++ b/mobile/test/features/channels/add_members_sheet_names_test.dart @@ -1,14 +1,20 @@ +import 'dart:async'; + import 'package:buzz/features/channels/add_members_sheet.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile_discovery_fetch.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile_proof.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/recipient_directory_session.dart'; +import '../../shared/verified_profiles/verified_profile_fixture.dart'; class _RecordingUserCache extends UserCacheNotifier { final preloaded = {}; @@ -26,6 +32,31 @@ class _RecordingUserCache extends UserCacheNotifier { } } +/// A relay whose profile search finds [heads] alone: people with a verified +/// profile and no kind:0. +class _VerifiedOnlySession extends RecipientDirectorySession { + _VerifiedOnlySession(this.heads) : super(const {}); + + final List heads; + + @override + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), + }) async => heads; +} + +class _Actions extends Fake implements ChannelActions { + final calls = >[]; + @override + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async => calls.add(pubkeys); +} + void main() { testWidgets('compares with every member but loads only shown choices', ( tester, @@ -78,4 +109,85 @@ void main() { // Only the shown choice's profile is requested, not the whole roster. expect(cache.preloaded, {agent}); }); + + testWidgets( + 'finds a verified person with no profile by full name once discovery ends', + (tester) async { + // The real shape: the display name is the username. + final gabi = signedProjection( + profileTags( + subjectA, + name: 'glilienfeld', + username: 'glilienfeld', + fullName: 'Gabi Lilienfeld', + ), + ); + // Neither of these finds anyone: another key's head, and a head that + // claims the relay key with a signature it never made. + final otherKey = signedProjection( + profileTags(subjectB, name: 'gabi_other'), + author: relayKeysOther(), + ); + final claimed = signedProjection( + profileTags('c' * 64, name: 'gabi_forged'), + ); + final forged = NostrEvent.fromJson({ + ...claimed.toJson(), + 'sig': '0' * 128, + }); + final discovery = Completer(); + final actions = _Actions(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(_RecordingUserCache.new), + channelActionsProvider.overrideWithValue(actions), + channelMembersProvider('channel').overrideWith((ref) async => []), + relaySessionProvider.overrideWith( + () => _VerifiedOnlySession([gabi, otherKey, forged]), + ), + verifiedProfileDiscoveryFetchProvider.overrideWith( + (ref) => + () => discovery.future, + ), + verifiedProfileProofCheckerProvider.overrideWithValue( + (events) async => [ + for (final e in events) hasVerifiedEventProof(e), + ], + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: AddChannelMembersSheet( + channelId: 'channel', + existingPubkeys: {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('add-channel-members-search')), + 'gabi', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + // The search waits for the relay to say who signs verified profiles. + expect(find.text('glilienfeld'), findsNothing); + discovery.complete(VerifiedProfilesEnabled(authority)); + await tester.pumpAndSettle(); + + expect(find.text('glilienfeld'), findsOneWidget); + expect(find.textContaining('gabi_'), findsNothing); + await tester.tap(find.text('glilienfeld')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add member')); + await tester.pumpAndSettle(); + expect(actions.calls, [ + [subjectA], + ]); + }, + ); } diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index d7ff45f64a..be61754e02 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -6,6 +6,10 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile.dart'; + +import '../../shared/verified_profiles/verified_profile_fixture.dart'; + /// Tests for [channelDetailsFromEvent]. /// /// The function maps a kind:39000 metadata event to [ChannelDetails], and is @@ -141,47 +145,59 @@ void main() { }); test('builds an alphabetized directory from the latest profile events', () { - final users = directoryUsersFromProfileEvents([ - NostrEvent( - id: 'alice-old', - pubkey: 'alice', - createdAt: 10, - kind: 0, - tags: const [], - content: '{"display_name":"Zoe"}', - sig: 'sig', - ), - NostrEvent( - id: 'bob', - pubkey: 'bob', - createdAt: 20, - kind: 0, - tags: const [], - content: '{"display_name":"Bob"}', - sig: 'sig', - ), - NostrEvent( - id: 'alice-new', - pubkey: 'ALICE', - createdAt: 30, - kind: 0, - tags: const [], - content: - '{"display_name":"Alice","picture":"https://example.com/alice.png"}', - sig: 'sig', - ), - NostrEvent( - id: 'not-a-profile', - pubkey: 'charlie', - createdAt: 40, - kind: 1, - tags: const [], - content: '{}', - sig: 'sig', - ), - ]); + final users = directoryUsersFromSearch( + searchedPeople([ + NostrEvent( + id: 'alice-old', + pubkey: 'alice', + createdAt: 10, + kind: 0, + tags: const [], + content: '{"display_name":"Zoe"}', + sig: 'sig', + ), + NostrEvent( + id: 'bob', + pubkey: 'bob', + createdAt: 20, + kind: 0, + tags: const [], + content: '{"display_name":"Bob"}', + sig: 'sig', + ), + NostrEvent( + id: 'alice-new', + pubkey: 'ALICE', + createdAt: 30, + kind: 0, + tags: const [], + content: + '{"display_name":"Alice","picture":"https://example.com/alice.png"}', + sig: 'sig', + ), + NostrEvent( + id: 'not-a-profile', + pubkey: 'charlie', + createdAt: 40, + kind: 1, + tags: const [], + content: '{}', + sig: 'sig', + ), + // Someone with no kind:0 is listed by the relay's verified head. + signedProjection(profileTags(subjectA, name: 'Gabi Lilienfeld')), + signedProjection( + profileTags(subjectB, name: 'Forged'), + author: relayKeysOther(), + ), + ], authority), + ); - expect(users.map((user) => user.label), ['Alice', 'Bob']); + expect(users.map((user) => user.label), [ + 'Alice', + 'Bob', + 'Gabi Lilienfeld', + ]); expect(users.first.pubkey, 'alice'); expect(users.first.avatarUrl, 'https://example.com/alice.png'); }); diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index f048237c65..f065bb7dd3 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -832,7 +832,12 @@ void main() { 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')]), + jsonEncode([ + profileJson(0, 'wes'), + profileJson(1, 'note'), + // A person with no kind:0 is found by their verified head. + profileJson(30382, 'head'), + ]), 200, ), ); @@ -841,7 +846,7 @@ void main() { .read(relaySessionProvider.notifier) .searchProfiles(' wes ', limit: 8); - expect(events.map((event) => event.kind), [0]); + expect(events.map((event) => event.kind), [0, 30382]); expect(h.requests, hasLength(1)); final request = h.requests.single; expect(request.method, 'POST'); diff --git a/mobile/test/shared/verified_profiles/verified_profile_test.dart b/mobile/test/shared/verified_profiles/verified_profile_test.dart index 73eb0f128c..8b4a3520c5 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_test.dart @@ -203,4 +203,52 @@ void main() { expect(hasVerifiedEventProof(tampered), isFalse); }); }); + + test('a profile search finds people by kind:0, or by the relay\'s head', () { + NostrEvent profile(String pubkey, String name, int createdAt) => NostrEvent( + id: '$pubkey$createdAt', + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: const [], + content: '{"name":"$name"}', + sig: 'sig', + ); + final c = 'c' * 64, d = 'd' * 64, e = 'e' * 64, f = 'f' * 64; + final events = [ + // A head-only person keeps the relay's rank. + signedProjection(profileTags(c, name: 'Gabi Lilienfeld')), + // A kind:0 wins over the head; the newest kind:0 counts. + profile(subjectA, 'old', 1), + signedProjection(profileTags(subjectA)), + profile(subjectA, 'Ada', 2), + // These find nobody: another key's head, a retraction, a bad subject. + signedProjection(profileTags(d), author: relayKeysOther()), + signedProjection(retractionTags(e)), + signedProjection(profileTags('F' * 64)), + profile(subjectB, 'Bea', 1), + NostrEvent( + id: f, + pubkey: f, + createdAt: 1, + kind: 1, + tags: const [], + content: 'note', + sig: 'sig', + ), + ]; + String row(SearchedPerson person) => + '${person.pubkey[0]}:' + '${person.verified?.displayName ?? 'kind0 ${person.profile!.content}'}'; + expect(searchedPeople(events, authority).map(row), [ + 'c:Gabi Lilienfeld', + 'a:kind0 {"name":"Ada"}', + 'b:kind0 {"name":"Bea"}', + ]); + // Without a known authority, no head finds anyone. + expect(searchedPeople(events, null).map(row), [ + 'a:kind0 {"name":"Ada"}', + 'b:kind0 {"name":"Bea"}', + ]); + }); }