diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d16084c3ee..7792d55623 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -481,7 +481,12 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.save(updated); final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); - await syncCommunitySnapshot(ref, updatedList); + if (enabled) { + await syncCommunitySnapshot(ref, updatedList); + } else { + // Presentation export must not hold up the durably journaled opt-out. + unawaited(syncCommunitySnapshot(ref, updatedList)); + } shouldDeactivate = !enabled && pushState.pendingTombstoneGeneration != null; }); @@ -548,7 +553,7 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.save(updated); final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); - await syncCommunitySnapshot(ref, updatedList); + unawaited(syncCommunitySnapshot(ref, updatedList)); } pendingCommunity = updated; pendingGeneration = pushState.pendingTombstoneGeneration; @@ -583,7 +588,7 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.save(updated); final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); - await syncCommunitySnapshot(ref, updatedList); + unawaited(syncCommunitySnapshot(ref, updatedList)); }); Future renameCommunity(String id, String name) async { diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index fa977786f2..5ca7da289c 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -42,7 +42,7 @@ void main() { tearDown(() => container.dispose()); - ProviderContainer createContainer() { + ProviderContainer createContainer({CommunitySnapshotWriter? snapshotWriter}) { Future writeSnapshot(List communities) async { snapshots.add(List.of(communities)); } @@ -50,7 +50,9 @@ void main() { return ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue(writeSnapshot), + communitySnapshotWriterProvider.overrideWithValue( + snapshotWriter ?? writeSnapshot, + ), communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( community, @@ -307,6 +309,99 @@ void main() { expect(deactivationGenerations, [2]); }); + test('opt-out dispatch does not wait for presentation export', () async { + final exportStarted = Completer(); + final finishExport = Completer(); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 7), + ); + deactivator = (community, {generation}) async { + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, 8); + deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); + }; + container = createContainer( + snapshotWriter: (communities) async { + if (communities.isNotEmpty && + !communities.single.pushNotificationsEnabled) { + if (!exportStarted.isCompleted) exportStarted.complete(); + await finishExport.future; + } + }, + ); + await container.read(communityListProvider.future); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + final disable = notifier.setPushNotificationsEnabled(community.id, false); + await exportStarted.future; + try { + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + // Presentation I/O is still blocked, but the durable opt-out must + // already be allowed to reach its relay-publication seam. + await container.pump(); + expect(deactivatedCommunityIds, [community.id]); + expect(deactivationGenerations, [8]); + await disable.timeout(const Duration(seconds: 1)); + final accepted = (await communityStorage.loadAll()).single; + expect( + accepted.pushSubscriptionState.pendingTombstoneGeneration, + isNull, + ); + } finally { + finishExport.complete(); + await disable; + } + }); + + test('opt-in still waits for presentation export', () async { + final exportStarted = Completer(); + final finishExport = Completer(); + final community = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith(pushNotificationsEnabled: false); + container = createContainer( + snapshotWriter: (communities) async { + if (communities.isNotEmpty && + communities.single.pushNotificationsEnabled) { + exportStarted.complete(); + await finishExport.future; + } + }, + ); + await container.read(communityListProvider.future); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + var completed = false; + final enable = notifier + .setPushNotificationsEnabled(community.id, true) + .then((_) => completed = true); + await exportStarted.future; + await container.pump(); + try { + expect(completed, isFalse); + expect(deactivatedCommunityIds, isEmpty); + } finally { + finishExport.complete(); + await enable; + } + expect(completed, isTrue); + }); + test('opt-out persists first and publishes a higher tombstone', () async { container = createContainer(); await container.read(communityListProvider.future);