mirror of
https://github.com/block/buzz.git
synced 2026-11-10 19:32:00 +00:00
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>
178 lines
6.2 KiB
Dart
178 lines
6.2 KiB
Dart
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);
|
|
}
|
|
}
|