From cf8fbbba55705a4330547e5045ff1253dd670289 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 11:15:41 -0800 Subject: [PATCH] fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a channel with more members than the verified-name interest limit (1,024), returning to the app made member names flip: a verified name changed to a profile name with a collision suffix (for example "Ankit · ykn9") or to an npub, then back once a refetch landed. Seen on an iPhone 12 mini in a 2,004-member channel; instrumented logging showed the verified-name map dropping from 956 entries to 0 on opening that channel. Tracking a large roster evicts the oldest-tracked subjects, and eviction dropped their records too. On resume, member and profile preloads track the evicted people again, which evicts the ones on screen, so their verified names disappear until the next fetch. Evicted records now stay as unchecked cache (up to 4,096, oldest untracked dropped first) and are rechecked when tracked again. Only tracked subjects accept new records, so the cache never grows from relay traffic. ### Validation - Added `a subject evicted by a large roster keeps its last known name`: it fails when eviction drops the record. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - Same phone, 2,004-member channel, after this change: the verified-name map held steady (978 entries, growing to 1,828) through opening the channel and two resumes after 12 s and 20 s in the background, and the "ankit, lhoward" and "sfoo, Dani" rows never changed in the screen recording. Before, both flipped (to "Ankit · ykn9" and an npub) and back after each resume. - Automated app run on the same phone with this change: Home, search, the large channel, and its resume after the reconnect grace period. This touches the same `_snapshot` line as [#9101](https://github.com/block/buzz/pull/9101), so whichever lands second needs a one-line rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../verified_profile_directory.dart | 31 ++++++++++++++----- .../verified_profile_directory_test.dart | 19 ++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) 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, ) {