diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index c3a258bb56..3bda442249 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -9,6 +9,11 @@ import 'verified_profile_discovery_fetch.dart'; import 'verified_profile_proof.dart'; const _wantedLimit = 1024; + +/// Records kept after their subject stops being tracked. A roster larger than +/// [_wantedLimit] keeps evicting and re-tracking the same people; their last +/// known names stay on screen, unchecked, instead of blinking to fallbacks. +const _cachedHeadLimit = 4 * _wantedLimit; const _eoseTimeout = Duration(seconds: 10); const _discoveryRefresh = Duration(minutes: 5); @@ -142,9 +147,9 @@ class VerifiedProfilesNotifier extends Notifier> { if (overflow > 0) { for (final oldest in _wanted.keys.take(overflow).toList()) { _wanted.remove(oldest); - _heads.remove(oldest); _checked.remove(oldest); } + _trimCachedHeads(); added = true; } if (!added) return; @@ -155,6 +160,19 @@ class VerifiedProfilesNotifier extends Notifier> { } } + /// Drops the oldest untracked records past [_cachedHeadLimit], in one pass. + void _trimCachedHeads() { + var excess = _heads.length - _cachedHeadLimit; + if (excess <= 0) return; + final drop = []; + for (final key in _heads.keys) { + if (_wanted.containsKey(key)) continue; + drop.add(key); + if (--excess == 0) break; + } + drop.forEach(_heads.remove); + } + Future _onConnected(int generation) async { if (generation != _generation) return; await _refreshDiscovery(generation); @@ -439,12 +457,11 @@ class VerifiedProfilesNotifier extends Notifier> { if (!_enabled || authority == null) return const {}; return { for (final MapEntry(:key, :value) in _heads.entries) - if (_wanted.containsKey(key)) - key: ?parseVerifiedProfile( - value, - authority, - checked: _discoveryFresh && _checked.contains(key), - ), + key: ?parseVerifiedProfile( + value, + authority, + checked: _discoveryFresh && _checked.contains(key), + ), }; } diff --git a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart index cd8d5bd45f..ac19b756ea 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart @@ -182,6 +182,25 @@ void main() { expect(subscribed, {for (var i = 4300 - 1024; i < 4300; i++) key(i)}); }); + journey('a subject evicted by a large roster keeps its last known name', (h) { + String key(int i) => i.toRadixString(16).padLeft(64, '0'); + h.relay.seed([signedProjection(profileTags(subjectA))]); + h.start([subjectA]); + expect(h.profiles[subjectA]?.checked, isTrue); + + final notifier = h.container.read(verifiedProfilesProvider.notifier); + notifier.track([for (var i = 0; i < 1100; i++) key(i)]); + h.settle(); + // Untracked, so no longer confirmed, but the name stays on screen rather + // than falling back to a profile name or an npub. + expect(h.profiles[subjectA]?.displayName, 'Ada Lovelace'); + expect(h.profiles[subjectA]?.checked, isFalse); + + notifier.track([subjectA]); + h.settle(); + expect(h.profiles[subjectA]?.checked, isTrue); + }); + journey('live updates, retractions and ordering apply on the subscription', ( h, ) {