diff --git a/mobile/lib/shared/community/community_membership_provider.dart b/mobile/lib/shared/community/community_membership_provider.dart index 4e1eb6c8a04..dcf6da77c51 100644 --- a/mobile/lib/shared/community/community_membership_provider.dart +++ b/mobile/lib/shared/community/community_membership_provider.dart @@ -104,10 +104,25 @@ CommunityMembershipSnapshot communityMembershipFromEvents( /// The HTTP query resolves from the first response rather than waiting for a /// WebSocket EOSE frame, and watching [relayConfigProvider] makes the result /// community-scoped. +/// +/// The snapshot is queried once per community and kept alive across consumer +/// remounts. It refreshes only on an explicit `ref.invalidate`, on a community +/// switch, or when the relay session (re)connects — never on every session +/// state emission. final communityMembershipProvider = FutureProvider.autoDispose((ref) async { ref.watch(relayConfigProvider); - final session = ref.watch(relaySessionProvider.notifier); + ref.keepAlive(); + // Re-fetch only after a (re)connect completes, mirroring + // channelMembersProvider. Reading (not watching) the notifier keeps + // reconnecting/disconnected transitions from rebuilding this provider. + ref.listen(relaySessionProvider, (previous, next) { + if (next.status == SessionStatus.connected && + previous?.status != SessionStatus.connected) { + ref.invalidateSelf(); + } + }); + final session = ref.read(relaySessionProvider.notifier); final events = await session.queryRelay([NostrFilters.relayMembers()]); return communityMembershipFromEvents(events); }); diff --git a/mobile/test/shared/community/community_membership_provider_test.dart b/mobile/test/shared/community/community_membership_provider_test.dart index 45731c34692..8896589b26a 100644 --- a/mobile/test/shared/community/community_membership_provider_test.dart +++ b/mobile/test/shared/community/community_membership_provider_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { const owner = @@ -58,6 +59,124 @@ void main() { expect(snapshot.members, isEmpty); expect(canManageCommunityInvites(snapshot.roleFor(owner)), isFalse); }); + + group('communityMembershipProvider caching', () { + late _CountingRelaySession session; + late ProviderContainer container; + + setUp(() { + session = _CountingRelaySession(owner); + container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new), + ], + ); + addTearDown(container.dispose); + }); + + test('does not re-query when the session state changes', () async { + final subscription = container.listen( + communityMembershipProvider, + (_, _) {}, + ); + addTearDown(subscription.close); + + final first = await container.read(communityMembershipProvider.future); + expect(first.roleFor(owner), CommunityMemberRole.owner); + expect(session.queryCount, 1); + + session.setStatus(SessionStatus.reconnecting); + await container.pump(); + session.setStatus(SessionStatus.disconnected); + await container.pump(); + await container.read(communityMembershipProvider.future); + + expect(session.queryCount, 1); + final cached = container.read(communityMembershipProvider).asData?.value; + expect(cached?.roleFor(owner), CommunityMemberRole.owner); + }); + + test('does not re-query when a consumer remounts', () async { + final subscription = container.listen( + communityMembershipProvider, + (_, _) {}, + ); + await container.read(communityMembershipProvider.future); + expect(session.queryCount, 1); + + subscription.close(); + await container.pump(); + + final remounted = container.listen( + communityMembershipProvider, + (_, _) {}, + ); + addTearDown(remounted.close); + await container.read(communityMembershipProvider.future); + + expect(session.queryCount, 1); + }); + + test('re-queries on explicit invalidation and on reconnect', () async { + final subscription = container.listen( + communityMembershipProvider, + (_, _) {}, + ); + addTearDown(subscription.close); + await container.read(communityMembershipProvider.future); + expect(session.queryCount, 1); + + container.invalidate(communityMembershipProvider); + await container.read(communityMembershipProvider.future); + expect(session.queryCount, 2); + + session.setStatus(SessionStatus.reconnecting); + await container.pump(); + expect(session.queryCount, 2); + session.setStatus(SessionStatus.connected); + await container.pump(); + await container.read(communityMembershipProvider.future); + expect(session.queryCount, 3); + }); + }); +} + +class _TestRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => + const RelayConfig(baseUrl: 'https://relay.example.com'); +} + +class _CountingRelaySession extends RelaySessionNotifier { + _CountingRelaySession(this.owner); + + final String owner; + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + void setStatus(SessionStatus status) { + state = SessionState(status: status); + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + return [ + _event( + createdAt: queryCount, + tags: [ + ['member', owner, 'owner'], + ], + ), + ]; + } } NostrEvent _event({required int createdAt, required List> tags}) =>