fix(mobile): find verified people who have no profile (#9086)

## 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 |
| --- | --- |
| <img width="300" alt="jrivera is found next to Jordan Park"
src="https://github.com/user-attachments/assets/d93b77b1-1bc7-4d09-afc1-f359616a192a"
/> | <img width="300" alt="jrivera is selected to add"
src="https://github.com/user-attachments/assets/ce6c7a71-b098-434e-8b0e-ed0de8d1a5a2"
/> |

## 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 <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Johnson
2026-11-05 18:53:40 -05:00
committed by GitHub
parent 66c3d6917d
commit c39ee507b2
11 changed files with 373 additions and 88 deletions

View File

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

View File

@@ -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<String> relayMemberPubkeysFromEvents(List<NostrEvent> 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<DirectoryUser> directoryUsersFromProfileEvents(
List<NostrEvent> events, {
List<DirectoryUser> directoryUsersFromSearch(
List<SearchedPerson> people, {
bool alphabetize = true,
}) {
final latestByPubkey = <String, NostrEvent>{};
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) {

View File

@@ -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 = <String, NostrEvent>{};
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<MentionCandidate> _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<UserProfile> verifiedMentionMatches(
pubkey: key,
name: value.displayName,
label: value.displayName,
aliases: [value.displayName],
aliases: [value.displayName, ...verifiedAliases(value)],
member: false,
),
], query).isNotEmpty)

View File

@@ -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,
],
);
}

View File

@@ -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<SessionState> {
/// 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<List<NostrEvent>> searchProfiles(
String query, {
int limit = 50,
@@ -191,7 +194,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
}, 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;

View File

@@ -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<String> 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<SearchedPerson> searchedPeople(
List<NostrEvent> events,
VerifiedProfileAuthority? authority,
) {
final order = <String>{};
final profiles = <String, NostrEvent>{};
final heads = <String, NostrEvent>{};
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),
];
}

View File

@@ -75,6 +75,7 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
bool _discoveryFresh = false;
bool _connected = false;
bool _discoveryInFlight = false;
Completer<void>? _discoveryDone;
bool _scheduled = false;
int _generation = 0;
int _interest = 0;
@@ -128,6 +129,45 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
_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<List<SearchedPerson>> searched(List<NostrEvent> 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<List<SearchedPerson>> _searchedWithHeads(
List<NostrEvent> events,
List<NostrEvent> 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<NostrEvent>.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<String> pubkeys) {
final valid = RegExp(r'^[0-9a-f]{64}$');
@@ -190,6 +230,7 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
Future<void> _refreshDiscovery(int generation) async {
if (_discoveryInFlight || generation != _generation || !_connected) return;
_discoveryInFlight = true;
final done = _discoveryDone = Completer<void>();
try {
final result = await ref.read(verifiedProfileDiscoveryFetchProvider)();
if (generation != _generation) return;
@@ -214,6 +255,8 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
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);

View File

@@ -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 = <String>{};
@@ -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<NostrEvent> heads;
@override
Future<List<NostrEvent>> searchProfiles(
String query, {
int limit = 50,
Duration timeout = const Duration(seconds: 8),
}) async => heads;
}
class _Actions extends Fake implements ChannelActions {
final calls = <List<String>>[];
@override
Future<void> addMembers({
required String channelId,
required List<String> 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<VerifiedProfileDiscovery>();
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],
]);
},
);
}

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -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"}',
]);
});
}