From dcf70232c07d2ec208a5cb3d785bef1c3e5b001b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 19 Oct 2026 09:12:33 -0700 Subject: [PATCH] feat(mobile): filter channel sections with hidden channel recovery Co-authored-by: Carl Signed-off-by: Taylor Ho --- mobile/README.md | 25 ++ .../lib/features/channels/channels_page.dart | 13 +- .../features/channels/channels_page/body.dart | 179 +++++++++- .../channels/channels_page/filters.dart | 326 ++++++++++++++++++ .../channels/channels_page/sections.dart | 41 ++- mobile/lib/shared/theme/buzz_icons.dart | 2 + .../features/channels/channels_page_test.dart | 121 +++++++ 7 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/features/channels/channels_page/filters.dart diff --git a/mobile/README.md b/mobile/README.md index c2e7e6f4aa..9c6bc1c2e2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -200,6 +200,31 @@ for names and app-rendered avatar thumbnails. It never fetches an avatar URL; missing, stale, or invalid enrichment falls back to the verified message with a short sender pubkey, community subtitle, and no image. +## Channel section filters + +Each section's options menu includes **Filter**. Its touch-sized sheet offers +All, Unreads, and Active in the last, followed by channels hidden by that filter. +Opening a hidden channel expands its section without changing the saved filter. +Filtered-empty sections retain their header and options. Sorting and section +membership are unchanged. + +Active opens a scrollable dialog with 1-, 7-, 30-, and 90-day choices. Apply saves +both the mode and duration; Cancel discards the draft. A failed Apply keeps the +draft for Retry. Unreads uses the shared unread computation, including manually +marked unread channels and observed unread thread replies. Active uses verified +message timestamps; unavailable activity leaves channels visible with Retry. + +Mobile reads and writes the desktop-compatible encrypted kind-30078 +`channel-filter` preference for the current account and community. It preserves +metadata registers, null All tombstones, durations, and unrelated record fields. +A cached copy is display-only until a complete relay read succeeds. Writes read +the fresh record, publish one update, and confirm the mode and duration on relay +readback. Failed saves roll back locally and offer explicit Retry/Dismiss. +Preference refresh happens on load, foreground resume, and pull to refresh; +instant cross-device preference synchronization is not promised. Filter choices +are disabled while preferences are unavailable; hidden-channel navigation remains +available. Deleting a custom section confirms its filter reset before removal. + ## Checks ```bash diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ff26cc9d13..96b24583e7 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,4 +1,8 @@ import 'channel_unread.dart'; +import 'channel_filter/channel_filter_manager.dart'; +import 'channel_filter/channel_filter_activity.dart'; +import 'channel_filter/channel_filter_provider.dart'; +import 'channel_filter/channel_filter_store.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/widgets/compose_morph_icon.dart'; import '../../shared/me/me_sync.dart'; @@ -65,6 +69,7 @@ import '../../shared/read_state/read_state_time.dart'; part 'channels_page/body.dart'; part 'channels_page/browse_channels_sheet.dart'; part 'channels_page/sections.dart'; +part 'channels_page/filters.dart'; part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; part 'channels_page/badges.dart'; @@ -540,7 +545,13 @@ class ChannelsPage extends HookConsumerWidget { topSectionHeight: topSectionHeight, usesPinnedGradient: usesPinnedGradient, scrollController: channelsScrollController, - onRefresh: () => ref.read(channelsProvider.notifier).refresh(), + onRefresh: () async { + await Future.wait([ + ref.read(channelsProvider.notifier).refresh(), + if (ref.read(channelFilterProvider) case final manager?) + manager.refresh(), + ]); + }, onSelectChannel: openChannel, ), ); diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ce2d3fb8d6..2039cd21a6 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -148,6 +148,54 @@ class _SliverChannelsList extends HookConsumerWidget { final channelsExpanded = useState(true); final dmsExpanded = useState(true); final sortState = ref.watch(channelSortProvider); + final filters = ref.watch(channelFilterProvider); + useListenable(filters); + final selectedChannel = useState(null); + final clock = useState(0); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final activity = >{}; + final liveFilterSectionIds = { + for (final section in sectionsState.store.sections) section.id, + }; + String filterGroupFor(Channel channel) { + if (channel.isDm) return 'dms'; + if (starredChannelIds.contains(channel.id)) return 'starred'; + final assignment = sectionsState.store.assignments[channel.id]; + return liveFilterSectionIds.contains(assignment) + ? sectionSortGroupKey(assignment!) + : 'channels'; + } + + var nextExpiry = 0; + for (final channel in visibleChannels) { + final group = filterGroupFor(channel); + if (filters?.store.modeFor(group) != ChannelFilterMode.active) continue; + final evidence = ref.watch(channelFilterActivityProvider(channel.id)); + activity[channel.id] = evidence; + final at = evidence.asData?.value; + if (at == null) continue; + final expiry = at + filters!.store.windowFor(group); + if (expiry > now && (nextExpiry == 0 || expiry < nextExpiry)) { + nextExpiry = expiry; + } + } + useEffect(() { + if (nextExpiry == 0) return null; + final timer = Timer( + Duration(seconds: nextExpiry - now), + () => clock.value++, + ); + return timer.cancel; + }, [nextExpiry, clock.value]); + Future openFilteredChannel(Channel channel) async { + selectedChannel.value = channel.id; + try { + await onSelectChannel(channel); + } finally { + if (context.mounted) selectedChannel.value = null; + } + } + final initialSeedComplete = useState(false); final seededPubkey = useRef(null); final seedCompleteForPubkey = @@ -190,7 +238,8 @@ class _SliverChannelsList extends HookConsumerWidget { ); final unreadChannelIds = { for (final channelId in unreadState) - if (seedCompleteForPubkey || + if (readState.locallyForcedChannelIds.contains(channelId) || + seedCompleteForPubkey || readState.effectiveTimestamp(channelId) != null) channelId, }; @@ -247,6 +296,50 @@ class _SliverChannelsList extends HookConsumerWidget { }; } + bool visible(String group, Channel channel) { + final evidence = activity[channel.id]; + return channelMatchesFilter( + mode: filters?.store.modeFor(group) ?? ChannelFilterMode.all, + unread: unreadState.contains(channel.id), + activitySeconds: evidence?.asData?.value, + activityKnown: evidence?.asData != null, + nowSeconds: now, + windowSeconds: + filters?.store.windowFor(group) ?? defaultActiveWindowSeconds, + selected: selectedChannel.value == channel.id, + ); + } + + void showFilter(String group, List roster, VoidCallback expand) { + unawaited( + showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => _SectionFilterSheet( + group: group, + channels: roster, + currentPubkey: currentPubkey, + selectedChannelId: selectedChannel.value, + onOpen: (channel) { + expand(); + return openFilteredChannel(channel); + }, + ), + ), + ); + } + + List customRoster(String id) => sortChannelsForList( + streamChannels + .where( + (c) => + sectionAssignments[c.id] == id && + !starredChannelIds.contains(c.id), + ) + .toList(), + sortState.sortModeFor(sectionSortGroupKey(id)), + ); + return SliverPadding( padding: EdgeInsets.only( top: Grid.xxs, @@ -254,6 +347,22 @@ class _SliverChannelsList extends HookConsumerWidget { ), sliver: SliverList.list( children: [ + if (activity.values.any((v) => v.hasError)) + ListTile( + title: const Text( + 'Some channel activity is unavailable. Channels remain visible.', + ), + trailing: TextButton( + onPressed: () { + for (final e in activity.entries) { + if (e.value.hasError) { + ref.invalidate(channelFilterActivityProvider(e.key)); + } + } + }, + child: const Text('Retry'), + ), + ), if (visibleChannels.isEmpty) const _EmptyState() else ...[ @@ -265,27 +374,35 @@ class _SliverChannelsList extends HookConsumerWidget { expanded: starredExpanded.value, onToggle: () => starredExpanded.value = !starredExpanded.value, channels: starredStreamChannels, + isVisible: (c) => visible('starred', c), + onFilter: () => showFilter( + 'starred', + starredStreamChannels, + () => starredExpanded.value = true, + ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: '', sortMode: sortState.sortModeFor('starred'), onSortModeChange: (mode) => setSortMode('starred', mode), - onSelectChannel: onSelectChannel, + onSelectChannel: openFilteredChannel, ), // User-defined sections for stream channels, in user-defined order. for (final section in userSections) _CustomChannelSection( section: section, - channels: sortChannelsForList( - streamChannels - .where( - (c) => - sectionAssignments[c.id] == section.id && - !starredChannelIds.contains(c.id), - ) - .toList(), - sortState.sortModeFor(sectionSortGroupKey(section.id)), + channels: customRoster(section.id), + isVisible: (c) => visible(sectionSortGroupKey(section.id), c), + onFilter: () => showFilter( + sectionSortGroupKey(section.id), + customRoster(section.id), + () { + sectionExpandedStates.value = { + ...sectionExpandedStates.value, + section.id: true, + }; + }, ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, @@ -336,6 +453,28 @@ class _SliverChannelsList extends HookConsumerWidget { ), ); if (confirmed == true) { + try { + if (filters == null) { + throw StateError('Filters unavailable'); + } + await filters.save( + sectionSortGroupKey(section.id), + ChannelFilterMode.all, + removeWindow: true, + ); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Could not reset the section filter. Retry deleting the section.', + ), + ), + ); + } + return; + } + if (!context.mounted) return; ref .read(channelSectionsProvider.notifier) .deleteSection(section.id); @@ -352,7 +491,7 @@ class _SliverChannelsList extends HookConsumerWidget { ), onSortModeChange: (mode) => setSortMode(sectionSortGroupKey(section.id), mode), - onSelectChannel: onSelectChannel, + onSelectChannel: openFilteredChannel, onMarkChannelRead: (channel) { final ts = dateTimeToUnixSeconds(channel.lastMessageAt); if (ts != null) { @@ -376,13 +515,19 @@ class _SliverChannelsList extends HookConsumerWidget { expanded: channelsExpanded.value, onToggle: () => channelsExpanded.value = !channelsExpanded.value, channels: ungroupedStreamChannels, + isVisible: (c) => visible('channels', c), + onFilter: () => showFilter( + 'channels', + ungroupedStreamChannels, + () => channelsExpanded.value = true, + ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No stream channels yet', sortMode: sortState.sortModeFor('channels'), onSortModeChange: (mode) => setSortMode('channels', mode), - onSelectChannel: onSelectChannel, + onSelectChannel: openFilteredChannel, ), _ChannelSection( title: 'DMs', @@ -390,13 +535,19 @@ class _SliverChannelsList extends HookConsumerWidget { expanded: dmsExpanded.value, onToggle: () => dmsExpanded.value = !dmsExpanded.value, channels: sortedDmChannels, + isVisible: (c) => visible('dms', c), + onFilter: () => showFilter( + 'dms', + sortedDmChannels, + () => dmsExpanded.value = true, + ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No direct messages yet', sortMode: sortState.sortModeFor('dms'), onSortModeChange: (mode) => setSortMode('dms', mode), - onSelectChannel: onSelectChannel, + onSelectChannel: openFilteredChannel, ), ], ], diff --git a/mobile/lib/features/channels/channels_page/filters.dart b/mobile/lib/features/channels/channels_page/filters.dart new file mode 100644 index 0000000000..9ec1b28ab0 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/filters.dart @@ -0,0 +1,326 @@ +part of '../channels_page.dart'; + +/// A sheet gives hidden channel rows the same touch targets and presentation +/// as the main list, including unread, mute and working indicators. +class _SectionFilterSheet extends HookConsumerWidget { + final String group; + final List channels; + final String? currentPubkey; + final String? selectedChannelId; + final Future Function(Channel) onOpen; + const _SectionFilterSheet({ + required this.group, + required this.channels, + required this.currentPubkey, + this.selectedChannelId, + required this.onOpen, + }); + @override + Widget build(BuildContext context, WidgetRef ref) { + final manager = ref.watch(channelFilterProvider); + useListenable(manager); + final readState = ref.watch(readStateProvider); + final latestChannels = ref.watch(channelsProvider).value ?? channels; + final sections = ref.watch(channelSectionsProvider).store; + final stars = ref.watch(channelStarsProvider).store.channels; + final personalIds = + ref.watch(meGroupsProvider).asData?.value.channels ?? const []; + final roster = latestChannels.where((c) { + if (personalIds.contains(c.id)) return false; + if (!c.isMember || c.isArchived || (!c.isDm && !c.isStream)) return false; + final currentGroup = c.isDm + ? 'dms' + : stars[c.id]?.starred == true + ? 'starred' + : sections.sections.any((s) => s.id == sections.assignments[c.id]) + ? sectionSortGroupKey(sections.assignments[c.id]!) + : 'channels'; + return currentGroup == group; + }).toList(); + final names = ref.watch(identityNameSourcesProvider); + final sortMode = ref.watch(channelSortProvider).sortModeFor(group); + final ordered = group == 'dms' && sortMode == ChannelSortMode.alpha + ? sortDmChannelsByDisplayLabel( + roster, + currentPubkey: currentPubkey, + names: names, + ) + : sortChannelsForList(roster, sortMode); + final liveUnread = computeUnreadChannelIds( + channels: roster, + readState: readState, + channelsNotifier: ref.read(channelsProvider.notifier), + ); + final liveMutes = ref.watch(channelMutesProvider).store.channels; + final clock = useState(0); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final hidden = []; + var nextExpiry = 0; + for (final channel in ordered) { + final mode = manager?.store.modeFor(group) ?? ChannelFilterMode.all; + final evidence = mode == ChannelFilterMode.active + ? ref.watch(channelFilterActivityProvider(channel.id)) + : null; + final at = evidence?.asData?.value; + final window = + manager?.store.windowFor(group) ?? defaultActiveWindowSeconds; + if (at != null && + at + window > now && + (nextExpiry == 0 || at + window < nextExpiry)) { + nextExpiry = at + window; + } + if (!channelMatchesFilter( + mode: mode, + unread: liveUnread.contains(channel.id), + activitySeconds: at, + activityKnown: evidence?.asData != null, + nowSeconds: now, + windowSeconds: window, + selected: channel.id == selectedChannelId, + )) { + hidden.add(channel); + } + } + useEffect(() { + if (nextExpiry == 0) return null; + final timer = Timer( + Duration(seconds: nextExpiry - now), + () => clock.value++, + ); + return timer.cancel; + }, [nextExpiry, clock.value]); + final failed = useState(null); + final saving = useState(false); + Future save(ChannelFilterMode mode) async { + if (manager == null) return; + saving.value = true; + try { + await manager.save(group, mode); + if (context.mounted) { + failed.value = null; + } + } catch (_) { + if (context.mounted) failed.value = mode; + } finally { + if (context.mounted) saving.value = false; + } + } + + final writable = + manager?.ready == true && manager?.busy == false && !saving.value; + final mode = manager?.store.modeFor(group) ?? ChannelFilterMode.all; + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.all(Grid.xs), + child: Text('Filter'), + ), + if (manager?.ready != true && manager?.error == null) + const Padding( + padding: EdgeInsets.all(Grid.xs), + child: Text( + 'Filters are read-only until preferences load from the community.', + ), + ), + if (manager?.error != null && failed.value == null) + ListTile( + title: Text(manager!.error!), + trailing: TextButton( + onPressed: manager.busy ? null : manager.refresh, + child: const Text('Retry'), + ), + ), + for (final choice in ChannelFilterMode.values) + Semantics( + checked: mode == choice, + inMutuallyExclusiveGroup: true, + child: ListTile( + enabled: writable, + leading: Icon( + mode == choice + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + title: Text(switch (choice) { + ChannelFilterMode.all => 'All', + ChannelFilterMode.unreads => 'Unreads', + ChannelFilterMode.active => 'Active in the last', + }), + subtitle: choice == ChannelFilterMode.active + ? Text( + '${(manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400} days', + ) + : null, + onTap: !writable + ? null + : () async { + if (choice == ChannelFilterMode.active) { + await showBuzzDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ActiveFilterDialog( + group: group, + manager: manager!, + ), + ); + } else { + await save(choice); + } + }, + ), + ), + if (failed.value != null) + Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Column( + children: [ + const Text('Could not confirm the filter save.'), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: saving.value + ? null + : () { + failed.value = null; + manager?.dismissError(); + }, + child: const Text('Dismiss'), + ), + TextButton( + onPressed: !writable + ? null + : () => save(failed.value!), + child: const Text('Retry'), + ), + ], + ), + ], + ), + ), + if (hidden.isNotEmpty) const Divider(), + for (final channel in hidden) + _ChannelTile( + channel: channel, + isUnread: liveUnread.contains(channel.id), + isMuted: liveMutes[channel.id]?.muted == true, + currentPubkey: currentPubkey, + sectionId: group.startsWith('section:') + ? group.substring(8) + : null, + onMarkRead: null, + onTap: () { + Navigator.of(context).pop(); + unawaited(onOpen(channel)); + }, + ), + ], + ), + ), + ); + } +} + +class _ActiveFilterDialog extends HookWidget { + final String group; + final ChannelFilterManager manager; + const _ActiveFilterDialog({required this.group, required this.manager}); + @override + Widget build(BuildContext context) { + useListenable(manager); + final days = useState(manager.store.windowFor(group) ~/ 86400); + final busy = useState(false); + final failed = useState(false); + return PopScope( + canPop: !busy.value, + child: AlertDialog( + title: const Text('Active in the last'), + content: SizedBox( + width: 320, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: Grid.xxs, + runSpacing: Grid.xxs, + children: [ + for (final preset in {1, 7, 30, 90, days.value}) + SizedBox( + width: 140, + child: OutlinedButton( + onPressed: busy.value + ? null + : () => days.value = preset, + child: Semantics( + checked: days.value == preset, + inMutuallyExclusiveGroup: true, + child: Text( + '$preset ${preset == 1 ? 'day' : 'days'}', + style: TextStyle( + fontWeight: days.value == preset + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ), + ], + ), + if (failed.value) + const Padding( + padding: EdgeInsets.only(top: Grid.xs), + child: Text( + 'Could not confirm the save. Your selection is retained. Retry or Cancel.', + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: busy.value + ? null + : () { + manager.dismissError(); + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: busy.value || !manager.ready + ? null + : () async { + busy.value = true; + failed.value = false; + try { + await manager.save( + group, + ChannelFilterMode.active, + windowSeconds: days.value * 86400, + ); + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + if (context.mounted) failed.value = true; + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text( + busy.value + ? 'Saving…' + : failed.value + ? 'Retry' + : 'Apply', + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 2a1bd87e3d..da2453cd92 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -5,6 +5,8 @@ const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0); class _CustomChannelSection extends StatelessWidget { final ChannelSection section; final List channels; + final VoidCallback? onFilter; + final bool Function(Channel)? isVisible; final Set unreadChannelIds; final Set mutedChannelIds; final String? currentPubkey; @@ -25,6 +27,8 @@ class _CustomChannelSection extends StatelessWidget { const _CustomChannelSection({ required this.section, required this.channels, + this.onFilter, + this.isVisible, required this.unreadChannelIds, required this.mutedChannelIds, required this.currentPubkey, @@ -51,6 +55,7 @@ class _CustomChannelSection extends StatelessWidget { if (showTopDivider) const _SectionDivider(), _CustomSectionHeader( section: section, + onFilter: onFilter, expanded: expanded, isFirst: isFirst, isLast: isLast, @@ -67,7 +72,9 @@ class _CustomChannelSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final channel in channels) + for (final channel in channels.where( + (c) => isVisible?.call(c) ?? true, + )) _ChannelTile( channel: channel, isUnread: unreadChannelIds.contains(channel.id), @@ -92,6 +99,7 @@ class _CustomSectionHeader extends StatelessWidget { final bool isFirst; final bool isLast; final VoidCallback onToggle; + final VoidCallback? onFilter; final VoidCallback onRename; final VoidCallback onDelete; final VoidCallback onMoveUp; @@ -105,6 +113,7 @@ class _CustomSectionHeader extends StatelessWidget { required this.isFirst, required this.isLast, required this.onToggle, + this.onFilter, required this.onRename, required this.onDelete, required this.onMoveUp, @@ -181,6 +190,7 @@ class _CustomSectionHeader extends StatelessWidget { ), ), ..._sortMenuItems(sortMode), + if (onFilter != null) _filterMenuItem(), PopupMenuItem( value: 'delete', padding: _sectionMenuItemPadding, @@ -193,6 +203,8 @@ class _CustomSectionHeader extends StatelessWidget { ], ); switch (value) { + case 'filter': + onFilter?.call(); case 'rename': onRename(); case 'move_up': @@ -328,6 +340,8 @@ class _ChannelSection extends StatelessWidget { final bool expanded; final VoidCallback onToggle; final List channels; + final VoidCallback? onFilter; + final bool Function(Channel)? isVisible; final bool showTopDivider; final Set unreadChannelIds; final Set mutedChannelIds; @@ -342,6 +356,8 @@ class _ChannelSection extends StatelessWidget { required this.expanded, required this.onToggle, required this.channels, + this.onFilter, + this.isVisible, required this.showTopDivider, required this.unreadChannelIds, required this.mutedChannelIds, @@ -361,6 +377,7 @@ class _ChannelSection extends StatelessWidget { _SectionHeader( label: title, expanded: expanded, + onFilter: onFilter, onToggle: onToggle, sortMode: sortMode, onSortModeChange: onSortModeChange, @@ -370,7 +387,7 @@ class _ChannelSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (channels.isEmpty) + if (channels.isEmpty && emptyLabel.isNotEmpty) Padding( padding: const EdgeInsets.only( left: _kChannelLabelInset, @@ -386,7 +403,9 @@ class _ChannelSection extends StatelessWidget { ), ) else - for (final channel in channels) + for (final channel in channels.where( + (c) => isVisible?.call(c) ?? true, + )) _ChannelTile( channel: channel, isUnread: unreadChannelIds.contains(channel.id), @@ -457,6 +476,7 @@ class _SectionHeader extends StatelessWidget { final String label; final bool expanded; final VoidCallback onToggle; + final VoidCallback? onFilter; final ChannelSortMode? sortMode; final ValueChanged? onSortModeChange; @@ -464,6 +484,7 @@ class _SectionHeader extends StatelessWidget { required this.label, required this.expanded, required this.onToggle, + this.onFilter, this.sortMode, this.onSortModeChange, }); @@ -519,8 +540,14 @@ class _SectionHeader extends StatelessWidget { side: BorderSide(color: context.colors.outline), ), surfaceKey: ValueKey('sort-popover-$label'), - items: _sortMenuItems(mode, showDivider: false), + items: [ + ..._sortMenuItems(mode, showDivider: false), + if (onFilter != null) _filterMenuItem(), + ], ); + if (value == 'filter') { + onFilter?.call(); + } if (value == _kSortRecentMenuValue) { onSortModeChange?.call(ChannelSortMode.recent); } else if (value == _kSortAlphaMenuValue) { @@ -587,3 +614,9 @@ class _SectionChevron extends StatelessWidget { ); } } + +PopupMenuItem _filterMenuItem() => const PopupMenuItem( + value: 'filter', + padding: _sectionMenuItemPadding, + child: _SectionMenuItemContent(icon: BuzzIcons.filter2, label: 'Filter'), +); diff --git a/mobile/lib/shared/theme/buzz_icons.dart b/mobile/lib/shared/theme/buzz_icons.dart index c7b3140889..9244902128 100644 --- a/mobile/lib/shared/theme/buzz_icons.dart +++ b/mobile/lib/shared/theme/buzz_icons.dart @@ -5,6 +5,8 @@ import 'package:flutter/widgets.dart'; /// Semantic names preserve existing call sites; artwork matches buzz-app. /// Native iOS controls share Tabler artwork except Back and Close navigation. abstract final class BuzzIcons { + /// Tabler `filter-2` (3.46.0). + static const filter2 = IconData(0x1014b, fontFamily: 'BuzzTabler'); static const cornerUpLeft = IconData(0xeb77, fontFamily: 'BuzzTabler'); static const droplet = IconData(0xea97, fontFamily: 'BuzzTabler'); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 902ecad2e1..42ae0f05d8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,3 +1,8 @@ +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel_filter/channel_filter_provider.dart'; +import 'package:buzz/features/channels/channel_filter/channel_filter_manager.dart'; +import 'package:buzz/features/channels/channel_filter/channel_filter_store.dart'; +import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart'; import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; @@ -197,6 +202,89 @@ void main() { testChannels, ); + testWidgets( + 'read-only filter retains an empty header and hidden navigation', + (tester) async { + final manager = _filterTestManager( + cached: ChannelFilterStore.empty().edit( + 'channels', + ChannelFilterMode.unreads, + ), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelFilterProvider.overrideWithValue(manager), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('general'), findsNothing); + expect(find.byTooltip('Channels options'), findsOneWidget); + await tester.tap(find.byTooltip('Channels options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Filter')); + await tester.pumpAndSettle(); + expect(find.text('general'), findsOneWidget); + final all = tester.widget( + find.ancestor(of: find.text('All'), matching: find.byType(ListTile)), + ); + expect(all.enabled, isFalse); + expect(tester.widget(_dmTileFor('general')).onTap, isNotNull); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }, + ); + + testWidgets( + 'failed Active Apply retains duration draft; Cancel discards it at enlarged text', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = _filterTestManager(failPublish: true)..ready = true; + await tester.pumpWidget( + buildTestable( + textScaler: const TextScaler.linear(2), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelFilterProvider.overrideWithValue(manager), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Channels options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Filter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Active in the last')); + await tester.pumpAndSettle(); + await tester.tap(find.text('7 days')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Apply')); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Could not confirm the save. Your selection is retained. Retry or Cancel.', + ), + findsOneWidget, + ); + final selected = tester.widget(find.text('7 days')); + expect(selected.style?.fontWeight, FontWeight.bold); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(manager.store.modeFor('channels'), ChannelFilterMode.all); + expect(manager.store.windowFor('channels'), defaultActiveWindowSeconds); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }, + ); + testWidgets('DM tile presence observation failure hides the dot', ( tester, ) async { @@ -4242,3 +4330,36 @@ class _CompletedPairingNotifier extends PairingNotifier { @override PairingState build() => const PairingState(status: PairingStatus.success); } + +ChannelFilterManager _filterTestManager({ + ChannelFilterStore? cached, + bool failPublish = false, +}) { + final keys = nostr.Keys.generate(); + final crypto = ChannelSortCrypto(keys.nsec, keys.public); + var history = []; + return ChannelFilterManager( + pubkey: keys.public, + crypto: crypto, + cached: cached, + cache: (_) {}, + fetch: () async => history, + publish: (content, at) async { + if (failPublish) throw StateError('rejected'); + history = [ + NostrEvent.fromJson( + nostr.Event.from( + kind: EventKind.readState, + tags: [ + ['d', 'channel-filter'], + ], + content: content, + secretKey: nostr.Nip19.decode(payload: keys.nsec).data, + createdAt: at, + verify: false, + ).toMap(), + ), + ]; + }, + ); +}