From 597a8d1970301ffbd089194d3f849da9d9b21489 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 6 Nov 2026 12:41:04 -0800 Subject: [PATCH] feat(mobile): rename Activity and add header filters (#9213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: kenny lopez Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../ios/Runner/JumpToLatestGlassButton.swift | 2 + mobile/ios/Runner/NativeTabler.swift | 1 + .../lib/features/activity/activity_page.dart | 267 ++++--- .../activity_page/header_actions.dart | 321 ++++---- .../activity/activity_page/inbox_row.dart | 35 - .../activity/activity_page/status_views.dart | 1 + mobile/lib/features/activity/inbox_item.dart | 4 +- mobile/lib/features/search/search_page.dart | 13 +- mobile/lib/shared/widgets/top_edge_fade.dart | 27 + .../features/activity/activity_page_test.dart | 705 ++++++++++++------ .../activity_page_test/tabs_journey.dart | 229 ++++++ .../features/activity/inbox_item_test.dart | 43 +- .../shared/widgets/top_edge_fade_test.dart | 50 ++ 13 files changed, 1126 insertions(+), 572 deletions(-) create mode 100644 mobile/lib/shared/widgets/top_edge_fade.dart create mode 100644 mobile/test/features/activity/activity_page_test/tabs_journey.dart create mode 100644 mobile/test/shared/widgets/top_edge_fade_test.dart diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index d317e5eceb..a26129ee14 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -483,6 +483,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { labelFontSize = (arguments?["labelFontSize"] as? NSNumber).map { CGFloat(truncating: $0) } showIconWithLabel = arguments?["showIconWithLabel"] as? Bool == true button.configuration?.imagePadding = showIconWithLabel ? 8 : 0 + button.configuration?.imagePlacement = icon == "chevronDown" ? .trailing : .leading if let accessibilityLabel = arguments?["accessibilityLabel"] as? String { button.accessibilityLabel = accessibilityLabel } @@ -545,6 +546,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { case "trash": buttonIconName = "trash" case "bell": buttonIconName = "bell" case "more": buttonIconName = "ellipsis" + case "chevronDown": buttonIconName = "chevron.down" case "reply": buttonIconName = "arrowshape.turn.up.left" case "systemAppearance": buttonIconName = "circle.lefthalf.filled" case "colorSwatch": buttonIconName = "circle.fill" diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index c12fd204f1..e9ebdee1ed 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -61,6 +61,7 @@ enum NativeTabler { "arrow.down": 0xea16, // arrow-down "xmark": 0xeb55, // x "xmark.circle.fill": 0xea6a, // circle-x + "chevron.down": 0xea5f, // chevron-down "chevron.backward": 0xea60, // chevron-left "chevron.left": 0xea60, // chevron-left "chevron.right": 0xea61, // chevron-right diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 8bddff508b..9c85d9a45b 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,5 +1,12 @@ 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'; @@ -19,8 +26,6 @@ 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/frosted_app_bar.dart'; -import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/channel.dart'; @@ -37,6 +42,7 @@ 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'; @@ -67,6 +73,15 @@ EdgeInsets _activityScrollPadding( /// 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}); @@ -79,6 +94,9 @@ class ActivityPage extends HookConsumerWidget { 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(() { @@ -110,12 +128,9 @@ class ActivityPage extends HookConsumerWidget { fontWeight: FontWeight.w600, color: navigationPrimaryForeground(context), ); - final topSectionHeight = frostedAppBarHeight( - context, - nativeLargeTitle: true, - titleStyle: headerTitleStyle, - bottomHeight: Grid.xxs, - ); + 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); @@ -138,15 +153,58 @@ class ActivityPage extends HookConsumerWidget { localDoneSet: localState.doneIds, ); + // Rows mark channel bot members as agents, so the sender filter does too. + final channelAgents = >{}; + 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 allItems) + for (final item in candidates) if (matchesInboxFilter(item, filter.value) && (!unreadOnly.value || !isDone(item))) item, ]; - // Preload sender profiles for visible rows. + // 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), @@ -157,14 +215,21 @@ class ActivityPage extends HookConsumerWidget { return null; }, [preloadPubkeysKey]); - final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length; - void markItemRead(InboxItem item) { final notifier = ref.read(readStateProvider.notifier); ref .read(inboxLocalStateProvider.notifier) .clearUnread(groupedInboxItemIds(item)); - final threadRootId = item.threadRootId; + 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), @@ -335,7 +400,6 @@ class ActivityPage extends HookConsumerWidget { } late final Widget body; - var bodyRidesOverTopSection = false; if (filter.value == InboxFilter.reminders) { body = _RemindersList( scrollController: scrollController, @@ -362,22 +426,11 @@ class ActivityPage extends HookConsumerWidget { unreadOnly: unreadOnly.value, ); } else { - // Compute the "New" boundary: index of the first unread row when the - // rows above it are read (list is newest-first, so unread rows sit on - // top; the divider marks where the unread block ends). - final firstReadIndex = visibleItems.indexWhere(isDone); - final newBoundaryIndex = !unreadOnly.value && firstReadIndex > 0 - ? firstReadIndex - : -1; - - bodyRidesOverTopSection = true; body = BeeRefreshIndicator( - edgeOffset: topSectionHeight, onRefresh: refresh, child: CustomScrollView( controller: scrollController, slivers: [ - SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)), DecoratedSliver( decoration: BoxDecoration( color: context.colors.surface, @@ -397,8 +450,6 @@ class ActivityPage extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (index == newBoundaryIndex) - const _NewBoundaryDivider(), _InboxRow( key: ValueKey(item.id), item: item, @@ -420,88 +471,98 @@ class ActivityPage extends HookConsumerWidget { ); } - return FrostedScaffold( - backgroundColor: context.colors.surface, - appBar: FrostedAppBar( - nativeTitle: 'Inbox', - nativeLeading: IosNavigationAction( - label: 'Close inbox', - symbol: 'xmark', - onPressed: () => Navigator.of(context).pop(), - ), - leading: IconButton( - tooltip: 'Close inbox', - icon: const Icon(BuzzIcons.x), - onPressed: () => Navigator.of(context).pop(), - ), - nativeLargeTitle: true, - nativeActions: [ - IosNavigationAction( - label: 'Filter inbox', - icon: BuzzIcons.filter2, + return IosScrollToTop( + child: Scaffold( + backgroundColor: context.colors.surface, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final entry in _filterLabels.entries) - IosNavigationAction( - label: entry.value, - selected: filter.value == entry.key, - onPressed: () => filter.value = entry.key, + 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)), + ], ), - ], - ), - IosNavigationAction( - label: 'Inbox options', - symbol: 'ellipsis', - children: [ - IosNavigationAction( - label: 'Unread only', - selected: unreadOnly.value, - onPressed: () => unreadOnly.value = !unreadOnly.value, ), - IosNavigationAction( - label: 'Mark all as read', - onPressed: unreadVisibleCount == 0 - ? null - : () { - for (final item in visibleItems) { - if (!isDone(item)) markItemRead(item); - } - }, + 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( + 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( + 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, + ), ), ], ), - ], - automaticallyImplyLeading: false, - horizontalInset: Grid.gutter, - - title: Text('Inbox', style: headerTitleStyle), - titleStyle: headerTitleStyle, - actions: [ - _ActivityActionsPill( - filter: filter.value, - unreadOnly: unreadOnly.value, - unreadCount: unreadVisibleCount, - onFilterChanged: (f) => filter.value = f, - onUnreadOnlyChanged: (v) => unreadOnly.value = v, - onMarkAllRead: () { - for (final item in visibleItems) { - if (!isDone(item)) markItemRead(item); - } - }, - ), - ], - bottomHeight: Grid.xxs, - bottom: const SizedBox.expand(), - ), - body: SafeArea( - key: const ValueKey('activity-content-safe-area'), - top: false, - bottom: false, - child: bodyRidesOverTopSection - ? body - : Padding( - padding: EdgeInsets.only(top: topSectionHeight), - child: body, - ), + ), ), ); } diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 5a9f58c3f6..de0e9ccf8d 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -1,110 +1,82 @@ part of '../activity_page.dart'; -const _filterLabels = { - InboxFilter.all: 'All', - InboxFilter.mention: 'Mentions', - InboxFilter.thread: 'Threads', - InboxFilter.needsAction: 'Needs Action', - InboxFilter.activity: 'Activity', - InboxFilter.agentActivity: 'Agents', - InboxFilter.reminders: 'Reminders', - InboxFilter.drafts: 'Drafts', -}; - -class _ActivityActionsPill extends StatelessWidget { - final InboxFilter filter; - final bool unreadOnly; - final int unreadCount; - final ValueChanged onFilterChanged; - final ValueChanged onUnreadOnlyChanged; - final VoidCallback onMarkAllRead; - - const _ActivityActionsPill({ - required this.filter, - required this.unreadOnly, - required this.unreadCount, - required this.onFilterChanged, - required this.onUnreadOnlyChanged, - required this.onMarkAllRead, +class _ActivityMenuButton extends StatelessWidget { + const _ActivityMenuButton({ + required this.value, + required this.options, + required this.onChanged, + required this.controlKey, + required this.popoverKey, + required this.semanticLabel, + required this.active, }); - - @override - Widget build(BuildContext context) => ClipRRect( - borderRadius: BorderRadius.circular(Radii.full), - child: DecoratedBox( - decoration: BoxDecoration( - color: context.controlSurface, - borderRadius: BorderRadius.circular(Radii.full), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _FilterMenuButton(filter: filter, onChanged: onFilterChanged), - _InboxOptionsButton( - unreadOnly: unreadOnly, - unreadCount: unreadCount, - onUnreadOnlyChanged: onUnreadOnlyChanged, - onMarkAllRead: onMarkAllRead, - ), - ], - ), - ), - ), - ); -} - -/// Compact filter dropdown replacing the old chip rail — mirrors desktop's -/// inbox filter menu (`FILTER_OPTIONS`). -class _FilterMenuButton extends StatelessWidget { - final InboxFilter filter; - final ValueChanged onChanged; - - const _FilterMenuButton({required this.filter, required this.onChanged}); + final T value; + final Map options; + final ValueChanged onChanged; + final String controlKey, popoverKey, semanticLabel; + final bool active; + String get label => options[value]!; @override Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + final textScaler = MediaQuery.textScalerOf(context); + final height = max(48.0, textScaler.scale(16) * 1.4 + 16); + final labelWidth = (TextPainter( + text: TextSpan( + text: label, + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w600), + ), + textScaler: textScaler, + textDirection: Directionality.of(context), + )..layout()).width; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: IosMessageCreateButton( + key: ValueKey(controlKey), + label: label, + labelFontSize: 16, + icon: 'chevronDown', + showIconWithLabel: true, + eagerGestures: false, + accessibilityLabel: semanticLabel, + width: labelWidth + 56, + height: height - 8, + plain: false, + fillWidth: true, + deferMenuActions: false, + selectedIndex: options.keys.toList().indexOf(value), + actions: [ + for (final entry in options.entries) + ( + label: entry.value, + symbol: '', + onPressed: () => onChanged(entry.key), + ), + ], + ), + ); + } return Builder( - builder: (buttonContext) => InkWell( - key: const ValueKey('activity-filter-menu'), - borderRadius: BorderRadius.circular(Radii.md), - onTap: () async { - final selected = await showAnchoredPopover( + builder: (buttonContext) => TextButton( + key: ValueKey(controlKey), + style: _activityFilterStyle(context, active), + onPressed: () async { + final selected = await showAnchoredPopover( context: buttonContext, width: 240, alignment: AnchoredPopoverAlignment.start, - offset: const Offset(0, Grid.half), - menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), - surfaceKey: const ValueKey('activity-filter-popover'), + surfaceKey: ValueKey(popoverKey), items: [ - for (final entry in _filterLabels.entries) + for (final entry in options.entries) PopupMenuItem( value: entry.key, height: Grid.xl, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), child: Row( children: [ - SizedBox( - width: Grid.sm, - child: entry.key == filter - ? Icon( - BuzzIcons.check, - size: 16, - color: context.colors.primary, - ) - : null, - ), - Expanded( - child: Text( - entry.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - ), - ), - ), + Expanded(child: Text(entry.value)), + if (value == entry.key) + const Icon(BuzzIcons.check, size: 16), ], ), ), @@ -112,28 +84,23 @@ class _FilterMenuButton extends StatelessWidget { ); if (buttonContext.mounted && selected != null) onChanged(selected); }, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: Grid.xl), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _filterLabels[filter]!, - style: context.textTheme.labelLarge?.copyWith( - color: navigationPrimaryForeground(context), - fontWeight: FontWeight.w600, - ), + child: Semantics( + label: semanticLabel, + selected: active, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: Grid.quarter), - Icon( - BuzzIcons.chevronDown, - size: 16, - color: navigationPrimaryForeground(context), - ), - ], - ), + ), + const SizedBox(width: Grid.quarter), + const Icon(BuzzIcons.chevronDown, size: 16), + ], ), ), ), @@ -141,80 +108,68 @@ class _FilterMenuButton extends StatelessWidget { } } -/// Overflow menu with the unread-only toggle and mark-all-read, mirroring -/// desktop's inbox options popover. -class _InboxOptionsButton extends StatelessWidget { - final bool unreadOnly; - final int unreadCount; - final ValueChanged onUnreadOnlyChanged; - final VoidCallback onMarkAllRead; - - const _InboxOptionsButton({ - required this.unreadOnly, - required this.unreadCount, - required this.onUnreadOnlyChanged, - required this.onMarkAllRead, - }); - +class _UnreadFilterButton extends StatelessWidget { + const _UnreadFilterButton({required this.selected, required this.onPressed}); + final bool selected; + final VoidCallback onPressed; @override Widget build(BuildContext context) { - return Builder( - builder: (buttonContext) => IconButton( - key: const ValueKey('activity-options-menu'), - tooltip: 'Inbox options', - color: navigationPrimaryForeground(context), - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - constraints: const BoxConstraints.tightFor( - width: Grid.xl, - height: Grid.xl, - ), - icon: const Icon(BuzzIcons.ellipsis, size: 20), - onPressed: () async { - final selected = await showAnchoredPopover( - context: buttonContext, - width: 216, - alignment: AnchoredPopoverAlignment.end, - surfaceKey: const ValueKey('activity-options-popover'), - items: [ - PopupMenuItem( - value: 'unread-only', - child: Row( - children: [ - Expanded( - child: Text(unreadOnly ? 'Show all' : 'Show unread'), - ), - if (unreadOnly) - Icon( - BuzzIcons.check, - size: 16, - color: context.colors.primary, - ), - ], - ), - ), - PopupMenuItem( - value: 'mark-all-read', - enabled: unreadCount > 0, - child: Row( - children: [ - const Expanded(child: Text('Mark all as read')), - if (unreadCount > 0) - Text( - '$unreadCount', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ); - if (!buttonContext.mounted || selected == null) return; - if (selected == 'unread-only') onUnreadOnlyChanged(!unreadOnly); - if (selected == 'mark-all-read') onMarkAllRead(); - }, + if (defaultTargetPlatform == TargetPlatform.iOS) { + final scaler = MediaQuery.textScalerOf(context); + final height = max(48.0, scaler.scale(16) * 1.4 + 16); + final width = + (TextPainter( + text: TextSpan( + text: 'Unread', + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w600), + ), + textScaler: scaler, + textDirection: Directionality.of(context), + )..layout()).width + + 32; + return IosGlassNavigationButton( + key: const ValueKey('activity-unread-filter'), + icon: IosGlassNavigationIcon.more, + label: 'Unread', + semanticLabel: 'Unread', + isSelected: selected, + onPressed: onPressed, + width: width, + height: height, + controlSize: height - 8, + fillWidth: true, + foregroundColor: context.colors.primary, + ); + } + return Semantics( + toggled: selected, + child: TextButton( + key: const ValueKey('activity-unread-filter'), + style: _activityFilterStyle(context, selected), + onPressed: onPressed, + child: const Text('Unread'), ), ); } } + +ButtonStyle _activityFilterStyle(BuildContext context, bool active) => + TextButton.styleFrom( + minimumSize: Size( + 0, + max(44.0, MediaQuery.textScalerOf(context).scale(16) * 1.4 + 12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: const StadiumBorder(), + textStyle: filterChipTextStyle.copyWith( + leadingDistribution: TextLeadingDistribution.even, + ), + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.padded, + foregroundColor: active + ? context.colors.primary + : context.colors.onSurface, + backgroundColor: active + ? context.colors.primaryContainer + : context.controlSurface, + ); diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index d07eabe835..844230e88c 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -5,41 +5,6 @@ const _inboxSwipeActionLabelMinWidth = Grid.xxl; const _inboxSwipeLabelRevealWidth = _inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2); -/// "New" boundary between unread and previously read rows. -class _NewBoundaryDivider extends StatelessWidget { - const _NewBoundaryDivider(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.half, - ), - child: Row( - children: [ - Expanded( - child: Divider(color: context.colors.error.withValues(alpha: 0.5)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - child: Text( - 'New', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.error, - fontWeight: FontWeight.w600, - ), - ), - ), - Expanded( - child: Divider(color: context.colors.error.withValues(alpha: 0.5)), - ), - ], - ), - ); - } -} - /// One conversation row, matching desktop's inbox item hierarchy: /// avatar | sender + unread dot + time | contextual label | preview. /// diff --git a/mobile/lib/features/activity/activity_page/status_views.dart b/mobile/lib/features/activity/activity_page/status_views.dart index 4e73ea6361..501f669ee9 100644 --- a/mobile/lib/features/activity/activity_page/status_views.dart +++ b/mobile/lib/features/activity/activity_page/status_views.dart @@ -124,6 +124,7 @@ class _EmptyFilterState extends StatelessWidget { ); } final (icon, message) = switch (filter) { + InboxFilter.dm => (BuzzIcons.messageSquare, 'No direct messages yet'), InboxFilter.mention => (BuzzIcons.atSign, 'No mentions yet'), InboxFilter.thread => (BuzzIcons.messageSquare, 'No thread replies yet'), InboxFilter.needsAction => ( diff --git a/mobile/lib/features/activity/inbox_item.dart b/mobile/lib/features/activity/inbox_item.dart index dea29a5b87..701d3c1509 100644 --- a/mobile/lib/features/activity/inbox_item.dart +++ b/mobile/lib/features/activity/inbox_item.dart @@ -8,6 +8,7 @@ import 'feed_item.dart'; enum InboxFilter { all, mention, + dm, thread, needsAction, activity, @@ -56,8 +57,8 @@ String inboxConversationId( }) { final thread = threadReferenceOf(tags); final threadId = thread.rootId ?? thread.parentId; - if (threadId != null) return threadId; if (dmChannelId != null) return 'dm:$dmChannelId'; + if (threadId != null) return threadId; return eventId; } @@ -165,6 +166,7 @@ class InboxItem { bool matchesInboxFilter(InboxItem item, InboxFilter filter) { return switch (filter) { InboxFilter.all => true, + InboxFilter.dm => item.conversationId.startsWith('dm:'), InboxFilter.thread => [ item.item, ...item.groupItems, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 7bfa577247..cc10d46958 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -1,3 +1,4 @@ +import '../../shared/widgets/top_edge_fade.dart'; import 'dart:async'; import 'package:buzz/shared/widgets/feedback_snackbar.dart'; @@ -249,11 +250,13 @@ class SearchPage extends HookConsumerWidget { ), const SizedBox(height: Grid.xxs), Expanded( - child: _SearchBody( - state: searchState, - filter: activeFilter.value, - currentPubkey: currentPubkey, - onRecentSearchSelected: runRecentSearch, + child: TopEdgeFade( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + onRecentSearchSelected: runRecentSearch, + ), ), ), ], diff --git a/mobile/lib/shared/widgets/top_edge_fade.dart b/mobile/lib/shared/widgets/top_edge_fade.dart new file mode 100644 index 0000000000..9d87f76ec7 --- /dev/null +++ b/mobile/lib/shared/widgets/top_edge_fade.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// Softens content entering a fixed header while retaining the viewport's clip. +/// Only the first 16 logical pixels fade; controls and the bottom stay sharp. +class TopEdgeFade extends StatelessWidget { + const TopEdgeFade({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => ClipRect( + child: MediaQuery.highContrastOf(context) + ? child + : ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => + const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.white], + ).createShader( + Rect.fromLTWH(bounds.left, bounds.top, bounds.width, 16), + ), + child: child, + ), + ); +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 1dfb850c70..d3b42abce2 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -1,11 +1,15 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; +import 'package:buzz/shared/widgets/ios_message_create_button.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:flutter/foundation.dart'; import 'package:buzz/features/activity/activity_page.dart'; import 'package:buzz/features/activity/activity_provider.dart'; import 'package:buzz/features/activity/compose_drafts_provider.dart'; import 'package:buzz/features/activity/feed_item.dart'; -import 'package:buzz/features/activity/inbox_item.dart'; import 'package:buzz/features/activity/reminders_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; @@ -31,6 +35,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/thread_ids.dart'; +part 'activity_page_test/tabs_journey.dart'; + void main() { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -130,6 +136,7 @@ void main() { List reminders = const [], Set knownAgentPubkeys = const {}, List overrides = const [], + UserCacheNotifier Function()? userCache, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -143,7 +150,7 @@ void main() { () => _FakeChannelsNotifier(channels ?? testChannels), ), userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier(users ?? testUsers), + userCache ?? () => _FakeUserCacheNotifier(users ?? testUsers), ), knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), readStateProvider.overrideWith( @@ -168,6 +175,14 @@ void main() { ); } + _activityTabJourneys( + ({Set knownAgentPubkeys = const {}, TextScaler? textScaler}) => + buildTestable( + knownAgentPubkeys: knownAgentPubkeys, + textScaler: textScaler, + ), + ); + testWidgets('shows loading skeleton while feed loads', (tester) async { await tester.pumpWidget( await buildTestable(activityNotifier: _PendingActivityNotifier.new), @@ -195,51 +210,31 @@ void main() { expect(find.text('No activity yet'), findsOneWidget); }); - testWidgets('Inbox exposes close navigation for its modal route', ( - tester, - ) async { - await tester.pumpWidget(await buildTestable()); - await tester.pumpAndSettle(); - - final appBar = tester.widget( - find.byType(FrostedAppBar).last, - ); - expect(appBar.automaticallyImplyLeading, isFalse); - expect(appBar.nativeLeading?.symbol, 'xmark'); - expect(appBar.nativeLeading?.label, 'Close inbox'); - expect(appBar.nativeTitle, 'Inbox'); - expect(appBar.frosted, isTrue); - - expect(appBar.bottomHeight, Grid.xxs); - expect(appBar.centerTitle, isFalse); - expect(find.byTooltip('Back'), findsNothing); - }); - - testWidgets('sizes the Activity app bar for its custom title style', ( - tester, - ) async { - await tester.pumpWidget( - await buildTestable(textScaler: const TextScaler.linear(2)), - ); - await tester.pumpAndSettle(); - - final appBar = tester.widget( - find.byType(FrostedAppBar).last, - ); - final titleStyle = appBar.titleStyle!; - expect(titleStyle.fontSize, 22); - expect( - tester.getSize(find.byType(ClipRect).last).height, - closeTo( - frostedAppBarHeight( - tester.element(find.byType(FrostedAppBar).last), - titleStyle: titleStyle, - bottomHeight: Grid.xxs, - ), - 0.01, - ), - ); - }); + testWidgets( + 'Activity keeps its header above the clipped feed at large text', + (tester) async { + await tester.pumpWidget( + await buildTestable(textScaler: const TextScaler.linear(2)), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Close activity'), findsOneWidget); + expect(find.byTooltip('Back'), findsNothing); + expect(find.byType(FrostedAppBar), findsNothing); + final filters = find.byKey(const ValueKey('activity-filters')); + final viewport = find.byKey(const ValueKey('activity-content-viewport')); + final cutoff = tester.getBottomLeft(filters).dy; + expect(tester.getTopLeft(viewport).dy, cutoff); + expect( + tester.getBottomLeft(find.text('Activity')).dy, + lessThanOrEqualTo(tester.getTopLeft(filters).dy), + ); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(viewport).dy, cutoff); + expect(tester.getBottomLeft(filters).dy, cutoff); + expect(tester.takeException(), isNull); + }, + ); testWidgets('keeps footer clearance inside the scrollable content', ( tester, @@ -249,12 +244,6 @@ void main() { ); await tester.pumpAndSettle(); - final safeArea = tester.widget( - find.byKey(const ValueKey('activity-content-safe-area')), - ); - expect(safeArea.top, isFalse); - expect(safeArea.bottom, isFalse); - final padding = tester.widget( find.descendant( of: find.byType(CustomScrollView), @@ -315,17 +304,19 @@ void main() { await tester.pumpWidget(await buildTestable()); await tester.pumpAndSettle(); - final filterTrigger = find.byKey(const ValueKey('activity-filter-menu')); + final filterTrigger = find.byKey(const ValueKey('activity-sender-menu')); expect( tester.getSize(filterTrigger).height, greaterThanOrEqualTo(Grid.xl), reason: 'The Activity filter trigger must keep a 48dp touch target.', ); + await tester.ensureVisible(filterTrigger); + await tester.pumpAndSettle(); await tester.tap(filterTrigger); await tester.pump(); - final surface = find.byKey(const ValueKey('activity-filter-popover')); + final surface = find.byKey(const ValueKey('activity-sender-popover')); final fade = find.byKey(const ValueKey('activity-popover-fade')); final scale = find.byKey(const ValueKey('activity-popover-scale')); expect(surface, findsOneWidget); @@ -368,103 +359,20 @@ void main() { expect(material.surfaceTintColor, Colors.transparent); expect(material.clipBehavior, Clip.antiAlias); - final items = tester.widgetList>( - find.byType(PopupMenuItem), + final items = tester.widgetList>( + find.byType(PopupMenuItem), ); - expect(items, hasLength(InboxFilter.values.length)); + expect(items, hasLength(3)); expect( items.every((item) => item.height >= Grid.xl), isTrue, reason: 'Activity filter choices must keep 48dp touch targets.', ); - await tester.tap(find.descendant(of: surface, matching: find.text('All'))); - await tester.pumpAndSettle(); - final optionsTrigger = find.byKey(const ValueKey('activity-options-menu')); - expect( - tester.getSize(optionsTrigger), - const Size(Grid.xl, Grid.xl), - reason: 'Activity options must retain a 48dp touch target.', - ); - await tester.tap(optionsTrigger); - await tester.pump(); - - final optionsSurface = find.byKey( - const ValueKey('activity-options-popover'), - ); - final optionsMaterial = tester.widget(optionsSurface); - final optionsShape = optionsMaterial.shape! as RoundedRectangleBorder; - expect(tester.getSize(optionsSurface).width, 216); - expect(optionsShape.borderRadius, BorderRadius.circular(Radii.popover)); - expect(optionsMaterial.elevation, appPopoverElevation); - expect( - tester - .widget( - find.byKey(const ValueKey('activity-popover-scale')), - ) - .alignment, - Alignment.topRight, - ); - }); - - testWidgets('filter stays indicator-free when drafts and reminders exist', ( - tester, - ) async { - final dueReminder = Reminder( - id: 'reminder-1', - notBefore: now - 1, - status: 'pending', - target: const ReminderTarget( - eventId: 'm1', - channelId: 'ch1', - preview: 'Follow up', - authorPubkey: - 'a11ce00000000000000000000000000000000000000000000000000000000000', - ), - note: null, - createdAt: now - 60, - eventId: 'reminder-event-1', - ); - final draft = ComposeDraft( - key: 'ch1', - channelId: 'ch1', - threadHeadId: null, - text: 'Unsent review note', - updatedAt: now, - ); - - await tester.pumpWidget( - await buildTestable(drafts: [draft], reminders: [dueReminder]), - ); - await tester.pumpAndSettle(); - - final filterTrigger = find.byKey(const ValueKey('activity-filter-menu')); - expect( - find.descendant( - of: filterTrigger, - matching: find.byWidgetPredicate((widget) { - if (widget is! Container) return false; - final constraints = widget.constraints; - return constraints?.minWidth == 6 && constraints?.minHeight == 6; - }), - ), - findsNothing, - ); - - await tester.tap(filterTrigger); - await tester.pumpAndSettle(); - final filterPopover = find.byKey(const ValueKey('activity-filter-popover')); - expect( - find.descendant(of: filterPopover, matching: find.text('1')), - findsNothing, - ); - await tester.tap( - find.descendant(of: filterPopover, matching: find.text('Drafts')), + find.descendant(of: surface, matching: find.text('Anyone')), ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('draft-row-ch1')), findsOneWidget); - expect(find.text('Unsent review note'), findsOneWidget); }); testWidgets('rows lead with sender, contextual label, and preview', ( @@ -482,7 +390,10 @@ void main() { // Contextual labels + channel chips. expect(find.text('Mentioned in'), findsOneWidget); expect(find.text('Thread in'), findsOneWidget); - expect(find.text('#general'), findsNWidgets(2)); // mention + agent + expect( + find.text('#general'), + findsNWidgets(2), + ); // mention and agent activity expect(find.text('#engineering'), findsOneWidget); // Context labels and channel pills share the compact Activity style. @@ -631,11 +542,11 @@ void main() { tester, ) async { await tester.pumpWidget( - await buildTestable(knownAgentPubkeys: const {'agent_pk'}), + await buildTestable(knownAgentPubkeys: const {'bob_pk'}), ); await tester.pumpAndSettle(); - final agentRow = find.byKey(const ValueKey('inbox-row-ag1')); + final agentRow = find.byKey(const ValueKey('inbox-row-a1')); final agentAvatar = tester.widget( find.descendant(of: agentRow, matching: find.byType(AvatarImage)), ); @@ -695,6 +606,323 @@ void main() { expect(find.textContaining('dm body dm-a'), findsNothing); }); + testWidgets('marking a DM row read covers replies under every thread root', ( + tester, + ) async { + final dmChannel = Channel( + id: 'dm1', + name: 'dm', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + participants: const ['Alice'], + ); + FeedItem reply(String id, String root, int age) => FeedItem( + id: id, + kind: 9, + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', + content: 'dm reply $id', + createdAt: now - age, + channelId: 'dm1', + channelName: '', + tags: [ + ['e', root, '', 'root'], + ['e', root, '', 'reply'], + ], + category: 'activity', + ); + const rootA = + 'aaaa000000000000000000000000000000000000000000000000000000000001'; + const rootB = + 'bbbb000000000000000000000000000000000000000000000000000000000002'; + + await tester.pumpWidget( + await buildTestable( + feed: HomeFeedResponse( + mentions: const [], + needsAction: const [], + activity: [reply('dm-r1', rootA, 300), reply('dm-r2', rootB, 200)], + agentActivity: const [], + ), + channels: [...testChannels, dmChannel], + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('inbox-unread-dot-dm-r2')), + findsOneWidget, + ); + + await tester.drag( + find.byKey(const ValueKey('inbox-row-dm-r2')), + const Offset(-500, 0), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('inbox-unread-dot-dm-r2')), findsNothing); + }); + + testWidgets( + 'sender filter keeps a person\'s message under a later agent reply', + (tester) async { + FeedItem threadEvent(String id, String pubkey, String body, int age) => + FeedItem( + id: id, + kind: 9, + pubkey: pubkey, + content: body, + createdAt: now - age, + channelId: 'ch1', + channelName: 'general', + tags: const [ + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdRoot1, '', 'reply'], + ], + category: 'mention', + ); + + await tester.pumpWidget( + await buildTestable( + knownAgentPubkeys: const {'agent_pk'}, + feed: HomeFeedResponse( + mentions: [ + threadEvent('p1', 'bob_pk', 'person asks', 300), + threadEvent('g1', 'agent_pk', 'agent answers', 200), + ], + needsAction: const [], + activity: const [], + agentActivity: const [], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('agent answers'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('activity-sender-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('People').last); + await tester.pumpAndSettle(); + + expect(find.textContaining('person asks'), findsOneWidget); + expect(find.textContaining('agent answers'), findsNothing); + }, + ); + + testWidgets('Agents filter counts channel bot members as agents', ( + tester, + ) async { + FeedItem event(String id, String pubkey, String body, int age) => FeedItem( + id: id, + kind: 9, + pubkey: pubkey, + content: body, + createdAt: now - age, + channelId: 'ch1', + channelName: 'general', + tags: const [], + category: 'mention', + ); + + await tester.pumpWidget( + await buildTestable( + feed: HomeFeedResponse( + mentions: [ + event('b1', 'bot_pk', 'bot reports', 300), + event('p1', 'bob_pk', 'person asks', 200), + ], + needsAction: const [], + activity: const [], + agentActivity: const [], + ), + overrides: [ + channelBotPubkeysProvider( + 'ch1', + ).overrideWith((ref) async => {'bot_pk'}), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('activity-sender-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Agents').last); + await tester.pumpAndSettle(); + + expect(find.textContaining('bot reports'), findsOneWidget); + expect(find.textContaining('person asks'), findsNothing); + }); + + testWidgets('Threads filter shows a DM reply, not a later DM message', ( + tester, + ) async { + final dmChannel = Channel( + id: 'dm1', + name: 'dm', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + participants: const ['Alice'], + ); + FeedItem dmEvent( + String id, + String body, + int age, + List> tags, + ) => FeedItem( + id: id, + kind: 9, + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', + content: body, + createdAt: now - age, + channelId: 'dm1', + channelName: '', + tags: tags, + category: 'activity', + ); + + await tester.pumpWidget( + await buildTestable( + feed: HomeFeedResponse( + mentions: const [], + needsAction: const [], + activity: [ + dmEvent('dm-reply', 'reply in thread', 300, const [ + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdRoot1, '', 'reply'], + ]), + dmEvent('dm-top', 'later top level', 200, const []), + ], + agentActivity: const [], + ), + channels: [...testChannels, dmChannel], + ), + ); + await tester.pumpAndSettle(); + await _chooseActivityType(tester, 'Threads'); + + expect(find.textContaining('reply in thread'), findsOneWidget); + expect(find.textContaining('later top level'), findsNothing); + }); + + testWidgets( + 'reading a DM reply under Threads leaves other DM messages unread', + (tester) async { + final dmChannel = Channel( + id: 'dm1', + name: 'dm', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + participants: const ['Alice'], + ); + FeedItem dmEvent(String id, int age, List> tags) => FeedItem( + id: id, + kind: 9, + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', + content: 'dm body $id', + createdAt: now - age, + channelId: 'dm1', + channelName: '', + tags: tags, + category: 'activity', + ); + + await tester.pumpWidget( + await buildTestable( + feed: HomeFeedResponse( + mentions: const [], + needsAction: const [], + activity: [ + dmEvent('dm-top', 300, const []), + dmEvent('dm-reply', 200, const [ + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdRoot1, '', 'reply'], + ]), + ], + agentActivity: const [], + ), + channels: [...testChannels, dmChannel], + ), + ); + await tester.pumpAndSettle(); + await _chooseActivityType(tester, 'Threads'); + await tester.drag( + find.byKey(const ValueKey('inbox-row-dm-reply')), + const Offset(-500, 0), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('inbox-unread-dot-dm-reply')), + findsNothing, + ); + + await _chooseActivityType(tester, 'All'); + expect( + find.byKey(const ValueKey('inbox-unread-dot-dm-reply')), + findsOneWidget, + ); + }, + ); + + testWidgets('sender filter preloads authors hidden under newer rows', ( + tester, + ) async { + final cache = _RecordingUserCacheNotifier(testUsers); + FeedItem threadEvent(String id, String pubkey, int age) => FeedItem( + id: id, + kind: 9, + pubkey: pubkey, + content: 'body $id', + createdAt: now - age, + channelId: 'ch1', + channelName: 'general', + tags: const [ + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdRoot1, '', 'reply'], + ], + category: 'mention', + ); + + await tester.pumpWidget( + await buildTestable( + feed: HomeFeedResponse( + mentions: [ + threadEvent('g1', 'owned_agent_pk', 300), + threadEvent('p1', 'bob_pk', 200), + ], + needsAction: const [], + activity: const [], + agentActivity: const [], + ), + userCache: () => cache, + ), + ); + await tester.pumpAndSettle(); + expect(cache.preloaded, isNot(contains('owned_agent_pk'))); + + await tester.tap(find.byKey(const ValueKey('activity-sender-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Agents').last); + await tester.pumpAndSettle(); + + expect(cache.preloaded, contains('owned_agent_pk')); + }); + testWidgets('unread rows show a dot; read rows do not', (tester) async { await tester.pumpWidget( await buildTestable( @@ -709,44 +937,39 @@ void main() { expect(find.byKey(const ValueKey('inbox-unread-dot-a1')), findsOneWidget); }); - testWidgets('New boundary separates unread rows from read rows', ( - tester, - ) async { - await tester.pumpWidget( - await buildTestable( - // Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read. - readContexts: {'thread:$threadIdRoot1': now, 'ch2': now}, - ), - ); - await tester.pumpAndSettle(); + testWidgets( + 'chronological Activity does not imply a contiguous unread boundary', + (tester) async { + await tester.pumpWidget( + await buildTestable( + // Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read. + readContexts: {'thread:$threadIdRoot1': now, 'ch2': now}, + ), + ); + await tester.pumpAndSettle(); - expect(find.text('New'), findsOneWidget); - }); + expect(find.text('New'), findsNothing); + }, + ); - testWidgets('filter menu switches sources and shows empty states', ( - tester, - ) async { + testWidgets('tabs switch sources and show empty states', (tester) async { await tester.pumpWidget(await buildTestable()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Mentions')); + await _chooseActivityType(tester, 'Mentions'); await tester.pumpAndSettle(); expect(find.textContaining('Hey check this out'), findsOneWidget); expect(find.textContaining('Deployed the fix'), findsNothing); expect(find.textContaining('Job completed successfully'), findsNothing); - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Needs Action')); + await _chooseActivityType(tester, 'DMs'); await tester.pumpAndSettle(); - expect(find.text('Nothing needs your action'), findsOneWidget); + expect(find.text('No direct messages yet'), findsOneWidget); }); - testWidgets('filter menu supports accessibility text scaling', ( + testWidgets('sender menu supports accessibility text scaling', ( tester, ) async { await tester.pumpWidget( @@ -754,105 +977,98 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); + await tester.ensureVisible( + find.byKey(const ValueKey('activity-sender-menu')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('activity-sender-menu'))); await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('activity-filter-popover')), + find.byKey(const ValueKey('activity-sender-popover')), findsOneWidget, ); expect(tester.takeException(), isNull); }); - testWidgets('opens a thread mention at the referenced message', ( - tester, - ) async { - final threadMention = FeedItem( - id: 'reply-event', - kind: 9, - pubkey: - 'a11ce00000000000000000000000000000000000000000000000000000000000', - content: 'Reply in a thread', - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - channelId: 'ch1', - channelName: 'general', - tags: const [ - ['e', threadIdThreadDashRoot, '', 'root'], - ['e', threadIdParentDashReply, '', 'reply'], - ], - category: 'mention', - ); - final feed = HomeFeedResponse( - mentions: [threadMention], - needsAction: const [], - activity: const [], - agentActivity: const [], + testWidgets('opens a thread reply at the referenced message', (tester) async { + final dmChannel = Channel( + id: 'dm1', + name: 'dm', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + participants: const ['Alice'], ); + // A DM reply must still open its thread: the DM timeline hides replies. + for (final channelId in ['ch1', 'dm1']) { + final threadMention = FeedItem( + id: 'reply-event', + kind: 9, + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', + content: 'Reply in a thread', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + channelId: channelId, + channelName: 'general', + tags: const [ + ['e', threadIdThreadDashRoot, '', 'root'], + ['e', threadIdParentDashReply, '', 'reply'], + ], + category: 'mention', + ); + final feed = HomeFeedResponse( + mentions: [threadMention], + needsAction: const [], + activity: const [], + agentActivity: const [], + ); - await tester.pumpWidget(await buildTestable(feed: feed)); - await tester.pumpAndSettle(); + await tester.pumpWidget( + await buildTestable(feed: feed, channels: [...testChannels, dmChannel]), + ); + await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event'))); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event'))); + await tester.pumpAndSettle(); - final page = tester.widget( - find.byType(ChannelDetailPage), - ); - expect(page.channel.id, 'ch1'); - expect(page.initialThreadRootId, threadIdParentDashReply); - expect(page.initialMessageId, 'reply-event'); - expect( - page.initialThreadRouteBehavior, - InitialThreadRouteBehavior.replaceCurrentRoute, - ); + final page = tester.widget( + find.byType(ChannelDetailPage), + ); + expect(page.channel.id, channelId); + expect(page.initialThreadRootId, threadIdParentDashReply); + expect(page.initialMessageId, 'reply-event'); + expect( + page.initialThreadRouteBehavior, + InitialThreadRouteBehavior.replaceCurrentRoute, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } }); testWidgets('thread filter matches grouped thread replies', (tester) async { await tester.pumpWidget(await buildTestable()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Threads')); + await _chooseActivityType(tester, 'Threads'); await tester.pumpAndSettle(); expect(find.textContaining('Deployed the fix'), findsOneWidget); expect(find.textContaining('Hey check this out'), findsNothing); }); - testWidgets('reminders filter shows the reminders empty surface', ( - tester, - ) async { - await tester.pumpWidget(await buildTestable()); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Reminders')); - await tester.pumpAndSettle(); - - expect(find.text('No reminders'), findsOneWidget); - }); - - testWidgets('drafts filter shows the drafts empty surface', (tester) async { - await tester.pumpWidget(await buildTestable()); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Drafts')); - await tester.pumpAndSettle(); - - expect(find.text('No drafts'), findsOneWidget); - }); - testWidgets('unread-only toggle hides read rows', (tester) async { await tester.pumpWidget(await buildTestable(readContexts: {'ch1': now})); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('activity-options-menu'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Show unread')); + expect(find.byKey(const ValueKey('activity-options-menu')), findsNothing); + expect(find.text('Mark all as read'), findsNothing); + await tester.tap(find.byKey(const ValueKey('activity-unread-filter'))); await tester.pumpAndSettle(); // Only the unread thread row remains. @@ -1076,6 +1292,17 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Map build() => _users; } +class _RecordingUserCacheNotifier extends _FakeUserCacheNotifier { + _RecordingUserCacheNotifier(super._users); + final preloaded = {}; + + @override + Future preload(List pubkeys) async { + preloaded.addAll(pubkeys); + return true; + } +} + class _FakeReadStateNotifier extends ReadStateNotifier { final Map _contexts; _FakeReadStateNotifier(this._contexts); diff --git a/mobile/test/features/activity/activity_page_test/tabs_journey.dart b/mobile/test/features/activity/activity_page_test/tabs_journey.dart new file mode 100644 index 0000000000..6bfeac4942 --- /dev/null +++ b/mobile/test/features/activity/activity_page_test/tabs_journey.dart @@ -0,0 +1,229 @@ +part of '../activity_page_test.dart'; + +void _activityTabJourneys( + Future Function({ + Set knownAgentPubkeys, + TextScaler? textScaler, + }) + buildTestable, +) { + testWidgets('Activity type and sender menus work at phone width', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + const directory = String.fromEnvironment('ACTIVITY_CAPTURE_DIR'); + if (directory.isNotEmpty) { + await tester.runAsync(() async { + for (final font in { + 'Inter': 'assets/fonts/InterVariable.ttf', + 'BuzzTabler': 'assets/fonts/TablerIcons.ttf', + }.entries) { + await (FontLoader( + font.key, + )..addFont(rootBundle.load(font.value))).load(); + } + }); + } + final capture = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: capture, + child: await buildTestable(knownAgentPubkeys: const {'bob_pk'}), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Activity'), findsOneWidget); + expect(find.byKey(const ValueKey('activity-filter-menu')), findsNothing); + expect(find.text('Anyone'), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Unread'), findsOneWidget); + expect(find.text('Threads'), findsNothing); + final typeSize = tester.getSize( + find.byKey(const ValueKey('activity-type-menu')), + ); + for (final key in [ + 'activity-type-menu', + 'activity-sender-menu', + 'activity-unread-filter', + ]) { + final button = find.byKey(ValueKey(key)); + expect(tester.getSize(button).height, typeSize.height); + expect( + tester.widget(button).style!.padding!.resolve({}), + const EdgeInsets.symmetric(horizontal: 12), + ); + expect( + tester.widget(button).style!.shape!.resolve({}), + isA(), + ); + } + + expect(tester.takeException(), isNull); + if (directory.isNotEmpty) { + await tester.runAsync(() async { + final boundary = + capture.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File( + '$directory/activity.png', + ).writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); + } + await tester.ensureVisible( + find.byKey(const ValueKey('activity-sender-menu')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('activity-sender-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Agents').last); + await tester.pumpAndSettle(); + expect(find.text('Agents'), findsOneWidget); + expect(find.text('Anyone'), findsNothing); + expect(find.text('Deployed the fix'), findsOneWidget); + expect(find.text('Hey check this out'), findsNothing); + await _chooseActivityType(tester, 'Mentions'); + await tester.pumpAndSettle(); + expect(find.text('No mentions yet'), findsOneWidget); + await _chooseActivityType(tester, 'Reminders'); + await tester.pumpAndSettle(); + expect(find.text('No reminders'), findsOneWidget); + await _chooseActivityType(tester, 'Drafts'); + await tester.pumpAndSettle(); + expect(find.text('No drafts'), findsOneWidget); + }); + + testWidgets('iOS renders both Activity menus as glass controls', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + await buildTestable(knownAgentPubkeys: const {'bob_pk'}), + ); + await tester.pumpAndSettle(); + IosMessageCreateButton sender() => tester.widget( + find.byKey(const ValueKey('activity-sender-menu')), + ); + for (final key in ['activity-type-menu', 'activity-sender-menu']) { + final view = tester.widget( + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(UiKitView), + ), + ); + expect(view.creationParams, containsPair('plain', false)); + } + final close = tester.widget( + find.byWidgetPredicate( + (w) => + w is IosGlassNavigationButton && + w.icon == IosGlassNavigationIcon.close, + ), + ); + expect(close.semanticLabel, 'Close activity'); + expect(close.controlSize, 44); + expect(close.onPressed, isNotNull); + final type = tester.widget( + find.byKey(const ValueKey('activity-type-menu')), + ); + expect(type.label, 'All'); + expect(type.accessibilityLabel, 'Activity type, All'); + expect(type.actions.map((a) => a.label), [ + 'All', + 'Threads', + 'Mentions', + 'DMs', + 'Reminders', + 'Drafts', + ]); + type.actions[1].onPressed!(); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('activity-type-menu')), + ) + .label, + 'Threads', + ); + expect( + tester + .widget( + find.byKey(const ValueKey('activity-type-menu')), + ) + .accessibilityLabel, + 'Activity type, Threads', + ); + type.actions.first.onPressed!(); + await tester.pumpAndSettle(); + expect(sender().label, 'Anyone'); + expect(find.byKey(const ValueKey('activity-options-menu')), findsNothing); + expect(sender().icon, 'chevronDown'); + expect(sender().showIconWithLabel, isTrue); + expect(sender().eagerGestures, isFalse); + expect( + find.descendant( + of: find.byKey(const ValueKey('activity-filters')), + matching: find.byKey(const ValueKey('activity-sender-menu')), + ), + findsOneWidget, + ); + sender().actions.last.onPressed!(); + await tester.pumpAndSettle(); + expect(sender().label, 'Agents'); + expect(sender().selectedIndex, 2); + expect(find.text('Deployed the fix'), findsOneWidget); + expect(find.text('Hey check this out'), findsNothing); + sender().actions[1].onPressed!(); + await tester.pumpAndSettle(); + expect(sender().label, 'People'); + expect(find.text('Hey check this out'), findsOneWidget); + tester + .widget( + find.byKey(const ValueKey('activity-unread-filter')), + ) + .onPressed!(); + await tester.pumpAndSettle(); + expect(find.text('Hey check this out'), findsOneWidget); + expect(find.text('Deployed the fix'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, variant: TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('Activity filter controls remain usable with large phone text', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + await buildTestable(textScaler: const TextScaler.linear(3)), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await _chooseActivityType(tester, 'DMs'); + await tester.pumpAndSettle(); + expect(find.text('No direct messages yet'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Future _chooseActivityType(WidgetTester tester, String label) async { + final trigger = find.byKey(const ValueKey('activity-type-menu')); + await tester.ensureVisible(trigger); + await tester.pumpAndSettle(); + await tester.tap(trigger); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('activity-type-popover')), + matching: find.text(label), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/mobile/test/features/activity/inbox_item_test.dart b/mobile/test/features/activity/inbox_item_test.dart index 600cbb2412..d163d403b1 100644 --- a/mobile/test/features/activity/inbox_item_test.dart +++ b/mobile/test/features/activity/inbox_item_test.dart @@ -161,7 +161,7 @@ void main() { expect(dmRow.latestActivityAt, 30); }); - test('thread replies inside a DM still group by thread root', () { + test('thread replies inside a DM stay in the DM conversation', () { bool isDm(String channelId) => channelId == 'dm1'; final rows = buildInboxItems([ item(id: threadIdInboxTop, createdAt: 10, channelId: 'dm1'), @@ -173,11 +173,9 @@ void main() { ), ], isDmChannel: isDm); - expect(rows, hasLength(2)); - expect(rows.map((r) => r.conversationId).toSet(), { - 'dm:dm1', - threadIdInboxTop, - }); + expect(rows, hasLength(1)); + expect(rows.single.conversationId, 'dm:dm1'); + expect(rows.single.groupItems, hasLength(2)); }); test('categories are priority sorted and set isActionRequired', () { @@ -274,6 +272,39 @@ void main() { ); }); + test('DM filter matches DM conversations, including thread replies', () { + bool isDm(String channelId) => channelId == 'dm1'; + InboxItem row(FeedItem feedItem) => + buildInboxItems([feedItem], isDmChannel: isDm).single; + expect( + matchesInboxFilter( + row(item(id: threadIdInboxA, channelId: 'dm1')), + InboxFilter.dm, + ), + isTrue, + ); + expect( + matchesInboxFilter( + row( + item( + id: threadIdInboxA, + channelId: 'dm1', + tags: replyTags(threadIdInboxR, threadIdInboxP), + ), + ), + InboxFilter.dm, + ), + isTrue, + ); + expect( + matchesInboxFilter( + row(item(id: threadIdInboxA, channelId: 'general')), + InboxFilter.dm, + ), + isFalse, + ); + }); + test('category filters match their category', () { final mention = rowOf(item(id: threadIdInboxA, category: 'mention')); expect(matchesInboxFilter(mention, InboxFilter.mention), isTrue); diff --git a/mobile/test/shared/widgets/top_edge_fade_test.dart b/mobile/test/shared/widgets/top_edge_fade_test.dart new file mode 100644 index 0000000000..d4a9ae9e67 --- /dev/null +++ b/mobile/test/shared/widgets/top_edge_fade_test.dart @@ -0,0 +1,50 @@ +import 'dart:ui' as ui; + +import 'package:buzz/shared/widgets/top_edge_fade.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('fades only the top edge and preserves high contrast', ( + tester, + ) async { + for (final highContrast in [false, true]) { + final key = GlobalKey(); + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(highContrast: highContrast), + child: Center( + child: RepaintBoundary( + key: key, + child: const SizedBox( + width: 64, + height: 64, + child: TopEdgeFade(child: ColoredBox(color: Colors.red)), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final data = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + int alpha(int y) => data.getUint8((y * 64 + 32) * 4 + 3); + if (highContrast) { + expect(alpha(0), 255); + } else { + expect(alpha(0), lessThan(20)); + expect(alpha(8), inInclusiveRange(100, 160)); + } + expect(alpha(16), 255); + expect(alpha(63), 255); + image.dispose(); + }); + } + }); +}