mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"}',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user