diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f1b362f58a..c542652094 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -2,6 +2,7 @@ import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; import 'message_footer_layout.dart'; +import 'reply_summary_link.dart'; import 'dart:async'; import 'dart:math' show cos, max, min, pi; import 'dart:ui' show ImageFilter, lerpDouble; 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 6597ce04e9..661e1525fc 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -167,9 +167,7 @@ class _MessageBubble extends HookConsumerWidget { ), child: MessageFooterLayout( animateSize: true, - overlap: message.reactions.isNotEmpty || replyFooter != null - ? Grid.xs - : 0, + overlap: message.reactions.isNotEmpty ? Grid.xs : 0, footer: message.reactions.isNotEmpty || replyFooter != null ? Padding( padding: const EdgeInsets.only( @@ -178,10 +176,9 @@ class _MessageBubble extends HookConsumerWidget { messageAvatarContentGap + Grid.xxs, ), - child: Wrap( - spacing: Grid.xxs, - runSpacing: Grid.half, - crossAxisAlignment: WrapCrossAlignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (message.reactions.isNotEmpty) ReactionRow( 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 ffaeffeade..1f5db4ee18 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -611,9 +611,10 @@ class _ThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final participants = summary.participantPubkeys.take(3).toList(); - final userCache = ref.watch(userCacheProvider); - return GestureDetector( + return ReplySummaryLink( + key: ValueKey('thread-summary-${message.id}'), + summary: summary, + hasReactions: message.reactions.isNotEmpty, onTap: () { Navigator.of(context).push( MaterialPageRoute( @@ -628,61 +629,6 @@ class _ThreadSummaryRow extends ConsumerWidget { ), ); }, - child: Semantics( - button: true, - child: Container( - key: ValueKey('thread-summary-${message.id}'), - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs + Grid.quarter, - vertical: Grid.half, - ), - decoration: BoxDecoration( - color: context.colors.secondaryContainer, - borderRadius: BorderRadius.circular(Radii.container), - border: Border.all(color: context.colors.surface, width: 2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (participants.isNotEmpty) ...[ - ExcludeSemantics( - child: SizedBox( - width: - Grid.gutter + (participants.length - 1) * Grid.twelve, - height: Grid.gutter, - child: Stack( - children: [ - for (var i = 0; i < participants.length; i++) - Positioned( - left: i * Grid.twelve, - child: SmallAvatar( - pubkey: participants[i], - userCache: userCache, - size: Grid.gutter, - ), - ), - ], - ), - ), - ), - const SizedBox(width: Grid.half), - ], - Flexible( - child: Text( - summary.isCountPending - ? 'Replies' - : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurface, - ), - ), - ), - ], - ), - ), - ), ); } } diff --git a/mobile/lib/features/channels/reply_summary_link.dart b/mobile/lib/features/channels/reply_summary_link.dart new file mode 100644 index 0000000000..c3cb376827 --- /dev/null +++ b/mobile/lib/features/channels/reply_summary_link.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/theme/theme.dart'; +import 'small_avatar.dart'; +import 'timeline_message.dart'; + +/// Desktop-style reply link with a curved branch below the message bubble. +class ReplySummaryLink extends ConsumerWidget { + const ReplySummaryLink({ + super.key, + required this.summary, + required this.onTap, + this.hasReactions = false, + }); + + final ThreadSummary summary; + final VoidCallback onTap; + final bool hasReactions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final participants = summary.participantPubkeys.take(3).toList(); + final userCache = ref.watch(userCacheProvider); + final label = summary.isCountPending + ? 'Replies' + : '${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), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + top: connectorTop, + child: IgnorePointer( + child: CustomPaint( + painter: _ReplyConnector( + context.colors.outlineVariant, + Directionality.of(context), + connectorTop, + ), + ), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(start: Grid.twelve), + child: Semantics( + button: true, + label: 'View thread: $label', + onTap: onTap, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(Radii.container), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.all(Grid.half), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (participants.isNotEmpty) ...[ + SizedBox( + width: + Grid.gutter + + (participants.length - 1) * Grid.twelve, + height: Grid.gutter, + child: Stack( + children: [ + for (var i = 0; i < participants.length; i++) + PositionedDirectional( + start: i * Grid.twelve, + child: SmallAvatar( + pubkey: participants[i], + userCache: userCache, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.half), + ], + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ReplyConnector extends CustomPainter { + const _ReplyConnector(this.color, this.direction, this.topOffset); + final Color color; + final TextDirection direction; + final double topOffset; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + if (direction == TextDirection.rtl) { + canvas.translate(size.width, 0); + canvas.scale(-1, 1); + } + // Keep the endpoint aligned with the unchanged reply avatars. + final middle = (size.height - topOffset) / 2; + // Desktop uses a 12px outer corner with a 2px border: the stroke's + // centerline follows an 11px circular arc, rather than a tight elbow. + const radius = Grid.twelve - 1; + final path = Path() + ..moveTo(1, 0) + ..lineTo(1, middle - radius) + ..arcToPoint( + Offset(Grid.twelve, middle), + radius: const Radius.circular(radius), + clockwise: false, + ); + canvas.drawPath( + path, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(_ReplyConnector oldDelegate) => + color != oldDelegate.color || + direction != oldDelegate.direction || + topOffset != oldDelegate.topOffset; +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 7890cd9622..0541f97229 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,5 +1,6 @@ import '../../shared/widgets/ios_scroll_to_top.dart'; import 'message_footer_layout.dart'; +import 'reply_summary_link.dart'; import 'dart:async'; import 'dart:math' as math; @@ -56,7 +57,6 @@ import 'message_media_avatar.dart'; import 'reaction_row.dart'; import '../../shared/read_state/read_state_provider.dart'; import 'send_message_provider.dart'; -import 'small_avatar.dart'; import 'sticky_date_header.dart'; import 'reading_marks.dart'; import 'timeline_message.dart'; 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 71b80a5cfe..6adf6e1ab6 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,91 +23,29 @@ class _NestedThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - - return GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: replyMessage, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - child: Padding( + return Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap + Grid.xxs, + bottom: Grid.xxs, + ), + child: ReplySummaryLink( key: ValueKey('nested-thread-summary-${replyMessage.id}'), - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Stacked participant avatars. - SizedBox( - width: - 32.0 + - (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, - height: 32, - child: Stack( - children: [ - for (var i = 0; i < summary.participantPubkeys.length; i++) - Positioned( - left: i * 20.0, - child: SmallAvatar( - pubkey: summary.participantPubkeys[i], - userCache: userCache, - size: 32, - ), - ), - ], + 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, ), ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: summary.isCountPending - ? 'Replies' - : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.primary, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' ยท ', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - ), - TextSpan( - text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + ); + }, ), ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 402ecf2f7f..8819553c15 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4253,7 +4253,7 @@ void main() { }, ); - testWidgets('attaches replies after reactions with an eight pixel gap', ( + testWidgets('places connected reply links below the reaction tray', ( tester, ) async { await tester.pumpWidget( @@ -4300,15 +4300,15 @@ void main() { } final pill = find.byKey(const ValueKey('thread-summary-$threadIdRoot')); final tray = find.byKey(const ValueKey('combined-reaction-tray')); - expect(tester.getTopLeft(pill).dx - tester.getTopRight(tray).dx, 8); expect( - tester.getCenter(pill).dy, - closeTo(tester.getCenter(tray).dy, 0.1), + tester.getTopLeft(pill).dy, + greaterThanOrEqualTo(tester.getBottomLeft(tray).dy), ); + expect(tester.getSize(pill).height, greaterThanOrEqualTo(44)); final body = find.text('Thread head', findRichText: true); expect( tester.getTopLeft(pill).dy, - lessThan(tester.getBottomLeft(body).dy + 24), + lessThan(tester.getBottomLeft(body).dy + 64), ); await tester.tap(pill); await tester.pumpAndSettle();