mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
326
mobile/lib/features/channels/channels_page/filters.dart
Normal file
326
mobile/lib/features/channels/channels_page/filters.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user