feat(mobile): filter channel sections with hidden channel recovery

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-10-19 09:12:33 -07:00
parent 490f2e56c6
commit dcf70232c0
7 changed files with 688 additions and 19 deletions

View File

@@ -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

View File

@@ -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,
),
);

View File

@@ -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<String?>(null);
final clock = useState(0);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final activity = <String, AsyncValue<int?>>{};
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<void> 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<String?>(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<Channel> roster, VoidCallback expand) {
unawaited(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => _SectionFilterSheet(
group: group,
channels: roster,
currentPubkey: currentPubkey,
selectedChannelId: selectedChannel.value,
onOpen: (channel) {
expand();
return openFilteredChannel(channel);
},
),
),
);
}
List<Channel> 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,
),
],
],

View File

@@ -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<Channel> channels;
final String? currentPubkey;
final String? selectedChannelId;
final Future<void> 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 <String>[];
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 = <Channel>[];
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<ChannelFilterMode?>(null);
final saving = useState(false);
Future<void> 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<void>(
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',
),
),
],
),
);
}
}

View File

@@ -5,6 +5,8 @@ const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final VoidCallback? onFilter;
final bool Function(Channel)? isVisible;
final Set<String> unreadChannelIds;
final Set<String> 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<Channel> channels;
final VoidCallback? onFilter;
final bool Function(Channel)? isVisible;
final bool showTopDivider;
final Set<String> unreadChannelIds;
final Set<String> 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<ChannelSortMode>? 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<String> _filterMenuItem() => const PopupMenuItem(
value: 'filter',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(icon: BuzzIcons.filter2, label: 'Filter'),
);

View File

@@ -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');

View File

@@ -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<ListTile>(
find.ancestor(of: find.text('All'), matching: find.byType(ListTile)),
);
expect(all.enabled, isFalse);
expect(tester.widget<InkWell>(_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<Text>(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 = <NostrEvent>[];
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(),
),
];
},
);
}