diff --git a/mobile/lib/features/channels/channel_filter/channel_filter_manager.dart b/mobile/lib/features/channels/channel_filter/channel_filter_manager.dart index 119fa9e9ec..3a11293e2d 100644 --- a/mobile/lib/features/channels/channel_filter/channel_filter_manager.dart +++ b/mobile/lib/features/channels/channel_filter/channel_filter_manager.dart @@ -100,9 +100,40 @@ class ChannelFilterManager extends ChangeNotifier { ChannelFilterMode mode, { int? windowSeconds, bool removeWindow = false, + }) => _save( + group, + mode, + windowSeconds: windowSeconds, + removeWindow: removeWindow, + ); + + /// Best-effort cleanup after local deletion. Section IDs are never reused; + /// failure leaves an unreachable preference, never blocks the local action. + Future clearDeletedSection(String sectionId) async { + final group = 'section:$sectionId'; + if (store.modeFor(group) == ChannelFilterMode.all && + !(store.toJson()['activeWindowSeconds'] as Map? ?? {}).containsKey( + group, + )) { + return; + } + await _save( + group, + ChannelFilterMode.all, + removeWindow: true, + deletedSection: true, + ); + } + + Future _save( + String group, + ChannelFilterMode mode, { + int? windowSeconds, + bool removeWindow = false, + bool deletedSection = false, }) async { if (_disposed || !ready || busy) throw StateError('Filters are read-only'); - if (isGroupLive?.call(group) == false) { + if (!deletedSection && isGroupLive?.call(group) == false) { throw StateError('Section no longer exists'); } busy = true; @@ -118,7 +149,7 @@ class ChannelFilterManager extends ChangeNotifier { ); _notify(); final fresh = await _read(); - if (isGroupLive?.call(group) == false) { + if (!deletedSection && isGroupLive?.call(group) == false) { throw StateError('Section no longer exists'); } final next = fresh.edit( diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index f10180757d..1338a58237 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -104,7 +104,12 @@ class ChannelSectionsNotifier extends Notifier { void renameSection(String sectionId, String newName) => _manager?.renameSection(sectionId, newName); - void deleteSection(String sectionId) => _manager?.deleteSection(sectionId); + void deleteSection(String sectionId) { + final manager = _manager; + if (manager == null) return; + manager.deleteSection(sectionId); + _emitManagerState(manager); + } void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId); diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index c2c7c8dd8e..0d0a774c49 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -418,32 +418,25 @@ class _SliverChannelsList extends HookConsumerWidget { ], ), ); - if (confirmed == true) { + if (confirmed == true && context.mounted) { + // Local section removal must work without filter-read/write + // authority. Cleanup is optional for this never-reused ID. + ref + .read(channelSectionsProvider.notifier) + .deleteSection(section.id); try { - if (filters == null) { - throw StateError('Filters unavailable'); - } - await filters.save( - sectionSortGroupKey(section.id), - ChannelFilterMode.all, - removeWindow: true, - ); + await filters?.clearDeletedSection(section.id); } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'Could not reset the section filter. Retry deleting the section.', + 'Section deleted. Its saved filter could not be cleared.', ), ), ); } - return; } - if (!context.mounted) return; - ref - .read(channelSectionsProvider.notifier) - .deleteSection(section.id); } }, onMoveUp: () => ref diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 701379dbe8..47458ae78e 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -4460,6 +4460,7 @@ class _CompletedPairingNotifier extends PairingNotifier { ChannelFilterManager _filterTestManager({ ChannelFilterStore? cached, bool failPublish = false, + VoidCallback? onPublish, }) { final keys = nostr.Keys.generate(); final crypto = ChannelSortCrypto(keys.nsec, keys.public); @@ -4471,6 +4472,7 @@ ChannelFilterManager _filterTestManager({ cache: (_) {}, fetch: () async => history, publish: (content, at) async { + onPublish?.call(); if (failPublish) throw StateError('rejected'); history = [ NostrEvent.fromJson( diff --git a/mobile/test/features/channels/channels_page_test/filter_feedback_tests.dart b/mobile/test/features/channels/channels_page_test/filter_feedback_tests.dart index ec2e65a0f7..d9443241bc 100644 --- a/mobile/test/features/channels/channels_page_test/filter_feedback_tests.dart +++ b/mobile/test/features/channels/channels_page_test/filter_feedback_tests.dart @@ -3,6 +3,76 @@ part of '../channels_page_test.dart'; void _filterFeedbackTests( Widget Function({required List overrides}) buildTestable, ) { + testWidgets( + 'deleting an unfiltered section persists offline without filter publication', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + const relayUrl = 'https://offline.example'; + final storage = ChannelSectionsStorage(prefs); + storage.write( + keys.public, + relayUrl, + const ChannelSectionStore( + sections: [ + ChannelSection(id: 'offline', name: 'Offline section', order: 0), + ], + ), + ); + var publications = 0; + final manager = _filterTestManager(onPublish: () => publications++); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([ + Channel( + id: 'channel', + name: 'General', + channelType: 'stream', + visibility: 'open', + description: '', + memberCount: 1, + createdBy: 'pk', + createdAt: DateTime(2025), + isMember: true, + ), + ]), + ), + relayConfigProvider.overrideWith( + () => _FilterFeedbackConfig( + RelayConfig(baseUrl: relayUrl, nsec: keys.nsec), + ), + ), + relaySessionProvider.overrideWith( + () => _ReconnectingRelaySession( + initialStatus: SessionStatus.disconnected, + ), + ), + savedPrefsProvider.overrideWithValue(prefs), + channelFilterProvider.overrideWithValue(manager), + ], + ), + ); + await tester.pumpAndSettle(); + expect(manager.ready, isFalse); + expect(find.text('Offline section'), findsOneWidget); + await tester.tap(find.byKey(const Key('section-menu-offline'))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Delete section')); + await tester.tap(find.text('Delete section')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.text('Offline section'), findsNothing); + expect(storage.read(keys.public, relayUrl).sections, isEmpty); + expect(publications, 0); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }, + ); + testWidgets('Unreads agrees with highlights in the pre-seed frame', ( tester, ) async { @@ -77,3 +147,10 @@ void _filterFeedbackTests( manager.dispose(); }); } + +class _FilterFeedbackConfig extends RelayConfigNotifier { + _FilterFeedbackConfig(this.config); + final RelayConfig config; + @override + RelayConfig build() => config; +}