Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions mobile/lib/features/channels/mentions/mention_candidates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ List<MentionCandidate> buildMentionCandidates({
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
String? currentPubkey,
bool directoryReady = true,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};
Expand All @@ -68,8 +69,10 @@ List<MentionCandidate> buildMentionCandidates({
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
final isAgent = member.isBot || ownerPubkey != null;
final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull;
if (policy?.ownerPubkey != null &&
!agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) {
if (ownerPubkey != null &&
(!directoryReady ||
policy?.ownerPubkey == null ||
!agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey))) {
continue;
}
candidates.add(
Expand Down Expand Up @@ -98,6 +101,7 @@ List<MentionCandidate> buildMentionCandidates({
}

for (final agent in relayAgents) {
if (!directoryReady && agent.ownerPubkey != null) continue;
final pk = agent.pubkey;
if (seen.contains(pk)) continue;
if (!sharedAgentPubkeys.contains(pk)) continue;
Expand Down Expand Up @@ -135,7 +139,10 @@ List<MentionCandidate> buildMentionCandidates({
final policy = relayAgents
.where((agent) => agent.pubkey == pk)
.firstOrNull;
if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) {
if (ownerPubkey != null &&
(!directoryReady ||
policy?.ownerPubkey == null ||
!sharedAgentPubkeys.contains(pk))) {
continue;
}
if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ final mentionCandidatesProvider = Provider.family
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final directory = ref.watch(agentDirectoryProvider);
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
directory.asData?.value ?? const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels = channelsAsync.asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
Expand All @@ -96,6 +96,7 @@ final mentionCandidatesProvider = Provider.family
final candidates = buildMentionCandidates(
members: members,
relayAgents: relayAgents,
directoryReady: !directory.isLoading && !directory.hasError,
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
Expand Down
68 changes: 20 additions & 48 deletions mobile/lib/features/profile/profile_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/crypto/nip_oa.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../../shared/relay/relay.dart';
Expand Down Expand Up @@ -69,9 +68,7 @@ final profileAvatarHandoffProvider =
/// WebSocket. Returns null when no nsec is configured or when the user has
/// not yet published a profile.
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
Map<String, dynamic> _metadata = {};
bool _hasHydrated = false;
int _lastCreatedAt = 0;
Future<void> _patchQueue = Future.value();

@override
Expand All @@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final myPk = context.pubkey;
if (myPk == null) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}

final session = context.session;
final events = await session.fetchHistory(NostrFilters.profile(myPk));
if (events.isEmpty) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}
final latest = _latestProfileEvent(events)!;
final metadata = _decodeProfileMetadata(latest);
final data = ProfileData.fromEvent(latest);
final profile = UserProfile(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(latest),
);
_requireCurrentWriteContext(context);
_metadata = metadata;
_lastCreatedAt = latest.createdAt;
final cache = ref.read(userCacheProvider.notifier);
for (final event in events) {
cache.cacheProfileEvent(event);
}
_hasHydrated = true;
return profile;
return ref.read(userCacheProvider)[myPk.toLowerCase()];
}

Future<void> refresh() async {
Expand Down Expand Up @@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
final currentHead = _latestProfileEvent(currentEvents);
if (_lastCreatedAt > 0 &&
(currentHead == null || currentHead.createdAt < _lastCreatedAt)) {
final cache = ref.read(userCacheProvider.notifier);
for (final event in currentEvents) {
cache.cacheProfileEvent(event);
}
final currentHead = cache.profileEvent(pubkey);
if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) {
throw StateError('Cannot confirm the latest profile metadata.');
}
final currentMetadata = currentHead == null
Expand All @@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final relay = SignedEventRelay(session: session, nsec: context.config.nsec);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final currentCreatedAt = currentHead?.createdAt ?? 0;
final previousCreatedAt = currentCreatedAt > _lastCreatedAt
? currentCreatedAt
: _lastCreatedAt;
final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1;
final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1;
NostrEvent? signedEvent;
await relay.submit(
kind: EventKind.profile,
Expand All @@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
if (submittedEvent == null) {
throw StateError('Profile update was not signed.');
}
final verifiedHead = _latestProfileEvent(
await session.fetchHistory(NostrFilters.profile(pubkey)),
final verification = await session.fetchHistory(
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
for (final event in verification) {
cache.cacheProfileEvent(event);
}
final verifiedHead = _latestProfileEvent(verification);
if (verifiedHead?.id != submittedEvent.id) {
throw StateError('Profile changed before the update could be confirmed.');
}

_metadata = nextMetadata;
_lastCreatedAt = createdAt;
final profile = UserProfile(
pubkey: pubkey,
displayName:
_metadata['display_name'] as String? ?? _metadata['name'] as String?,
avatarUrl: _metadata['picture'] as String?,
about: _metadata['about'] as String?,
nip05Handle: _metadata['nip05'] as String?,
ownerPubkey: verifiedOaOwnerPubkey(submittedEvent),
);
state = AsyncData(profile);
ref.read(userCacheProvider.notifier).put(profile);
cache.cacheProfileEvent(submittedEvent);
state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]);
}

void _requireCurrentWriteContext(_ProfileWriteContext context) {
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/shared/mentions/agent_authorization.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ part of 'agent_identity_provider.dart';

/// Fresh exact-key policy and relay-signed membership. Directory entries are
/// hints; only this read may authorize agent mentions at a destination.
/// [resolveProfileOwners] optionally folds fetched profiles into an ordered,
/// session-bound cache after the scope check and returns its current verified
/// owners. Omit it for side-effect-free send-time reads.
Future<List<AgentDirectoryEntry>> readAgentAuthorization(
RelaySessionNotifier session,
Set<String> requestedKeys, {
required String? viewer,
String? channelId,
required bool Function() isCurrent,
Map<String, String> Function(Iterable<NostrEvent>)? resolveProfileOwners,
}) async {
void check() {
if (!isCurrent()) throw StateError('Agent authorization scope changed');
Expand Down Expand Up @@ -66,6 +70,7 @@ Future<List<AgentDirectoryEntry>> readAgentAuthorization(
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
requestedKeys: requestedKeys,
checkCurrent: check,
resolveProfileOwners: resolveProfileOwners,
);
check();
final latest = <String, NostrEvent>{};
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/mentions/agent_discovery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ class _AgentDirectoryUpdates extends Notifier<int> {
),
(event) {
if (!current()) return;
if (disposed) return;
if (event.kind == 0) {
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
}
if (event.kind == 5 && !_isAgentCoordinateDeletion(event)) return;
changed();
},
Expand Down
40 changes: 27 additions & 13 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/crypto/signed_event.dart';
import '../../shared/relay/relay.dart';
import '../profile/user_cache_provider.dart';

part 'agent_policy.dart';
part 'agent_authorization.dart';
Expand Down Expand Up @@ -92,28 +93,41 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
if (!current()) return [];
return readAgentAuthorization(
session,
{...owned, ...events.map((event) => event.pubkey)},
{
...owned, ...events.map((event) => event.pubkey),
// Refresh previously authenticated owners even after runtime/policy
// discovery stops returning them (for example across reconnect).
for (final profile in ref.read(userCacheProvider).values)
if (profile.ownerPubkey != null) profile.pubkey,
},
viewer: viewer,
isCurrent: current,
resolveProfileOwners: (profiles) {
if (!current()) throw StateError('Profile authority scope changed');
final cache = ref.read(userCacheProvider.notifier);
for (final profile in profiles) {
cache.cacheProfileEvent(profile);
}
return {
for (final profile in ref.read(userCacheProvider).values)
if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!,
};
},
);
});

/// 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 latestProfileEvents(events).values) {
final owner = verifiedOaOwnerPubkey(event);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
await ref.watch(agentDirectoryProvider.future);
// The profile cache is the single ordered ownership source for lifecycle,
// provenance and mention fallbacks; never re-query a parallel owner snapshot.
final profiles = ref.watch(userCacheProvider);
return {
for (final profile in profiles.values)
if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!,
};
});

/// Pubkeys currently known to represent agents across the active relay.
Expand Down
14 changes: 13 additions & 1 deletion mobile/lib/shared/mentions/agent_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ Future<List<NostrEvent>> _queryAgentFilters(
}

/// Overlay current owner-authenticated policy onto the existing runtime
/// directory. This does not expand discovery to owner-only coordinates yet.
/// directory, optionally including exact [requestedKeys] without a runtime.
/// [resolveProfileOwners] receives snapshot profiles after [checkCurrent] and
/// returns verified owners from the caller's ordered authority cache, so an
/// older snapshot cannot undo a newer live revocation. Without the callback,
/// ownership derives solely from this fresh query; no cache writes occur.
Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
RelaySessionNotifier session,
List<NostrEvent> runtimeEvents, {
Set<String>? requestedKeys,
Map<String, String> Function(Iterable<NostrEvent>)? resolveProfileOwners,
void Function()? checkCurrent,
}) async {
final latest = <String, NostrEvent>{};
Expand All @@ -44,13 +49,20 @@ Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
NostrFilter(kinds: const [0], authors: [key], limit: 1),
], checkCurrent: checkCurrent),
);
checkCurrent?.call();
final owners = <String, String>{};
for (final profile in profiles.values) {
final owner = verifiedOaOwnerPubkey(profile);
if (owner != null && keys.contains(profile.pubkey)) {
owners[profile.pubkey] = owner;
}
}
if (resolveProfileOwners != null) {
final currentOwners = resolveProfileOwners(profiles.values);
owners
..clear()
..addEntries(currentOwners.entries.where((e) => keys.contains(e.key)));
}
checkCurrent?.call();
final policies = await _queryAgentFilters(session, [
for (final owner in owners.entries)
Expand Down
Loading
Loading