fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111)

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-05 11:15:41 -08:00
committed by GitHub
parent 244038b495
commit cf8fbbba55
2 changed files with 43 additions and 7 deletions

View File

@@ -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<Map<String, VerifiedProfile>> {
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<Map<String, VerifiedProfile>> {
}
}
/// Drops the oldest untracked records past [_cachedHeadLimit], in one pass.
void _trimCachedHeads() {
var excess = _heads.length - _cachedHeadLimit;
if (excess <= 0) return;
final drop = <String>[];
for (final key in _heads.keys) {
if (_wanted.containsKey(key)) continue;
drop.add(key);
if (--excess == 0) break;
}
drop.forEach(_heads.remove);
}
Future<void> _onConnected(int generation) async {
if (generation != _generation) return;
await _refreshDiscovery(generation);
@@ -439,12 +457,11 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
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),
),
};
}

View File

@@ -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,
) {