feat(mobile): add the contextual identity-name resolver (#7894)

🤖
## Summary

When two identities share a display name, Buzz mobile shows the same
label for both. A reader cannot tell a person from an agent, or one
person's agent from another's. For example, a channel can show three
agents and one human all named "murderbot".

This PR adds the naming rules that fix this, without changing any screen
yet. It ports the contextual identity-name contract (v1) from the
desktop app to Dart. Given the identities shown together, the resolver
gives each public key a distinct label:

- A human keeps the plain name. Agents that share it get a qualifier.
- The viewer's own agents are named before other people's agents.
- An agent is qualified by its owner's name first ("Wes's Honey"). A
short public-key suffix is added only if that is still ambiguous
("baxen's murderbot · xa8v").

It also adds two providers that screens will use:

- `channelIdentityNamesProvider` compares names against a channel's
members, plus any referenced non-member such as an old author or an
outside mention.
- `watchIdentityNames` compares a set of identities shown together
outside a channel, and loads their profiles.

The next PRs in this stack connect these to the screens.

Part 1 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910 (default "Fizz" agents from different users collide in
one channel).

### Testing

- The contract's 31 portable fixture cases run against the production
resolver. The fixture file is copied unchanged from the contract, so
reviewers can skip it.
- Unit tests cover owner and suffix qualification, the viewer's own
identities, and invalid public keys.
- Provider tests check that a channel keeps its names while its roster
reloads, that an offline cached bot still counts as an agent, and that
changing relay or account never shows the old roster.
- `flutter analyze` is clean.

No UI change, so there are no screenshots.

---------

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-09-29 12:21:14 -04:00
committed by GitHub
parent bba75a71eb
commit efe04288ea
9 changed files with 2824 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
/// Contextual identity labels for one channel, thread, or DM.
///
/// The comparison context is the channel's members; a referenced non-member
/// (historical author, outside mention) is compared against the members plus
/// itself. Watch one label with
/// `ref.watch(channelIdentityNamesProvider(id).select((n) => n.labelFor(pk)))`.
final channelIdentityNamesProvider = Provider.autoDispose
.family<IdentityNames, String>((ref, channelId) {
final sources = ref.watch(identityNameSourcesProvider);
final members =
watchLatestChannelMembers(ref, channelId) ??
(ref.watch(channelsProvider).asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId));
final names = sources.scope(
[for (final member in members) member.pubkey],
// The roster's own bot roles stay available offline, when the
// relay-backed bot lookup and agent directory are empty.
agentPubkeys: {
...ref.watch(agentMentionPubkeysProvider(channelId)),
for (final member in members)
if (member.isBot) member.pubkey,
},
fallbackNames: {
for (final member in members) member.pubkey: ?member.displayName,
},
);
loadIdentityNameOwners(ref, sources, names.candidates);
return names;
});
/// Watches one identity's contextual label in [channelId].
String watchChannelIdentityLabel(
WidgetRef ref,
String channelId,
String pubkey,
) => ref.watch(
channelIdentityNamesProvider(
channelId,
).select((names) => names.labelFor(pubkey)),
);
/// Watches contextual labels for [pubkeys] in [channelId], keyed by
/// lowercase pubkey. Rebuilds only when one of these labels changes.
Map<String, String> watchChannelIdentityLabels(
WidgetRef ref,
String channelId,
Iterable<String> pubkeys,
) {
final keys = {for (final pubkey in pubkeys) pubkey.toLowerCase()};
if (keys.isEmpty) return const {};
return ref.watch(
channelIdentityNamesProvider(channelId).select(
(names) => _LabelMap({for (final key in keys) key: names.labelFor(key)}),
),
);
}
class _LabelMap extends UnmodifiableMapView<String, String> {
_LabelMap(super.labels);
@override
bool operator ==(Object other) =>
other is Map<String, String> && mapEquals(this, other);
@override
int get hashCode => Object.hashAllUnordered(
entries.map((entry) => Object.hash(entry.key, entry.value)),
);
}

View File

@@ -586,6 +586,24 @@ final channelMembersProvider = FutureProvider.autoDispose
return members;
});
/// The latest roster for [channelId] in the current relay, account, and
/// channel scope, or null if none has loaded in that scope yet.
///
/// While [channelMembersProvider] reloads (for example after a kind:39002
/// update), this keeps the roster from the last completed load in the same
/// scope instead of dropping it. Another relay or account never sees it.
List<ChannelMember>? watchLatestChannelMembers(Ref ref, String channelId) {
final loaded = ref.watch(channelMembersProvider(channelId)).asData?.value;
if (loaded != null) return loaded;
return ref
.read(_channelMembersSnapshotCacheProvider)
.read(
relayBaseUrl: ref.watch(relayConfigProvider).baseUrl,
pubkey: ref.watch(myPubkeyProvider)?.toLowerCase(),
channelId: channelId,
);
}
/// Channel canvas (kind:40100 for the channel).
final channelCanvasProvider = FutureProvider.family<ChannelCanvas, String>((
ref,

View File

@@ -0,0 +1,239 @@
import 'package:flutter/foundation.dart';
import '../utils/string_utils.dart';
/// Pinned version of the portable identity-name contract implemented here.
///
/// Spec and fixtures:
/// https://github.com/block/buzz-app/blob/92c2fb2/src/bundled/identity-naming/README.md
const identityNamePolicyVersion = 1;
/// One naming fact for an identity. [pubkey] and [ownerPubkey] must be valid
/// 64-character hex public keys (either case); callers choose names and
/// fallbacks first.
@immutable
class NamingIdentity {
final String pubkey;
final String name;
final bool isAgent;
final String? ownerPubkey;
const NamingIdentity({
required this.pubkey,
required this.name,
this.isAgent = false,
this.ownerPubkey,
});
@override
bool operator ==(Object other) =>
other is NamingIdentity &&
other.pubkey == pubkey &&
other.name == name &&
other.isAgent == isAgent &&
other.ownerPubkey == ownerPubkey;
@override
int get hashCode => Object.hash(pubkey, name, isAgent, ownerPubkey);
}
/// A resolved display label. [qualifier] is only the key suffix, when added.
@immutable
class ResolvedIdentityName {
final String name;
final String? qualifier;
const ResolvedIdentityName(this.name, [this.qualifier]);
@override
bool operator ==(Object other) =>
other is ResolvedIdentityName &&
other.name == name &&
other.qualifier == qualifier;
@override
int get hashCode => Object.hash(name, qualifier);
@override
String toString() => 'ResolvedIdentityName($name, $qualifier)';
}
// ECMAScript String.prototype.trim code points, as the contract requires.
// Dart's String.trim also removes U+0085, which the contract keeps.
bool _isContractWhitespace(int unit) =>
(unit >= 0x09 && unit <= 0x0D) ||
unit == 0x20 ||
unit == 0xA0 ||
unit == 0x1680 ||
(unit >= 0x2000 && unit <= 0x200A) ||
unit == 0x2028 ||
unit == 0x2029 ||
unit == 0x202F ||
unit == 0x205F ||
unit == 0x3000 ||
unit == 0xFEFF;
/// Trims [value] exactly as the identity-name contract specifies.
String trimIdentityName(String value) {
var start = 0;
var end = value.length;
while (start < end && _isContractWhitespace(value.codeUnitAt(start))) {
start++;
}
while (end > start && _isContractWhitespace(value.codeUnitAt(end - 1))) {
end--;
}
return value.substring(start, end);
}
final RegExp _hexKey = RegExp(r'^[0-9a-fA-F]{64}$');
void _requireKey(String key, String name) {
if (!_hexKey.hasMatch(key)) {
throw ArgumentError.value(key, name, 'not a 64-character hex public key');
}
}
class _Row {
final String key;
final NamingIdentity identity;
final String original;
final int priority;
final bool mine;
String base;
String label;
int length = 0;
String? suffix;
_Row({
required this.key,
required this.identity,
required this.original,
required this.priority,
required this.mine,
}) : base = original,
label = original;
}
/// Resolves distinct display labels for the selected identities.
///
/// Display policy only: a readable owner or key suffix grants no authority.
/// Implements contextual identity names v1 (see [identityNamePolicyVersion]).
/// Omitting [candidates] selects every supplied fact; an empty set selects
/// none.
///
/// Every supplied key — each fact's [NamingIdentity.pubkey] and
/// [NamingIdentity.ownerPubkey], [viewer], and each candidate — must be a
/// valid public key. The contract leaves invalid keys to the caller, so this
/// throws [ArgumentError] before resolving anything rather than returning a
/// label for a value that is not an identity.
Map<String, ResolvedIdentityName> resolveIdentityNames(
List<NamingIdentity> identities, {
String? viewer,
Iterable<String>? candidates,
}) {
for (final identity in identities) {
_requireKey(identity.pubkey, 'pubkey');
if (identity.ownerPubkey case final owner?) {
_requireKey(owner, 'ownerPubkey');
}
}
if (viewer != null) _requireKey(viewer, 'viewer');
final selected = candidates?.map((key) {
_requireKey(key, 'candidates');
return key.toLowerCase();
}).toSet();
final normalizedViewer = viewer?.toLowerCase();
// Last fact per key: the preferred alias and the owner-lookup name.
final preferred = <String, NamingIdentity>{
for (final identity in identities) identity.pubkey.toLowerCase(): identity,
};
// One working row per (key, trimmed name); the last fact supplies metadata.
final aliases = <(String, String), NamingIdentity>{};
for (final identity in identities) {
final key = (
identity.pubkey.toLowerCase(),
trimIdentityName(identity.name),
);
aliases[key] = identity;
}
final rows = <_Row>[
for (final MapEntry(key: (key, name), value: identity) in aliases.entries)
if (selected == null || selected.contains(key))
() {
final owner = identity.ownerPubkey?.toLowerCase();
final mine =
normalizedViewer != null &&
(key == normalizedViewer || owner == normalizedViewer);
return _Row(
key: key,
identity: identity,
original: name,
mine: mine,
priority: identity.isAgent
? (mine ? 2 : 3)
: (key == normalizedViewer ? 0 : 1),
);
}(),
];
final npubs = <String, String>{};
String npubFor(String key) => npubs[key] ??= fullNpub(key)!;
while (true) {
final groups = <String, List<_Row>>{};
for (final row in rows) {
(groups[row.label] ??= []).add(row);
}
final collisions = [
for (final group in groups.values)
if (group.map((row) => row.key).toSet().length > 1) group,
];
if (collisions.isEmpty) break;
for (final group in collisions) {
final best = group
.map((row) => row.priority)
.reduce((a, b) => a < b ? a : b);
final winners = {
for (final row in group)
if (row.priority == best) row.key,
};
final changing = [
for (final row in group)
if (winners.length != 1 || !winners.contains(row.key)) row,
];
final groupHasHuman = group.any((row) => !row.identity.isAgent);
var qualified = false;
for (final row in changing) {
if (!row.identity.isAgent || row.length != 0) continue;
final ownerKey = row.identity.ownerPubkey?.toLowerCase();
final ownerFact = ownerKey == null ? null : preferred[ownerKey];
final owner = ownerFact == null ? '' : trimIdentityName(ownerFact.name);
final readable = !row.mine && owner.isNotEmpty
? '$owner\u2019s ${row.original}'
: groupHasHuman
? '${row.original} (agent)'
: row.base;
if (readable != row.base) {
row.base = readable;
row.label = readable;
qualified = true;
}
}
if (qualified) continue;
for (final row in changing) {
row.length = row.length == 0 ? 4 : row.length + 1;
final npub = npubFor(row.key);
row.suffix = row.length <= npub.length
? npub.substring(npub.length - row.length)
: '$npub \u00b7 ${row.length - npub.length}';
row.label = '${row.base} \u00b7 ${row.suffix}';
}
}
}
return {
for (final row in rows)
if (row.original == trimIdentityName(preferred[row.key]!.name))
row.key: ResolvedIdentityName(row.label, row.suffix),
};
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/foundation.dart';
import '../profile/user_profile.dart';
import '../utils/string_utils.dart';
import 'identity_name_policy.dart';
final RegExp _hexPubkey = RegExp(r'^[0-9a-f]{64}$');
/// Immutable naming facts known to this client, independent of any view.
///
/// Mobile's owner hint is the verified NIP-OA owner from the profile `auth`
/// tag (or agent directory). It is display ranking only, never authority.
@immutable
class IdentityNameSources {
final Map<String, UserProfile> profiles;
final Set<String> agentPubkeys;
final Map<String, String> agentDisplayNames;
final Map<String, String> agentOwners;
final String? viewer;
const IdentityNameSources({
this.profiles = const {},
this.agentPubkeys = const {},
this.agentDisplayNames = const {},
this.agentOwners = const {},
this.viewer,
});
static const empty = IdentityNameSources();
/// The naming fact for [pubkey], or null when it is not a valid key.
///
/// Name: non-blank profile display name, then agent-directory name, then
/// [fallbackName], then the compact npub. The resolver trims the name.
NamingIdentity? factFor(
String pubkey, {
String? fallbackName,
bool isAgent = false,
}) {
final key = pubkey.toLowerCase();
if (!_hexPubkey.hasMatch(key)) return null;
final profile = profiles[key];
final owner = (profile?.ownerPubkey ?? agentOwners[key])?.toLowerCase();
return NamingIdentity(
pubkey: key,
name:
_nonBlank(profile?.displayName) ??
_nonBlank(agentDisplayNames[key]) ??
_nonBlank(fallbackName) ??
shortPubkey(key),
isAgent: isAgent || owner != null || agentPubkeys.contains(key),
ownerPubkey: owner != null && _hexPubkey.hasMatch(owner) ? owner : null,
);
}
/// Owner keys of [candidates] whose profile is not cached yet. Callers load
/// them so readable owner prefixes can appear; none are invented meanwhile.
Set<String> missingOwnerProfiles(Iterable<String> candidates) => candidates
.map((key) => factFor(key)?.ownerPubkey)
.nonNulls
.where((owner) => !profiles.containsKey(owner))
.toSet();
/// Owner lookup fact: only a real, non-blank owner profile name.
NamingIdentity? _ownerFact(String owner) {
final name = _nonBlank(profiles[owner]?.displayName);
return name == null ? null : NamingIdentity(pubkey: owner, name: name);
}
/// Resolves labels for one view. [candidates] is the view's comparison
/// context (for example channel members); [agentPubkeys] adds view-local
/// agent roles such as channel bots; [fallbackNames] supplies view-local
/// names used only when no profile or directory name exists.
IdentityNames scope(
Iterable<String> candidates, {
Set<String> agentPubkeys = const {},
Map<String, String> fallbackNames = const {},
}) => IdentityNames._(
this,
{
for (final key in candidates)
if (_hexPubkey.hasMatch(key.toLowerCase())) key.toLowerCase(),
},
{for (final key in agentPubkeys) key.toLowerCase()},
{
for (final MapEntry(:key, :value) in fallbackNames.entries)
key.toLowerCase(): value,
},
);
}
/// Contextual display labels for one view's comparison context.
///
/// Labels are presentation only: keep the exact key for navigation, mentions,
/// and every identity-bound action. A key outside the context is resolved
/// against the context plus that key, as the contract requires for
/// historical or non-member references.
class IdentityNames {
final IdentityNameSources _sources;
final Set<String> _candidates;
final Set<String> _agentPubkeys;
final Map<String, String> _fallbackNames;
final Map<String, ResolvedIdentityName?> _outside = {};
late final Map<String, ResolvedIdentityName> _members = _resolve(_candidates);
IdentityNames._(
this._sources,
this._candidates,
this._agentPubkeys,
this._fallbackNames,
);
/// The comparison context's lowercase keys.
Set<String> get candidates => _candidates;
/// The resolved label and qualifier for [pubkey], or null for an invalid key.
ResolvedIdentityName? resolve(String pubkey) {
final key = pubkey.toLowerCase();
if (_candidates.contains(key)) return _members[key];
return _outside.putIfAbsent(key, () {
if (!_hexPubkey.hasMatch(key)) return null;
return _resolve({..._candidates, key})[key];
});
}
/// The display label for [pubkey]. A malformed key is outside the naming
/// contract: it keeps its plain known name and is never disambiguated.
String labelFor(String pubkey) =>
resolve(pubkey)?.name ?? _plainName(pubkey.toLowerCase());
String _plainName(String key) =>
_nonBlank(_sources.profiles[key]?.displayName)?.trim() ??
_nonBlank(_sources.agentDisplayNames[key])?.trim() ??
_nonBlank(_fallbackNames[key])?.trim() ??
shortPubkey(key);
Map<String, ResolvedIdentityName> _resolve(Set<String> candidates) {
final facts = <NamingIdentity>[];
final owners = <String>{};
for (final key in candidates) {
final fact = _sources.factFor(
key,
fallbackName: _fallbackNames[key],
isAgent: _agentPubkeys.contains(key),
);
if (fact == null) continue;
facts.add(fact);
if (fact.ownerPubkey case final owner? when !candidates.contains(owner)) {
owners.add(owner);
}
}
// Owners outside the context are lookup facts, not collision candidates;
// placing them first keeps a candidate's own fact preferred.
final ownerFacts = [
for (final owner in owners) ?_sources._ownerFact(owner),
];
return resolveIdentityNames(
[...ownerFacts, ...facts],
viewer: _sources.viewer,
candidates: candidates,
);
}
}
String? _nonBlank(String? value) {
if (value == null) return null;
final trimmed = trimIdentityName(value);
return trimmed.isEmpty ? null : value;
}

View File

@@ -0,0 +1,66 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../mentions/agent_identity_provider.dart';
import '../profile/user_cache_provider.dart';
import '../relay/relay.dart';
import 'identity_names.dart';
/// Client-wide naming facts. Views choose their own comparison context with
/// [IdentityNameSources.scope]; never resolve against this whole cache.
final identityNameSourcesProvider = Provider<IdentityNameSources>((ref) {
return IdentityNameSources(
profiles: ref.watch(userCacheProvider),
agentPubkeys: ref.watch(knownAgentPubkeysProvider),
agentDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentOwners: ref.watch(agentOwnersProvider).asData?.value ?? const {},
viewer: ref.watch(myPubkeyProvider)?.toLowerCase(),
);
});
/// Requests missing owner profiles for [names] after the current build.
void loadIdentityNameOwners(
Ref ref,
IdentityNameSources sources,
Iterable<String> candidates,
) {
final missing = sources.missingOwnerProfiles(candidates);
if (missing.isEmpty) return;
Future.microtask(() {
if (ref.mounted) {
ref.read(userCacheProvider.notifier).preload(missing.toList());
}
});
}
/// Labels for a displayed collection with no channel context, such as search
/// results, a Pulse timeline, or a picker's choices. [candidates] is that
/// collection; keys outside it resolve against it plus themselves.
IdentityNames watchIdentityNames(
WidgetRef ref,
Iterable<String> candidates, {
Set<String> agentPubkeys = const {},
Map<String, String> fallbackNames = const {},
}) {
final sources = ref.watch(identityNameSourcesProvider);
final names = sources.scope(
candidates,
agentPubkeys: agentPubkeys,
fallbackNames: fallbackNames,
);
// A displayed identity's profile carries its owner hint, so load uncached
// candidates as well as their owners; channels load member profiles
// elsewhere, but a displayed collection has no other loader.
final missing = {
for (final key in names.candidates)
if (!sources.profiles.containsKey(key)) key,
...sources.missingOwnerProfiles(names.candidates),
};
if (missing.isNotEmpty) {
Future.microtask(() {
if (ref.context.mounted) {
ref.read(userCacheProvider.notifier).preload(missing.toList());
}
});
}
return names;
}

View File

@@ -0,0 +1,233 @@
import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_identity_names_provider.dart';
import 'package:buzz/features/channels/channels_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:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const _channelId = '11111111-1111-4111-8111-111111111111';
final _human = 'c' * 64;
final _bot = 'd' * 64;
void main() {
late _MembershipRelaySession session;
late ProviderContainer container;
setUp(() {
session = _MembershipRelaySession();
container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new),
myPubkeyProvider.overrideWith((ref) => ref.watch(_accountProvider)),
channelsProvider.overrideWith(_EmptyChannelsNotifier.new),
userCacheProvider.overrideWith(
() => _FixedUserCacheNotifier({
_human: UserProfile(pubkey: _human, displayName: 'Honey'),
_bot: UserProfile(pubkey: _bot, displayName: 'Honey'),
}),
),
],
);
addTearDown(container.dispose);
});
ProviderSubscription<Object?> listenNames() {
final subscription = container.listen(
channelIdentityNamesProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
return subscription;
}
List<String> labels() {
final names = container.read(channelIdentityNamesProvider(_channelId));
return [names.labelFor(_human), names.labelFor(_bot)];
}
Future<void> settle() async {
for (var i = 0; i < 5; i++) {
await container.pump();
await Future<void>.delayed(Duration.zero);
}
}
test('keeps the same-scope roster while membership reloads', () async {
listenNames();
await settle();
expect(labels(), ['Honey', 'Honey (agent)']);
expect(session.membershipEvents, isNotNull);
// A live kind:39002 update reloads the roster. Hold that reload open.
final reload = session.holdMemberFetches();
session.membershipEvents!(_membersEvent());
await settle();
expect(session.heldMemberFetches, isPositive);
expect(labels(), ['Honey', 'Honey (agent)']);
reload.complete();
await settle();
expect(labels(), ['Honey', 'Honey (agent)']);
});
test('uses cached bot roles when the relay is offline', () async {
final subscription = listenNames();
await settle();
expect(labels(), ['Honey', 'Honey (agent)']);
// Unmount, go offline, and mount again from the cached roster. The
// relay-backed bot lookup and agent directory are empty while offline.
subscription.close();
await settle();
session.setStatus(SessionStatus.disconnected);
await settle();
listenNames();
await settle();
final names = container.read(channelIdentityNamesProvider(_channelId));
expect(names.candidates, {_human, _bot});
expect(labels(), ['Honey', 'Honey (agent)']);
});
for (final reset in _scopeResets.entries) {
test('drops the roster when the ${reset.key} changes', () async {
listenNames();
await settle();
expect(
container.read(channelIdentityNamesProvider(_channelId)).candidates,
{_human, _bot},
);
final reload = session.holdMemberFetches();
reset.value(container);
await settle();
expect(session.heldMemberFetches, isPositive);
expect(
container.read(channelIdentityNamesProvider(_channelId)).candidates,
isEmpty,
);
reload.complete();
await settle();
});
}
}
final _scopeResets = <String, void Function(ProviderContainer)>{
'relay': (container) => container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://second-community.example'),
'account': (container) =>
container.read(_accountProvider.notifier).set('e' * 64),
};
NostrEvent _membersEvent() => NostrEvent(
id: 'members',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: [
const ['d', _channelId],
['p', _human, '', 'member'],
['p', _bot, '', 'bot'],
],
content: '',
sig: 'sig',
);
final _accountProvider = NotifierProvider<_AccountNotifier, String?>(
_AccountNotifier.new,
);
class _AccountNotifier extends Notifier<String?> {
@override
String? build() => 'a' * 64;
void set(String pubkey) => state = pubkey;
}
class _TestRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
const RelayConfig(baseUrl: 'https://first-community.example');
@override
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
class _EmptyChannelsNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => const [];
}
class _FixedUserCacheNotifier extends UserCacheNotifier {
_FixedUserCacheNotifier(this._users);
final Map<String, UserProfile> _users;
@override
Map<String, UserProfile> build() => _users;
@override
Future<bool> preload(List<String> pubkeys) async => true;
}
/// Serves the channel's kind:39002 roster, can hold member fetches open, and
/// exposes the live membership subscription's event callback.
class _MembershipRelaySession extends RelaySessionNotifier {
void Function(NostrEvent)? membershipEvents;
Completer<void>? _hold;
int heldMemberFetches = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
void setStatus(SessionStatus status) => state = SessionState(status: status);
Completer<void> holdMemberFetches() => _hold = Completer<void>();
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (!(filter.kinds.contains(39002))) return const [];
if (_hold case final hold?) {
heldMemberFetches++;
await hold.future;
}
return [_membersEvent()];
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
@override
Future<void Function()> subscribeWithStatus(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
required void Function(RelaySubscriptionStatus status) onStatusChanged,
}) async {
if (filter.kinds.contains(39002)) membershipEvents = onEvent;
onStatusChanged(RelaySubscriptionStatus.ready);
return () {};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import 'dart:convert';
import 'dart:io';
import 'package:buzz/shared/identity_names/identity_name_policy.dart';
import 'package:flutter_test/flutter_test.dart';
// Vendored, unmodified copy of the portable fixtures pinned in
// identity_name_policy.dart. Expected results are stored literals.
const _fixturePath = 'test/shared/identity_names/identity-names.fixtures.json';
void main() {
final conformance =
jsonDecode(File(_fixturePath).readAsStringSync()) as Map<String, dynamic>;
final cases = (conformance['cases'] as List).cast<Map<String, dynamic>>();
test('validates the portable fixture version and non-empty case list', () {
expect(conformance['version'], identityNamePolicyVersion);
expect(cases, isNotEmpty);
});
for (final fixture in cases) {
test('conforms to the portable naming contract: ${fixture['name']}', () {
final identities = [
for (final raw
in (fixture['identities'] as List).cast<Map<String, dynamic>>())
NamingIdentity(
pubkey: raw['pubkey'] as String,
name: raw['name'] as String,
isAgent: raw['isAgent'] as bool? ?? false,
ownerPubkey: raw['ownerPubkey'] as String?,
),
];
final actual = resolveIdentityNames(
identities,
viewer: fixture['viewer'] as String?,
candidates: (fixture['candidates'] as List?)?.cast<String>(),
);
expect({
for (final MapEntry(:key, :value) in actual.entries)
key: {'name': value.name, 'qualifier': value.qualifier},
}, fixture['expected']);
});
}
test('trims only the contract whitespace set', () {
const key =
'1111111111111111111111111111111111111111111111111111111111111111';
// U+0085 is not ECMAScript whitespace, unlike Dart String.trim.
final result = resolveIdentityNames([
const NamingIdentity(pubkey: key, name: '\u0085Honey\u3000'),
]);
expect(result[key], const ResolvedIdentityName('\u0085Honey'));
});
group('rejects an invalid key before resolving', () {
const valid =
'1111111111111111111111111111111111111111111111111111111111111111';
test('for a fact that needs no suffix', () {
expect(
() => resolveIdentityNames(const [
NamingIdentity(pubkey: 'not-a-key', name: 'Unique'),
]),
throwsArgumentError,
);
});
test('for a colliding fact', () {
expect(
() => resolveIdentityNames(const [
NamingIdentity(pubkey: 'not-a-key', name: 'Honey'),
NamingIdentity(pubkey: valid, name: 'Honey'),
]),
throwsArgumentError,
);
});
test('for an owner key', () {
expect(
() => resolveIdentityNames(const [
NamingIdentity(
pubkey: valid,
name: 'Scout',
isAgent: true,
ownerPubkey: 'not-a-key',
),
]),
throwsArgumentError,
);
});
test('for the viewer', () {
expect(
() => resolveIdentityNames(const [
NamingIdentity(pubkey: valid, name: 'Honey'),
], viewer: 'not-a-key'),
throwsArgumentError,
);
});
test('for a candidate', () {
expect(
() => resolveIdentityNames(
const [NamingIdentity(pubkey: valid, name: 'Honey')],
candidates: const [valid, 'not-a-key'],
),
throwsArgumentError,
);
});
test('for a 64-character value that is not hex', () {
expect(
() => resolveIdentityNames([
NamingIdentity(pubkey: 'g' * 64, name: 'Unique'),
]),
throwsArgumentError,
);
});
test('but accepts an upper-case hex key', () {
expect(
resolveIdentityNames([
NamingIdentity(pubkey: valid.replaceAll('1', 'A'), name: 'Honey'),
]),
{valid.replaceAll('1', 'a'): const ResolvedIdentityName('Honey')},
);
});
});
}

View File

@@ -0,0 +1,100 @@
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:flutter_test/flutter_test.dart';
String _key(String digit) => digit * 64;
final _logan = _key('1');
final _wes = _key('2');
final _human = _key('c');
final _mine = _key('a');
final _wesAgent = _key('b');
IdentityNameSources _sources() => IdentityNameSources(
viewer: _logan,
profiles: {
_logan: UserProfile(pubkey: _logan, displayName: 'Logan'),
_wes: UserProfile(pubkey: _wes, displayName: 'Wes'),
_human: UserProfile(pubkey: _human, displayName: 'Honey'),
_mine: UserProfile(
pubkey: _mine,
displayName: 'Honey',
ownerPubkey: _logan,
),
_wesAgent: UserProfile(
pubkey: _wesAgent,
displayName: 'Honey',
ownerPubkey: _wes,
),
},
);
void main() {
test('channel context: human, then mine, then owner-qualified others', () {
// Wes is not a member: his profile is only an owner lookup fact.
final names = _sources().scope([_logan, _human, _mine, _wesAgent]);
expect(names.labelFor(_human), 'Honey');
expect(names.labelFor(_mine), 'Honey (agent)');
expect(names.labelFor(_wesAgent), 'Wes’s Honey');
expect(names.labelFor(_logan), 'Logan');
});
test('a non-member reference is compared with the members plus itself', () {
final names = _sources().scope([_human]);
// Alone, Logan's agent is plain; against the human it is marked.
expect(_sources().scope([_mine]).labelFor(_mine), 'Honey');
expect(names.labelFor(_mine), 'Honey (agent)');
expect(names.labelFor(_human), 'Honey');
});
test('profiles outside the context do not create collisions', () {
final names = _sources().scope([_wesAgent]);
expect(names.labelFor(_wesAgent), 'Honey');
});
test('view-local agent roles and fallback names become facts', () {
final bot = _key('d');
final names = IdentityNameSources(
profiles: {_human: UserProfile(pubkey: _human, displayName: 'Honey')},
).scope([_human, bot], agentPubkeys: {bot}, fallbackNames: {bot: 'Honey'});
expect(names.labelFor(bot), 'Honey (agent)');
expect(names.labelFor(_human), 'Honey');
});
test(
'blank names fall back to the compact npub; invalid keys stay plain',
() {
final names = IdentityNameSources(
profiles: {
_human: UserProfile(pubkey: _human, displayName: ' '),
'legacy': const UserProfile(pubkey: 'legacy', displayName: 'Honey'),
},
).scope([_human, 'not-a-key', 'legacy']);
expect(names.candidates, {_human});
expect(names.labelFor(_human), shortPubkey(_human));
expect(names.labelFor('not-a-key'), unknownIdentityLabel);
// A malformed key is not an identity fact: plain name, no qualifier.
expect(names.labelFor('legacy'), 'Honey');
},
);
test('reports owner profiles to load, without inventing owner names', () {
final sources = IdentityNameSources(
viewer: _logan,
profiles: {
_human: UserProfile(pubkey: _human, displayName: 'Honey'),
_wesAgent: UserProfile(
pubkey: _wesAgent,
displayName: 'Honey',
ownerPubkey: _wes,
),
},
);
expect(sources.missingOwnerProfiles([_human, _wesAgent]), {_wes});
expect(
sources.scope([_human, _wesAgent]).labelFor(_wesAgent),
'Honey (agent)',
);
});
}