mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:  Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
149
mobile/lib/features/channels/reply_summary_link.dart
Normal file
149
mobile/lib/features/channels/reply_summary_link.dart
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user