From 27b9c827fc082ae16b36217f40a82c6453081eef Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 18 Oct 2026 15:17:25 -0400 Subject: [PATCH] Polish mobile message layout and navigation controls (#8427) Align outgoing messages and galleries to the right, reclaim the outgoing avatar gutter, and match desktop calendar dividers with balanced spacing. Standardize navigation and sheet icons at 24 points, restore native Edit/Done styling, and align section menus with trailing channel indicators. Validation: clean analysis; 3,513 mobile tests and push-configuration checks passed; iPhone Release build installed and tested by the owner. Includes regression coverage for message placement, date spacing, gallery direction, and section alignment. --------- Signed-off-by: kenny lopez Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/IosNavigationBar.swift | 11 +- .../ios/Runner/JumpToLatestGlassButton.swift | 2 +- mobile/ios/Runner/NativeReactionDetails.swift | 2 +- mobile/ios/Runner/NativeTabler.swift | 4 - .../channel_detail_page/message_bubble.dart | 120 +++-- .../channel_detail_page/message_list.dart | 14 + .../channel_detail_page/system_rows.dart | 105 +++-- .../channels/channels_page/badges.dart | 15 +- .../channels/channels_page/channel_tile.dart | 13 +- .../channels/channels_page/sections.dart | 49 +- .../channels/compose_bar/camera_preview.dart | 2 +- .../compose_bar/photo_gallery_picker.dart | 2 +- mobile/lib/features/channels/day_divider.dart | 97 +++- .../media_viewer_page/image_controls.dart | 2 +- .../features/channels/message_content.dart | 22 +- .../message_content/media_carousel.dart | 441 ++++++++++-------- .../channels/message_gallery_frame.dart | 11 +- .../features/channels/reply_summary_link.dart | 16 +- .../thread_detail_page/message_list.dart | 8 + .../nested_thread_summary_row.dart | 58 ++- .../thread_detail_page/thread_message.dart | 89 ++-- .../profile/profile_text_edit_sheet.dart | 2 +- .../theme_preview_sheet.dart | 2 +- .../lib/shared/widgets/buzz_sheet_header.dart | 2 +- .../widgets/ios_glass_navigation_button.dart | 2 +- .../shared/widgets/message_author_meta.dart | 68 +-- .../channels/channel_detail_page_test.dart | 67 ++- .../bubble_tests.dart | 216 +++++++++ .../features/channels/channels_page_test.dart | 117 +++++ .../features/channels/day_divider_test.dart | 69 +++ .../message_carousel_preview_cases.dart | 174 +++++++ .../channels/message_content_test.dart | 2 + 32 files changed, 1381 insertions(+), 423 deletions(-) create mode 100644 mobile/test/features/channels/message_carousel_preview_cases.dart diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 1b9b1f4675..a425a1cae3 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -606,7 +606,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { private func makeAction(_ data: [String: Any]) -> UIAction { let id = data["id"] as? String ?? "" let enabled = data["enabled"] as? Bool == true - let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0) } + let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0, size: 24) } return UIAction(title: data["label"] as? String ?? "", image: symbol, attributes: enabled ? [] : [.disabled], state: data["selected"] as? Bool == true ? .on : .off) { [weak self] _ in @@ -615,8 +615,13 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { } private func makeControlGroup(_ actions: [[String: Any]]) -> UIBarButtonItem { - if actions.count == 1, actions[0]["plain"] as? Bool == true { - return makeItem(actions[0]) + if actions.count == 1 { + let item = makeItem(actions[0]) + // Let UIKit retain its navigation typography and sizing for text actions + // such as Edit/Done, rather than inheriting UIButton's default styling. + if actions[0]["plain"] as? Bool == true || (item.image == nil && item.customView == nil) { + return item + } } let surface = UIVisualEffectView() if #available(iOS 26.0, *) { diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 44e7fc8b85..170763c184 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -452,7 +452,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { ) let pointSize: CGFloat = icon == "shutter" ? controlSize * Self.shutterIconRatio - : 17 + : 24 buttonImage = NativeTabler.image(buttonIconName, size: pointSize) } updateDisplayedContent() diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 2580c77093..637da5fffe 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -72,7 +72,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa configuration.baseBackgroundColor = .secondarySystemBackground } configuration.cornerStyle = .capsule - configuration.image = NativeTabler.image("xmark", size: 17) + configuration.image = NativeTabler.image("xmark", size: 24) configuration.baseForegroundColor = foregroundColor close.configuration = configuration close.tintColor = foregroundColor diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index cdffe39a80..e4b9c51160 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -16,10 +16,6 @@ enum NativeTabler { }() static func image(_ symbol: String, size: CGFloat = 20) -> UIImage? { - // Back and Close deliberately retain native navigation artwork. - if ["chevron.backward", "chevron.left", "xmark", "xmark.circle.fill"].contains(symbol) { - return UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .semibold)) - } guard let code = glyphs[symbol], let scalar = UnicodeScalar(code), let name = fontName, let font = UIFont(name: name, size: size) else { assertionFailure("Missing Tabler icon: \(symbol)") 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 d846f455e5..6e4c812de4 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -5,6 +5,8 @@ class _MessageBubble extends HookConsumerWidget { final bool showAuthor; final bool typingHandoff; final bool showAvatar; + final bool startsDay; + final bool endsDay; final bool hasReplies; final Widget? replyFooter; final Map channelNames; @@ -21,6 +23,8 @@ class _MessageBubble extends HookConsumerWidget { required this.showAuthor, this.typingHandoff = false, this.showAvatar = true, + this.startsDay = false, + this.endsDay = false, required this.hasReplies, this.replyFooter, required this.channelNames, @@ -52,6 +56,7 @@ class _MessageBubble extends HookConsumerWidget { ); // Watch only this user's profile to avoid rebuilding on unrelated cache changes. final pk = message.pubkey.toLowerCase(); + final ownMessage = currentPubkey?.toLowerCase() == pk; final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); @@ -133,7 +138,7 @@ class _MessageBubble extends HookConsumerWidget { return true; }, child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), @@ -165,46 +170,60 @@ class _MessageBubble extends HookConsumerWidget { ), child: Padding( padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + bottom: endsDay + ? 0 + : (showAvatar ? Grid.quarter : Grid.quarter / 2), ), child: MessageFooterLayout( animateSize: true, overlap: message.reactions.isNotEmpty ? Grid.xs : 0, footer: message.reactions.isNotEmpty || replyFooter != null - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (message.reactions.isNotEmpty) - ReactionRow( - combined: true, - messageId: message.id, - channelId: currentChannelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ? Align( + alignment: ownMessage + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + right: ownMessage ? Grid.xxs : 0, + left: ownMessage + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (message.reactions.isNotEmpty) + ReactionRow( + combined: true, + messageId: message.id, + channelId: currentChannelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), - ), - if (replyFooter != null) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.xxs, + if (replyFooter != null) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.xxs, + ), + child: Directionality( + textDirection: Directionality.of(context), + child: replyFooter!, + ), ), - child: replyFooter, - ), - ], + ], + ), ), ) : const SizedBox.shrink(), @@ -214,29 +233,41 @@ class _MessageBubble extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Row( + textDirection: ownMessage + ? TextDirection.rtl + : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), + if (!ownMessage) + const SizedBox(width: messageAvatarSize), + if (!ownMessage) + const SizedBox(width: messageAvatarContentGap), Expanded( child: Padding( padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, + top: showAuthor && !startsDay ? Grid.half : 0, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (showAuthor) Padding( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( bottom: Grid.quarter, - left: Grid.xs, + left: ownMessage ? 0 : Grid.xs, + right: ownMessage ? Grid.xs : 0, ), child: Row( children: [ Expanded( child: MessageAuthorMeta( showTimestamp: true, + reversed: false, + alignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, nameStyle: messageUsernameTextStyle .copyWith( @@ -318,8 +349,8 @@ class _MessageBubble extends HookConsumerWidget { replyFooter != null, bubbleEnd: showAvatar, bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, + ownMessage: ownMessage, + trimTrailingSpacing: endsDay, mentionNames: resolvedMentionNames, mentionLabels: mentionLabels, agentMentionPubkeys: agentMentionPubkeys, @@ -392,9 +423,10 @@ class _MessageBubble extends HookConsumerWidget { ), ], ), - if (showAvatar) + if (showAvatar && !ownMessage) Positioned( - left: 0, + left: ownMessage ? null : 0, + right: ownMessage ? 0 : null, bottom: 0, child: MessageMediaAvatar( offset: mediaOffset, 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 adc60fd5d1..f3cfb1d10e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -963,6 +963,13 @@ class _MessageList extends HookConsumerWidget { if (message.isSystem) _SystemMessageRow( message: message, + startsDay: showDayDivider, + endsDay: + nextMessage != null && + !isSameDay( + message.createdAt, + nextMessage.createdAt, + ), groupedMessages: entryGroup.length > 1 ? entryGroup .map((entry) => entry.message) @@ -977,6 +984,13 @@ class _MessageList extends HookConsumerWidget { else ...[ _MessageBubble( message: message, + startsDay: showDayDivider, + endsDay: + nextMessage != null && + !isSameDay( + message.createdAt, + nextMessage.createdAt, + ), showAuthor: showAuthor || isRecentAgentReplyHandoff( 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 b4dff4aa91..76d2321ac1 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -8,6 +8,8 @@ class _SystemMessageRow extends HookConsumerWidget { final List? allMessages; final bool isMember; final bool isArchived; + final bool startsDay; + final bool endsDay; const _SystemMessageRow({ required this.message, @@ -17,6 +19,8 @@ class _SystemMessageRow extends HookConsumerWidget { this.allMessages, this.isMember = false, this.isArchived = false, + this.startsDay = false, + this.endsDay = false, }); @override @@ -136,37 +140,43 @@ class _SystemMessageRow extends HookConsumerWidget { systemEvent.type == SystemEventType.huddleStarted && systemEvent.ephemeralChannelId != null; final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase(); + final ownActor = currentPubkey?.toLowerCase() == actor; + final ownHuddle = + ownActor && + (isHuddle || systemEvent.type == SystemEventType.huddleEnded); Widget withActor(Widget content) { if (!isHuddle) return content; return Row( key: ValueKey('huddle-activity-${message.id}'), + textDirection: ownHuddle ? TextDirection.rtl : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Semantics( - container: true, - label: 'Open ${resolveLabel(actor)} profile', - button: true, - excludeSemantics: true, - onTap: () => showUserProfileSheet( - context, - actor, - names: channelIdentityNamesProvider(channelId), - ), - child: GestureDetector( - excludeFromSemantics: true, + if (!ownHuddle) + Semantics( + container: true, + label: 'Open ${resolveLabel(actor)} profile', + button: true, + excludeSemantics: true, onTap: () => showUserProfileSheet( context, actor, names: channelIdentityNamesProvider(channelId), ), - child: _UserAvatar( - profile: userCache[actor], - pubkey: actor, - isAgent: userCache[actor]?.ownerPubkey != null, + child: GestureDetector( + excludeFromSemantics: true, + onTap: () => showUserProfileSheet( + context, + actor, + names: channelIdentityNamesProvider(channelId), + ), + child: _UserAvatar( + profile: userCache[actor], + pubkey: actor, + isAgent: userCache[actor]?.ownerPubkey != null, + ), ), ), - ), - const SizedBox(width: messageAvatarContentGap), + if (!ownHuddle) const SizedBox(width: messageAvatarContentGap), Expanded(child: content), ], ); @@ -183,9 +193,14 @@ class _SystemMessageRow extends HookConsumerWidget { borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: EdgeInsets.only( + top: startsDay ? 0 : Grid.xxs, + bottom: endsDay ? 0 : Grid.xxs, + ), child: Column( - crossAxisAlignment: isHuddle + crossAxisAlignment: ownHuddle + ? CrossAxisAlignment.end + : isHuddle ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ @@ -193,7 +208,9 @@ class _SystemMessageRow extends HookConsumerWidget { RepaintBoundary( key: spotlightKey, child: Align( - alignment: isHuddle + alignment: ownHuddle + ? Alignment.centerRight + : isHuddle ? Alignment.centerLeft : Alignment.center, child: Padding( @@ -244,25 +261,32 @@ class _SystemMessageRow extends HookConsumerWidget { overlap: reactions.isNotEmpty ? Grid.xs : 0, footer: reactions.isEmpty ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: ReactionRow( - combined: true, - messageId: message.id, - channelId: channelId, - reactions: reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + : Align( + alignment: ownHuddle + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + left: ownHuddle + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + right: ownHuddle ? Grid.xxs : 0, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), ), ), @@ -609,6 +633,7 @@ class _ThreadSummaryRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return ReplySummaryLink( key: ValueKey('thread-summary-${message.id}'), + mirrored: currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), summary: summary, hasReactions: message.reactions.isNotEmpty, onTap: () { diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 15c081045e..c2641a433d 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -12,11 +12,16 @@ class _EphemeralBadge extends StatelessWidget { return Tooltip( message: display.tooltipLabel, - child: Icon( - BuzzIcons.clockFading, - key: Key('channel-ephemeral-${channel.id}'), - size: 16, - color: context.colors.onSurfaceVariant, + child: SizedBox( + width: _kChannelIconSize, + child: Center( + child: Icon( + BuzzIcons.clockFading, + key: Key('channel-ephemeral-${channel.id}'), + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 7903b07fb2..553df6abaf 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -86,10 +86,15 @@ class _ChannelTile extends ConsumerWidget { ], if (isMuted) ...[ const SizedBox(width: Grid.xxs), - Icon( - BuzzIcons.bellOff, - size: 12, - color: context.colors.onSurface.withValues(alpha: 0.4), + SizedBox( + width: _kChannelIconSize, + child: Center( + child: Icon( + BuzzIcons.bellOff, + size: 12, + color: context.colors.onSurface.withValues(alpha: 0.4), + ), + ), ), ], if (!channel.isMember && !channel.isDm) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 2a1bd87e3d..0987bb27a2 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -138,10 +138,9 @@ class _CustomSectionHeader extends StatelessWidget { ), ), Builder( - builder: (buttonContext) => IconButton( + builder: (buttonContext) => _SectionOptionsButton( key: ValueKey('section-menu-${section.id}'), tooltip: '${section.name} options', - visualDensity: VisualDensity.compact, icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, @@ -209,7 +208,6 @@ class _CustomSectionHeader extends StatelessWidget { }, ), ), - const SizedBox(width: Grid.quarter), ], ), ), @@ -495,10 +493,9 @@ class _SectionHeader extends StatelessWidget { ), if (sortMode case final mode?) ...[ Builder( - builder: (buttonContext) => IconButton( + builder: (buttonContext) => _SectionOptionsButton( key: ValueKey('sort-menu-$label'), tooltip: '$label options', - visualDensity: VisualDensity.compact, icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, @@ -509,15 +506,6 @@ class _SectionHeader extends StatelessWidget { context: buttonContext, width: 216, alignment: AnchoredPopoverAlignment.end, - color: context.colors.surface, - elevation: 4, - shadowColor: context.colors.shadow.withValues( - alpha: 0.18, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: context.colors.outline), - ), surfaceKey: ValueKey('sort-popover-$label'), items: _sortMenuItems(mode, showDivider: false), ); @@ -529,7 +517,6 @@ class _SectionHeader extends StatelessWidget { }, ), ), - const SizedBox(width: Grid.quarter), ], ], ), @@ -587,3 +574,35 @@ class _SectionChevron extends StatelessWidget { ); } } + +class _SectionOptionsButton extends StatelessWidget { + const _SectionOptionsButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final Widget icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: tooltip, + onTap: onPressed, + excludeSemantics: true, + child: Tooltip( + message: tooltip, + child: SizedBox.square( + dimension: 40, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(Radii.full), + child: Align(alignment: Alignment.centerRight, child: icon), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index 12e80e53ee..85ed7ed943 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -309,7 +309,7 @@ class _CameraCloseButton extends StatelessWidget { ), foregroundColor: Colors.white, ), - icon: Icon(BuzzIcons.arrowLeft, size: emphasized ? 24 : 18), + icon: Icon(BuzzIcons.arrowLeft, size: 24), ), ); } diff --git a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 60ddbf588f..65139a9d1a 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -179,7 +179,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', visualDensity: VisualDensity.compact, - icon: const Icon(BuzzIcons.arrowLeft, size: 20), + icon: const Icon(BuzzIcons.arrowLeft, size: 24), ), const SizedBox(width: Grid.quarter), Expanded( diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart index 24f5bb798e..ab51482c66 100644 --- a/mobile/lib/features/channels/day_divider.dart +++ b/mobile/lib/features/channels/day_divider.dart @@ -28,11 +28,49 @@ class DayDivider extends StatelessWidget { : const Duration(milliseconds: 120), curve: Curves.easeOutCubic, opacity: isSticky ? 0 : 1, - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues(alpha: 0.72), - fontWeight: FontWeight.w500, + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: Divider( + color: context.colors.outlineVariant, + thickness: 0.5, + ), + ), + const SizedBox(width: Grid.xs), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth - Grid.xs * 2) * 0.6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (dayTimestamp != null) ...[ + _CalendarDateTile(timestamp: dayTimestamp!), + const SizedBox(width: Grid.xxs), + ], + Flexible( + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.72, + ), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + thickness: 0.5, + ), + ), + ], ), ), ), @@ -44,7 +82,7 @@ class DayDivider extends StatelessWidget { final activeTimestamp = stickyDayTimestamp; final timestamp = dayTimestamp; return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter), + padding: const EdgeInsets.symmetric(vertical: Grid.gutter), child: Center( child: activeTimestamp == null || timestamp == null ? _buildOpacity(context, isSticky: false) @@ -59,3 +97,50 @@ class DayDivider extends StatelessWidget { ); } } + +/// Decorative desktop calendar artwork; the adjacent label owns semantics. +class _CalendarDateTile extends StatelessWidget { + const _CalendarDateTile({required this.timestamp}); + + final int timestamp; + + @override + Widget build(BuildContext context) { + final day = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000).day; + return ExcludeSemantics( + child: Container( + key: ValueKey('day-calendar-$timestamp'), + width: Grid.gutter, + height: Grid.gutter, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all(color: context.colors.outlineVariant, width: 0.5), + ), + child: Column( + children: [ + const SizedBox( + height: Grid.gutter * 7 / 24, + width: double.infinity, + child: ColoredBox(color: Color(0xFFFF0000)), + ), + Expanded( + child: Center( + child: Text( + '$day', + textScaler: TextScaler.noScaling, + style: const TextStyle( + color: Color(0xFF111111), + fontSize: Grid.gutter / 2, + height: 1, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/media_viewer_page/image_controls.dart b/mobile/lib/features/channels/media_viewer_page/image_controls.dart index 571f6bbcd5..c4e8102efa 100644 --- a/mobile/lib/features/channels/media_viewer_page/image_controls.dart +++ b/mobile/lib/features/channels/media_viewer_page/image_controls.dart @@ -102,7 +102,7 @@ class _MediaViewerCircleButton extends StatelessWidget { child: IconButton( onPressed: onPressed, tooltip: tooltip, - icon: Icon(icon, color: Colors.white, size: 20), + icon: Icon(icon, color: Colors.white, size: 24), ), ), ); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 6e7e0d019e..daf0b42566 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -101,6 +101,9 @@ class MessageContent extends HookConsumerWidget { /// Distinguishes messages authored by the signed-in identity. final bool ownMessage; + /// Lets an adjacent day divider own spacing after the final text segment. + final bool trimTrailingSpacing; + /// Marks the final message in a sender run for rounded corners and dots. final bool bubbleEnd; @@ -171,6 +174,7 @@ class MessageContent extends HookConsumerWidget { required this.content, this.bubble = false, this.ownMessage = false, + this.trimTrailingSpacing = false, this.bubbleEnd = true, this.bubbleStart = true, this.hasReactions = false, @@ -395,7 +399,9 @@ class MessageContent extends HookConsumerWidget { : const <({String text, bool attachment})>[]; final markdown = bubble ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (final (index, segment) in segments.indexed) @@ -413,7 +419,14 @@ class MessageContent extends HookConsumerWidget { else Padding( key: ValueKey('message-text-bubble-$index'), - padding: const EdgeInsets.only(bottom: Grid.quarter), + padding: EdgeInsets.only( + bottom: + trimTrailingSpacing && + trailingGallery == null && + index == segments.length - 1 + ? 0 + : Grid.quarter, + ), child: LocalSendBubbleSurface( color: ownMessage ? chatColors.outgoing @@ -479,7 +492,9 @@ class MessageContent extends HookConsumerWidget { if (trailingGallery == null) return markdown; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (trailingGallery.content.trim().isNotEmpty) markdown, @@ -493,6 +508,7 @@ class MessageContent extends HookConsumerWidget { trailingGallery.items.map((item) => item.url).join('\u0000'), ), items: trailingGallery.items, + mirrored: ownMessage, leadingOverflow: mediaCarouselLeadingOverflow, trailingOverflow: mediaCarouselTrailingOverflow, onReply: onMediaReply, diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 3225689484..2006e2c520 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -11,21 +11,24 @@ class _MessageGalleryPrecache extends HookWidget { @override Widget build(BuildContext context) { + final prefetch = useMemoized(_GalleryPrecache.new, const []); + useEffect(() => prefetch.dispose, [prefetch]); final providerSignature = Object.hashAll(providers); useEffect(() { var cancelled = false; WidgetsBinding.instance.addPostFrameCallback((_) { - if (cancelled || !context.mounted) { - return; - } - for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) { - if (index < 0 || index >= providers.length) { - continue; - } - unawaited( - precacheImage(providers[index], context, onError: (_, _) {}), - ); - } + if (cancelled || !context.mounted) return; + // Prepare a five-photo strip immediately, then keep four ahead and two + // behind the current page. Two workers bound speculative network work. + final indices = [ + for (var i = focusedIndex; i <= focusedIndex + 4; i++) i, + focusedIndex - 1, + focusedIndex - 2, + ]; + prefetch.update(context, [ + for (final i in indices) + if (i >= 0 && i < providers.length) providers[i], + ]); }); return () => cancelled = true; }, [focusedIndex, providerSignature]); @@ -33,8 +36,50 @@ class _MessageGalleryPrecache extends HookWidget { } } +class _GalleryPrecache { + BuildContext? _context; + List> _window = []; + final _ready = >{}; + final _loading = >{}; + + void update(BuildContext context, List> window) { + _context = context; + _window = window; + _ready.removeWhere((provider) => !window.contains(provider)); + _drain(); + } + + void _drain() { + final context = _context; + if (context == null || !context.mounted) return; + for (final provider in _window) { + if (_loading.length >= 2) break; + if (_ready.contains(provider) || _loading.contains(provider)) continue; + _loading.add(provider); + unawaited(_load(context, provider)); + } + } + + Future _load( + BuildContext context, + ImageProvider provider, + ) async { + await precacheImage(provider, context, onError: (_, _) {}); + _loading.remove(provider); + if (_window.contains(provider)) _ready.add(provider); + _drain(); + } + + void dispose() { + _context = null; + _window = []; + _ready.clear(); + } +} + class _MessageImageCarousel extends HookConsumerWidget { final List items; + final bool mirrored; final double leadingOverflow; final double trailingOverflow; final VoidCallback? onReply; @@ -43,6 +88,7 @@ class _MessageImageCarousel extends HookConsumerWidget { const _MessageImageCarousel({ super.key, required this.items, + required this.mirrored, required this.leadingOverflow, required this.trailingOverflow, required this.onReply, @@ -51,6 +97,7 @@ class _MessageImageCarousel extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final viewerContext = context; final itemSignature = items.map((item) => item.url).join('\u0000'); final heroTags = useMemoized( () => [for (var index = 0; index < items.length; index++) Object()], @@ -62,211 +109,223 @@ class _MessageImageCarousel extends HookConsumerWidget { return MessageGalleryFrame( count: items.length, - child: LayoutBuilder( - builder: (context, constraints) { - final contentWidth = constraints.hasBoundedWidth - ? constraints.maxWidth - : messageMediaMaxWidth(context); - final carouselWidth = - contentWidth + leadingOverflow + trailingOverflow; - final leadingExtent = leadingOverflow; - final isLeftToRight = Directionality.of(context) == TextDirection.ltr; - final itemTrailingPaddings = [ - for (var index = 0; index < items.length; index++) - index == items.length - 1 ? Grid.gutter : Grid.half, - ]; - final previewDecodeWidths = [ - for (var index = 0; index < items.length; index++) - math.max(1.0, carouselWidth * 0.9 - itemTrailingPaddings[index]), - ]; - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); - final previewProviders = [ - for (var index = 0; index < items.length; index++) - ResizeImage.resizeIfNeeded( - (previewDecodeWidths[index] * devicePixelRatio).ceil(), - null, - MediaImageProvider( - url: items[index].url, - auth: mediaAuth, - client: mediaClient, + mirrored: mirrored, + child: Directionality( + textDirection: mirrored ? TextDirection.rtl : TextDirection.ltr, + child: LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : messageMediaMaxWidth(context); + final carouselWidth = + contentWidth + leadingOverflow + trailingOverflow; + // Directionality mirrors both the starting edge and trailing overflow. + final leadingExtent = leadingOverflow; + final isLeftToRight = + Directionality.of(context) == TextDirection.ltr; + final itemTrailingPaddings = [ + for (var index = 0; index < items.length; index++) + index == items.length - 1 ? Grid.gutter : Grid.half, + ]; + final previewDecodeWidths = [ + for (var index = 0; index < items.length; index++) + math.max( + 1.0, + carouselWidth * 0.9 - itemTrailingPaddings[index], ), - ), - ]; - final viewerItems = [ - for (var index = 0; index < items.length; index++) - MediaViewerImage( - url: items[index].url, - heroTag: heroTags[index], - semanticLabel: items[index].semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: items[index].aspectRatio, - preloadProvider: previewProviders[index], - ), - ]; - final nativeGutter = math.max( - 0.0, - MediaQuery.sizeOf(context).width - carouselWidth, - ); - final carousel = SizedBox( - key: const ValueKey('message-media-carousel'), - width: carouselWidth, - height: messageMediaCarouselHeight, - child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS - ? OverflowBox( - minWidth: carouselWidth + 2 * nativeGutter, - maxWidth: carouselWidth + 2 * nativeGutter, - child: IosMediaCarousel( - key: ValueKey((itemSignature, mediaAuth)), - providers: previewProviders, - labels: [for (final item in items) item.semanticLabel], + ]; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final previewProviders = [ + for (var index = 0; index < items.length; index++) + ResizeImage.resizeIfNeeded( + (previewDecodeWidths[index] * devicePixelRatio).ceil(), + null, + MediaImageProvider( + url: items[index].url, + auth: mediaAuth, + client: mediaClient, + ), + ), + ]; + final viewerItems = [ + for (var index = 0; index < items.length; index++) + MediaViewerImage( + url: items[index].url, + heroTag: heroTags[index], + semanticLabel: items[index].semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: items[index].aspectRatio, + preloadProvider: previewProviders[index], + ), + ]; + final nativeGutter = math.max( + 0.0, + MediaQuery.sizeOf(context).width - carouselWidth, + ); + final carousel = SizedBox( + key: const ValueKey('message-media-carousel'), + width: carouselWidth, + height: messageMediaCarouselHeight, + child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS + ? OverflowBox( + minWidth: carouselWidth + 2 * nativeGutter, + maxWidth: carouselWidth + 2 * nativeGutter, + child: IosMediaCarousel( + key: ValueKey((itemSignature, mediaAuth)), + providers: previewProviders, + labels: [for (final item in items) item.semanticLabel], + width: carouselWidth, + gutter: nativeGutter, + gap: Grid.half, + trailingPadding: Grid.gutter, + radius: + (MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md)) + .topLeft + .x, + onScroll: (offset) => MessageGalleryScrollNotification( + offset, + ).dispatch(context), + onPageChanged: (index) { + if (index != currentIndex.value) { + unawaited(HapticFeedback.selectionClick()); + } + currentIndex.value = index; + }, + onOpen: (index) => openImageViewer( + viewerContext, + imageUrl: items[index].url, + heroTag: heroTags[index], + semanticLabel: items[index].semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: items[index].aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + ), + ) + : _MessageCarouselViewport( width: carouselWidth, - gutter: nativeGutter, - gap: Grid.half, - trailingPadding: Grid.gutter, - radius: - (MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md)) - .topLeft - .x, - onScroll: (offset) => MessageGalleryScrollNotification( - offset, - ).dispatch(context), + currentIndex: currentIndex.value, + itemCount: items.length, onPageChanged: (index) { if (index != currentIndex.value) { unawaited(HapticFeedback.selectionClick()); } currentIndex.value = index; }, - onOpen: (index) => openImageViewer( - context, - imageUrl: items[index].url, - heroTag: heroTags[index], - semanticLabel: items[index].semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: items[index].aspectRatio, - galleryItems: viewerItems, - initialIndex: index, - onReply: onReply, - onMore: onMore, - ), - ), - ) - : _MessageCarouselViewport( - width: carouselWidth, - currentIndex: currentIndex.value, - itemCount: items.length, - onPageChanged: (index) { - if (index != currentIndex.value) { - unawaited(HapticFeedback.selectionClick()); - } - currentIndex.value = index; - }, - itemBuilder: (context, index) { - final item = items[index]; - return Padding( - key: ValueKey( - 'message-media-carousel-page:${item.url}', - ), - padding: EdgeInsetsDirectional.only( - end: itemTrailingPaddings[index], - ), - child: Semantics( - button: true, - excludeSemantics: true, - label: 'Open ${item.semanticLabel}', - child: GestureDetector( - key: ValueKey( - 'message-media-carousel-item:${item.url}', - ), - onTap: () => openImageViewer( - context, - imageUrl: item.url, - heroTag: heroTags[index], - semanticLabel: item.semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: item.aspectRatio, - galleryItems: viewerItems, - initialIndex: index, - onReply: onReply, - onMore: onMore, - ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: - MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant, - ), + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + key: ValueKey( + 'message-media-carousel-page:${item.url}', + ), + padding: EdgeInsetsDirectional.only( + end: itemTrailingPaddings[index], + ), + child: Semantics( + button: true, + excludeSemantics: true, + label: 'Open ${item.semanticLabel}', + child: GestureDetector( + key: ValueKey( + 'message-media-carousel-item:${item.url}', ), - child: MediaViewerHero( - tag: heroTags[index], - child: ClipRRect( + onTap: () => openImageViewer( + viewerContext, + imageUrl: item.url, + heroTag: heroTags[index], + semanticLabel: item.semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: item.aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, borderRadius: MessageAttachmentCorners.of(context) ?? BorderRadius.circular(Radii.md), - child: MediaImage( - url: item.url, - decodeWidth: previewDecodeWidths[index], - fit: BoxFit.cover, - semanticLabel: item.semanticLabel, - frameBuilder: - (context, child, frame, synchronous) => - frame != null || synchronous - ? child - : const MediaLoadingPlaceholder( - label: 'Loading image', - ), - errorBuilder: (_, _, _) => - const _MediaPreviewFallback( - icon: BuzzIcons.imageOff, - label: 'Image unavailable', - ), + border: Border.all( + color: context.colors.outlineVariant, + ), + ), + child: MediaViewerHero( + tag: heroTags[index], + child: ClipRRect( + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), + child: MediaImage( + url: item.url, + decodeWidth: previewDecodeWidths[index], + fit: BoxFit.cover, + semanticLabel: item.semanticLabel, + frameBuilder: + ( + context, + child, + frame, + synchronous, + ) => frame != null || synchronous + ? child + : const MediaLoadingPlaceholder( + label: 'Loading image', + ), + errorBuilder: (_, _, _) => + const _MediaPreviewFallback( + icon: BuzzIcons.imageOff, + label: 'Image unavailable', + ), + ), ), ), ), ), ), - ), - ); - }, - ), - ); + ); + }, + ), + ); - final carouselSurface = Stack( - clipBehavior: Clip.none, - children: [ - carousel, - if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + final carouselSurface = Stack( + clipBehavior: Clip.none, + children: [ + carousel, _MessageGalleryPrecache( providers: previewProviders, focusedIndex: currentIndex.value, ), - ], - ); + ], + ); - if (leadingExtent <= 0 && trailingOverflow <= 0) { - return carouselSurface; - } - return SizedBox( - width: contentWidth, - height: messageMediaCarouselHeight, - child: OverflowBox( - alignment: AlignmentDirectional.centerStart, - minWidth: carouselWidth, - maxWidth: carouselWidth, - child: Transform.translate( - offset: Offset( - isLeftToRight ? -leadingExtent : leadingExtent, - 0, + if (leadingExtent <= 0 && trailingOverflow <= 0) { + return carouselSurface; + } + return SizedBox( + width: contentWidth, + height: messageMediaCarouselHeight, + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + minWidth: carouselWidth, + maxWidth: carouselWidth, + child: Transform.translate( + offset: Offset( + isLeftToRight ? -leadingExtent : leadingExtent, + 0, + ), + child: carouselSurface, ), - child: carouselSurface, ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/message_gallery_frame.dart b/mobile/lib/features/channels/message_gallery_frame.dart index 6c3b3627a8..e66e4664a5 100644 --- a/mobile/lib/features/channels/message_gallery_frame.dart +++ b/mobile/lib/features/channels/message_gallery_frame.dart @@ -11,11 +11,13 @@ class MessageGalleryFrame extends StatelessWidget { final int count; final Widget child; final bool loading; + final bool mirrored; const MessageGalleryFrame({ required this.count, required this.child, this.loading = false, + this.mirrored = false, super.key, }); @@ -36,11 +38,16 @@ class MessageGalleryFrame extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: Grid.half), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: mirrored + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.xs), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.xs, + right: mirrored ? Grid.xs : 0, + ), child: loading ? ExcludeSemantics( child: Stack( diff --git a/mobile/lib/features/channels/reply_summary_link.dart b/mobile/lib/features/channels/reply_summary_link.dart index c3cb376827..55c3d34498 100644 --- a/mobile/lib/features/channels/reply_summary_link.dart +++ b/mobile/lib/features/channels/reply_summary_link.dart @@ -13,12 +13,16 @@ class ReplySummaryLink extends ConsumerWidget { required this.summary, required this.onTap, this.hasReactions = false, + this.mirrored = false, }); final ThreadSummary summary; final VoidCallback onTap; final bool hasReactions; + /// Places the connector on the outgoing edge without reversing its contents. + final bool mirrored; + @override Widget build(BuildContext context, WidgetRef ref) { final participants = summary.participantPubkeys.take(3).toList(); @@ -28,7 +32,10 @@ class ReplySummaryLink extends ConsumerWidget { : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}'; final connectorTop = hasReactions ? -Grid.half : Grid.half; return Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.xxs), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.xxs, + right: mirrored ? Grid.xxs : 0, + ), child: Stack( clipBehavior: Clip.none, children: [ @@ -38,14 +45,17 @@ class ReplySummaryLink extends ConsumerWidget { child: CustomPaint( painter: _ReplyConnector( context.colors.outlineVariant, - Directionality.of(context), + mirrored ? TextDirection.rtl : TextDirection.ltr, connectorTop, ), ), ), ), Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.twelve), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.twelve, + right: mirrored ? Grid.twelve : 0, + ), child: Semantics( button: true, label: 'View thread: $label', diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index d4894bfe29..cfd7572b5d 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -97,6 +97,7 @@ class _ThreadMessageList extends HookWidget { ), _ThreadMessage( message: head, + startsDay: true, channelNames: channelNames, channelId: channelId, currentPubkey: currentPubkey, @@ -300,6 +301,13 @@ class _ThreadMessageList extends HookWidget { ), _ThreadMessage( message: reply, + startsDay: showDayDivider, + endsDay: + nextReply != null && + !isSameDay( + reply.createdAt, + nextReply.createdAt, + ), channelNames: channelNames, channelId: channelId, currentPubkey: currentPubkey, diff --git a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart index 6adf6e1ab6..fc79a822d0 100644 --- a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart +++ b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart @@ -23,29 +23,41 @@ class _NestedThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap + Grid.xxs, - bottom: Grid.xxs, - ), - child: ReplySummaryLink( - key: ValueKey('nested-thread-summary-${replyMessage.id}'), - summary: summary, - hasReactions: replyMessage.reactions.isNotEmpty, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: replyMessage, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, + final ownMessage = + currentPubkey?.toLowerCase() == replyMessage.pubkey.toLowerCase(); + return Align( + alignment: ownMessage ? Alignment.centerRight : Alignment.centerLeft, + child: Directionality( + textDirection: Directionality.of(context), + child: Padding( + padding: EdgeInsets.only( + left: ownMessage + ? 0 + : messageAvatarSize + messageAvatarContentGap + Grid.xxs, + right: ownMessage ? Grid.xxs : 0, + bottom: Grid.xxs, + ), + child: ReplySummaryLink( + key: ValueKey('nested-thread-summary-${replyMessage.id}'), + summary: summary, + mirrored: ownMessage, + hasReactions: replyMessage.reactions.isNotEmpty, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: replyMessage, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index aa4f6e1d71..7bbec9def0 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -7,6 +7,8 @@ class _ThreadMessage extends HookConsumerWidget { final String? currentPubkey; final bool showAuthor; final bool showAvatar; + final bool startsDay; + final bool endsDay; final bool isHighlighted; final List? allMessages; final bool isMember; @@ -25,6 +27,8 @@ class _ThreadMessage extends HookConsumerWidget { required this.currentPubkey, required this.showAuthor, this.showAvatar = true, + this.startsDay = false, + this.endsDay = false, this.isHighlighted = false, this.allMessages, this.isMember = false, @@ -39,6 +43,7 @@ class _ThreadMessage extends HookConsumerWidget { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final pk = message.pubkey.toLowerCase(); + final ownMessage = currentPubkey?.toLowerCase() == pk; final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); @@ -134,7 +139,7 @@ class _ThreadMessage extends HookConsumerWidget { return true; }, child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0), child: DecoratedBox( key: ValueKey('thread-message-${message.id}'), decoration: BoxDecoration( @@ -156,31 +161,40 @@ class _ThreadMessage extends HookConsumerWidget { snapshotKey: messageSnapshotKey, child: Padding( padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + bottom: endsDay + ? 0 + : (showAvatar ? Grid.quarter : Grid.quarter / 2), ), child: MessageFooterLayout( animateSize: true, overlap: message.reactions.isNotEmpty ? Grid.xs : 0, footer: (isThreadHead || message.reactions.isNotEmpty) - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: ReactionRow( - combined: true, - messageId: message.id, - channelId: channelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ? Align( + alignment: ownMessage + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + right: ownMessage ? Grid.xxs : 0, + left: ownMessage + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), ), ) @@ -191,29 +205,41 @@ class _ThreadMessage extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Row( + textDirection: ownMessage + ? TextDirection.rtl + : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), + if (!ownMessage) + const SizedBox(width: messageAvatarSize), + if (!ownMessage) + const SizedBox(width: messageAvatarContentGap), Expanded( child: Padding( padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, + top: showAuthor && !startsDay ? Grid.half : 0, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (showAuthor) Padding( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( bottom: Grid.quarter, - left: Grid.xs, + left: ownMessage ? 0 : Grid.xs, + right: ownMessage ? Grid.xs : 0, ), child: Row( children: [ Expanded( child: MessageAuthorMeta( showTimestamp: true, + reversed: false, + alignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, nameStyle: messageUsernameTextStyle .copyWith( @@ -290,8 +316,8 @@ class _ThreadMessage extends HookConsumerWidget { message.reactions.isNotEmpty, bubbleEnd: showAvatar, bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, + ownMessage: ownMessage, + trimTrailingSpacing: endsDay, mentionNames: resolvedMentionNames, mentionLabels: mentionLabels, agentMentionPubkeys: agentMentionPubkeys, @@ -362,9 +388,10 @@ class _ThreadMessage extends HookConsumerWidget { ), ], ), - if (showAvatar) + if (showAvatar && !ownMessage) Positioned( - left: 0, + left: ownMessage ? null : 0, + right: ownMessage ? 0 : null, bottom: 0, child: MessageMediaAvatar( offset: mediaOffset, diff --git a/mobile/lib/features/profile/profile_text_edit_sheet.dart b/mobile/lib/features/profile/profile_text_edit_sheet.dart index a0e9dce3c4..9cbb89d286 100644 --- a/mobile/lib/features/profile/profile_text_edit_sheet.dart +++ b/mobile/lib/features/profile/profile_text_edit_sheet.dart @@ -96,7 +96,7 @@ class ProfileTextEditSheet extends HookWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); diff --git a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart index cd7e6bd8ce..0dfe750849 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart @@ -305,7 +305,7 @@ class _ThemePreviewCloseButton extends StatelessWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); } diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart index e51f6973cd..14353bcd33 100644 --- a/mobile/lib/shared/widgets/buzz_sheet_header.dart +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -107,7 +107,7 @@ class _SheetCloseButton extends StatelessWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); } diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 0c64993478..2a5b93a333 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -258,7 +258,7 @@ class IosGlassNavigationButton extends HookWidget { }, size: icon == IosGlassNavigationIcon.shutter ? controlSize * 0.72 - : 22, + : 24, color: icon == IosGlassNavigationIcon.colorSwatch ? swatchColor : effectiveForeground, diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index 2de13ad5b5..402c0d1f40 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -43,6 +43,12 @@ class MessageAuthorMeta extends StatelessWidget { /// Base text style for [timestamp], with [metadataColor] applied. final TextStyle timestampStyle; + /// Mirrors metadata order without changing text reading direction. + final bool reversed; + + /// Positions the metadata within the available message width. + final MainAxisAlignment alignment; + /// Creates an inline author row with optional username and tap handling. const MessageAuthorMeta({ super.key, @@ -51,6 +57,8 @@ class MessageAuthorMeta extends StatelessWidget { required this.nameColor, required this.metadataColor, this.username, + this.alignment = MainAxisAlignment.start, + this.reversed = false, this.showTimestamp = true, this.onAuthorTap, this.displayNameKey, @@ -91,37 +99,39 @@ class MessageAuthorMeta extends StatelessWidget { ? constraints.maxWidth / (showUsername ? 3 : 2) : double.infinity; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible(child: authorName), - if (showUsername) ...[ - const SizedBox(width: Grid.half), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - normalizedUsername, - key: usernameKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedMetadataStyle, - ), + final children = [ + Flexible(child: authorName), + if (showUsername) ...[ + const SizedBox(width: Grid.half), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + normalizedUsername, + key: usernameKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedMetadataStyle, ), - ], - if (showTimestamp) ...[ - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - timestamp, - key: timestampKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedTimestampStyle, - ), - ), - ], + ), ], + if (showTimestamp) ...[ + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + timestamp, + key: timestampKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedTimestampStyle, + ), + ), + ], + ]; + return Row( + mainAxisAlignment: alignment, + crossAxisAlignment: CrossAxisAlignment.center, + children: reversed ? children.reversed.toList() : children, ); }, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4f212b3952..d9f7585e0c 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5679,7 +5679,7 @@ void main() { for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { for (final multiline in [false, true]) { testWidgets( - 'group avatar stays fixed throughout a local send: $platform, multiline=$multiline', + 'outgoing group stays avatar-free throughout a local send: $platform, multiline=$multiline', (tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = platform; @@ -5710,7 +5710,7 @@ void main() { of: find.byKey(ValueKey('message-row-$id')), matching: find.byType(AvatarImage), ); - final before = tester.getRect(avatar('first')); + expect(avatar('first'), findsNothing); final container = ProviderScope.containerOf( tester.element(find.byType(ChannelDetailPage)), ); @@ -5730,19 +5730,19 @@ void main() { ]); for (var frame = 0; frame < 32; frame++) { await tester.pump(const Duration(milliseconds: 16)); - final visibleAvatar = avatar('second').evaluate().isNotEmpty - ? avatar('second') - : avatar('first'); - expect(visibleAvatar, findsOneWidget); - final current = tester.getRect(visibleAvatar); expect( - current.top, - closeTo(before.top, 1), - reason: 'avatar frame $frame', + avatar('first'), + findsNothing, + reason: 'first avatar frame $frame', + ); + expect( + avatar('second'), + findsNothing, + reason: 'second avatar frame $frame', ); } await tester.pumpAndSettle(); - expect(avatar('second'), findsOneWidget); + expect(avatar('second'), findsNothing); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } @@ -6508,6 +6508,51 @@ void main() { }, ); + testWidgets('own ended huddle reclaims avatar space and aligns reactions', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'alice', + messages: [ + _huddleMsg(id: 'own-huddle', kind: EventKind.huddleStarted), + _huddleMsg( + id: 'own-ending', + kind: EventKind.huddleEnded, + createdAt: 1500, + ), + _reaction(id: 'own-huddle-reaction', targetId: 'own-huddle'), + ], + ), + ); + await tester.pumpAndSettle(); + final card = tester.getRect( + find.byKey(const ValueKey('huddle-card-own-huddle')), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('huddle-activity-own-huddle')), + matching: find.byType(AvatarImage), + ), + findsNothing, + ); + expect(find.text('Huddle ended'), findsOneWidget); + expect( + card.right, + closeTo( + tester + .getRect(find.byKey(const ValueKey('huddle-activity-own-huddle'))) + .right, + 0.01, + ), + ); + final tray = tester.getRect( + find.byKey(const ValueKey('combined-reaction-tray')), + ); + expect(tray.right, lessThan(card.right)); + expect(tray.right, greaterThan(card.center.dx)); + }); + testWidgets('renders a compact huddle activity row', (tester) async { await tester.pumpWidget( _buildTestable( diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index 454cb733f5..5af1a5d680 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -1,6 +1,222 @@ part of '../channel_detail_page_test.dart'; void bubbleLayoutTests() { + for (final author in ['self', 'alice']) { + testWidgets('date divider owns equal space around $author messages', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [ + _textMsg( + id: 'before-day', + pubkey: author, + content: 'Before', + createdAt: 1000, + ), + _textMsg( + id: 'after-day', + pubkey: author, + content: 'After', + createdAt: 87400, + ), + ], + ), + ); + await tester.pumpAndSettle(); + final before = tester.getRect( + find + .descendant( + of: find.byKey(const ValueKey('message-row-before-day')), + matching: find.byType(LocalSendBubbleSurface), + ) + .first, + ); + final divider = tester.getRect( + find.byKey(const ValueKey('channel-day-divider-opacity-87400')), + ); + final after = tester.getRect( + find.byKey(const ValueKey('message-author-after-day')), + ); + expect( + divider.top - before.bottom, + closeTo(after.top - divider.bottom, 1), + ); + expect(divider.top - before.bottom, closeTo(Grid.gutter, 1)); + expect(tester.takeException(), isNull); + }); + } + + for (final personalChat in [false, true]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'viewer bubbles align right: personal=$personalChat scale=$scale', + (tester) async { + tester.view.physicalSize = const Size(390, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + personalChat: personalChat, + textScaler: TextScaler.linear(scale), + users: const { + 'self': UserProfile(pubkey: 'self', displayName: 'Me'), + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + messages: [ + _textMsg( + id: 'incoming', + pubkey: 'alice', + content: 'Hello', + createdAt: 1000, + ), + _textMsg( + id: 'outgoing', + pubkey: 'self', + content: 'Hi', + createdAt: 1001, + ), + _reaction( + id: 'reaction-in', + targetId: 'incoming', + pubkey: 'self', + ), + _reaction( + id: 'reaction-out', + targetId: 'outgoing', + pubkey: 'alice', + ), + ], + ), + ); + await tester.pumpAndSettle(); + Rect bounds(String id, Finder child) => tester.getRect( + find + .descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: child, + ) + .first, + ); + final incoming = bounds( + 'incoming', + find.byType(LocalSendBubbleSurface), + ); + final outgoing = bounds( + 'outgoing', + find.byType(LocalSendBubbleSurface), + ); + expect(outgoing.center.dx, greaterThan(incoming.center.dx)); + expect( + bounds( + 'outgoing', + find.byKey(const ValueKey('message-author-outgoing')), + ).right, + lessThan( + bounds( + 'outgoing', + find.byKey(const ValueKey('message-timestamp-outgoing')), + ).left, + ), + ); + expect( + bounds( + 'incoming', + find.byKey(const ValueKey('message-author-incoming')), + ).right, + lessThan( + bounds( + 'incoming', + find.byKey(const ValueKey('message-timestamp-incoming')), + ).left, + ), + ); + + expect(outgoing.right, greaterThan(300)); + expect(incoming.left, lessThan(90)); + final incomingAvatar = bounds( + 'incoming', + find.byType(MessageMediaAvatar), + ); + expect(incomingAvatar.right, lessThan(incoming.left)); + expect( + find.descendant( + of: find.byKey(const ValueKey('message-row-outgoing')), + matching: find.byType(MessageMediaAvatar), + ), + findsNothing, + ); + expect( + outgoing.right, + closeTo( + tester + .getRect(find.byKey(const ValueKey('message-row-outgoing'))) + .right, + 0.01, + ), + ); + final incomingReaction = bounds( + 'incoming', + find.byKey(const ValueKey('combined-reaction-tray')), + ); + final outgoingReaction = bounds( + 'outgoing', + find.byKey(const ValueKey('combined-reaction-tray')), + ); + expect( + outgoingReaction.center.dx, + greaterThan(incomingReaction.center.dx), + ); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('thread replies reclaim the outgoing avatar gutter', ( + tester, + ) async { + final root = _textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Root'); + final reply = _textMsg( + id: threadIdR1, + pubkey: 'self', + content: 'My reply', + createdAt: 1100, + extraTags: const [ + ['e', threadIdRoot, '', 'reply'], + ], + ); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [root, reply], + threadReplies: { + threadIdRoot: [reply], + }, + initialThreadRootId: threadIdRoot, + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('thread-message-row-$threadIdR1')); + final bubble = tester.getRect( + find + .descendant(of: row, matching: find.byType(LocalSendBubbleSurface)) + .first, + ); + expect( + find.descendant(of: row, matching: find.byType(MessageMediaAvatar)), + findsNothing, + ); + expect(bubble.right, closeTo(tester.getRect(row).right, 0.01)); + expect(tester.takeException(), isNull); + }); + testWidgets('large activity members build and fetch only visible rows', ( tester, ) async { diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 902ecad2e1..fcd67f123c 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -248,6 +248,98 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + for (final label in ['Channels options', 'Design options']) { + testWidgets('$label exposes a labeled button and tap action', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection( + id: 'section-1', + name: 'Design', + icon: '🎨', + order: 0, + ), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final control = find.bySemanticsLabel(label); + expect(control, findsOneWidget); + final node = tester.getSemantics(control); + expect(node.flagsCollection.isButton, isTrue); + expect( + node.getSemanticsData().hasAction(ui.SemanticsAction.tap), + isTrue, + ); + node.owner!.performAction(node.id, ui.SemanticsAction.tap); + await tester.pumpAndSettle(); + expect( + find.byKey( + ValueKey( + label == 'Channels options' + ? 'sort-popover-Channels' + : 'section-popover-section-1', + ), + ), + findsOneWidget, + ); + } finally { + semantics.dispose(); + } + }); + } + + testWidgets('section ellipsis aligns with temporary channel icon', ( + tester, + ) async { + final temporary = Channel( + id: 'temporary', + name: 'temporary', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ttlSeconds: 3600, + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier([temporary])), + ], + ), + ); + await tester.pumpAndSettle(); + final ellipsis = find.descendant( + of: find.byTooltip('Channels options'), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ); + expect( + tester.getCenter(ellipsis).dx, + closeTo( + tester + .getCenter(find.byKey(const Key('channel-ephemeral-temporary'))) + .dx, + 0.01, + ), + ); + }); + testWidgets('shows grouped channel list when data loads', (tester) async { // Valid fixture keys whose npub encodings were verified against the // NIP-19 codec independently of the code under test. @@ -325,6 +417,17 @@ void main() { findsOneWidget, ); + expect( + tester + .getTopRight( + find.descendant( + of: find.byTooltip('Channels options'), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ), + ) + .dx, + closeTo(tester.getTopRight(find.byTooltip('Channels options')).dx, 0.01), + ); await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); expect(find.text('Sort: Recent'), findsOneWidget); @@ -986,6 +1089,20 @@ void main() { AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.07), ); } + expect( + tester + .getTopRight( + find.descendant( + of: find.byKey(const Key('section-menu-section-1')), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ), + ) + .dx, + closeTo( + tester.getTopRight(find.byKey(const Key('section-menu-section-1'))).dx, + 0.01, + ), + ); await tester.tap(find.byKey(const Key('section-menu-section-1'))); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/day_divider_test.dart b/mobile/test/features/channels/day_divider_test.dart index 23b3905268..240f88ee3e 100644 --- a/mobile/test/features/channels/day_divider_test.dart +++ b/mobile/test/features/channels/day_divider_test.dart @@ -4,6 +4,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + for (final dark in [false, true]) { + testWidgets('inline calendar shows local day before label, dark=$dark', ( + tester, + ) async { + final date = DateTime(2026, 10, 18, 12); + final timestamp = date.millisecondsSinceEpoch ~/ 1000; + await tester.pumpWidget( + MaterialApp( + theme: dark ? AppTheme.dark() : AppTheme.light(), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: SizedBox( + width: 320, + child: DayDivider( + label: 'October 18, 2026', + dayTimestamp: timestamp, + ), + ), + ), + ), + ), + ); + final tile = find.byKey(ValueKey('day-calendar-$timestamp')); + expect( + find.descendant(of: tile, matching: find.text('18')), + findsOneWidget, + ); + expect( + tester.getRect(tile).right, + lessThan(tester.getRect(find.text('October 18, 2026')).left), + ); + expect(find.byType(Divider), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + } + + for (final label in ['Today', 'October 18, 2026']) { + testWidgets('date group centered with full-width rules: $label', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + width: 390, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: DayDivider(label: label, dayTimestamp: 1000), + ), + ), + ), + ), + ); + final left = tester.getRect(find.byType(Divider).first); + final right = tester.getRect(find.byType(Divider).last); + final icon = tester.getRect( + find.byKey(const ValueKey('day-calendar-1000')), + ); + final text = tester.getRect(find.text(label)); + expect(left.left, closeTo(Grid.gutter, 0.01)); + expect(right.right, closeTo(390 - Grid.gutter, 0.01)); + expect(left.width, closeTo(right.width, 0.01)); + expect((icon.left + text.right) / 2, closeTo(195, 0.01)); + expect(tester.takeException(), isNull); + }); + } + testWidgets('fades the in-flow date while that day is sticky', ( tester, ) async { diff --git a/mobile/test/features/channels/message_carousel_preview_cases.dart b/mobile/test/features/channels/message_carousel_preview_cases.dart new file mode 100644 index 0000000000..1dff030bf3 --- /dev/null +++ b/mobile/test/features/channels/message_carousel_preview_cases.dart @@ -0,0 +1,174 @@ +part of 'message_content_test.dart'; + +void carouselPreviewTests() { + testWidgets( + 'iOS prepares five thumbnails before scrolling with two preload requests', + (tester) async { + final started = []; + + final responses = >{}; + final urls = List.generate( + 10, + (i) => 'https://example.com/preload-$i.png', + ); + final messenger = tester.binding.defaultBinaryMessenger; + const connectivity = MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + messenger.setMockMethodCallHandler(connectivity, (_) async => null); + addTearDown(() => messenger.setMockMethodCallHandler(connectivity, null)); + final channels = []; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (_) async => null); + } + return null; + }); + final client = MockClient((request) { + final url = request.url.toString(); + started.add(url); + final response = responses[url] = Completer(); + + return response.future; + }); + Future waitForCount(int count) async { + for ( + var attempt = 0; + attempt < 100 && started.length < count; + attempt++ + ) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + expect(started.length, count); + } + + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', + ); + void release(String url) { + final response = responses[url]!; + if (!response.isCompleted) { + response.complete(http.Response.bytes(png, 200)); + } + } + + addTearDown(() async { + for (final response in responses.values) { + if (!response.isCompleted) response.complete(http.Response('', 404)); + } + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + + client.close(); + }); + await tester.pumpWidget( + _testable( + MessageContent( + content: urls.map((url) => '![image]($url)').join('\n'), + ), + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://example.com', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + ), + ); + await tester.pump(); + { + await waitForCount(2); + expect(started, hasLength(2)); + for (var i = 0; i < 3; i++) { + release(started[i]); + await waitForCount(i + 3); + expect(started, hasLength(i + 3)); + } + release(started[3]); + release(started[4]); + } + await tester.pumpAndSettle(); + expect(started.toSet(), urls.take(5).toSet()); + // No native subscribeImage or page callback was sent by this harness. + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'outgoing gallery starts at the right and mirrors its spacing', + (tester) async { + final messenger = tester.binding.defaultBinaryMessenger; + final channels = []; + final configs = >[]; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'configure') { + configs.add(Map.from(call.arguments as Map)); + } + return null; + }); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + }); + final counts = []; + final widths = []; + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: MessageContent( + key: ValueKey(own), + ownMessage: own, + bubble: true, + mediaCarouselTrailingOverflow: Grid.gutter, + content: + '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', + ), + ), + ), + ); + await tester.pumpAndSettle(); + counts.add( + tester.getRect( + find.byKey(const ValueKey('message-media-carousel-count')), + ), + ); + widths.add( + tester + .getSize(find.byKey(const ValueKey('message-media-carousel'))) + .width, + ); + expect(configs.last['rtl'], own); + } + expect(widths[0], widths[1]); + expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 6f28932c16..3715f8cf97 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -37,6 +37,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; part 'message_content_bubble_cases.dart'; +part 'message_carousel_preview_cases.dart'; Widget _testable( Widget child, { @@ -379,6 +380,7 @@ class _TestChannelsNotifier extends ChannelsNotifier { } void main() { + carouselPreviewTests(); bubbleContentTests(); test('wide voice-note waveforms distribute bars across their full width', () { const width = 320.0;