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;