Files
buzz/mobile/lib/shared/profile/profile_snapshot.dart
Tom Brow 733d164e80 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>
2026-11-09 06:58:48 -08:00

183 lines
6.9 KiB
Dart

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);
});