mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
mobile: persist user profiles so names and avatars render on launch (#9116)
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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<List<Community>> {
|
||||
|
||||
Future<void> _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<Community>> {
|
||||
List<Community> 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<void> switchCommunity(String id) {
|
||||
|
||||
182
mobile/lib/shared/profile/profile_snapshot.dart
Normal file
182
mobile/lib/shared/profile/profile_snapshot.dart
Normal file
@@ -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<String, int>>();
|
||||
|
||||
Map<String, int> 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<ParsedProfileEvent> 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<String, dynamic>;
|
||||
if (json['v'] != _version) throw const FormatException('version');
|
||||
final profiles = json['profiles'] as List<dynamic>;
|
||||
if (profiles.length > maxProfileSnapshotProfiles) {
|
||||
throw const FormatException('count');
|
||||
}
|
||||
return [
|
||||
for (final entry in profiles) _decode(entry as Map<String, dynamic>),
|
||||
];
|
||||
} 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<bool> write(
|
||||
ProfileSnapshotToken token,
|
||||
Iterable<ParsedProfileEvent> 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 = <String>[];
|
||||
// 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<void> remove(String pubkey, String relayUrl) =>
|
||||
_removeKey(ProfileSnapshotStorage.key(pubkey, relayUrl));
|
||||
|
||||
/// Retires and deletes snapshots without a stored community.
|
||||
Future<void> removeOrphans(Set<String> 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<void> _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<String, Object?> _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<String, dynamic> 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<ProfileSnapshotStorage?>((ref) {
|
||||
final SharedPreferences prefs;
|
||||
try {
|
||||
prefs = ref.watch(savedPrefsProvider);
|
||||
} catch (_) {
|
||||
// The unprovided override throws, wrapped by Riverpod.
|
||||
return null;
|
||||
}
|
||||
return ProfileSnapshotStorage(prefs);
|
||||
});
|
||||
@@ -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<Map<String, UserProfile>> {
|
||||
// The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000).
|
||||
static const _maximumProfilesPerQuery = 1000;
|
||||
static const _snapshotWriteDelay = Duration(seconds: 2);
|
||||
|
||||
final Set<String> _pending = {};
|
||||
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
|
||||
@@ -24,11 +31,22 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
Timer? _batchTimer;
|
||||
Completer<bool>? _batchCompleter;
|
||||
|
||||
/// Restored from the snapshot and not yet checked with the relay this scope.
|
||||
final Set<String> _unrevalidated = {};
|
||||
|
||||
/// Lookups whose batch query is on the wire.
|
||||
final Set<String> _inFlight = {};
|
||||
void Function()? _snapshotWriter;
|
||||
Timer? _snapshotTimer;
|
||||
Map<String, UserProfile> _latest = const {};
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> 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<Map<String, UserProfile>> {
|
||||
_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<String, UserProfile> _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 = <String, UserProfile>{};
|
||||
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<String, UserProfile> 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<Map<String, UserProfile>> {
|
||||
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<Map<String, UserProfile>> {
|
||||
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<Map<String, UserProfile>> {
|
||||
Future<bool> preload(List<String> 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<bool>();
|
||||
@@ -128,6 +211,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
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<Map<String, UserProfile>> {
|
||||
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<bool>();
|
||||
_scheduleBatch();
|
||||
@@ -165,6 +253,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
_batchCompleter = null;
|
||||
|
||||
_flushInFlight = true;
|
||||
_inFlight.addAll(pubkeys);
|
||||
final generation = _generation;
|
||||
var succeeded = false;
|
||||
try {
|
||||
@@ -177,6 +266,8 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
);
|
||||
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<Map<String, UserProfile>> {
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
177
mobile/test/shared/profile/user_cache_snapshot_test.dart
Normal file
177
mobile/test/shared/profile/user_cache_snapshot_test.dart
Normal file
@@ -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<void>.delayed(const Duration(milliseconds: 80));
|
||||
expect(offline.requests, 1);
|
||||
expect(cache2.state[_alice]?.displayName, 'Alice');
|
||||
cache2.get(_alice);
|
||||
await Future<void>.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<List<NostrEvent>>();
|
||||
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<void>.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<void>.delayed(const Duration(milliseconds: 80));
|
||||
expect(cache3.state[_alice]?.displayName, 'Alice Renamed');
|
||||
cache3.get(_alice);
|
||||
await cache3.preload(const [_alice]);
|
||||
await Future<void>.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<Future<List<NostrEvent>>> _results;
|
||||
final bool fail;
|
||||
int requests = 0;
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
requests++;
|
||||
if (fail) throw StateError('offline');
|
||||
return _results.isEmpty ? const [] : await _results.removeAt(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user