mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
239
mobile/lib/shared/identity_names/identity_name_policy.dart
Normal file
239
mobile/lib/shared/identity_names/identity_name_policy.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
169
mobile/lib/shared/identity_names/identity_names.dart
Normal file
169
mobile/lib/shared/identity_names/identity_names.dart
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 () {};
|
||||
}
|
||||
}
|
||||
1787
mobile/test/shared/identity_names/identity-names.fixtures.json
Normal file
1787
mobile/test/shared/identity_names/identity-names.fixtures.json
Normal file
File diff suppressed because it is too large
Load Diff
129
mobile/test/shared/identity_names/identity_name_policy_test.dart
Normal file
129
mobile/test/shared/identity_names/identity_name_policy_test.dart
Normal 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')},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
mobile/test/shared/identity_names/identity_names_test.dart
Normal file
100
mobile/test/shared/identity_names/identity_names_test.dart
Normal 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)',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user