diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index 3bda442249..6aa83ec060 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -452,17 +452,46 @@ class VerifiedProfilesNotifier extends Notifier> { } } + /// Validates one head. Tests override this to count parses. + @visibleForTesting + VerifiedProfile? parseHead( + NostrEvent event, + VerifiedProfileAuthority authority, { + required bool checked, + }) => parseVerifiedProfile(event, authority, checked: checked); + + /// The last parse of each subject's head. Every publish rebuilds the whole + /// map, so without it each batch re-validated every head already known. + Map _parsed = const {}; + Map _snapshot() { final authority = _authority; - if (!_enabled || authority == null) return const {}; - return { - for (final MapEntry(:key, :value) in _heads.entries) - key: ?parseVerifiedProfile( - value, - authority, - checked: _discoveryFresh && _checked.contains(key), - ), - }; + if (!_enabled || authority == null) { + _parsed = const {}; + return const {}; + } + final parsed = {}; + final profiles = {}; + for (final MapEntry(:key, :value) in _heads.entries) { + final checked = _discoveryFresh && _checked.contains(key); + final previous = _parsed[key]; + final head = + previous != null && + identical(previous.event, value) && + previous.authority == authority + ? previous + : _ParsedHead( + value, + authority, + parseHead(value, authority, checked: checked), + ); + parsed[key] = head; + if (head.withChecked(checked) case final profile?) { + profiles[key] = profile; + } + } + _parsed = parsed; + return profiles; } void _publish() { @@ -472,6 +501,30 @@ class VerifiedProfilesNotifier extends Notifier> { } } +/// A head's parse, kept while the head and authority stand. Only `checked` +/// varies between publishes, so it is applied without validating again. +class _ParsedHead { + _ParsedHead(this.event, this.authority, this._profile); + + final NostrEvent event; + final VerifiedProfileAuthority authority; + VerifiedProfile? _profile; + + VerifiedProfile? withChecked(bool checked) { + final profile = _profile; + if (profile == null || profile.checked == checked) return profile; + return _profile = VerifiedProfile( + displayName: profile.displayName, + username: profile.username, + fullName: profile.fullName, + issuerLabel: profile.issuerLabel, + verifiedAt: profile.verifiedAt, + checked: checked, + multipleIssuers: profile.multipleIssuers, + ); + } +} + final verifiedProfilesProvider = NotifierProvider>( VerifiedProfilesNotifier.new, 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 ac19b756ea..ce93027eba 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart @@ -55,6 +55,22 @@ class _CountingNotifier extends VerifiedProfilesNotifier { Map newInterestMap() => _CountingMap(iterations); } +class _ParseCountingNotifier extends VerifiedProfilesNotifier { + _ParseCountingNotifier(this.parses); + + final List parses; + + @override + VerifiedProfile? parseHead( + NostrEvent event, + VerifiedProfileAuthority authority, { + required bool checked, + }) { + parses.add(verifiedProfileSubject(event, authority)!); + return super.parseHead(event, authority, checked: checked); + } +} + class Harness { Harness(this.async, {VerifiedProfilesNotifier Function()? notifier}) { container = ProviderContainer( @@ -129,6 +145,27 @@ void main() { }); }); + test('a later batch reuses profiles already parsed', () { + fakeAsync((async) { + final parses = []; + final h = Harness(async, notifier: () => _ParseCountingNotifier(parses)); + addTearDown(h.container.dispose); + h.relay.seed([ + signedProjection(profileTags(subjectA)), + signedProjection(profileTags(subjectB)), + ]); + h.start([subjectA]); + expect(h.profiles[subjectA]?.checked, isTrue); + h.container.read(verifiedProfilesProvider.notifier).track([subjectB]); + h.settle(); + // Rebatching unchecks and rechecks A; every publish rebuilds the map, + // but each head is validated once. + expect(h.profiles[subjectA]?.checked, isTrue); + expect(h.profiles[subjectB]?.checked, isTrue); + expect(parses..sort(), [subjectA, subjectB]); + }); + }); + journey('no marks until discovery succeeds and advertises public_profile', ( h, ) {