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(); + }); + } + }); +}