From 733d164e8068eed5f0416afb36dfe7912aaa2688 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 9 Nov 2026 06:58:48 -0800 Subject: [PATCH] mobile: persist user profiles so names and avatars render on launch (#9116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a slow connection, mobile shows npubs and blank avatars on every cold launch and community switch, because user profiles live only in memory and are refetched from the relay each time. This persists the verified profiles the app has already seen, per identity and relay, so names and avatar URLs render on launch before the relay answers. ### Behavior - Profiles are stored as a disposable cache in the same shape as the existing channel list snapshot: one SharedPreferences entry per identity and relay, versioned, capped at 2000 profiles and 2 MB (oversized inline avatars are skipped), deleted on sign-out and by orphan cleanup. - On launch the cache restores the snapshot synchronously. Each restored profile is still revalidated against the relay on its first lookup that session. `get` and `preload` return the restored data right away instead of waiting on the network, and a failed revalidation retries on the next lookup. - Identity-sensitive callers keep their guarantees: `resolve()` and `refresh()` treat restored profiles as unconfirmed and query the relay, including while a background revalidation is still in flight. - Writes are debounced (2 s) and flushed when the community scope changes. Not in this PR: avatar image bytes still use only Flutter's in-memory image cache, so avatar images still download after a cold launch (names and URLs no longer wait). The NSE keeps its own app-group profile cache, which this doesn't share yet. ### Demo Cold relaunch into a synthetic Solo chat ("QA demo"), iPhone 17 Pro simulator, same clip timing on both builds. To make the gap visible on a fast connection, both builds had a local-only 5 s delay on profile fetches (not part of this PR). The channel's system lines show the member name once its profile is known and a shortened npub until then. Before (main): "npub1...created this channel" for about 5 s until the relay answers, and the agent avatar starts as a letter placeholder. https://github.com/user-attachments/assets/de558521-a210-4ac6-b216-d77260036985 After (this branch): "tomb created this channel" and the agent avatar render from the first frame. https://github.com/user-attachments/assets/093b30fc-ec24-4f26-8535-7cc1791d2e44 ### Validation - Added tests: a journey across three simulated launches (relay reachable, offline, reachable with a rename) covering restore, non-blocking revalidation, retry after failure, a concurrent `resolve()` during revalidation, and sign-out removal; plus storage scoping, bounds, writer retirement and corrupt-data handling. The revalidation and in-flight guards were each removed once to confirm the journey fails. - Full mobile check and test suite pass locally. - iOS simulator (iPhone 17 Pro, Remote Agents Pilot as Tom's Builderlab account): after browsing, the snapshot held 16 verified profiles (6.4 KB), and cold relaunch rendered normally. Offline cold launch was not exercised on the simulator. - iOS simulator, synthetic Solo chat, main vs this branch (see Demo): with profile fetches artificially delayed 5 s, main showed shortened npubs in the channel's system lines until the relay answered, and this branch showed the restored names on the first frame. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../shared/community/community_provider.dart | 28 ++- .../lib/shared/profile/profile_snapshot.dart | 182 ++++++++++++++++++ .../shared/profile/user_cache_provider.dart | 117 +++++++++-- .../profile/user_cache_snapshot_test.dart | 177 +++++++++++++++++ 4 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 mobile/lib/shared/profile/profile_snapshot.dart create mode 100644 mobile/test/shared/profile/user_cache_snapshot_test.dart diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 1f1a5b620e..035ba67484 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,4 +1,5 @@ import '../hosted/hosted_relay_auth.dart'; + import 'dart:async'; import 'dart:developer' as developer; import 'dart:math'; @@ -10,6 +11,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; import '../channels/channel_list_snapshot.dart'; import '../hosted/community_entry_requirements.dart'; +import '../profile/profile_snapshot.dart'; import '../push/dev_push_lease.dart'; import '../push/push_bridge.dart'; import '../push/push_lease_revocation_outbox.dart'; @@ -375,12 +377,16 @@ class CommunityListNotifier extends AsyncNotifier> { Future _removeChannelListSnapshot(Community community) async { final snapshots = ref.read(channelListSnapshotStorageProvider); + final profiles = ref.read(profileSnapshotStorageProvider); final pubkey = _channelSnapshotPubkey(community); - if (snapshots == null || pubkey == null) return; + if (pubkey == null) return; try { // Retire writers before the revocation journal or credential changes. // A disposable cache must never prevent local sign-out. - await snapshots.remove(pubkey, community.relayUrl); + await Future.wait([ + ?snapshots?.remove(pubkey, community.relayUrl), + ?profiles?.remove(pubkey, community.relayUrl), + ]); } catch (error, stackTrace) { developer.log( 'Channel snapshot deletion failed; orphan cleanup will retry', @@ -395,12 +401,22 @@ class CommunityListNotifier extends AsyncNotifier> { List communities, ) async { final snapshots = ref.read(channelListSnapshotStorageProvider); - if (snapshots == null) return; - await snapshots.removeOrphans({ + final profiles = ref.read(profileSnapshotStorageProvider); + final scopes = [ for (final community in communities) if (_channelSnapshotPubkey(community) case final pubkey?) - ChannelListSnapshotStorage.key(pubkey, community.relayUrl), - }); + (pubkey: pubkey, relay: community.relayUrl), + ]; + await Future.wait([ + ?snapshots?.removeOrphans({ + for (final scope in scopes) + ChannelListSnapshotStorage.key(scope.pubkey, scope.relay), + }), + ?profiles?.removeOrphans({ + for (final scope in scopes) + ProfileSnapshotStorage.key(scope.pubkey, scope.relay), + }), + ]); } Future switchCommunity(String id) { diff --git a/mobile/lib/shared/profile/profile_snapshot.dart b/mobile/lib/shared/profile/profile_snapshot.dart new file mode 100644 index 0000000000..250483e467 --- /dev/null +++ b/mobile/lib/shared/profile/profile_snapshot.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../relay/relay_provider.dart' show RelayConfig; +import '../theme/community_theme_preference.dart'; +import '../theme/theme_provider.dart'; +import 'profile_event_parser.dart'; +import 'user_profile.dart'; + +const _keyPrefix = 'buzz.profiles.v1'; +const _version = 1; + +/// Profiles beyond these bounds are not stored; they load from the relay. +const maxProfileSnapshotProfiles = 2000; +const maxProfileSnapshotBytes = 2 * 1024 * 1024; + +/// A single stored profile may not exceed this. Inline `data:` avatars can be +/// large; such profiles are skipped rather than crowding out everyone else. +const maxProfileSnapshotEntryBytes = 16 * 1024; + +/// Permission to write one scope's snapshot, captured when the cache starts. +/// +/// [ProfileSnapshotStorage.remove] retires every token captured before it, so +/// a pending write during sign-out cannot recreate the snapshot. +class ProfileSnapshotToken { + const ProfileSnapshotToken._(this._key, this._epoch); + + final String _key; + final int _epoch; +} + +/// Stores the last verified profiles seen per relay and identity, so names and +/// avatars render on launch before the relay answers. +/// +/// The data is a cache derived from relay kind:0 events: unreadable, +/// unsupported or oversized data is discarded rather than migrated. Restored +/// profiles are revalidated against the relay on first use each session. +class ProfileSnapshotStorage { + ProfileSnapshotStorage(this._prefs); + + final SharedPreferences _prefs; + + /// Retirement epochs shared by every storage over the same preferences. + static final _epochsByPrefs = Expando>(); + + Map get _epochs => _epochsByPrefs[_prefs] ??= {}; + + /// The scope key. `wss://`/`ws://` origins fold to `https://`/`http://` + /// like `RelayConfig.baseUrl`, so both onboarding paths share a snapshot. + static String key(String pubkey, String relayUrl) { + final relay = normalizeCommunityRelayUrl( + RelayConfig(baseUrl: normalizeCommunityRelayUrl(relayUrl)).baseUrl, + ); + return '$_keyPrefix:${pubkey.toLowerCase()}:${Uri.encodeComponent(relay)}'; + } + + /// Returns the scope's profiles, or an empty list when absent or unusable. + List read(String pubkey, String relayUrl) { + final key = ProfileSnapshotStorage.key(pubkey, relayUrl); + final raw = _prefs.getString(key); + if (raw == null) return const []; + try { + if (raw.length > maxProfileSnapshotBytes || + utf8.encode(raw).length > maxProfileSnapshotBytes) { + throw const FormatException('size'); + } + final json = jsonDecode(raw) as Map; + if (json['v'] != _version) throw const FormatException('version'); + final profiles = json['profiles'] as List; + if (profiles.length > maxProfileSnapshotProfiles) { + throw const FormatException('count'); + } + return [ + for (final entry in profiles) _decode(entry as Map), + ]; + } catch (_) { + // Unusable cache: drop it without retiring the live writer. + _prefs.remove(key).ignore(); + return const []; + } + } + + /// Captures write permission for the scope. + ProfileSnapshotToken capture(String pubkey, String relayUrl) { + final key = ProfileSnapshotStorage.key(pubkey, relayUrl); + return ProfileSnapshotToken._(key, _epochs[key] ?? 0); + } + + /// Replaces the scope's snapshot with the newest [profiles] that fit the + /// bounds. Returns false when [token] was retired or storage rejected it. + Future write( + ProfileSnapshotToken token, + Iterable profiles, + ) async { + if ((_epochs[token._key] ?? 0) != token._epoch) return false; + final newestFirst = profiles.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final entries = []; + // Two bytes per entry cover the list's brackets and separators. + var bytes = 32; + for (final profile in newestFirst) { + if (entries.length == maxProfileSnapshotProfiles) break; + final entry = jsonEncode(_encode(profile)); + final entryBytes = utf8.encode(entry).length; + if (entryBytes > maxProfileSnapshotEntryBytes) continue; + if (bytes + entryBytes + 1 > maxProfileSnapshotBytes) break; + bytes += entryBytes + 1; + entries.add(entry); + } + final raw = '{"v":$_version,"profiles":[${entries.join(',')}]}'; + if (_prefs.getString(token._key) == raw) return true; + // The token check and SharedPreferences' synchronous in-memory update run + // without an intervening await, so a later remove always wins. + return _prefs.setString(token._key, raw); + } + + /// Deletes the scope's snapshot and retires its outstanding tokens. + Future remove(String pubkey, String relayUrl) => + _removeKey(ProfileSnapshotStorage.key(pubkey, relayUrl)); + + /// Retires and deletes snapshots without a stored community. + Future removeOrphans(Set retainedKeys) async { + final keys = {..._prefs.getKeys(), ..._epochs.keys}; + await Future.wait([ + for (final key in keys) + if (key.startsWith('$_keyPrefix:') && !retainedKeys.contains(key)) + _removeKey(key).catchError((Object error) { + debugPrint('[ProfileSnapshotStorage] cleanup failed: $error'); + }), + ]); + } + + Future _removeKey(String key) async { + _epochs[key] = (_epochs[key] ?? 0) + 1; + if (!await _prefs.remove(key)) { + throw StateError('Profile snapshot could not be removed.'); + } + } + + static Map _encode(ParsedProfileEvent parsed) { + final profile = parsed.profile; + return { + 'pubkey': profile.pubkey, + 'event_id': parsed.eventId, + 'created_at': parsed.createdAt, + 'display_name': ?profile.displayName, + 'avatar_url': ?profile.avatarUrl, + 'about': ?profile.about, + 'nip05': ?profile.nip05Handle, + 'owner': ?profile.ownerPubkey, + }; + } + + static ParsedProfileEvent _decode(Map json) => ( + profile: UserProfile( + pubkey: (json['pubkey'] as String).toLowerCase(), + displayName: json['display_name'] as String?, + avatarUrl: json['avatar_url'] as String?, + about: json['about'] as String?, + nip05Handle: json['nip05'] as String?, + ownerPubkey: json['owner'] as String?, + ), + createdAt: json['created_at'] as int, + eventId: json['event_id'] as String, + ); +} + +/// The app's profile snapshot storage, or null where preferences were never +/// provided (isolated tests). Null means profiles live only in memory. +final profileSnapshotStorageProvider = Provider((ref) { + final SharedPreferences prefs; + try { + prefs = ref.watch(savedPrefsProvider); + } catch (_) { + // The unprovided override throws, wrapped by Riverpod. + return null; + } + return ProfileSnapshotStorage(prefs); +}); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index ccdd2919b5..921739c6d9 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -1,13 +1,19 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; import '../verified_profiles/verified_profile_directory.dart'; import 'user_profile.dart'; import 'profile_event_parser.dart'; +import 'profile_snapshot.dart'; -/// In-memory cache of user profiles, fetched in batches from the relay. +/// Cache of user profiles, fetched in batches from the relay. +/// +/// Verified profiles persist per identity and relay ([ProfileSnapshotStorage]) +/// so names and avatars render on launch, even on a slow link. Restored +/// profiles are served immediately and revalidated on their first lookup. /// /// Lookups requested via [get] or [preload] are coalesced into a single /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. Larger requests @@ -15,6 +21,7 @@ import 'profile_event_parser.dart'; class UserCacheNotifier extends Notifier> { // The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000). static const _maximumProfilesPerQuery = 1000; + static const _snapshotWriteDelay = Duration(seconds: 2); final Set _pending = {}; final Map _profileEventOrders = {}; @@ -24,11 +31,22 @@ class UserCacheNotifier extends Notifier> { Timer? _batchTimer; Completer? _batchCompleter; + /// Restored from the snapshot and not yet checked with the relay this scope. + final Set _unrevalidated = {}; + + /// Lookups whose batch query is on the wire. + final Set _inFlight = {}; + void Function()? _snapshotWriter; + Timer? _snapshotTimer; + Map _latest = const {}; + @override Map build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); _generation++; _profileEventOrders.clear(); + _unrevalidated.clear(); + _inFlight.clear(); ref.onDispose(() { _generation++; _pending.clear(); @@ -36,18 +54,77 @@ class UserCacheNotifier extends Notifier> { _batchTimer = null; _batchCompleter?.complete(false); _batchCompleter = null; + // Flush rather than drop: switching community must not lose the + // profiles learned since the last write. + if (_snapshotTimer?.isActive ?? false) _snapshotWriter?.call(); + _snapshotTimer?.cancel(); + _snapshotTimer = null; + _snapshotWriter = null; }); - return {}; + return _restoreSnapshot(config); } + Map _restoreSnapshot(RelayConfig config) { + final storage = ref.read(profileSnapshotStorageProvider); + final pubkey = pubkeyFromNsec(config.nsec); + if (storage == null || pubkey == null) return {}; + final relay = config.baseUrl; + final token = storage.capture(pubkey, relay); + _snapshotWriter = () { + final profiles = [ + for (final MapEntry(:key, value: order) in _profileEventOrders.entries) + if (_latest[key] case final profile?) + ( + profile: profile, + createdAt: order.createdAt, + eventId: order.eventId, + ), + ]; + storage + .write(token, profiles) + .then( + (saved) { + if (!saved) debugPrint('[UserCache] snapshot was not saved'); + }, + onError: (Object error, StackTrace stack) { + debugPrint('[UserCache] snapshot write failed: $error'); + }, + ); + }; + final restored = {}; + for (final parsed in storage.read(pubkey, relay)) { + final key = parsed.profile.pubkey; + restored[key] = parsed.profile; + _profileEventOrders[key] = ( + createdAt: parsed.createdAt, + eventId: parsed.eventId, + ); + _unrevalidated.add(key); + } + return _latest = restored; + } + + @override + set state(Map value) { + super.state = _latest = value; + if (_snapshotWriter == null) return; + _snapshotTimer ??= Timer(_snapshotWriteDelay, () { + _snapshotTimer = null; + _snapshotWriter?.call(); + }); + } + + /// Whether [pubkey] has a profile that is current for this scope. + bool _isCached(String pubkey) => + state.containsKey(pubkey) && !_unrevalidated.contains(pubkey); + /// Request a profile for [pubkey]. Returns immediately from cache if /// available, otherwise schedules a batch fetch. UserProfile? get(String pubkey) { _trackVerified([pubkey]); - final cached = state[pubkey.toLowerCase()]; - if (cached != null) return cached; - _scheduleFetch(pubkey.toLowerCase()); - return null; + final key = pubkey.toLowerCase(); + _scheduleFetch(key); + return state[key]; } /// Stores a profile that was fetched or updated outside the batch loader. @@ -67,7 +144,7 @@ class UserCacheNotifier extends Notifier> { final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet(); _trackVerified(requested); final toFetch = requested - .where((pubkey) => refreshCached || !state.containsKey(pubkey)) + .where((pubkey) => refreshCached || !_isCached(pubkey)) .toList(); final generation = _generation; final session = ref.read(relaySessionProvider.notifier); @@ -85,6 +162,7 @@ class UserCacheNotifier extends Notifier> { if (!_isCurrent(generation)) { throw StateError('Profile request belongs to a retired cache'); } + _unrevalidated.removeAll(toFetch); return {for (final pubkey in requested) pubkey: ?state[pubkey]}; } @@ -93,11 +171,16 @@ class UserCacheNotifier extends Notifier> { Future preload(List pubkeys) { _trackVerified(pubkeys); final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet(); - final alreadyPending = normalized.any(_pending.contains); + // Restored profiles being revalidated already render; don't wait on them. + final alreadyPending = normalized.any( + (pk) => _pending.contains(pk) && !state.containsKey(pk), + ); final uncached = normalized - .map((pk) => pk.toLowerCase()) .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) .toList(); + // Restored profiles already render; revalidate them without making the + // caller wait on the network. + normalized.where(_unrevalidated.contains).forEach(_scheduleFetch); if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); final completer = _batchCompleter ??= Completer(); @@ -128,6 +211,7 @@ class UserCacheNotifier extends Notifier> { if (!_isCurrent(generation)) return false; if (!await _verifyAndMerge(events, generation)) return false; } + _unrevalidated.removeAll(normalized); return true; } catch (_) { return false; @@ -149,7 +233,11 @@ class UserCacheNotifier extends Notifier> { ref.read(verifiedProfilesProvider.notifier).track(pubkeys); void _scheduleFetch(String pubkey) { - if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; + if (_isCached(pubkey) || + _pending.contains(pubkey) || + _inFlight.contains(pubkey)) { + return; + } _pending.add(pubkey); _batchCompleter ??= Completer(); _scheduleBatch(); @@ -165,6 +253,7 @@ class UserCacheNotifier extends Notifier> { _batchCompleter = null; _flushInFlight = true; + _inFlight.addAll(pubkeys); final generation = _generation; var succeeded = false; try { @@ -177,6 +266,8 @@ class UserCacheNotifier extends Notifier> { ); if (!_isCurrent(generation)) return; if (!await _verifyAndMerge(events, generation)) return; + // Confirmed by the relay, whether or not it still has a profile. + _unrevalidated.removeAll(batch); remaining -= batch.length; if (remaining == 0) { // All requested pages are ready. @@ -185,8 +276,10 @@ class UserCacheNotifier extends Notifier> { } } } catch (_) { - // Silently fail — non-gating callers will just show pubkeys. + // Silently fail — non-gating callers will just show pubkeys, or the + // restored profile, which the next lookup revalidates again. } finally { + if (_isCurrent(generation)) _inFlight.removeAll(pubkeys); if (completer != null && !completer.isCompleted) { completer.complete(succeeded); } diff --git a/mobile/test/shared/profile/user_cache_snapshot_test.dart b/mobile/test/shared/profile/user_cache_snapshot_test.dart new file mode 100644 index 0000000000..91ea22238f --- /dev/null +++ b/mobile/test/shared/profile/user_cache_snapshot_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/profile/profile_snapshot.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:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +const _relay = 'https://relay.test'; +final _nsec = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '1' * 64, +); +const _alice = 'a11ce'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + ProviderContainer cacheScope(_ProfileSession session) { + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(_Config.new), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('profiles persist across launches and revalidate once', () async { + // First launch: nothing on disk, so the name loads from the relay. + final first = _ProfileSession([ + Future.value([_profile('v1', 1, 'Alice')]), + ]); + final launch1 = cacheScope(first); + final cache1 = launch1.read(userCacheProvider.notifier); + expect(cache1.get(_alice), isNull); + await cache1.preload(const [_alice]); + expect(cache1.state[_alice]?.displayName, 'Alice'); + // Leaving the scope flushes the pending snapshot write. + launch1.dispose(); + await pumpEventQueue(); + + // Second launch, relay unreachable: the stored name renders immediately + // and the failed revalidation leaves it in place for another attempt. + final offline = _ProfileSession([], fail: true); + final launch2 = cacheScope(offline); + final cache2 = launch2.read(userCacheProvider.notifier); + expect(cache2.get(_alice)?.displayName, 'Alice'); + expect(await cache2.preload(const [_alice]), isTrue); + await Future.delayed(const Duration(milliseconds: 80)); + expect(offline.requests, 1); + expect(cache2.state[_alice]?.displayName, 'Alice'); + cache2.get(_alice); + await Future.delayed(const Duration(milliseconds: 80)); + expect(offline.requests, 2, reason: 'a failed revalidation retries'); + launch2.dispose(); + + // Third launch, relay reachable: one revalidation picks up the rename, + // and later lookups are served from memory. + final held = Completer>(); + final online = _ProfileSession([ + held.future, + Future.value([_profile('v2', 2, 'Alice Renamed')]), + ]); + final launch3 = cacheScope(online); + final cache3 = launch3.read(userCacheProvider.notifier); + expect(cache3.get(_alice)?.displayName, 'Alice'); + await Future.delayed(const Duration(milliseconds: 80)); + expect(online.requests, 1); + // A gate resolving while that revalidation is on the wire must not trust + // the restored profile; it confirms with the relay itself. + final resolved = await cache3.resolve(const [_alice]); + expect(resolved[_alice]?.displayName, 'Alice Renamed'); + expect(online.requests, 2); + held.complete(const []); + await Future.delayed(const Duration(milliseconds: 80)); + expect(cache3.state[_alice]?.displayName, 'Alice Renamed'); + cache3.get(_alice); + await cache3.preload(const [_alice]); + await Future.delayed(const Duration(milliseconds: 80)); + expect(online.requests, 2); + launch3.dispose(); + await pumpEventQueue(); + + // Signing out of the community deletes the snapshot. + final storage = ProfileSnapshotStorage(prefs); + final pubkey = pubkeyFromNsec(_nsec)!; + expect( + storage.read(pubkey, _relay).single.profile.displayName, + 'Alice Renamed', + ); + await storage.remove(pubkey, _relay); + final launch4 = cacheScope(_ProfileSession([])); + expect(launch4.read(userCacheProvider), isEmpty); + }); + + test('snapshot scopes are per identity and relay, and bounded', () async { + final storage = ProfileSnapshotStorage(prefs); + final huge = _parsed('huge', 3, 'x' * maxProfileSnapshotEntryBytes); + final token = storage.capture('ME', 'wss://relay.test'); + expect( + await storage.write(token, [_parsed(_alice, 1, 'Alice'), huge]), + isTrue, + ); + // The wss:// and https:// onboarding paths share a scope. + expect(storage.read('me', _relay).map((p) => p.profile.pubkey), [_alice]); + expect(storage.read('someone-else', _relay), isEmpty); + + // A removal retires writers captured before it. + await storage.remove('me', _relay); + expect(await storage.write(token, [_parsed(_alice, 1, 'Alice')]), isFalse); + expect(storage.read('me', _relay), isEmpty); + + // Corrupt data is discarded. + await prefs.setString(ProfileSnapshotStorage.key('me', _relay), '{'); + expect(storage.read('me', _relay), isEmpty); + expect(prefs.getString(ProfileSnapshotStorage.key('me', _relay)), isNull); + }); +} + +NostrEvent _profile(String id, int createdAt, String name) => NostrEvent( + id: id, + pubkey: _alice, + createdAt: createdAt, + kind: 0, + tags: const [], + content: jsonEncode({'name': name}), + sig: 'sig', +); + +({UserProfile profile, int createdAt, String eventId}) _parsed( + String pubkey, + int createdAt, + String name, +) => ( + profile: UserProfile(pubkey: pubkey, displayName: name), + createdAt: createdAt, + eventId: 'e-$pubkey', +); + +class _Config extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig(baseUrl: _relay, nsec: _nsec); +} + +class _ProfileSession extends RelaySessionNotifier { + _ProfileSession(this._results, {this.fail = false}); + + final List>> _results; + final bool fail; + int requests = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + requests++; + if (fail) throw StateError('offline'); + return _results.isEmpty ? const [] : await _results.removeAt(0); + } +}