mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Mobile Bestie used a special flat timeline that hid reply threads. It now opens the normal DM screen, matching desktop’s pinned Bestie (block/buzz-app#1935), with existing threads, reply actions, and top-level sending in the same conversation. Thread-scoped agent typing in DMs stays beneath its originating message. Working avatars and mobile’s bouncing dots share a compact neutral container, matching desktop’s subtle activity control. The elbow is visible from the start, with the working container already at the final thread-footer position. For a single-agent reply, the same avatar stays mounted while the container and dots fade out over 200 ms, the avatar slides into its summary position, and the reply count fades/scales in from 95%. The elbow stays fixed. Reduced motion switches immediately. The avatar-to-dots gap is 4 points. Reply text uses its natural line height so its glyphs center on the avatar and elbow rather than drawing at the top of the footer. Multi-participant summaries retain their grouped layout. The accessible View thread action, reaction placement, and final reply-footer position are preserved. Validation: - The final commit (`c6fef5d80`) passed full analysis, 4,220 Flutter tests (40 skipped), 4 push-configuration tests, and 27 crypto tests. - Container alignment checks pass in both directions, including avatar continuity, intermediate fade and final geometry, reduced motion, and thread actions. Glyph-box alignment is checked against the avatar center. An additional regression covers a reply arriving before typing stops and work restarting mid-transition. - Updated DM journeys cover opening Bestie, existing threads, threaded and top-level sending, swipe-to-reply, and absence of duplicate main-row thread typing. - Installed the final product source (`c6fef5d80`) in place on Pixel 10 and iPhone Air previews. The iPhone uses a release-mode preview so it launches from the home screen; saved Kenny Lopez / Starry Night conversation verified after install. Pixel retains Testing Agent Sessions. The pending/midpoint/completed handoff was rendered in widget checks, and the corrected reply-label alignment was verified on the Pixel against the same messages before and after; human acceptance of this final animation remains pending. - Mandatory push hooks passed, including file-size and branch checks. A rendered widget preview verifies the container and elbow; live acceptance remains pending. Self-review found no blockers in the changed paths. Review status is preserved. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
262 lines
10 KiB
Dart
262 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import '../../shared/profile/user_profile.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import 'small_avatar.dart';
|
|
import 'reply_activity_handoff.dart';
|
|
import 'timeline_message.dart';
|
|
import 'typing_dots.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,
|
|
this.mirrored = false,
|
|
this.working = const [],
|
|
this.workingNames = const [],
|
|
});
|
|
|
|
/// Null while agents work on a message that has no replies yet.
|
|
final ThreadSummary? summary;
|
|
final VoidCallback onTap;
|
|
final bool hasReactions;
|
|
|
|
/// Agents working in this thread, shown with typing dots, as on desktop.
|
|
final List<String> working;
|
|
|
|
/// Display names for [working], in the same order, for the spoken label.
|
|
final List<String> workingNames;
|
|
|
|
/// Places the connector on the outgoing edge without reversing its contents.
|
|
final bool mirrored;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final summary = this.summary;
|
|
final workingKeys = {for (final pubkey in working) pubkey.toLowerCase()};
|
|
// As on desktop, working agents leave the participant stack for the
|
|
// working group: at most two avatars, then a count.
|
|
final participants = [
|
|
for (final pubkey in summary?.participantPubkeys ?? const <String>[])
|
|
if (!workingKeys.contains(pubkey.toLowerCase())) pubkey,
|
|
].take(3).toList();
|
|
final shownWorking = workingKeys.take(2).toList();
|
|
final hiddenWorking = workingKeys.length - shownWorking.length;
|
|
final userCache = ref.watch(userCacheProvider);
|
|
final label = summary == null
|
|
? 'View thread'
|
|
: summary.isCountPending
|
|
? 'Replies'
|
|
: '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}';
|
|
// Desktop's spoken strings, in MessageRow's thread summary.
|
|
final spoken = switch (workingNames) {
|
|
[] => summary == null ? '' : label,
|
|
_ when summary != null => '$label. ${workingNames.join(', ')} working',
|
|
[final only] => '$only is working',
|
|
_ => '${workingNames.length} agents are working',
|
|
};
|
|
final identities = {...participants, ...workingKeys};
|
|
final singleAgent = identities.length == 1;
|
|
final connectorTop = hasReactions ? -Grid.half : Grid.half;
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: mirrored ? 0 : Grid.xxs * 2,
|
|
right: mirrored ? Grid.xxs * 2 : 0,
|
|
),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Working replies already belong to this thread.
|
|
Positioned.fill(
|
|
top: connectorTop,
|
|
child: IgnorePointer(
|
|
child: CustomPaint(
|
|
painter: _ReplyConnector(
|
|
context.colors.outlineVariant,
|
|
mirrored ? TextDirection.rtl : TextDirection.ltr,
|
|
connectorTop,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left: mirrored ? 0 : Grid.twelve,
|
|
right: mirrored ? Grid.twelve : 0,
|
|
),
|
|
child: Semantics(
|
|
button: true,
|
|
label: 'View thread: $spoken',
|
|
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: singleAgent
|
|
? ReplyActivityHandoff(
|
|
key: ValueKey(identities.single),
|
|
pubkey: identities.single,
|
|
userCache: userCache,
|
|
working: workingKeys.isNotEmpty,
|
|
label: summary == null ? null : label,
|
|
)
|
|
: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (participants.isNotEmpty) ...[
|
|
_AvatarStack(participants, userCache),
|
|
const SizedBox(width: Grid.half),
|
|
],
|
|
if (shownWorking.isNotEmpty) ...[
|
|
Container(
|
|
key: const ValueKey(
|
|
'thread-working-container',
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(
|
|
6,
|
|
4,
|
|
8,
|
|
4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.controlSurface,
|
|
borderRadius: BorderRadius.circular(
|
|
Grid.twelve,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Working agents sit side by side, as on desktop.
|
|
for (final (i, pubkey)
|
|
in shownWorking.indexed) ...[
|
|
if (i > 0)
|
|
const SizedBox(width: Grid.xxs),
|
|
SmallAvatar(
|
|
pubkey: pubkey,
|
|
userCache: userCache,
|
|
agent: true,
|
|
),
|
|
],
|
|
if (hiddenWorking > 0) ...[
|
|
const SizedBox(width: Grid.xxs),
|
|
Text(
|
|
'+$hiddenWorking',
|
|
style: context.textTheme.labelMedium
|
|
?.copyWith(
|
|
color: context
|
|
.colors
|
|
.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(width: Grid.half),
|
|
const TypingDots(
|
|
key: ValueKey('thread-working-dots'),
|
|
dotSize: 5,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (summary != null)
|
|
const SizedBox(width: Grid.half),
|
|
],
|
|
if (summary != null)
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.labelMedium
|
|
?.copyWith(
|
|
color:
|
|
context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AvatarStack extends StatelessWidget {
|
|
const _AvatarStack(this.pubkeys, this.userCache);
|
|
final List<String> pubkeys;
|
|
final Map<String, UserProfile> userCache;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SizedBox(
|
|
width: Grid.gutter + (pubkeys.length - 1) * Grid.twelve,
|
|
height: Grid.gutter,
|
|
child: Stack(
|
|
children: [
|
|
for (var i = 0; i < pubkeys.length; i++)
|
|
PositionedDirectional(
|
|
start: i * Grid.twelve,
|
|
child: SmallAvatar(pubkey: pubkeys[i], userCache: userCache),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|