Files
buzz/mobile/lib/shared/mentions/agent_identity_provider.dart
Logan Johnson 82656ffea0 fix(mobile): standardize public-key identity display on npub (#7493)
🤖

## Summary

In the mobile app, anyone who hasn't set a display name shows up as a
raw 64-character hex key (e.g. `3a5d4f9c…`) — unreadable, and
unrecognizable as the same identity across screens. Profile and Settings
also let you copy that raw hex. Nostr public keys have a standard
readable form — `npub1…`, the same encoding other Nostr apps and our
desktop app already display. This PR makes every mobile identity surface
render npub instead:

- **Unnamed people everywhere** — message and thread authors, reactions,
typing indicators, member lists, channel details, DM headers and tiles,
inbox, search, forum cards, Pulse notes and reply context, mention
suggestions, and invite rows — now show a compact npub label: first 8 +
last 4 characters of the full npub joined by an ellipsis
(`npub1abcd…wxyz`), the same truncation desktop uses. Previously these
showed truncated raw hex.
- **DM fallback avatars and blank names** — 1:1 DM tiles and headers key
their fallback avatar to the same non-self counterpart the label names,
including self-first participant order; a self-DM keeps its
hex-key-derived initial. Blank or whitespace-only display names fall
back to the compact npub instead of rendering empty, while nonblank
authored names render verbatim (padding included).
- **Profile sheet → "Copy public key"** now copies the full canonical
npub — never raw hex. When the identity string isn't a valid public key,
the copy tile is disabled, so a malformed key never reaches the
clipboard.
- **Settings → Identity (pubkey)** displays and copies the full npub; an
invalid identity reads "Identity unavailable" with copy disabled.
- **Invalid identities never leak truncated raw hex** into the UI
anywhere — they render a neutral "Unknown identity" label.
- **Unchanged on purpose:** display names and verified handles (NIP-05 —
the `name@domain` badge) still render as before. Unnamed avatars keep
distinct per-key initials, derived from the underlying hex key rather
than the npub — otherwise every unnamed key would render the same "N"
initial. Event IDs are not public keys, so they keep their hex
truncation (in Pulse's "Replying to", the parent author shows npub while
an event-id fallback still shows hex). The nevent share link, private
keys, and internal hex storage are untouched. Inputs that accept a key
(invite/member entry) accept both hex and npub and keep working in hex
internally.

### Related issue

N/A. Searched open issues/PRs for npub identity display on mobile —
closest related: none found. Desktop's parallel npub standardization
lives in the stacked desktop PRs (#7488 foundation, #7489 controls,
#7495 display surfaces); this is the independent mobile slice (based
directly on `main`, not on those branches).

### Testing

At exact head `5a620e420a1fd57d9d8011ac26434eed32fcf765` (base: `main`
`44316ff72`; 40 files, +1,345/−154):

- Full mobile suite: 2,098 tests passing (`cd mobile && flutter test`);
`flutter analyze` clean; `dart format --set-exit-if-changed .` clean —
the same checks CI runs.
- Widget/unit coverage at production seams: compact labels and hex-keyed
avatar initials for DM headers/tiles, member rows, mention suggestions,
and Pulse reply context; DM fallback avatars keyed to the labeled
counterpart (self-first order and self-DMs); blank/whitespace
display-name npub fallback with nonblank authored labels verbatim,
including the Activity inbox sender and profile-sheet heading (each with
its own empty/whitespace production-seam regression); full-npub copy and
disabled-copy semantics in profile and settings; invalid-key
suppression; and hex↔npub input round-trips.

Verified via unit and widget tests — no device/simulator validation is
claimed.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:08:24 -04:00

323 lines
11 KiB
Dart

import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart';
/// A relay agent parsed from its kind:10100 agent-profile event.
///
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
/// channels it sits in.
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;
const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
});
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
final content = _tryDecodeJsonMap(event.content);
return AgentDirectoryEntry(
pubkey: event.pubkey.toLowerCase(),
displayName:
(content?['display_name'] as String?) ??
(content?['name'] as String?),
respondTo: content?['respond_to'] as String?,
respondToAllowlist: [
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
if (value is String) value.toLowerCase(),
],
channelIds: [
for (final value in (content?['channel_ids'] as List?) ?? [])
if (value is String) value,
],
);
}
}
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
/// Relay agent directory from kind:10100 agent-profile events.
///
/// Watches the session and only fetches after the WebSocket connects.
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.agentProfiles());
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
});
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in events) {
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
});
/// Pubkeys currently known to represent agents across the active relay.
///
/// Message surfaces that do not own channel membership can use this shared
/// identity source; channel features add their bot roles separately.
final knownAgentPubkeysProvider = Provider<Set<String>>((ref) {
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
return _AgentPubkeySet({
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
});
});
/// Directory display names keyed by agent pubkey for mention presentation.
final agentDirectoryDisplayNamesProvider = Provider<Map<String, String>>((ref) {
final agents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
return Map.unmodifiable({
for (final agent in agents)
if (agent.displayName?.trim().isNotEmpty == true)
agent.pubkey.toLowerCase(): agent.displayName!.trim(),
});
});
/// Adds channel bot roles to relay-wide agent identities.
Set<String> agentPubkeysWithChannelBots({
required Set<String> knownAgentPubkeys,
required Iterable<String> channelBotPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Adds agent identities derived from locally cached verified profiles.
Set<String> agentPubkeysWithProfileOwners({
required Set<String> knownAgentPubkeys,
required Iterable<String> profileOwnedAgentPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Preserves profile labels while filling missing agent mentions from the
/// relay's agent directory.
Map<String, String> mentionNamesWithDirectoryLabels({
required Iterable<String> mentionPubkeys,
required Map<String, String> profileMentionNames,
required Map<String, String> directoryDisplayNames,
required Set<String> agentMentionPubkeys,
}) {
final names = Map<String, String>.from(profileMentionNames);
for (final pubkey in mentionPubkeys) {
final normalizedPubkey = pubkey.toLowerCase();
if (names[normalizedPubkey]?.trim().isEmpty == true) {
names.remove(normalizedPubkey);
}
final directoryName = directoryDisplayNames[normalizedPubkey];
if (!names.containsKey(normalizedPubkey) && directoryName != null) {
names[normalizedPubkey] = directoryName;
}
if (!names.containsKey(normalizedPubkey) &&
agentMentionPubkeys.contains(normalizedPubkey)) {
names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey);
}
}
return names;
}
String _agentFallbackLabel(String pubkey) => shortPubkey(pubkey);
/// Readiness and refresh state for a channel's live membership subscription.
class ChannelMembershipUpdateState {
final int version;
final bool isReady;
final Object? error;
const ChannelMembershipUpdateState({
this.version = 0,
this.isReady = false,
this.error,
});
}
/// Keeps the role feed alive for consumers that render mentions outside the
/// channel timeline, such as search results. A membership change refreshes the
/// shared bot-role lookup below, regardless of which surface owns the channel.
class _ChannelBotRoleSubscription
extends Notifier<ChannelMembershipUpdateState> {
final String channelId;
void Function()? _unsubscribe;
int _subscriptionVersion = 0;
_ChannelBotRoleSubscription(this.channelId);
@override
ChannelMembershipUpdateState build() {
final sessionState = ref.watch(relaySessionProvider);
final subscriptionVersion = ++_subscriptionVersion;
_clearSubscription();
ref.onDispose(() {
_subscriptionVersion++;
_clearSubscription();
});
if (sessionState.status != SessionStatus.connected) {
return const ChannelMembershipUpdateState();
}
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
return const ChannelMembershipUpdateState();
}
Future<void> _subscribe(String channelId, int subscriptionVersion) async {
final session = ref.read(relaySessionProvider.notifier);
var subscriptionStatus = RelaySubscriptionStatus.retrying;
try {
final unsubscribe = await session.subscribeWithStatus(
NostrFilter(
kinds: const [39002],
tags: {
'#d': [channelId],
},
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(_) {
if (_isCurrent(subscriptionVersion)) {
state = ChannelMembershipUpdateState(
version: state.version + 1,
isReady: state.isReady,
);
}
},
onClosed: (message) {
if (_isCurrent(subscriptionVersion)) {
state = ChannelMembershipUpdateState(
version: state.version,
error: Exception(message),
);
}
},
onStatusChanged: (status) {
subscriptionStatus = status;
if (!_isCurrent(subscriptionVersion)) return;
state = ChannelMembershipUpdateState(
version: state.version,
isReady: status == RelaySubscriptionStatus.ready,
);
},
);
if (!_isCurrent(subscriptionVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
state = ChannelMembershipUpdateState(
version: state.version,
isReady: subscriptionStatus == RelaySubscriptionStatus.ready,
);
} catch (error) {
if (_isCurrent(subscriptionVersion)) {
state = ChannelMembershipUpdateState(
version: state.version,
error: error,
);
debugPrint(
'[ChannelBotRoleSubscription] failed for $channelId: $error',
);
}
}
}
bool _isCurrent(int subscriptionVersion) =>
subscriptionVersion == _subscriptionVersion;
void _clearSubscription() {
_unsubscribe?.call();
_unsubscribe = null;
}
}
/// Monotonically increments when the channel's kind:39002 membership snapshot
/// changes. Channel-member and agent-role views share this source so remote
/// membership updates refresh both snapshots together.
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
.family<_ChannelBotRoleSubscription, ChannelMembershipUpdateState, String>(
_ChannelBotRoleSubscription.new,
);
/// Bot pubkeys currently assigned a channel bot role.
final channelBotPubkeysProvider = FutureProvider.autoDispose
.family<Set<String>, String>((ref, channelId) async {
ref.watch(
channelMembershipUpdateProvider(
channelId,
).select((update) => update.version),
);
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const {};
return _AgentPubkeySet({
for (final member in membersFromEvent(events.first))
if (member.role == 'bot') member.pubkey.toLowerCase(),
});
});
/// Pubkeys currently known to represent agents in a channel.
final agentMentionPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final channelBotPubkeys =
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
const <String>{};
return agentPubkeysWithChannelBots(
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
channelBotPubkeys: channelBotPubkeys,
);
});
class _AgentPubkeySet extends UnmodifiableSetView<String> {
_AgentPubkeySet(Iterable<String> pubkeys) : super(Set.unmodifiable(pubkeys));
@override
bool operator ==(Object other) =>
other is Set && length == other.length && every(other.contains);
@override
int get hashCode => Object.hashAllUnordered(this);
}