diff --git a/mobile/lib/features/channels/channels_page/filters.dart b/mobile/lib/features/channels/channels_page/filters.dart index 811bd50230..2d93bf3c17 100644 --- a/mobile/lib/features/channels/channels_page/filters.dart +++ b/mobile/lib/features/channels/channels_page/filters.dart @@ -58,12 +58,12 @@ class _SectionViewSheet extends HookConsumerWidget { final days = useState( (manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400, ); - final dirty = useState(false); + final filterDirty = useState(false); final saving = useState(false); final failed = useState(false); // A confirmed initial load may arrive while the sheet is open. Preserve edits. useEffect(() { - if (!dirty.value && !saving.value) { + if (!filterDirty.value && !saving.value) { mode.value = manager?.store.modeFor(group) ?? ChannelFilterMode.all; days.value = (manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ @@ -105,7 +105,7 @@ class _SectionViewSheet extends HookConsumerWidget { (manager?.store.modeFor(group) ?? ChannelFilterMode.all) || (mode.value == ChannelFilterMode.active && days.value * 86400 != manager?.store.windowFor(group)); - if (filterChanged) { + if (filterDirty.value && filterChanged) { if (manager == null || !manager.ready || manager.busy) { throw StateError('Preferences are read-only'); } @@ -157,7 +157,6 @@ class _SectionViewSheet extends HookConsumerWidget { onTap: change == null ? null : () { - dirty.value = true; change(entry.key); }, ), @@ -208,7 +207,12 @@ class _SectionViewSheet extends HookConsumerWidget { ChannelFilterMode.active: 'Active', }, mode.value, - writable ? (value) => mode.value = value : null, + writable + ? (value) { + filterDirty.value = true; + mode.value = value; + } + : null, ), if (mode.value == ChannelFilterMode.active) choices( @@ -218,7 +222,12 @@ class _SectionViewSheet extends HookConsumerWidget { day: '$day ${day == 1 ? 'day' : 'days'}', }, days.value, - writable ? (value) => days.value = value : null, + writable + ? (value) { + filterDirty.value = true; + days.value = value; + } + : null, ), if (manager?.ready != true) const Text( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 3d522f2e96..17084d245f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -10,6 +10,7 @@ import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; +import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; @@ -253,7 +254,32 @@ void main() { testWidgets( 'section view stages choices until Apply and reopens saved duration', (tester) async { - final manager = _filterTestManager()..ready = true; + var publications = 0; + final keys = nostr.Keys.generate(); + final crypto = ChannelSortCrypto(keys.nsec, keys.public); + final remote = NostrEvent.fromJson( + nostr.Event.from( + kind: EventKind.readState, + tags: [ + ['d', 'channel-filter'], + ], + content: crypto.encrypt( + jsonEncode( + ChannelFilterStore.empty() + .edit('channels', ChannelFilterMode.unreads) + .toJson(), + ), + ), + secretKey: nostr.Nip19.decode(payload: keys.nsec).data, + createdAt: 10, + verify: false, + ).toMap(), + ); + final manager = _filterTestManager( + fetch: () async => [remote], + testKeys: keys, + onPublish: () => publications++, + ); await tester.pumpWidget( buildTestable( overrides: [ @@ -265,12 +291,32 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); + // Sort is available while preferences load. A late verified load must + // update the untouched filter draft and never cause an All publication. + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + await manager.refresh(); + await tester.pumpAndSettle(); + expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads); + final unreads = tester.widget( + find.ancestor( + of: find.text('Unreads'), + matching: find.byType(AppListRow), + ), + ); + expect(unreads.trailing, isA()); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(publications, 0); + expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads); + await tester.tap(find.byTooltip('Channels options')); + await tester.pumpAndSettle(); await tester.tap(find.text('Active')); await tester.pumpAndSettle(); await tester.ensureVisible(find.text('7 days')); await tester.tap(find.text('7 days')); await tester.pumpAndSettle(); - expect(manager.store.modeFor('channels'), ChannelFilterMode.all); + expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads); await tester.tap(find.text('Apply')); await tester.pumpAndSettle(); expect(manager.store.modeFor('channels'), ChannelFilterMode.active); @@ -4414,11 +4460,13 @@ class _CompletedPairingNotifier extends PairingNotifier { } ChannelFilterManager _filterTestManager({ + Future> Function()? fetch, + nostr.Keys? testKeys, ChannelFilterStore? cached, bool failPublish = false, VoidCallback? onPublish, }) { - final keys = nostr.Keys.generate(); + final keys = testKeys ?? nostr.Keys.generate(); final crypto = ChannelSortCrypto(keys.nsec, keys.public); var history = []; return ChannelFilterManager( @@ -4426,7 +4474,8 @@ ChannelFilterManager _filterTestManager({ crypto: crypto, cached: cached, cache: (_) {}, - fetch: () async => history, + fetch: () async => + history.isEmpty && fetch != null ? await fetch() : history, publish: (content, at) async { onPublish?.call(); if (failPublish) throw StateError('rejected');