Files
buzz/mobile/lib/shared/profile/user_cache_provider.dart
Wes ab73cd9fb5 perf(mobile): restore cached channels before community sync (#8580)
## Summary

Returning to a previously visited community shows its saved channel list
immediately, including offline, while membership and unread state
refresh in the background. Initial revalidation failures keep the list
visible and retry every 60 seconds without overlapping background
attempts. After successful history revalidation, ordinary reconnects use
metadata-only refreshes. A reconnect arriving during a background
request queues one follow-up attempt; timer ticks never queue
overlapping work. History deadlines retain the full-revalidation retry
until history succeeds.

Snapshots are disposable, versioned and scoped by canonical relay URL
and signing identity, capped at 2 MB / 2,000 member channels. They
include hidden-DM IDs and prefer fresher in-memory state. Community
removal and identity replacement retire outstanding writes; re-adding
unchanged credentials preserves the current cache and writer. Startup
sweeps orphan snapshots without waiting for disk acknowledgement;
community mutations also sweep orphan snapshots, including data restored
without secure community records. Cache-delete failures do not block
sign-out or strand push revocation; deletion is attempted before
journaling and retried by orphan cleanup. Successfully persisted
unchanged snapshots skip disk writes; failed writes remain retryable.

The cold path reuses fetched membership rosters, overlaps
profile/latest-message requests, shares verified DM profiles with
avatars, and fetches the own profile once per authenticated connection.
Community connection work overlaps the switching animation; avatar
hydration no longer gates landing. Full channel refreshes also
revalidate cached DM participant profiles so relay-side name changes
reach labels and avatars; metadata-only reconnects and background
refreshes reuse admitted profiles.

Me-chat and invite starter recovery use an explicit fresh membership
fetch that propagates errors while leaving the cached list visible.
Cached layout/read preferences permit immediate landing; newer
cross-device preferences may update ordering or badges after reveal.

Integrated with main’s #8490 idle/resume changes: hidden chats detach
live subscriptions, background membership polling stays quiet, and each
resume reconciles once. Chat history follows main’s live-admission-first
ordering to avoid missing messages committed during relay admission;
cached community landing remains immediate.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,778
tests passed, 19 skipped; four checks for builds without push
configuration passed. File-size and diff checks passed.
- Provider/storage journeys cover offline startup, failed initial
revalidation recovering live delivery, bounded retries, lightweight
reconnects, scope isolation, default-port URLs, padded/mismatched stored
credentials, credential replacement, backup-restored orphan data,
removal races, failed platform deletes and failed writes.
- Follow-up regressions cover interrupted native Me-chat lookup, invite
failure propagation, retry after an initial list failure, history
deadlines, queued reconnects, escaped-path keys, unchanged credential
re-adds and non-blocking startup cleanup. Reverting each of the eight
behavior/caller/cleanup-order fixes fails its corresponding regression.
- DM-name regression changes only the relay profile, verifies a full
refresh updates the channel label and shared profile cache, and confirms
ordinary reconnects avoid another profile request. The original
cache-only implementation fails this regression.
- Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's
413 channels went from 6.26–6.30 s to approximately 1.5 s on returning;
all three warmed communities switched in 1.47–1.54 s. Real socket pause
restored the cached list offline and retained it on reconnect. These are
debug simulator observations, not release-device benchmarks.
- Integration with current main also passes the real-session
resume/background/AUTH and live-admission/detach journeys.
- Follow-up failure paths were exercised with deterministic
provider/storage tests and widget journeys; the running simulator was
not modified during this follow-up.

Human acceptance remains pending; this PR stays draft. Try switching
among previously visited communities, opening a channel, restarting and
repeating. First visits and oversized snapshots require network loading.
Cached metadata can be stale until refresh; it lives in ordinary
preferences without additional application-level encryption. The
snapshot contains no message bodies or credentials. Persistent
disk-deletion failures can leave metadata until a later cleanup
succeeds.

---------

Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz>
Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz>
Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
2026-10-26 11:57:07 -06:00

286 lines
10 KiB
Dart

import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import 'user_profile.dart';
import 'profile_event_parser.dart';
/// In-memory cache of user profiles, fetched in batches from the relay.
///
/// Lookups requested via [get] or [preload] are coalesced into a single
/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. Larger requests
/// drain sequentially in pages bounded by the relay response limit.
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
// The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000).
static const _maximumProfilesPerQuery = 1000;
final Set<String> _pending = {};
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
int _generation = 0;
bool _flushInFlight = false;
Future<void> _verificationQueue = Future.value();
Timer? _batchTimer;
Completer<bool>? _batchCompleter;
@override
Map<String, UserProfile> build() {
ref.watch(relayConfigProvider);
_generation++;
_profileEventOrders.clear();
ref.onDispose(() {
_generation++;
_pending.clear();
_batchTimer?.cancel();
_batchTimer = null;
_batchCompleter?.complete(false);
_batchCompleter = null;
});
return {};
}
/// Request a profile for [pubkey]. Returns immediately from cache if
/// available, otherwise schedules a batch fetch.
UserProfile? get(String pubkey) {
final cached = state[pubkey.toLowerCase()];
if (cached != null) return cached;
_scheduleFetch(pubkey.toLowerCase());
return null;
}
/// Stores a profile that was fetched or updated outside the batch loader.
void put(UserProfile profile) {
state = {...state, profile.pubkey.toLowerCase(): profile};
}
/// Resolves profiles through verified batch admission.
///
/// Unlike [preload], failures propagate to callers whose result requires
/// profile hydration. Set [refreshCached] for a full revalidation; otherwise
/// cached profiles remain reusable within this scope.
Future<Map<String, UserProfile>> resolve(
List<String> pubkeys, {
bool refreshCached = false,
}) async {
final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet();
final toFetch = requested
.where((pubkey) => refreshCached || !state.containsKey(pubkey))
.toList();
final generation = _generation;
final session = ref.read(relaySessionProvider.notifier);
for (final batch in _profileQueryBatches(toFetch)) {
if (!_isCurrent(generation)) {
throw StateError('Profile request belongs to a retired cache');
}
final events = await session.fetchHistory(
NostrFilters.profilesBatch(batch),
);
if (!await _verifyAndMerge(events, generation)) {
throw StateError('Profile request belongs to a retired cache');
}
}
if (!_isCurrent(generation)) {
throw StateError('Profile request belongs to a retired cache');
}
return {for (final pubkey in requested) pubkey: ?state[pubkey]};
}
/// Preload profiles for a list of pubkeys (e.g. channel members).
/// Returns whether the batch completed successfully.
Future<bool> preload(List<String> pubkeys) {
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet();
final alreadyPending = normalized.any(_pending.contains);
final uncached = normalized
.map((pk) => pk.toLowerCase())
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
.toList();
if (uncached.isEmpty && !alreadyPending) return Future.value(true);
_pending.addAll(uncached);
final completer = _batchCompleter ??= Completer<bool>();
_scheduleBatch();
return completer.future;
}
/// Force-refresh profiles for identity-sensitive gates.
///
/// Unlike [preload], this fetches cached pubkeys too so stale human profiles
/// cannot be trusted after a verified agent-owner profile was published.
Future<bool> refresh(List<String> pubkeys) async {
final normalized = pubkeys
.map((pubkey) => pubkey.toLowerCase())
.where((pubkey) => pubkey.isNotEmpty)
.toSet()
.toList();
if (normalized.isEmpty) return true;
final generation = _generation;
try {
final session = ref.read(relaySessionProvider.notifier);
for (final batch in _profileQueryBatches(normalized)) {
if (!_isCurrent(generation)) return false;
final events = await session.fetchHistory(
NostrFilters.profilesBatch(batch),
);
if (!_isCurrent(generation)) return false;
if (!await _verifyAndMerge(events, generation)) return false;
}
return true;
} catch (_) {
return false;
}
}
/// Applies a live kind:0 profile event to the cache.
///
/// Surfaces that keep a participant-scoped profile subscription can use this
/// to update names and avatars without discarding the rest of the cache.
void cacheProfileEvent(NostrEvent event) {
if (event.kind != 0) return;
final updated = Map<String, UserProfile>.from(state);
if (_cacheProfileEvent(event, updated)) state = updated;
}
void _scheduleFetch(String pubkey) {
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
_pending.add(pubkey);
_batchCompleter ??= Completer<bool>();
_scheduleBatch();
}
Future<void> _flushPending() async {
_batchTimer = null;
if (_pending.isEmpty) return;
final pubkeys = _pending.toList();
_pending.clear();
final completer = _batchCompleter;
_batchCompleter = null;
_flushInFlight = true;
final generation = _generation;
var succeeded = false;
try {
final session = ref.read(relaySessionProvider.notifier);
var remaining = pubkeys.length;
for (final batch in _profileQueryBatches(pubkeys)) {
if (!_isCurrent(generation)) return;
final events = await session.fetchHistory(
NostrFilters.profilesBatch(batch),
);
if (!_isCurrent(generation)) return;
if (!await _verifyAndMerge(events, generation)) return;
remaining -= batch.length;
if (remaining == 0) {
// All requested pages are ready.
succeeded = true;
completer?.complete(true);
}
}
} catch (_) {
// Silently fail — non-gating callers will just show pubkeys.
} finally {
if (completer != null && !completer.isCompleted) {
completer.complete(succeeded);
}
_flushInFlight = false;
if (ref.mounted && _pending.isNotEmpty) _scheduleBatch();
}
}
Iterable<List<String>> _profileQueryBatches(List<String> pubkeys) sync* {
for (
var start = 0;
start < pubkeys.length;
start += _maximumProfilesPerQuery
) {
final end = start + _maximumProfilesPerQuery;
yield pubkeys.sublist(start, end < pubkeys.length ? end : pubkeys.length);
}
}
void _scheduleBatch() {
if (_flushInFlight) return;
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}
Future<bool> _verifyAndMerge(List<NostrEvent> events, int generation) {
// Both refresh and preload share one worker slot. Keep this queue across
// community changes so an old worker cannot overlap a new community's job.
final result = _verificationQueue.then((_) async {
if (!_isCurrent(generation)) return false;
// Match live-cache admission before parsing. In particular, a malformed
// stale profile must not fail a refresh that previously ignored it.
final pending = [
for (final event in events)
if (event.kind == 0 &&
_isNewer(event.pubkey.toLowerCase(), event.createdAt, event.id))
event,
];
if (pending.isEmpty) return true;
final profiles = await ref.read(profileEventBatchParserProvider)(pending);
if (!_isCurrent(generation)) return false;
_mergeParsedProfiles(profiles);
return true;
});
// Callers receive the original error; only the sequencing tail recovers so
// one rejected batch does not poison all future refreshes.
_verificationQueue = result.then<void>(
(_) {},
onError: (Object error, StackTrace stack) {},
);
return result;
}
bool _isCurrent(int generation) => ref.mounted && generation == _generation;
void _mergeParsedProfiles(List<ParsedProfileEvent> profiles) {
// Read the current state after the isolate completes: live events and other
// batches may have installed newer profiles while this batch was parsing.
final updated = Map<String, UserProfile>.from(state);
var changed = false;
for (final parsed in profiles) {
if (!_isNewer(parsed.profile.pubkey, parsed.createdAt, parsed.eventId)) {
continue;
}
updated[parsed.profile.pubkey] = parsed.profile;
_profileEventOrders[parsed.profile.pubkey] = (
createdAt: parsed.createdAt,
eventId: parsed.eventId,
);
changed = true;
}
if (changed) state = updated;
}
bool _isNewer(String pubkey, int createdAt, String eventId) {
final current = _profileEventOrders[pubkey];
return current == null ||
createdAt > current.createdAt ||
(createdAt == current.createdAt &&
eventId.compareTo(current.eventId) < 0);
}
bool _cacheProfileEvent(NostrEvent event, Map<String, UserProfile> profiles) {
if (event.kind != 0) return false;
final pubkey = event.pubkey.toLowerCase();
if (!_isNewer(pubkey, event.createdAt, event.id)) return false;
profiles[pubkey] = parseProfileEvent(event).profile;
_profileEventOrders[pubkey] = (
createdAt: event.createdAt,
eventId: event.id,
);
return true;
}
}
/// Profile history parser, isolated from provider state so batch completion can
/// be delayed independently of relay delivery.
final profileEventBatchParserProvider =
Provider<Future<List<ParsedProfileEvent>> Function(List<NostrEvent>)>(
(ref) => parseProfileEventBatch,
);
final userCacheProvider =
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
UserCacheNotifier.new,
);