mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
The UI-only part of #9186 by @klopez4212, split out so it can land without the Activity sync changes. - The Inbox page becomes Activity, with header controls instead of menus: a type menu (All, Threads, Mentions, DMs, Reminders, Drafts), a sender menu (Anyone, People, Agents) and an Unread toggle. All of them filter the feed the app already loads. - Activity and Search get a top edge fade. Bulk mark-as-read and the "New" divider are gone. - DM thread replies group with their DM row, so the DMs filter includes them. Left out, for a separate sync PR: the Activity history cache, subscriptions to every joined channel, the refresh coalescer, thread-follow evidence, Reactions and Solo rows, and the read-state changes. The Activity provider and read-state writes are unchanged from main. One fix on top: the iOS filter labels pass an unscaled font size, since the native button already applies Dynamic Type. Before, large text was scaled twice. ### Validation - Kenny's added tests for the header controls (`tabs_journey`) and `TopEdgeFade`, adjusted for the removed Reactions option. Added tests for the DM filter, marking a DM row with replies under two thread roots read, and sender filtering per event. - Full mobile suite: see the latest CI run. Locally, 11 push and notification-toggle tests fail identically on an unchanged checkout. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Activity filters, row taps, read and unread, and Search, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
570 lines
21 KiB
Dart
570 lines
21 KiB
Dart
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
|
||
|
||
import 'dart:async';
|
||
import 'dart:math' show max;
|
||
|
||
import '../../shared/widgets/ios_message_create_button.dart';
|
||
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
||
import '../../shared/widgets/ios_scroll_to_top.dart';
|
||
import '../../shared/widgets/top_edge_fade.dart';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||
|
||
import '../../shared/identity_names/identity_names_provider.dart';
|
||
import '../../shared/mentions/agent_identity_provider.dart';
|
||
import '../../shared/mentions/mention_tags.dart';
|
||
import '../../shared/relay/relay.dart';
|
||
import '../../shared/theme/theme.dart';
|
||
import '../../shared/utils/string_utils.dart';
|
||
import '../../shared/widgets/sheet_action_section.dart';
|
||
import '../../shared/widgets/avatar_image.dart';
|
||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||
import '../../shared/widgets/bee_refresh_indicator.dart';
|
||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||
import '../../shared/widgets/message_author_meta.dart';
|
||
import '../../shared/widgets/modal_presentation.dart';
|
||
import '../channels/channel.dart';
|
||
import '../channels/channel_detail_page.dart';
|
||
import '../channels/channel_identity_names_provider.dart';
|
||
import '../channels/channel_management_provider.dart';
|
||
import '../channels/channels_provider.dart';
|
||
import '../channels/dm_channel_labels.dart';
|
||
import '../channels/message_content.dart';
|
||
import '../../shared/read_state/read_state_format.dart';
|
||
import '../../shared/read_state/read_state_provider.dart';
|
||
import '../../shared/profile/user_cache_provider.dart';
|
||
import '../../shared/profile/user_profile.dart';
|
||
import 'activity_provider.dart';
|
||
import 'compose_drafts_provider.dart';
|
||
import 'dm_resurface.dart';
|
||
import 'feed_item.dart';
|
||
import 'inbox_item.dart';
|
||
import 'inbox_local_state_provider.dart';
|
||
import 'inbox_read_state.dart';
|
||
import 'reminders_provider.dart';
|
||
|
||
part 'activity_page/header_actions.dart';
|
||
part 'activity_page/inbox_row.dart';
|
||
part 'activity_page/lists.dart';
|
||
part 'activity_page/status_views.dart';
|
||
|
||
EdgeInsets _activityScrollPadding(
|
||
BuildContext context, {
|
||
double horizontal = 0,
|
||
double top = Grid.xxs,
|
||
double bottom = Grid.xxs,
|
||
}) => EdgeInsets.fromLTRB(
|
||
horizontal,
|
||
top,
|
||
horizontal,
|
||
MediaQuery.paddingOf(context).bottom + bottom,
|
||
);
|
||
|
||
/// Conversation-oriented Activity inbox.
|
||
///
|
||
/// Matches desktop's Home inbox item design and semantics (see
|
||
/// `desktop/src/features/home/ui/InboxListPane.tsx`): full sender avatar +
|
||
/// name, contextual "Mentioned in #channel"-style label, unread dot + time,
|
||
/// message preview — while keeping mobile's list → canonical destination
|
||
/// navigation. Row taps deep-link to the represented message (oldest unread
|
||
/// for grouped conversations) rather than just opening the channel.
|
||
const _activityTypeOptions = {
|
||
InboxFilter.all: 'All',
|
||
InboxFilter.thread: 'Threads',
|
||
InboxFilter.mention: 'Mentions',
|
||
InboxFilter.dm: 'DMs',
|
||
InboxFilter.reminders: 'Reminders',
|
||
InboxFilter.drafts: 'Drafts',
|
||
};
|
||
|
||
class ActivityPage extends HookConsumerWidget {
|
||
const ActivityPage({this.tabReselection, super.key});
|
||
|
||
/// Notifies this page when its already-selected tab is tapped again.
|
||
final ValueListenable<int>? tabReselection;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final feedAsync = ref.watch(activityProvider);
|
||
final channelsAsync = ref.watch(channelsProvider);
|
||
final filter = useState(InboxFilter.all);
|
||
final unreadOnly = useState(false);
|
||
final sender = useState('Anyone');
|
||
final agents = ref.watch(knownAgentPubkeysProvider);
|
||
final profiles = ref.watch(userCacheProvider);
|
||
final scrollController = useScrollController();
|
||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||
useEffect(() {
|
||
final tabReselection = this.tabReselection;
|
||
if (tabReselection == null) return null;
|
||
|
||
void scrollToTop() {
|
||
if (!scrollController.hasClients) return;
|
||
final position = scrollController.position;
|
||
if (position.pixels <= position.minScrollExtent + 0.5) return;
|
||
if (reducedMotion) {
|
||
scrollController.jumpTo(position.minScrollExtent);
|
||
return;
|
||
}
|
||
unawaited(
|
||
scrollController.animateTo(
|
||
position.minScrollExtent,
|
||
duration: const Duration(milliseconds: 260),
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
);
|
||
}
|
||
|
||
tabReselection.addListener(scrollToTop);
|
||
return () => tabReselection.removeListener(scrollToTop);
|
||
}, [tabReselection, scrollController, reducedMotion]);
|
||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w600,
|
||
color: navigationPrimaryForeground(context),
|
||
);
|
||
final filterHeight =
|
||
max(48.0, MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) +
|
||
Grid.xxs;
|
||
|
||
final readState = ref.watch(readStateProvider);
|
||
final localState = ref.watch(inboxLocalStateProvider);
|
||
final drafts = ref.watch(composeDraftsProvider);
|
||
final allItems = ref.watch(inboxItemsProvider);
|
||
final myPk = ref.watch(myPubkeyProvider);
|
||
|
||
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
||
final hasLoadedOnce = useRef(false);
|
||
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
||
|
||
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
||
final channelById = {for (final c in channels) c.id: c};
|
||
|
||
int? markerOf(String contextId) => readState.effectiveTimestamp(contextId);
|
||
bool isDone(InboxItem item) => isInboxItemDone(
|
||
item,
|
||
markerOf: markerOf,
|
||
localUnreadOverrides: localState.unreadIds,
|
||
localDoneSet: localState.doneIds,
|
||
);
|
||
|
||
// Rows mark channel bot members as agents, so the sender filter does too.
|
||
final channelAgents = <String, Set<String>>{};
|
||
bool includesSender(FeedItem e) {
|
||
if (sender.value == 'Anyone') return true;
|
||
final pk = e.pubkey.toLowerCase();
|
||
final channelId = e.channelId;
|
||
final known = channelId == null
|
||
? agents
|
||
: channelAgents.putIfAbsent(
|
||
channelId,
|
||
() => ref.watch(agentMentionPubkeysProvider(channelId)),
|
||
);
|
||
final isAgent = known.contains(pk) || profiles[pk]?.ownerPubkey != null;
|
||
return sender.value == 'Agents' ? isAgent : !isAgent;
|
||
}
|
||
|
||
// Mentions, Threads and sender filters apply to each event before
|
||
// regrouping, so a row shows the matching activity rather than a later
|
||
// unrelated message from the same conversation.
|
||
final candidates = buildInboxItems(
|
||
allItems
|
||
.expand((i) => i.groupItems)
|
||
.where(
|
||
(e) =>
|
||
(filter.value != InboxFilter.mention ||
|
||
e.category == 'mention') &&
|
||
(filter.value != InboxFilter.thread || isThreadReply(e.tags)) &&
|
||
includesSender(e),
|
||
),
|
||
isDmChannel: (id) =>
|
||
(channelById[id]?.isDm ?? false) ||
|
||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(id),
|
||
);
|
||
// A filtered DM row holds only part of its conversation, so the DM
|
||
// channel marker would also read events this view hides.
|
||
final rowsArePartial =
|
||
filter.value == InboxFilter.thread ||
|
||
filter.value == InboxFilter.mention ||
|
||
sender.value != 'Anyone';
|
||
final visibleItems = [
|
||
for (final item in candidates)
|
||
if (matchesInboxFilter(item, filter.value) &&
|
||
(!unreadOnly.value || !isDone(item)))
|
||
item,
|
||
];
|
||
|
||
// Preload sender profiles for visible rows, and for every author while a
|
||
// sender filter needs their agent ownership.
|
||
final preloadPubkeys = {
|
||
if (sender.value != 'Anyone')
|
||
for (final item in allItems)
|
||
for (final e in item.groupItems) e.pubkey.toLowerCase(),
|
||
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
|
||
for (final item in visibleItems)
|
||
...mentionedPubkeysFromTags(item.item.tags),
|
||
}.toList()..sort();
|
||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||
useEffect(() {
|
||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||
return null;
|
||
}, [preloadPubkeysKey]);
|
||
|
||
void markItemRead(InboxItem item) {
|
||
final notifier = ref.read(readStateProvider.notifier);
|
||
ref
|
||
.read(inboxLocalStateProvider.notifier)
|
||
.clearUnread(groupedInboxItemIds(item));
|
||
final isDmRow = item.conversationId.startsWith('dm:');
|
||
if (isDmRow && rowsArePartial) {
|
||
for (final e in {item.item, ...item.groupItems}) {
|
||
notifier.markContextRead(msgContextKey(e.id), e.createdAt);
|
||
}
|
||
return;
|
||
}
|
||
// A DM row can group replies under several thread roots; its channel
|
||
// marker reads all of them.
|
||
final threadRootId = isDmRow ? null : item.threadRootId;
|
||
if (threadRootId != null) {
|
||
notifier.markContextRead(
|
||
threadContextKey(threadRootId),
|
||
item.latestActivityAt,
|
||
);
|
||
final channelRead = groupedChannelReadTimestamp(item);
|
||
if (channelRead != null) {
|
||
notifier.markContextRead(
|
||
channelRead.channelId,
|
||
channelRead.timestamp,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
final channelId = item.item.channelId;
|
||
if (channelId != null) {
|
||
notifier.markContextRead(channelId, item.latestActivityAt);
|
||
ref
|
||
.read(channelsProvider.notifier)
|
||
.clearObservedUnreadCoveredByRead(channelId, item.latestActivityAt);
|
||
return;
|
||
}
|
||
ref.read(inboxLocalStateProvider.notifier).markDone(item.id);
|
||
}
|
||
|
||
void markItemUnread(InboxItem item) {
|
||
ref
|
||
.read(inboxLocalStateProvider.notifier)
|
||
.markUnread(groupedInboxItemIds(item));
|
||
}
|
||
|
||
Future<void> openItem(InboxItem item) async {
|
||
final channelId = item.item.channelId;
|
||
if (channelId == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('No linked channel')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
var channel = channelById[channelId];
|
||
if (channel == null &&
|
||
myPk != null &&
|
||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(channelId)) {
|
||
final expectedPubkey = myPk.toLowerCase();
|
||
final expectedRelayUrl = ref.read(relayConfigProvider).baseUrl;
|
||
bool isCurrentScope() =>
|
||
context.mounted &&
|
||
ref.read(myPubkeyProvider)?.toLowerCase() == expectedPubkey &&
|
||
ref.read(relayConfigProvider).baseUrl == expectedRelayUrl;
|
||
try {
|
||
final members = await ref.read(
|
||
channelMembersProvider(channelId).future,
|
||
);
|
||
if (!isCurrentScope()) return;
|
||
final peers = dmPeerPubkeysFromMembers(
|
||
members.map((member) => member.pubkey),
|
||
expectedPubkey,
|
||
);
|
||
if (peers.isEmpty) {
|
||
throw StateError('Could not determine the DM membership.');
|
||
}
|
||
final reopened = await ref
|
||
.read(channelActionsProvider)
|
||
.openDm(pubkeys: peers.toList());
|
||
if (!isCurrentScope()) return;
|
||
if (reopened.id != channelId) {
|
||
throw StateError('Relay reopened a different DM conversation.');
|
||
}
|
||
channel = reopened;
|
||
} catch (error) {
|
||
if (!isCurrentScope()) return;
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
SnackBar(content: Text('Could not reopen conversation: $error')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
if (channel == null) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Channel not found here')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
final resolvedChannel = channel;
|
||
|
||
// Deep-link to the represented message: oldest unread in the group,
|
||
// falling back to the latest event.
|
||
final target = item.deepLinkTarget(
|
||
(event) => isInboxEventRead(event, markerOf: markerOf),
|
||
);
|
||
final thread = threadReferenceOf(target.tags);
|
||
final threadRootId = isBroadcastReply(target.tags)
|
||
? null
|
||
: thread.parentId;
|
||
|
||
if (!context.mounted) return;
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: resolvedChannel,
|
||
initialMessageId: target.id,
|
||
initialThreadRootId: threadRootId,
|
||
initialThreadRouteBehavior:
|
||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void openDraft(ComposeDraft draft) {
|
||
final channel = channelById[draft.channelId];
|
||
if (channel == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Draft’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: channel,
|
||
initialThreadRootId: draft.threadHeadId,
|
||
initialThreadRouteBehavior:
|
||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void openReminder(Reminder reminder) {
|
||
final target = reminder.target;
|
||
if (target == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder has no linked message')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
final channel = channelById[target.channelId];
|
||
if (channel == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: channel,
|
||
initialMessageId: target.eventId,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
await Future.wait([
|
||
ref.read(activityProvider.notifier).refresh(),
|
||
ref.read(remindersProvider.notifier).refresh(),
|
||
]);
|
||
}
|
||
|
||
late final Widget body;
|
||
if (filter.value == InboxFilter.reminders) {
|
||
body = _RemindersList(
|
||
scrollController: scrollController,
|
||
onOpen: openReminder,
|
||
onRefresh: refresh,
|
||
);
|
||
} else if (filter.value == InboxFilter.drafts) {
|
||
body = _DraftsList(
|
||
drafts: drafts,
|
||
scrollController: scrollController,
|
||
channelById: channelById,
|
||
myPubkey: myPk,
|
||
onOpen: openDraft,
|
||
onDelete: (draft) =>
|
||
ref.read(composeDraftsProvider.notifier).remove(draft.key),
|
||
);
|
||
} else if (feedAsync.hasError && allItems.isEmpty) {
|
||
body = _ErrorView(onRetry: refresh);
|
||
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
||
body = _LoadingSkeleton(scrollController: scrollController);
|
||
} else if (visibleItems.isEmpty) {
|
||
body = _EmptyFilterState(
|
||
filter: filter.value,
|
||
unreadOnly: unreadOnly.value,
|
||
);
|
||
} else {
|
||
body = BeeRefreshIndicator(
|
||
onRefresh: refresh,
|
||
child: CustomScrollView(
|
||
controller: scrollController,
|
||
slivers: [
|
||
DecoratedSliver(
|
||
decoration: BoxDecoration(
|
||
color: context.colors.surface,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(Radii.dialog),
|
||
),
|
||
),
|
||
sliver: SliverPadding(
|
||
padding: _activityScrollPadding(context),
|
||
sliver: SliverList.builder(
|
||
itemCount: visibleItems.length,
|
||
itemBuilder: (context, index) {
|
||
final item = visibleItems[index];
|
||
final channel = item.item.channelId != null
|
||
? channelById[item.item.channelId]
|
||
: null;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_InboxRow(
|
||
key: ValueKey(item.id),
|
||
item: item,
|
||
channel: channel,
|
||
currentPubkey: myPk,
|
||
isDone: isDone(item),
|
||
onTap: () => unawaited(openItem(item)),
|
||
onMarkRead: () => markItemRead(item),
|
||
onMarkUnread: () => markItemUnread(item),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return IosScrollToTop(
|
||
child: Scaffold(
|
||
backgroundColor: context.colors.surface,
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.xxs,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
defaultTargetPlatform == TargetPlatform.iOS
|
||
? Padding(
|
||
padding: const EdgeInsets.only(right: Grid.twelve),
|
||
child: IosGlassNavigationButton(
|
||
icon: IosGlassNavigationIcon.close,
|
||
semanticLabel: 'Close activity',
|
||
width: 44,
|
||
height: 44,
|
||
controlSize: 44,
|
||
foregroundColor: navigationPrimaryForeground(
|
||
context,
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
)
|
||
: IconButton(
|
||
tooltip: 'Close activity',
|
||
icon: const Icon(BuzzIcons.x),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
Expanded(child: Text('Activity', style: headerTitleStyle)),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: filterHeight,
|
||
child: SingleChildScrollView(
|
||
key: const ValueKey('activity-filters'),
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.half,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_ActivityMenuButton<InboxFilter>(
|
||
controlKey: 'activity-type-menu',
|
||
popoverKey: 'activity-type-popover',
|
||
semanticLabel:
|
||
'Activity type, ${_activityTypeOptions[filter.value]}',
|
||
value: filter.value,
|
||
active: filter.value != InboxFilter.all,
|
||
options: _activityTypeOptions,
|
||
onChanged: (value) => filter.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_ActivityMenuButton<String>(
|
||
controlKey: 'activity-sender-menu',
|
||
popoverKey: 'activity-sender-popover',
|
||
semanticLabel: 'From ${sender.value}',
|
||
value: sender.value,
|
||
active: sender.value != 'Anyone',
|
||
options: const {
|
||
'Anyone': 'Anyone',
|
||
'People': 'People',
|
||
'Agents': 'Agents',
|
||
},
|
||
onChanged: (value) => sender.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_UnreadFilterButton(
|
||
selected: unreadOnly.value,
|
||
onPressed: () => unreadOnly.value = !unreadOnly.value,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: TopEdgeFade(
|
||
key: const ValueKey('activity-content-viewport'),
|
||
child: body,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|