Match mobile reply links to desktop connectors (#8362)

## Summary
Reply summaries now appear below mobile message bubbles as inset
avatar/count links with the same rounded connector used on desktop.
Reaction trays keep their overlap with the bubble; connector clearance
adjusts when reactions are present. Channel and nested reply summaries
share the same 20px avatar stack, capped at three, with a 44px minimum
tap target and accessible thread-opening label.

### Related issue
Follows merged #8359 and now targets `main`. This diff contains only the
reply-link changes. No matching duplicate was found in the open issue/PR
search for reply connectors.

### Testing
- Full mobile formatting, analysis, and tests passed before stacking:
3,348 tests plus 3 push-configuration tests, 4 skips.
- Normal pre-push checks passed on the rebased head against main:
formatting, analysis, full mobile tests, and repository file-size
checks.
- Before stacking, installed and launched the preview on Pixel 10 and
iPhone Air with existing app data. Ten profile/community/draft
preference values remained unchanged on each. Verified both connector
spacing variants and thread opening on Pixel; the owner tried and
approved the preview. iPhone screenshot verification was unavailable.
- The rebased build has not been reinstalled on devices. Device evidence
above is from the approved preview before rebasing.

Before: channel replies were filled pills beside reactions; nested
replies used a separate larger avatar row. After, as verified in the
device preview:

![Pixel preview showing reply connectors with and without
reactions](https://buzz.block.builderlab.xyz/media/119f0fbd358895ef3fe5e6c1bc4f2c399c660b1debded5efff08864ff796792c.png)

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-15 13:08:41 -04:00
committed by GitHub
parent 42b395cdef
commit 9296660d1e
7 changed files with 184 additions and 153 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<void>(
@@ -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,
),
),
),
],
),
),
),
);
}
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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<void>(
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<void>(
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,
),
),
],
),
);
},
),
);
}

View File

@@ -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();