diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a1a283fb49..42c922e9f2 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -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'; @@ -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 { - Map _metadata = {}; bool _hasHydrated = false; - int _lastCreatedAt = 0; Future _patchQueue = Future.value(); @override @@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier { 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 refresh() async { @@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier { 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 @@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier { 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, @@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier { 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) { diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 915a0ff33f..0ce0cb0c6d 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -15,7 +15,7 @@ import 'user_profile.dart'; class UserCacheNotifier extends Notifier> { final Set _pending = {}; int _generation = 0; - final Map _profileEventOrders = {}; + final Map _profileEvents = {}; Timer? _batchTimer; Completer? _batchCompleter; @@ -25,7 +25,7 @@ class UserCacheNotifier extends Notifier> { ref.watch(myPubkeyProvider); _generation++; _pending.clear(); - _profileEventOrders.clear(); + _profileEvents.clear(); ref.onDispose(() { _generation++; _batchTimer?.cancel(); @@ -45,11 +45,19 @@ class UserCacheNotifier extends Notifier> { return null; } - /// Stores a profile that was fetched or updated outside the batch loader. + /// Seeds an absent profile for local display before signed evidence arrives. + /// Never replaces existing state; fetched/confirmed profiles must use + /// [cacheProfileEvent] so ownership and event metadata advance together. void put(UserProfile profile) { - state = {...state, profile.pubkey.toLowerCase(): profile}; + final pubkey = profile.pubkey.toLowerCase(); + if (state.containsKey(pubkey)) return; + state = {...state, pubkey: profile}; } + /// The governing kind:0 event, including metadata needed for profile edits. + NostrEvent? profileEvent(String pubkey) => + _profileEvents[pubkey.toLowerCase()]; + /// Preload profiles for a list of pubkeys (e.g. channel members). /// Returns whether the batch completed successfully. Future preload(List pubkeys) { @@ -85,13 +93,11 @@ class UserCacheNotifier extends Notifier> { ); if (generation != _generation) return false; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -138,14 +144,12 @@ class UserCacheNotifier extends Notifier> { if (generation != _generation) return; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -163,21 +167,21 @@ class UserCacheNotifier extends Notifier> { bool _cacheProfileEvent( NostrEvent event, Map profiles, [ - Map? orders, + Map? orders, ]) { if (event.kind != 0) return false; - final eventOrders = orders ?? _profileEventOrders; + final eventOrders = orders ?? _profileEvents; final pubkey = event.pubkey.toLowerCase(); final current = eventOrders[pubkey]; final isNewer = current == null || event.createdAt > current.createdAt || (event.createdAt == current.createdAt && - event.id.compareTo(current.eventId) < 0); + event.id.compareTo(current.id) < 0); if (!isNewer) return false; profiles[pubkey] = _profileFromEvent(event); - eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + eventOrders[pubkey] = event; return true; } diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 239dc808a2..96281d302e 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -77,6 +77,89 @@ void main() { ); }); + test( + 'late confirmed publication retains newer cache authority and metadata', + () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final initial = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: 1, + kind: 0, + tags: [_authTag(owner, keys.public)], + content: jsonEncode({'display_name': 'Owned'}), + ).toMap(), + ); + final session = _ProfileRelaySession(initial); + final container = _profileContainer(keys.nsec, session); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + await container.read(profileProvider.future); + expect(cache.profileEvent(keys.public)?.id, initial.id); + NostrEvent? revoked; + session.beforeHistoryReturn = () { + if (session.published.length != 1) return; + revoked = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: session.published.single.createdAt + 1, + kind: 0, + tags: const [], + content: jsonEncode({'display_name': 'Revoked', 'custom': 'new'}), + ).toMap(), + ); + cache.cacheProfileEvent(revoked!); + }; + await container.read(profileProvider.notifier).updateAbout('Late'); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + final submitted = session.published.single; + cache.put( + UserProfile( + pubkey: keys.public, + displayName: 'Unordered', + ownerPubkey: owner.public, + ), + ); + cache.cacheProfileEvent(submitted); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, revoked!.id); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + isNull, + ); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + // A stale hydration response must use the same governing event, not + // recreate detached state or lower the metadata used by the next edit. + session.beforeHistoryReturn = null; + await container.read(profileProvider.notifier).refresh(); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + await expectLater( + container.read(profileProvider.notifier).updateAbout('Unsafe'), + throwsStateError, + ); + expect(session.published, hasLength(1)); + session.profile = revoked!; + await container.read(profileProvider.notifier).updateAbout('Safe'); + final next = session.published.last; + expect(next.createdAt, greaterThan(revoked!.createdAt)); + expect(next.tags, isEmpty); + expect(jsonDecode(next.content)['custom'], 'new'); + expect(cache.profileEvent(keys.public)?.id, next.id); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, next.id); + }, + ); + test('clearing a display name restores the pubkey label fallback', () async { final keys = nostr.Keys.generate(); final relaySession = _ProfileRelaySession( @@ -607,7 +690,8 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); - final NostrEvent profile; + NostrEvent profile; + void Function()? beforeHistoryReturn; final List published = []; @override @@ -617,7 +701,11 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile, ...published]; + }) async { + final result = [profile, ...published]; + beforeHistoryReturn?.call(); + return result; + } @override Future publish( diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 5154feb1b0..64d979780d 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; 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'; @@ -176,6 +177,34 @@ void main() { expect(cache.state['agent']?.displayName, 'Valid'); }); + test( + 'unordered seed is first hydration only and cannot replace an event', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.put(const UserProfile(pubkey: 'AGENT', displayName: 'Local')); + expect(cache.state['agent']?.displayName, 'Local'); + expect(cache.profileEvent('agent'), isNull); + cache.cacheProfileEvent( + _profileEvent(id: 'b', createdAt: 2, name: 'New'), + ); + cache.put( + const UserProfile( + pubkey: 'agent', + displayName: 'Late', + ownerPubkey: 'owner', + ), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'a', createdAt: 1, name: 'Old'), + ); + expect(cache.state['agent']?.displayName, 'New'); + expect(cache.state['agent']?.ownerPubkey, isNull); + expect(cache.profileEvent('AGENT')?.id, 'b'); + }, + ); + test('same-second profile tie keeps the lowest event id', () { final container = ProviderContainer(); addTearDown(container.dispose);