From 63add0912864c0c109649ddec0fd7e05a70e1659 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 17:12:10 -0700 Subject: [PATCH 1/2] feat(mobile): refine channel and home UI --- .../channels/channel_detail_page.dart | 52 +-- .../channels/channel_detail_page/app_bar.dart | 3 +- .../channel_detail_page/message_bubble.dart | 161 ++++--- .../channel_detail_page/message_list.dart | 25 +- .../channel_detail_page/system_rows.dart | 402 ++++++++++++++++-- .../lib/features/channels/channels_page.dart | 68 +-- .../channels/channels_page/channel_tile.dart | 2 +- .../channels/channels_page/community.dart | 43 +- .../channels/channels_page/quick_actions.dart | 322 ++++++++++++++ .../channels/channels_page/sections.dart | 4 +- .../channels/channels_page/sheets.dart | 37 -- .../features/channels/date_formatters.dart | 52 ++- mobile/lib/features/channels/day_divider.dart | 50 ++- .../mentions/mention_candidates_provider.dart | 27 ++ .../features/channels/message_content.dart | 85 +++- .../lib/features/channels/small_avatar.dart | 16 +- .../features/channels/thread_detail_page.dart | 186 ++++---- .../features/channels/timeline_message.dart | 107 ++++- mobile/lib/shared/theme/text_theme.dart | 5 +- .../lib/shared/widgets/frosted_app_bar.dart | 66 +-- .../channels/channel_detail_page_test.dart | 213 +++++++++- .../channels/date_formatters_test.dart | 43 ++ .../channels/message_content_test.dart | 24 +- .../channels/timeline_message_test.dart | 153 ++++++- 24 files changed, 1758 insertions(+), 388 deletions(-) create mode 100644 mobile/lib/features/channels/channels_page/quick_actions.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a73da29189..8a44b499b5 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -32,6 +32,7 @@ import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; +import 'mentions/mention_candidates_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; @@ -183,6 +184,7 @@ class ChannelDetailPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( + iconColor: context.colors.primary, title: resolvedChannel.isDm ? _DmAppBarTitle( channel: resolvedChannel, @@ -190,46 +192,31 @@ class ChannelDetailPage extends HookConsumerWidget { ) : Row( children: [ - Icon( - channelIcon(resolvedChannel), - size: 18, - color: context.colors.onSurfaceVariant, + SizedBox.square( + dimension: 22, + child: Center( + child: Icon(channelIcon(resolvedChannel), size: 18), + ), ), const SizedBox(width: Grid.half), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - resolveDmChannelDisplayLabel( - resolvedChannel, - currentPubkey: currentPubkey, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (resolvedChannel.isEphemeral) ...[ - const SizedBox(width: Grid.quarter), - _HeaderEphemeralBadge(channel: resolvedChannel), - ], - ], - ), - if (resolvedChannel.isStream) - Text( - resolvedChannel.description.isNotEmpty - ? resolvedChannel.description - : '${resolvedChannel.memberCount} member${resolvedChannel.memberCount == 1 ? '' : 's'}', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + Flexible( + child: Text( + resolveDmChannelDisplayLabel( + resolvedChannel, + currentPubkey: currentPubkey, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), + ), + if (resolvedChannel.isEphemeral) ...[ + const SizedBox(width: Grid.quarter), + _HeaderEphemeralBadge(channel: resolvedChannel), + ], ], ), ), @@ -243,6 +230,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), if (!resolvedChannel.isDm) IconButton( + color: context.colors.primary, onPressed: () async { final shouldClose = await showModalBottomSheet( context: context, @@ -259,7 +247,7 @@ class ChannelDetailPage extends HookConsumerWidget { } }, tooltip: 'Manage channel', - icon: const Icon(LucideIcons.ellipsis), + icon: const Icon(LucideIcons.ellipsisVertical, size: 22), ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 3f17f01720..de1dd56da8 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -82,6 +82,7 @@ class _MembersButton extends ConsumerWidget { .isNotEmpty; return IconButton( + color: context.colors.primary, onPressed: () { showModalBottomSheet( context: context, @@ -95,7 +96,7 @@ class _MembersButton extends ConsumerWidget { icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(LucideIcons.users), + const Icon(LucideIcons.users, size: 22), if (hasWorkingBot) Positioned( top: -2, diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 6b8e1b7545..453e788271 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -32,11 +32,19 @@ class _MessageBubble extends ConsumerWidget { // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); + final knownAgentPubkeys = ref.watch( + mentionAgentPubkeysProvider(currentChannelId), + ); final mentionNames = {}; + final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { - final p = userCache[mpk.toLowerCase()]; + final normalizedPubkey = mpk.toLowerCase(); + final p = userCache[normalizedPubkey]; if (p?.displayName != null) { - mentionNames[mpk.toLowerCase()] = p!.displayName!; + mentionNames[normalizedPubkey] = p!.displayName!; + } + if (knownAgentPubkeys.contains(normalizedPubkey)) { + agentMentionPubkeys.add(normalizedPubkey); } } @@ -67,70 +75,74 @@ class _MessageBubble extends ConsumerWidget { child: _UserAvatar(profile: profile, pubkey: message.pubkey), ) else - const SizedBox(width: 28), + const SizedBox(width: 36), const SizedBox(width: Grid.xxs), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - children: [ - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + child: Transform.translate( + offset: Offset(0, showAuthor ? -Grid.quarter : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: Text( + displayName, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), ), ), - ), - const SizedBox(width: Grid.xxs), - Text( - formatMessageTime(message.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + const SizedBox(width: Grid.xxs), + _messageTimestamp(context, message.createdAt), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ), + ], ], - ], + ), ), + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - channelNames: channelNames, - tags: message.tags, - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => toggleReaction(ref, message, emoji), - ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), ], @@ -140,11 +152,28 @@ class _MessageBubble extends ConsumerWidget { } } +Widget _messageTimestamp(BuildContext context, int createdAt) { + return Text( + formatMessageTime(createdAt), + style: context.textTheme.labelSmall?.copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: context.textTheme.titleSmall?.letterSpacing, + color: context.colors.onSurfaceVariant, + ), + ); +} + class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final double size; - const _UserAvatar({required this.profile, required this.pubkey}); + const _UserAvatar({ + required this.profile, + required this.pubkey, + this.size = 36, + }); @override Widget build(BuildContext context) { @@ -154,14 +183,18 @@ class _UserAvatar extends StatelessWidget { return AvatarImage( imageUrl: avatarUrl, - radius: 14, + radius: size / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), + style: + (size > 28 + ? context.textTheme.labelMedium + : context.textTheme.labelSmall) + ?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), ), ); } diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 82dced5237..5f64687203 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -23,6 +23,7 @@ class _MessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final isLoadingOlder = useState(false); @@ -35,12 +36,12 @@ class _MessageList extends HookConsumerWidget { int? reversedIndexOf(String? messageId) { if (messageId == null) return null; - final chronologicalIndex = entries.indexWhere( - (entry) => entry.message.id == messageId, + final chronologicalIndex = displayEntries.indexWhere( + (group) => group.any((entry) => entry.message.id == messageId), ); return chronologicalIndex < 0 ? null - : entries.length - 1 - chronologicalIndex; + : displayEntries.length - 1 - chronologicalIndex; } Future scrollToLatest() async { @@ -68,7 +69,7 @@ class _MessageList extends HookConsumerWidget { .map((position) => position.index) .reduce((a, b) => a > b ? a : b); if (!hasUserScrolled.value || - oldestVisible < entries.length - 3 || + oldestVisible < displayEntries.length - 3 || isLoadingOlder.value) { return; } @@ -201,10 +202,10 @@ class _MessageList extends HookConsumerWidget { top: frostedAppBarHeight(context), bottom: Grid.xxs, ), - itemCount: entries.length + (isLoadingOlder.value ? 1 : 0), + itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { // Loading indicator at the top (last index in reversed list). - if (index >= entries.length) { + if (index >= displayEntries.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: Grid.xs), child: Center( @@ -218,12 +219,15 @@ class _MessageList extends HookConsumerWidget { } // Reversed list: index 0 = newest (bottom of screen). - final chronIdx = entries.length - 1 - index; - final entry = entries[chronIdx]; + final chronIdx = displayEntries.length - 1 - index; + final entryGroup = displayEntries[chronIdx]; + final entry = entryGroup.first; final message = entry.message; // Day boundary check — applies to all messages including system. - final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null; + final prevEntry = chronIdx > 0 + ? displayEntries[chronIdx - 1].last + : null; final prevMessage = prevEntry?.message; final showDayDivider = prevMessage == null || @@ -246,6 +250,9 @@ class _MessageList extends HookConsumerWidget { if (message.isSystem) _SystemMessageRow( message: message, + groupedMessages: entryGroup.length > 1 + ? entryGroup.map((entry) => entry.message).toList() + : null, channelId: channelId, currentPubkey: currentPubkey, allMessages: null, diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index f2dcb420d6..a1cbefd9aa 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -2,6 +2,7 @@ part of '../channel_detail_page.dart'; class _SystemMessageRow extends ConsumerWidget { final TimelineMessage message; + final List? groupedMessages; final String channelId; final String? currentPubkey; final List? allMessages; @@ -10,6 +11,7 @@ class _SystemMessageRow extends ConsumerWidget { const _SystemMessageRow({ required this.message, + this.groupedMessages, required this.channelId, this.currentPubkey, this.allMessages, @@ -23,6 +25,11 @@ class _SystemMessageRow extends ConsumerWidget { if (systemEvent == null) return const SizedBox.shrink(); final userCache = ref.watch(userCacheProvider); + final sourceMessages = groupedMessages ?? [message]; + final groupedMembership = _membershipDisplayEvent(sourceMessages); + final channelCreator = systemEvent.type == SystemEventType.channelCreated + ? systemEvent.actorPubkey?.trim() + : null; String resolveLabel(String? pubkey) { if (pubkey == null) return 'Someone'; @@ -32,7 +39,34 @@ class _SystemMessageRow extends ConsumerWidget { return profile?.label ?? shortPubkey(pubkey); } - final description = systemEvent.describe(resolveLabel); + final reactions = groupedMessages == null + ? message.reactions + : _aggregateSystemMessageReactions(sourceMessages); + + void toggleGroupedReaction(String emoji) { + final actions = ref.read(channelActionsProvider); + final reactedMessages = sourceMessages.where( + (source) => source.reactions.any( + (reaction) => + reaction.emoji == emoji && + reaction.reactedByCurrentUser && + reaction.currentUserReactionId != null, + ), + ); + if (reactedMessages.isEmpty) { + actions.addReaction(message.id, emoji); + return; + } + for (final source in reactedMessages) { + final reaction = source.reactions.firstWhere( + (candidate) => + candidate.emoji == emoji && + candidate.reactedByCurrentUser && + candidate.currentUserReactionId != null, + ); + actions.removeReaction(reaction.currentUserReactionId!, emoji); + } + } return GestureDetector( behavior: HitTestBehavior.opaque, @@ -52,32 +86,45 @@ class _SystemMessageRow extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - _systemEventAvatar(context, systemEvent, userCache), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - description, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + if (groupedMembership != null) + _MembershipSystemMessageContent( + event: groupedMembership, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + else if (channelCreator != null && channelCreator.isNotEmpty) + _MessageStyleSystemMessageContent( + displayPubkey: channelCreator, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: const [TextSpan(text: 'created this channel')], + ) + else + Row( + children: [ + _systemEventAvatar(context, systemEvent, userCache), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + systemEvent.describe(resolveLabel), + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), - ), - Text( - formatMessageTime(message.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - if (message.reactions.isNotEmpty) + _messageTimestamp(context, message.createdAt), + ], + ), + if (reactions.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 28), child: ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => toggleReaction(ref, message, emoji), + reactions: reactions, + onToggle: groupedMessages == null + ? (emoji) => toggleReaction(ref, message, emoji) + : toggleGroupedReaction, ), ), ], @@ -87,6 +134,267 @@ class _SystemMessageRow extends ConsumerWidget { } } +const _maxVisibleAdditionalMemberNames = 3; + +class _MembershipDisplayEvent { + final String? actorPubkey; + final List targetPubkeys; + final bool isSelfJoin; + + const _MembershipDisplayEvent({ + required this.actorPubkey, + required this.targetPubkeys, + required this.isSelfJoin, + }); +} + +_MembershipDisplayEvent? _membershipDisplayEvent( + List messages, +) { + if (messages.isEmpty) return null; + + final first = messages.first.systemEvent; + final firstActor = first?.actorPubkey?.trim().toLowerCase(); + final firstTarget = first?.targetPubkey?.trim().toLowerCase(); + if (first?.type != SystemEventType.memberJoined || + firstActor == null || + firstActor.isEmpty || + firstTarget == null || + firstTarget.isEmpty) { + return null; + } + + final isSelfJoin = firstActor == firstTarget; + final targets = []; + for (final message in messages) { + final event = message.systemEvent; + final actor = event?.actorPubkey?.trim().toLowerCase(); + final target = event?.targetPubkey?.trim().toLowerCase(); + if (event?.type != SystemEventType.memberJoined || + actor == null || + actor.isEmpty || + target == null || + target.isEmpty || + (isSelfJoin + ? actor != target + : actor != firstActor || actor == target)) { + return null; + } + targets.add(target); + } + + return _MembershipDisplayEvent( + actorPubkey: isSelfJoin ? null : firstActor, + targetPubkeys: targets, + isSelfJoin: isSelfJoin, + ); +} + +List _aggregateSystemMessageReactions( + List messages, +) { + final byEmoji = >{}; + for (final message in messages) { + for (final reaction in message.reactions) { + byEmoji.putIfAbsent(reaction.emoji, () => []).add(reaction); + } + } + + return [ + for (final entry in byEmoji.entries) + () { + final reactions = entry.value; + final userPubkeys = { + for (final reaction in reactions) ...reaction.userPubkeys, + }.toList(); + final reactedByCurrentUser = reactions.any( + (reaction) => reaction.reactedByCurrentUser, + ); + final currentUserReactionId = reactions + .where((reaction) => reaction.currentUserReactionId != null) + .firstOrNull + ?.currentUserReactionId; + final fallbackCount = reactions.fold( + 0, + (total, reaction) => total + reaction.count, + ); + return TimelineReaction( + emoji: entry.key, + count: userPubkeys.isEmpty ? fallbackCount : userPubkeys.length, + reactedByCurrentUser: reactedByCurrentUser, + userPubkeys: userPubkeys, + emojiUrl: reactions.first.emojiUrl, + currentUserReactionId: currentUserReactionId, + ); + }(), + ]; +} + +class _MembershipSystemMessageContent extends StatelessWidget { + final _MembershipDisplayEvent event; + final int createdAt; + final String Function(String? pubkey) resolveLabel; + final Map userCache; + + const _MembershipSystemMessageContent({ + required this.event, + required this.createdAt, + required this.resolveLabel, + required this.userCache, + }); + + @override + Widget build(BuildContext context) { + final firstTarget = event.targetPubkeys.first; + final additionalTargets = event.targetPubkeys.skip(1).toList(); + final visibleTargets = additionalTargets + .take(_maxVisibleAdditionalMemberNames) + .toList(); + final hiddenTargets = additionalTargets + .skip(_maxVisibleAdditionalMemberNames) + .toList(); + final actionStyle = _systemActionTextStyle(context); + final actionSpans = [ + TextSpan( + text: event.isSelfJoin + ? 'joined the channel' + : 'was added by ${resolveLabel(event.actorPubkey)}', + ), + if (additionalTargets.isNotEmpty) + TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '), + ..._memberNameSpans( + context, + visibleTargets: visibleTargets, + hiddenTargets: hiddenTargets, + resolveLabel: resolveLabel, + style: actionStyle, + ), + ]; + + return _MessageStyleSystemMessageContent( + displayPubkey: firstTarget, + createdAt: createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: actionSpans, + ); + } +} + +TextStyle? _systemActionTextStyle(BuildContext context) { + return context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ); +} + +class _MessageStyleSystemMessageContent extends StatelessWidget { + final String displayPubkey; + final int createdAt; + final String Function(String? pubkey) resolveLabel; + final Map userCache; + final List actionSpans; + + const _MessageStyleSystemMessageContent({ + required this.displayPubkey, + required this.createdAt, + required this.resolveLabel, + required this.userCache, + required this.actionSpans, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _UserAvatar( + profile: userCache[displayPubkey.toLowerCase()], + pubkey: displayPubkey, + size: 36, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Transform.translate( + offset: const Offset(0, -Grid.quarter), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + resolveLabel(displayPubkey), + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + _messageTimestamp(context, createdAt), + ], + ), + Text.rich( + TextSpan( + style: _systemActionTextStyle(context), + children: actionSpans, + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +List _memberNameSpans( + BuildContext context, { + required List visibleTargets, + required List hiddenTargets, + required String Function(String? pubkey) resolveLabel, + required TextStyle? style, +}) { + final spans = []; + for (var index = 0; index < visibleTargets.length; index++) { + final isLast = index == visibleTargets.length - 1; + final separator = index == 0 + ? '' + : isLast && hiddenTargets.isEmpty + ? (visibleTargets.length == 2 ? ' and ' : ', and ') + : ', '; + spans.add( + TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'), + ); + } + + if (hiddenTargets.isNotEmpty) { + final hiddenLabels = hiddenTargets.map(resolveLabel).toList(); + spans + ..add(const TextSpan(text: ', and ')) + ..add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Tooltip( + message: hiddenLabels.join('\n'), + triggerMode: TooltipTriggerMode.tap, + child: Text( + '${hiddenTargets.length} others', + key: const Key('membership-overflow'), + style: style?.copyWith( + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.dotted, + ), + ), + ), + ), + ); + } + + return spans; +} + Widget _systemEventAvatar( BuildContext context, SystemEvent event, @@ -175,7 +483,7 @@ class _ThreadSummaryRow extends ConsumerWidget { }, child: Padding( padding: const EdgeInsets.only( - left: 36, + left: 36 + Grid.xxs, top: Grid.half, bottom: Grid.half, ), @@ -184,35 +492,55 @@ class _ThreadSummaryRow extends ConsumerWidget { children: [ // Stacked participant avatars. SizedBox( - width: 20.0 + (summary.participantPubkeys.length - 1) * 12.0, - height: 20, + width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0, + height: 32, child: Stack( children: [ for (var i = 0; i < summary.participantPubkeys.length; i++) Positioned( - left: i * 12.0, + left: i * 20.0, child: SmallAvatar( pubkey: summary.participantPubkeys[i], userCache: userCache, + size: 32, ), ), ], ), ), const SizedBox(width: Grid.xxs), - Text( - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, + Text.rich( + TextSpan( + children: [ + TextSpan( + text: + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + ], + ], ), ), - const SizedBox(width: Grid.half), - Icon( - LucideIcons.chevronRight, - size: 14, - color: context.colors.primary, - ), ], ), ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 99ba5dfb65..838f1f3554 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' show pi; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -44,6 +46,7 @@ part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; part 'channels_page/badges.dart'; part 'channels_page/community.dart'; +part 'channels_page/quick_actions.dart'; enum _QuickAction { createChannel, newDm } @@ -57,8 +60,6 @@ const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; -const double _kCommunityAvatarInset = - _kChannelSectionInset - Grid.quarter; // FrostedAppBar adds Grid.quarter. const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); @@ -155,6 +156,7 @@ class ChannelsPage extends HookConsumerWidget { cachedChannels.value = data; } final channels = cachedChannels.value; + final quickActionsOpen = useState(false); Future openChannel(Channel channel) async { if (!context.mounted) return; @@ -165,16 +167,13 @@ class ChannelsPage extends HookConsumerWidget { ); } - Future openQuickActions() async { - final action = await showModalBottomSheet<_QuickAction>( - context: context, - showDragHandle: true, - builder: (_) => const _QuickActionsSheet(), - ); - - if (!context.mounted || action == null) { - return; + Future selectQuickAction(_QuickAction action) async { + final reducedMotion = MediaQuery.of(context).disableAnimations; + quickActionsOpen.value = false; + if (!reducedMotion) { + await Future.delayed(_kMorphCloseDuration); } + if (!context.mounted) return; switch (action) { case _QuickAction.createChannel: @@ -242,6 +241,7 @@ class ChannelsPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( + horizontalInset: _kChannelSectionInset, leading: _CommunityIndicator( onTap: () => showModalBottomSheet( context: context, @@ -256,27 +256,37 @@ class ChannelsPage extends HookConsumerWidget { MaterialPageRoute(builder: (_) => const SettingsPage()), ), ), - const SizedBox(width: Grid.twelve + Grid.quarter), ], ), - floatingActionButton: FloatingActionButton( - heroTag: 'channels-fab', - onPressed: openQuickActions, - tooltip: 'Create or start conversation', - backgroundColor: context.colors.primary, - foregroundColor: context.colors.onPrimary, - shape: const CircleBorder(), - child: const Icon(LucideIcons.plus), + floatingActionButton: _MorphingQuickActionsButton( + open: quickActionsOpen.value, + onToggle: () => quickActionsOpen.value = !quickActionsOpen.value, + onSelected: (action) => unawaited(selectQuickAction(action)), ), - body: _ChannelsBody( - channels: channels, - channelsAsync: channelsAsync, - showError: showError.value, - sessionStatus: sessionState.status, - showConnectionBanner: showConnectionBanner.value, - currentPubkey: currentPubkey, - onRefresh: () => ref.read(channelsProvider.notifier).refresh(), - onSelectChannel: openChannel, + body: Stack( + children: [ + _ChannelsBody( + channels: channels, + channelsAsync: channelsAsync, + showError: showError.value, + sessionStatus: sessionState.status, + showConnectionBanner: showConnectionBanner.value, + currentPubkey: currentPubkey, + onRefresh: () => ref.read(channelsProvider.notifier).refresh(), + onSelectChannel: openChannel, + ), + if (quickActionsOpen.value) + Positioned.fill( + child: Semantics( + button: true, + label: 'Close quick actions', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => quickActionsOpen.value = false, + ), + ), + ), + ], ), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 32f70c8b4f..2d730a34d2 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -67,7 +67,7 @@ class _ChannelTile extends ConsumerWidget { ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( + style: context.textTheme.bodyLarge?.copyWith( color: context.colors.onSurface, fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400, ), diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 61d2824099..733d2dd6ad 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -259,35 +259,32 @@ class _CommunityIndicator extends ConsumerWidget { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(left: _kCommunityAvatarInset), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CommunityAvatar(name: name), - const SizedBox(width: Grid.xxs), - if (name != null) - Flexible( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ) - else - Text( - 'Community', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CommunityAvatar(name: name), + const SizedBox(width: Grid.xxs), + if (name != null) + Flexible( + child: Text( + name, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w600, ), ), - ], - ), + ) + else + Text( + 'Community', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], ), ); } diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart new file mode 100644 index 0000000000..80fc717ddb --- /dev/null +++ b/mobile/lib/features/channels/channels_page/quick_actions.dart @@ -0,0 +1,322 @@ +part of '../channels_page.dart'; + +const _kMorphOpenDuration = Duration(milliseconds: 350); +const _kMorphCloseDuration = Duration(milliseconds: 250); +const _kMorphFadeDuration = Duration(milliseconds: 200); +const _kMorphOpenCurve = Cubic(0.34, 1.25, 0.64, 1); +const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1); +const double _kMorphClosedSize = 56; +const double _kMorphOpenHeight = 160; +const double _kMorphOpenRadius = 20; +const double _kMorphSlide = 40; +const double _kMorphScale = 0.97; +const double _kMorphBlur = 2; + +class _MorphingQuickActionsButton extends HookWidget { + final bool open; + final VoidCallback onToggle; + final ValueChanged<_QuickAction> onSelected; + + const _MorphingQuickActionsButton({ + required this.open, + required this.onToggle, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.of(context).disableAnimations; + final mediaQuery = MediaQuery.of(context); + final openWidth = + (mediaQuery.size.width - mediaQuery.padding.horizontal - (Grid.xs * 2)) + .clamp(_kMorphClosedSize, double.infinity) + .toDouble(); + final surfaceController = useAnimationController( + duration: reducedMotion ? Duration.zero : _kMorphOpenDuration, + reverseDuration: reducedMotion ? Duration.zero : _kMorphCloseDuration, + initialValue: open ? 1 : 0, + ); + final fadeController = useAnimationController( + duration: reducedMotion ? Duration.zero : _kMorphFadeDuration, + reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration, + initialValue: open ? 1 : 0, + ); + final surfaceAnimation = useMemoized( + () => CurvedAnimation( + parent: surfaceController, + curve: _kMorphOpenCurve, + reverseCurve: _kMorphCloseCurve, + ), + [surfaceController], + ); + final fadeAnimation = useMemoized( + () => CurvedAnimation( + parent: fadeController, + curve: _kMorphCloseCurve, + reverseCurve: _kMorphCloseCurve, + ), + [fadeController], + ); + final animation = useMemoized( + () => Listenable.merge([surfaceAnimation, fadeAnimation]), + [surfaceAnimation, fadeAnimation], + ); + + useEffect(() => surfaceAnimation.dispose, [surfaceAnimation]); + useEffect(() => fadeAnimation.dispose, [fadeAnimation]); + + useEffect(() { + surfaceController.duration = reducedMotion + ? Duration.zero + : _kMorphOpenDuration; + surfaceController.reverseDuration = reducedMotion + ? Duration.zero + : _kMorphCloseDuration; + fadeController.duration = reducedMotion + ? Duration.zero + : _kMorphFadeDuration; + fadeController.reverseDuration = reducedMotion + ? Duration.zero + : _kMorphFadeDuration; + + if (reducedMotion) { + surfaceController.value = open ? 1 : 0; + fadeController.value = open ? 1 : 0; + } else if (open) { + unawaited(surfaceController.forward()); + unawaited(fadeController.forward()); + } else { + unawaited(surfaceController.reverse()); + unawaited(fadeController.reverse()); + } + return null; + }, [fadeController, open, reducedMotion, surfaceController]); + + return AnimatedBuilder( + animation: animation, + builder: (context, _) { + final surfaceValue = surfaceAnimation.value; + final fadeValue = fadeAnimation.value.clamp(0.0, 1.0); + final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!; + final height = lerpDouble( + _kMorphClosedSize, + _kMorphOpenHeight, + surfaceValue, + )!; + final radius = lerpDouble( + _kMorphClosedSize / 2, + _kMorphOpenRadius, + surfaceValue, + )!; + final borderRadius = BorderRadius.circular(radius); + + return SizedBox( + width: width, + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.primary, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: context.colors.shadow.withValues(alpha: 0.24), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: OverflowBox( + alignment: Alignment.bottomRight, + minWidth: openWidth, + maxWidth: openWidth, + minHeight: _kMorphOpenHeight, + maxHeight: _kMorphOpenHeight, + child: IgnorePointer( + ignoring: !open || fadeValue < 0.9, + child: ExcludeSemantics( + excluding: !open, + child: Opacity( + opacity: fadeValue, + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: _kMorphBlur * (1 - fadeValue), + sigmaY: _kMorphBlur * (1 - fadeValue), + ), + child: Transform.translate( + offset: Offset( + _kMorphSlide * (1 - surfaceValue), + 0, + ), + child: Transform.scale( + alignment: Alignment.bottomRight, + scale: + _kMorphScale + + ((1 - _kMorphScale) * surfaceValue), + child: _QuickActionsMenu( + onSelected: onSelected, + ), + ), + ), + ), + ), + ), + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + width: _kMorphClosedSize, + height: _kMorphClosedSize, + child: IgnorePointer( + ignoring: open, + child: ExcludeSemantics( + excluding: open, + child: Opacity( + opacity: 1 - fadeValue, + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: _kMorphBlur * fadeValue, + sigmaY: _kMorphBlur * fadeValue, + ), + child: Transform.translate( + offset: Offset(-_kMorphSlide * surfaceValue, 0), + child: Transform.rotate( + angle: (pi / 4) * surfaceValue, + child: Transform.scale( + scale: + 1 - ((1 - _kMorphScale) * surfaceValue), + child: Tooltip( + message: 'Create or start conversation', + child: Semantics( + button: true, + label: 'Create or start conversation', + expanded: open, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onToggle, + child: Center( + child: Icon( + LucideIcons.plus, + color: context.colors.onPrimary, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _QuickActionsMenu extends StatelessWidget { + final ValueChanged<_QuickAction> onSelected; + + const _QuickActionsMenu({required this.onSelected}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _QuickActionItem( + icon: LucideIcons.hash, + title: 'Create channel', + subtitle: 'Start a new stream channel', + onTap: () => onSelected(_QuickAction.createChannel), + ), + _QuickActionItem( + icon: LucideIcons.messagesSquare, + title: 'New direct message', + subtitle: 'Message one or more people', + onTap: () => onSelected(_QuickAction.newDm), + ), + ], + ), + ); + } +} + +class _QuickActionItem extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _QuickActionItem({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final foreground = context.colors.onPrimary; + + return Expanded( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Row( + children: [ + Icon(icon, size: 22, color: foreground), + const SizedBox(width: Grid.twelve), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.quarter), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith( + color: foreground.withValues(alpha: 0.72), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 40b47cf85f..394f68f6f7 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -154,7 +154,7 @@ class _CustomSectionHeader extends ConsumerWidget { const SizedBox(width: _kChannelLabelGap), Text( section.name, - style: context.textTheme.bodyMedium?.copyWith( + style: context.textTheme.bodyLarge?.copyWith( color: sectionColor, fontWeight: FontWeight.w600, ), @@ -430,7 +430,7 @@ class _SectionHeader extends StatelessWidget { const SizedBox(width: _kChannelLabelGap), Text( label, - style: context.textTheme.bodyMedium?.copyWith( + style: context.textTheme.bodyLarge?.copyWith( color: sectionColor, fontWeight: FontWeight.w600, ), diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index ef7039e0d9..a4ff4d9ee6 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -1,42 +1,5 @@ part of '../channels_page.dart'; -class _QuickActionsSheet extends StatelessWidget { - const _QuickActionsSheet(); - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: const Icon(LucideIcons.hash), - title: const Text('Create channel'), - subtitle: const Text('Start a new stream channel'), - onTap: () => - Navigator.of(context).pop(_QuickAction.createChannel), - ), - ListTile( - leading: const Icon(LucideIcons.messagesSquare), - title: const Text('New direct message'), - subtitle: const Text('Open a DM with one or more people'), - onTap: () => Navigator.of(context).pop(_QuickAction.newDm), - ), - ], - ), - ), - ); - } -} - class _CreateChannelSheet extends HookConsumerWidget { final String channelType; diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index 9d9ddc878b..2378c305fb 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -5,6 +5,8 @@ import 'package:intl/intl.dart'; export '../../shared/utils/string_utils.dart' show shortPubkey; final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); +final _shortMonthFormat = DateFormat('MMM'); +final _messageTimeFormat = DateFormat('h:mm a', 'en_US'); /// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". /// @@ -62,20 +64,48 @@ String relativeTime(int unixSeconds) { return '${time.month}/${time.day}/${time.year}'; } -/// Compact time label: "HH:MM" for today, "M/D HH:MM" for older messages. -String formatMessageTime(int unixSeconds) { - final dt = DateTime.fromMillisecondsSinceEpoch( +/// Returns desktop-parity thread activity copy such as "just now", +/// "3 hours ago", or "on May 19th". +String formatThreadSummaryLastReplyTime( + int unixSeconds, { + @visibleForTesting int? nowSeconds, +}) { + nowSeconds ??= DateTime.now().millisecondsSinceEpoch ~/ 1000; + var diff = nowSeconds - unixSeconds; + if (diff < 0) diff = 0; + + if (diff < 60) return 'just now'; + if (diff < 3600) return _formatAgo(diff ~/ 60, 'minute'); + if (diff < 86400) return _formatAgo(diff ~/ 3600, 'hour'); + if (diff < 604800) return _formatAgo(diff ~/ 86400, 'day'); + + final date = DateTime.fromMillisecondsSinceEpoch( unixSeconds * 1000, isUtc: true, ).toLocal(); - final now = DateTime.now(); - final diff = now.difference(dt); + return 'on ${_shortMonthFormat.format(date)} ' + '${date.day}${_ordinalSuffix(date.day)}'; +} - final hh = dt.hour.toString().padLeft(2, '0'); - final mm = dt.minute.toString().padLeft(2, '0'); +String _formatAgo(int value, String unit) => + '$value $unit${value == 1 ? '' : 's'} ago'; - if (diff.inDays > 0) { - return '${dt.month}/${dt.day} $hh:$mm'; - } - return '$hh:$mm'; +String _ordinalSuffix(int day) { + final lastTwoDigits = day % 100; + if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th'; + return switch (day % 10) { + 1 => 'st', + 2 => 'nd', + 3 => 'rd', + _ => 'th', + }; +} + +/// Desktop-parity message clock time, e.g. "2:34 PM". +String formatMessageTime(int unixSeconds) { + final date = DateTime.fromMillisecondsSinceEpoch( + unixSeconds * 1000, + isUtc: true, + ).toLocal(); + return _messageTimeFormat.format(date); } diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart index d9b9b1d875..7d65ad58fe 100644 --- a/mobile/lib/features/channels/day_divider.dart +++ b/mobile/lib/features/channels/day_divider.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import '../../shared/theme/theme.dart'; -/// A centered label between two horizontal dividers, used to separate -/// messages by calendar day ("TODAY", "YESTERDAY", full dates). +/// Desktop-parity day separator with a centered label over a horizontal rule. class DayDivider extends StatelessWidget { final String label; @@ -13,21 +12,42 @@ class DayDivider extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Row( - children: [ - Expanded(child: Divider(color: context.colors.outlineVariant)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - child: Text( - label.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - letterSpacing: 2.0, + child: SizedBox( + width: double.infinity, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: 0, + right: 0, + child: Divider( + height: 1, + thickness: 1, + color: context.colors.outlineVariant.withValues(alpha: 0.35), ), ), - ), - Expanded(child: Divider(color: context.colors.outlineVariant)), - ], + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs + Grid.quarter, + vertical: Grid.half, + ), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.7), + letterSpacing: 0.22, + ), + ), + ), + ], + ), ), ); } diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 8736c7ff1b..c2aa056a05 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -41,6 +41,33 @@ final agentOwnersProvider = FutureProvider>((ref) async { return owners; }); +/// Pubkeys currently known to represent agents for rendered mention chips. +/// +/// Uses the same three identity sources as mention autocomplete: channel bot +/// roles, relay agent-directory entries, and verified NIP-OA ownership. +final mentionAgentPubkeysProvider = Provider.family, String>(( + ref, + channelId, +) { + final members = + ref.watch(channelMembersProvider(channelId)).asData?.value ?? + const []; + final relayAgents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final userCache = ref.watch(userCacheProvider); + + return { + for (final member in members) + if (member.isBot) member.pubkey.toLowerCase(), + for (final agent in relayAgents) agent.pubkey.toLowerCase(), + ...owners.keys.map((pubkey) => pubkey.toLowerCase()), + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(), + }; +}); + /// Debounce before a mention query hits the relay search endpoint. const _mentionSearchDebounce = Duration(milliseconds: 250); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 368a2a1ec2..c55494d67f 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -30,6 +30,10 @@ class MessageContent extends HookConsumerWidget { /// Keys are lowercase pubkeys, values are display names. final Map mentionNames; + /// Mentioned pubkeys that resolve to agents. Agent chips use the desktop + /// robot treatment instead of an `@` prefix. + final Set agentMentionPubkeys; + /// Known channel names for #channel links. Keys are lowercase channel /// names, values are channel IDs. final Map channelNames; @@ -52,6 +56,7 @@ class MessageContent extends HookConsumerWidget { super.key, required this.content, this.mentionNames = const {}, + this.agentMentionPubkeys = const {}, this.channelNames = const {}, this.tags = const [], this.onChannelTap, @@ -151,7 +156,11 @@ class MessageContent extends HookConsumerWidget { _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), maxLines: maxLines, inlineComponents: [ - _MentionMd(mentionNames: mentionNames, onMentionTap: onMentionTap), + _MentionMd( + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + onMentionTap: onMentionTap, + ), CustomEmojiMd(customEmoji), _ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap), ...MarkdownComponent.inlineComponents, @@ -578,6 +587,7 @@ class _MessageCodeBlock extends HookWidget { class _MentionMd extends InlineMd { final Map mentionNames; + final Set agentMentionPubkeys; final void Function(String pubkey)? onMentionTap; late final RegExp _exp = _buildPrefixPattern( prefix: '@', @@ -585,7 +595,11 @@ class _MentionMd extends InlineMd { genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*', ); - _MentionMd({required this.mentionNames, this.onMentionTap}); + _MentionMd({ + required this.mentionNames, + required this.agentMentionPubkeys, + this.onMentionTap, + }); @override RegExp get exp => _exp; @@ -614,8 +628,11 @@ class _MentionMd extends InlineMd { } } - final pill = _TokenPill( - text: '@${displayName ?? raw.substring(1)}', + final isAgent = + pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase()); + final pill = _MentionPill( + label: displayName ?? raw.substring(1), + isAgent: isAgent, textStyle: config.style, ); @@ -629,6 +646,66 @@ class _MentionMd extends InlineMd { } } +class _MentionPill extends StatelessWidget { + final String label; + final bool isAgent; + final TextStyle? textStyle; + + const _MentionPill({ + required this.label, + required this.isAgent, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = + (textStyle ?? context.textTheme.bodyMedium)?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ) ?? + TextStyle( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + + return Container( + padding: const EdgeInsets.fromLTRB( + Grid.half, + Grid.quarter + 1, + Grid.half, + Grid.quarter, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isAgent) ...[ + Icon( + LucideIcons.bot, + size: fontSize * 0.95, + color: context.colors.primary, + ), + const SizedBox(width: Grid.quarter), + ] else + Transform.translate( + offset: const Offset(0, -Grid.quarter), + child: Text('@', style: style), + ), + Text(label, style: style), + ], + ), + ); + } +} + class _ChannelLinkMd extends InlineMd { final Map channelNames; final void Function(String channelId)? onChannelTap; diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index 44b90ba45b..54e2ff178b 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -8,8 +8,14 @@ import '../profile/user_profile.dart'; class SmallAvatar extends StatelessWidget { final String pubkey; final Map userCache; + final double size; - const SmallAvatar({super.key, required this.pubkey, required this.userCache}); + const SmallAvatar({ + super.key, + required this.pubkey, + required this.userCache, + this.size = 20, + }); @override Widget build(BuildContext context) { @@ -19,20 +25,20 @@ class SmallAvatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); return Container( - width: 20, - height: 20, + width: size, + height: size, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: context.colors.surface, width: 1.5), ), child: AvatarImage( imageUrl: avatarUrl, - radius: 9, + radius: (size - 2) / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, style: TextStyle( - fontSize: 8, + fontSize: size * 0.4, fontWeight: FontWeight.w600, color: context.colors.onPrimaryContainer, ), diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 495b7f4fba..8353017809 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/theme/theme.dart'; @@ -19,6 +18,7 @@ import 'date_formatters.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; +import 'mentions/mention_candidates_provider.dart'; import 'reaction_row.dart'; import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; @@ -293,6 +293,7 @@ ThreadSummary _buildNestedSummary( threadHeadId: messageId, replyCount: children.length, participantPubkeys: participants.reversed.toList(), + lastReplyAt: children.last.createdAt, ); } @@ -338,7 +339,7 @@ class _NestedThreadSummaryRow extends ConsumerWidget { }, child: Padding( padding: const EdgeInsets.only( - left: 36, + left: 36 + Grid.xxs, top: Grid.half, bottom: Grid.half, ), @@ -348,36 +349,56 @@ class _NestedThreadSummaryRow extends ConsumerWidget { // Stacked participant avatars. SizedBox( width: - 20.0 + - (summary.participantPubkeys.length - 1).clamp(0, 2) * 12.0, - height: 20, + 32.0 + + (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, + height: 32, child: Stack( children: [ for (var i = 0; i < summary.participantPubkeys.length; i++) Positioned( - left: i * 12.0, + left: i * 20.0, child: SmallAvatar( pubkey: summary.participantPubkeys[i], userCache: userCache, + size: 32, ), ), ], ), ), const SizedBox(width: Grid.xxs), - Text( - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, + Text.rich( + TextSpan( + children: [ + TextSpan( + text: + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + ], + ], ), ), - const SizedBox(width: Grid.half), - Icon( - LucideIcons.chevronRight, - size: 14, - color: context.colors.primary, - ), ], ), ), @@ -415,11 +436,17 @@ class _ThreadMessage extends ConsumerWidget { final displayName = profile?.label ?? shortPubkey(message.pubkey); final userCache = ref.watch(userCacheProvider); + final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); final mentionNames = {}; + final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { - final p = userCache[mpk.toLowerCase()]; + final normalizedPubkey = mpk.toLowerCase(); + final p = userCache[normalizedPubkey]; if (p?.displayName != null) { - mentionNames[mpk.toLowerCase()] = p!.displayName!; + mentionNames[normalizedPubkey] = p!.displayName!; + } + if (knownAgentPubkeys.contains(normalizedPubkey)) { + agentMentionPubkeys.add(normalizedPubkey); } } @@ -450,70 +477,83 @@ class _ThreadMessage extends ConsumerWidget { child: _Avatar(profile: profile, pubkey: message.pubkey), ) else - const SizedBox(width: 28), + const SizedBox(width: 36), const SizedBox(width: Grid.xxs), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - children: [ - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + child: Transform.translate( + offset: Offset(0, showAuthor ? -Grid.quarter : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: Text( + displayName, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), ), ), - ), - const SizedBox(width: Grid.xxs), - Text( - formatMessageTime(message.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), + const SizedBox(width: Grid.xxs), Text( - '(edited)', + formatMessageTime(message.createdAt), style: context.textTheme.labelSmall?.copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: + context.textTheme.titleSmall?.letterSpacing, color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, ), ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], ], - ], + ), ), + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - channelNames: channelNames, - tags: message.tags, - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => toggleReaction(ref, message, emoji), - ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), ], @@ -601,11 +641,11 @@ class _Avatar extends StatelessWidget { return AvatarImage( imageUrl: avatarUrl, - radius: 14, + radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, - style: context.textTheme.labelSmall?.copyWith( + style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, ), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 9bda6c99a5..496060e50b 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -94,7 +94,7 @@ class SystemEvent { return '$actor joined the channel'; } final target = resolveLabel(targetPubkey); - return '$actor added $target to the channel'; + return '$target was added by $actor'; }(), SystemEventType.memberLeft => '$actor left the channel', SystemEventType.memberRemoved => () { @@ -180,11 +180,13 @@ class ThreadSummary { /// Up to 3 most recent unique participant pubkeys. final List participantPubkeys; + final int? lastReplyAt; const ThreadSummary({ required this.threadHeadId, required this.replyCount, required this.participantPubkeys, + this.lastReplyAt, }); } @@ -197,6 +199,107 @@ class MainTimelineEntry { const MainTimelineEntry({required this.message, this.summary}); } +const _membershipGroupWindowSeconds = 5 * 60; + +@immutable +class _MembershipChange { + final String? actor; + final bool isSelfJoin; + + const _MembershipChange({required this.actor, required this.isSelfJoin}); +} + +_MembershipChange? _membershipChange(MainTimelineEntry entry) { + final event = entry.message.systemEvent; + if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) { + return null; + } + + final actor = event?.actorPubkey?.trim().toLowerCase(); + final target = event?.targetPubkey?.trim().toLowerCase(); + if (actor == null || actor.isEmpty || target == null || target.isEmpty) { + return null; + } + + final isSelfJoin = actor == target; + return _MembershipChange( + actor: isSelfJoin ? null : actor, + isSelfJoin: isSelfJoin, + ); +} + +bool _membershipChangesCanGroup( + _MembershipChange first, + _MembershipChange second, +) { + return first.isSelfJoin == second.isSelfJoin && + (first.isSelfJoin || first.actor == second.actor); +} + +bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) { + final first = DateTime.fromMillisecondsSinceEpoch(firstTimestamp * 1000); + final second = DateTime.fromMillisecondsSinceEpoch(secondTimestamp * 1000); + return first.year == second.year && + first.month == second.month && + first.day == second.day; +} + +/// Groups consecutive membership arrivals using the same display rule as +/// desktop: matching additions (or self-joins) within a fixed five-minute +/// window become one render item. Other events and local day boundaries break +/// the group. +/// +/// Each inner list is one renderable timeline item. Non-grouped entries are +/// returned as single-item lists. +List> groupMembershipTimelineEntries( + List entries, +) { + final groupsByStart = {}; + + for (var end = entries.length - 1; end >= 0;) { + final newestEntry = entries[end]; + final newestChange = _membershipChange(newestEntry); + if (newestChange == null) { + end -= 1; + continue; + } + + var start = end; + while (start > 0) { + final candidate = entries[start - 1]; + final candidateChange = _membershipChange(candidate); + if (candidateChange == null || + !_membershipChangesCanGroup(candidateChange, newestChange) || + !_isSameLocalDay( + candidate.message.createdAt, + newestEntry.message.createdAt, + ) || + newestEntry.message.createdAt < candidate.message.createdAt || + newestEntry.message.createdAt - candidate.message.createdAt > + _membershipGroupWindowSeconds) { + break; + } + start -= 1; + } + + if (start < end) groupsByStart[start] = end; + end = start - 1; + } + + final result = >[]; + for (var index = 0; index < entries.length;) { + final groupEnd = groupsByStart[index]; + if (groupEnd == null) { + result.add([entries[index]]); + index += 1; + continue; + } + result.add(entries.sublist(index, groupEnd + 1)); + index = groupEnd + 1; + } + return result; +} + /// Process a chronologically-sorted list of [NostrEvent]s into a list of /// [TimelineMessage]s, applying deletions, edits, reactions, and system event /// parsing. @@ -419,6 +522,7 @@ ThreadSummary? _buildSummary( threadHeadId: messageId, replyCount: relaySummary.replyCount, participantPubkeys: relaySummary.participantPubkeys.take(3).toList(), + lastReplyAt: relaySummary.lastReplyAt, ); } @@ -437,6 +541,7 @@ ThreadSummary? _buildSummary( threadHeadId: messageId, replyCount: replies.length, participantPubkeys: participants.reversed.toList(), + lastReplyAt: replies.last.createdAt, ); } diff --git a/mobile/lib/shared/theme/text_theme.dart b/mobile/lib/shared/theme/text_theme.dart index 2fb780f59e..693b50d0bc 100644 --- a/mobile/lib/shared/theme/text_theme.dart +++ b/mobile/lib/shared/theme/text_theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; const _fontFamily = 'Inter'; +const _chatLineHeight = 22 / 16; const textTheme = TextTheme( displayLarge: TextStyle( @@ -63,7 +64,7 @@ const textTheme = TextTheme( fontFamily: _fontFamily, fontSize: 16, fontWeight: FontWeight.w500, - height: 1.35, + height: _chatLineHeight, letterSpacing: 0, ), labelLarge: TextStyle( @@ -91,7 +92,7 @@ const textTheme = TextTheme( fontFamily: _fontFamily, fontSize: 16, fontWeight: FontWeight.w400, - height: 1.38, + height: 20 / 16, letterSpacing: 0, ), bodyMedium: TextStyle( diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 27066ce55e..34a2da8970 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -30,11 +30,19 @@ class FrostedAppBar extends StatelessWidget { /// Widgets displayed on the trailing (right) side. final List actions; + /// Horizontal inset for the app bar's leading, title, and actions. + final double horizontalInset; + + /// Color applied to icons in the app bar. + final Color? iconColor; + const FrostedAppBar({ super.key, this.leading, this.title, this.actions = const [], + this.horizontalInset = Grid.quarter, + this.iconColor, }); @override @@ -50,6 +58,7 @@ class FrostedAppBar extends StatelessWidget { height: 48, child: IconButton( onPressed: () => Navigator.of(context).pop(), + color: iconColor, icon: const Icon(LucideIcons.chevronLeft), tooltip: 'Back', ), @@ -76,35 +85,38 @@ class FrostedAppBar extends StatelessWidget { child: SizedBox( height: _kBarContentHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), - child: Row( - children: [ - ?effectiveLeading, - if (title != null) - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: effectiveLeading != null - ? 0 - : Grid.gutter - Grid.quarter, - right: actions.isEmpty - ? Grid.gutter - Grid.quarter - : 0, - ), - child: DefaultTextStyle.merge( - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + padding: EdgeInsets.symmetric(horizontal: horizontalInset), + child: IconTheme.merge( + data: IconThemeData(color: iconColor), + child: Row( + children: [ + ?effectiveLeading, + if (title != null) + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null + ? 0 + : Grid.gutter - Grid.quarter, + right: actions.isEmpty + ? Grid.gutter - Grid.quarter + : 0, + ), + child: DefaultTextStyle.merge( + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: title!, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, ), - ), - ) - else - const Spacer(), - ...actions, - ], + ) + else + const Spacer(), + ...actions, + ], + ), ), ), ), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 629776ef13..6e7c7ead76 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -11,10 +11,12 @@ import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; @@ -177,6 +179,21 @@ Finder findRichText(String text) { }, description: 'RichText containing "$text"'); } +double? effectiveFontSizeForText( + InlineSpan span, + String text, [ + TextStyle? inheritedStyle, +]) { + if (span is! TextSpan) return null; + final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style; + if ((span.text ?? '').contains(text)) return effectiveStyle?.fontSize; + for (final child in span.children ?? const []) { + final size = effectiveFontSizeForText(child, text, effectiveStyle); + if (size != null) return size; + } + return null; +} + void main() { group('ChannelDetailPage', () { testWidgets('defers read-state mark until after build', (tester) async { @@ -450,6 +467,82 @@ void main() { expect(findRichText('Hey Alice!'), findsOneWidget); expect(find.text('Alice'), findsOneWidget); expect(find.text('Bob'), findsOneWidget); + final messageAvatars = find.byType(CircleAvatar); + expect(messageAvatars, findsNWidgets(2)); + for (final avatar in messageAvatars.evaluate()) { + expect( + tester.getSize(find.byWidget(avatar.widget)), + const Size.square(36), + ); + } + final aliceName = find.text('Alice'); + final aliceText = tester.widget(aliceName); + final titleStyle = Theme.of( + tester.element(aliceName), + ).textTheme.titleSmall; + expect(aliceText.style?.fontSize, titleStyle?.fontSize); + final helloContent = findRichText('Hello world!'); + final helloText = tester.widget(helloContent); + final bodyStyle = Theme.of( + tester.element(helloContent), + ).textTheme.bodyLarge; + expect( + effectiveFontSizeForText(helloText.text, 'Hello world!'), + bodyStyle?.fontSize, + ); + }); + + testWidgets('uses larger participant avatars in reply summaries', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread head', + createdAt: 1000, + ), + _textMsg( + id: 'reply-1', + pubkey: 'bob', + content: 'First reply', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + _textMsg( + id: 'reply-2', + pubkey: 'carol', + content: 'Second reply', + createdAt: 1200, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect( + findRichText( + '2 replies · last reply ' + '${formatThreadSummaryLastReplyTime(1200)}', + ), + findsOneWidget, + ); + expect(find.byIcon(LucideIcons.chevronRight), findsNothing); + final replyAvatars = find.byType(SmallAvatar); + expect(replyAvatars, findsNWidgets(2)); + for (final avatar in replyAvatars.evaluate()) { + expect( + tester.getSize(find.byWidget(avatar.widget)), + const Size.square(32), + ); + } }); testWidgets('can jump back to latest when newer messages are offscreen', ( @@ -605,7 +698,26 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Alice created this channel'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + final createdAction = findRichText('created this channel'); + expect(createdAction, findsOneWidget); + expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); + final nameRect = tester.getRect(find.text('Alice')); + final nameText = tester.widget(find.text('Alice')); + final nameStyle = Theme.of( + tester.element(find.text('Alice')), + ).textTheme.titleSmall; + expect(nameText.style?.fontSize, nameStyle?.fontSize); + final timestampRect = tester.getRect(find.text(formatMessageTime(1000))); + expect(timestampRect.left - nameRect.right, Grid.xxs); + final createdText = tester.widget(createdAction); + final bodyStyle = Theme.of( + tester.element(createdAction), + ).textTheme.bodyLarge; + expect( + effectiveFontSizeForText(createdText.text, 'created this channel'), + bodyStyle?.fontSize, + ); }); testWidgets('renders member_joined (self-join) system event', ( @@ -626,7 +738,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Bob joined the channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(findRichText('joined the channel'), findsOneWidget); + expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); }); testWidgets('renders member_joined (added by other) system event', ( @@ -650,7 +764,94 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Alice added Bob to the channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + final addedAction = findRichText('was added by Alice'); + expect(addedAction, findsOneWidget); + expect(find.text('Alice added Bob to the channel'), findsNothing); + expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); + final nameRect = tester.getRect(find.text('Bob')); + final timestampRect = tester.getRect(find.text(formatMessageTime(1000))); + expect(timestampRect.left - nameRect.right, Grid.xxs); + final addedText = tester.widget(addedAction); + final bodyStyle = Theme.of( + tester.element(addedAction), + ).textTheme.bodyLarge; + expect( + effectiveFontSizeForText(addedText.text, 'was added by Alice'), + bodyStyle?.fontSize, + ); + }); + + testWidgets('groups member additions with tappable overflow names', ( + tester, + ) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'}, + createdAt: 1000, + ), + _systemMsg( + id: 'sys2', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1060, + ), + _systemMsg( + id: 'sys3', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'dave', + }, + createdAt: 1120, + ), + _systemMsg( + id: 'sys4', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'erin', + }, + createdAt: 1180, + ), + _systemMsg( + id: 'sys5', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'frank', + }, + createdAt: 1240, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'), + 'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'), + 'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'), + 'frank': const UserProfile(pubkey: 'frank', displayName: 'Frank'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Bob'), findsOneWidget); + expect( + findRichText('was added by Alice, along with Carol, Dave, Erin, and '), + findsOneWidget, + ); + expect(find.byKey(const Key('membership-overflow')), findsOneWidget); + expect(find.text('1 others'), findsOneWidget); + expect(find.byTooltip('Frank'), findsOneWidget); }); testWidgets('renders member_left system event', (tester) async { @@ -1126,9 +1327,11 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Alice created this channel'), findsOneWidget); + expect(find.text('Alice'), findsNWidgets(2)); + expect(findRichText('created this channel'), findsOneWidget); expect(findRichText('Welcome everyone!'), findsOneWidget); - expect(find.text('Bob joined the channel'), findsOneWidget); + expect(find.text('Bob'), findsNWidgets(2)); + expect(findRichText('joined the channel'), findsOneWidget); expect(findRichText('Thanks for the invite!'), findsOneWidget); }); }); diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart index 5420e26bc7..42363699f7 100644 --- a/mobile/test/features/channels/date_formatters_test.dart +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -56,4 +56,47 @@ void main() { expect(isSameDay(a, b), isFalse); }); }); + + group('formatThreadSummaryLastReplyTime', () { + const now = 2_000_000; + + test('uses expanded relative units', () { + expect( + formatThreadSummaryLastReplyTime(now - 30, nowSeconds: now), + 'just now', + ); + expect( + formatThreadSummaryLastReplyTime(now - 60, nowSeconds: now), + '1 minute ago', + ); + expect( + formatThreadSummaryLastReplyTime(now - 3 * 3600, nowSeconds: now), + '3 hours ago', + ); + expect( + formatThreadSummaryLastReplyTime(now - 2 * 86400, nowSeconds: now), + '2 days ago', + ); + }); + + test('uses a short month and ordinal for older replies', () { + final may19 = _ts(DateTime(2026, 5, 19, 12)); + final may27 = _ts(DateTime(2026, 5, 27, 12)); + + expect( + formatThreadSummaryLastReplyTime( + may19, + nowSeconds: _ts(DateTime(2026, 5, 27, 12)), + ), + 'on May 19th', + ); + expect( + formatThreadSummaryLastReplyTime( + may27, + nowSeconds: _ts(DateTime(2026, 6, 4, 12)), + ), + 'on May 27th', + ); + }); + }); } diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index e9d36aed3c..02821157a8 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -728,8 +728,10 @@ void main() { ), ); - // Mention should be rendered as @Alice in a highlighted container. - expect(find.text('@Alice'), findsOneWidget); + // The desktop-style mention chip renders the prefix and label + // separately so they can be aligned independently. + expect(find.text('@'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); }); testWidgets('highlights an entire multi-word display name', ( @@ -744,7 +746,8 @@ void main() { ), ); - expect(find.text('@Kenny Lopez'), findsOneWidget); + expect(find.text('@'), findsOneWidget); + expect(find.text('Kenny Lopez'), findsOneWidget); expect(find.text('@Kenny'), findsNothing); expect(_allRichText(tester), isNot(contains('Lopez Lopez'))); }); @@ -759,7 +762,8 @@ void main() { ), ); - expect(find.text('@unknown'), findsOneWidget); + expect(find.text('@'), findsOneWidget); + expect(find.text('unknown'), findsOneWidget); }); testWidgets('does not treat email addresses as mentions', (tester) async { @@ -788,7 +792,7 @@ void main() { ), ); - await tester.tap(find.text('@Alice')); + await tester.tap(find.text('Alice')); expect(tappedPubkey, 'pk1'); }); @@ -806,7 +810,7 @@ void main() { ), ); - await tester.tap(find.text('@Kenny Lopez')); + await tester.tap(find.text('Kenny Lopez')); expect(tappedPubkey, 'pk1'); }); @@ -822,7 +826,7 @@ void main() { ), ); - await tester.tap(find.text('@unknown'), warnIfMissed: false); + await tester.tap(find.text('unknown'), warnIfMissed: false); expect(tapped, isFalse); }); }); @@ -896,7 +900,8 @@ void main() { ); expect(_hasBoldSpan(tester, 'Important'), isTrue); - expect(find.text('@Alice'), findsOneWidget); + expect(find.text('@'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); }); testWidgets('preserves markdown around mentions', (tester) async { @@ -909,7 +914,8 @@ void main() { ), ); - expect(find.text('@Alice'), findsOneWidget); + expect(find.text('@'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); expect(_allRichText(tester), isNot(contains('**'))); }); diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 334b061ab9..87f39d1c65 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -243,7 +243,7 @@ void main() { actorPubkey: 'pk1', targetPubkey: 'pk2', ); - expect(event.describe(resolve), 'Alice added Bob to the channel'); + expect(event.describe(resolve), 'Bob was added by Alice'); }); test('member_left', () { @@ -776,6 +776,7 @@ void main() { expect(entries[0].summary, isNotNull); expect(entries[0].summary!.replyCount, 2); expect(entries[0].summary!.threadHeadId, 'a'); + expect(entries[0].summary!.lastReplyAt, 3000); }); test('summary counts only direct children, not nested replies', () { @@ -828,4 +829,154 @@ void main() { expect(buildMainTimelineEntries([]), isEmpty); }); }); + + group('groupMembershipTimelineEntries', () { + List entries(List events) => + buildMainTimelineEntries(formatTimeline(events)); + + test('groups consecutive additions by one actor within five minutes', () { + final grouped = groupMembershipTimelineEntries( + entries([ + _systemMsg( + id: 'a', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + createdAt: 1000, + ), + _systemMsg( + id: 'b', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1060, + ), + _systemMsg( + id: 'c', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'dave', + }, + createdAt: 1300, + ), + ]), + ); + + expect(grouped, hasLength(1)); + expect(grouped.single.map((entry) => entry.message.id), ['a', 'b', 'c']); + }); + + test('groups self-joins from different people', () { + final grouped = groupMembershipTimelineEntries( + entries([ + _systemMsg( + id: 'a', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'alice', + }, + createdAt: 1000, + ), + _systemMsg( + id: 'b', + payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'}, + createdAt: 1060, + ), + ]), + ); + + expect(grouped.single, hasLength(2)); + }); + + test('uses a fixed window anchored on the newest addition', () { + final grouped = groupMembershipTimelineEntries( + entries([ + _systemMsg( + id: 'a', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + createdAt: 1000, + ), + _systemMsg( + id: 'b', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1240, + ), + _systemMsg( + id: 'c', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'dave', + }, + createdAt: 1301, + ), + ]), + ); + + expect(grouped.map((group) => group.length), [1, 2]); + }); + + test('actor changes, messages, and day boundaries break groups', () { + final dayOne = + DateTime(2026, 7, 14, 23, 59).millisecondsSinceEpoch ~/ 1000; + final dayTwo = DateTime(2026, 7, 15).millisecondsSinceEpoch ~/ 1000; + final grouped = groupMembershipTimelineEntries( + entries([ + _systemMsg( + id: 'a', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + createdAt: dayOne, + ), + _systemMsg( + id: 'b', + payload: { + 'type': 'member_joined', + 'actor': 'carol', + 'target': 'dave', + }, + createdAt: dayOne + 30, + ), + _textMsg(id: 'message', createdAt: dayOne + 40), + _systemMsg( + id: 'c', + payload: { + 'type': 'member_joined', + 'actor': 'carol', + 'target': 'erin', + }, + createdAt: dayOne + 50, + ), + _systemMsg( + id: 'd', + payload: { + 'type': 'member_joined', + 'actor': 'carol', + 'target': 'frank', + }, + createdAt: dayTwo, + ), + ]), + ); + + expect(grouped.map((group) => group.length), [1, 1, 1, 1, 1]); + }); + }); } From 4deb38ab8a9e605f755b9983cc2def40e8ce2d06 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:18:48 -0700 Subject: [PATCH 2/2] fix(mobile): address channel review feedback --- .../channel_detail_page/system_rows.dart | 2 +- .../features/channels/thread_detail_page.dart | 10 ++ .../channels/channel_detail_page_test.dart | 131 ++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index a1cbefd9aa..8ff09b4e5c 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -119,7 +119,7 @@ class _SystemMessageRow extends ConsumerWidget { ), if (reactions.isNotEmpty) Padding( - padding: const EdgeInsets.only(left: 28), + padding: const EdgeInsets.only(left: 36 + Grid.xxs), child: ReactionRow( reactions: reactions, onToggle: groupedMessages == null diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 8353017809..94e95b8fe5 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -15,6 +15,7 @@ import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'date_formatters.dart'; +import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; @@ -167,6 +168,7 @@ class ThreadDetailPage extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + DayDivider(label: formatDayHeading(liveHead.createdAt)), _ThreadMessage( message: liveHead, channelNames: channelNamesMap, @@ -205,8 +207,14 @@ class ThreadDetailPage extends HookConsumerWidget { final chronIdx = replies.length - 1 - index; final reply = replies[chronIdx]; final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null; + final previousMessage = prevReply ?? liveHead; + final showDayDivider = !isSameDay( + previousMessage.createdAt, + reply.createdAt, + ); final showAuthor = prevReply == null || + showDayDivider || prevReply.pubkey.toLowerCase() != reply.pubkey.toLowerCase() || (reply.createdAt - prevReply.createdAt) > 300; @@ -221,6 +229,8 @@ class ThreadDetailPage extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (showDayDivider) + DayDivider(label: formatDayHeading(reply.createdAt)), _ThreadMessage( message: reply, channelNames: channelNamesMap, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 6e7c7ead76..c1236c2a67 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -12,7 +12,10 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; import 'package:buzz/features/channels/date_formatters.dart'; +import 'package:buzz/features/channels/day_divider.dart'; +import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; +import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; @@ -72,6 +75,25 @@ NostrEvent _systemMsg({ sig: '', ); +NostrEvent _reaction({ + required String id, + required String targetId, + String pubkey = 'bob', + int createdAt = 2000, + String content = '👍', +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: EventKind.reaction, + tags: [ + ['h', _channelId], + ['e', targetId], + ], + content: content, + sig: '', +); + NostrEvent _deletion({ required String id, required List targetIds, @@ -121,6 +143,7 @@ Widget _buildTestable({ ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, String? canvasContent, + List? threadReplies, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -155,6 +178,10 @@ Widget _buildTestable({ channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) readStateProvider.overrideWith(() => readStateNotifier), + if (threadReplies != null) + threadRepliesProvider( + const ThreadRepliesArgs(channelId: _channelId, rootId: 'thread-root'), + ).overrideWith((ref) async => threadReplies), // Stub the relay client provider so preloadMembers doesn't crash. relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -854,6 +881,44 @@ void main() { expect(find.byTooltip('Frank'), findsOneWidget); }); + testWidgets('aligns grouped reactions with the system message content', ( + tester, + ) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'}, + createdAt: 1000, + ), + _systemMsg( + id: 'sys2', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1060, + ), + _reaction(id: 'reaction-1', targetId: 'sys1'), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'), + }, + ), + ); + await tester.pumpAndSettle(); + + final avatarRect = tester.getRect(find.byType(CircleAvatar)); + final reactionRect = tester.getRect(find.byType(ReactionRow)); + expect(reactionRect.left, avatarRect.left + 36 + Grid.xxs); + }); + testWidgets('renders member_left system event', (tester) async { final messages = [ _systemMsg( @@ -1452,6 +1517,72 @@ void main() { expect(observer.pushCount, initialPushCount + 1); }); + + testWidgets('thread shows day dividers when replies cross days', ( + tester, + ) async { + final rootCreatedAt = + DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final nextDayCreatedAt = + DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: rootCreatedAt, + ); + final replies = [ + _textMsg( + id: 'reply-same-day', + pubkey: 'bob', + content: 'Same day', + createdAt: rootCreatedAt + 60, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + _textMsg( + id: 'reply-next-day', + pubkey: 'bob', + content: 'Next day', + createdAt: nextDayCreatedAt, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: replies, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DayDivider), findsNWidgets(2)); + expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget); + expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget); + }); }); }