Files
buzz/mobile/lib/features/channels/reply_summary_link.dart
klopez4212 3f03aa0450 fix(mobile): restore ordinary DM behavior for Bestie (#8987)
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>
2026-11-03 18:16:14 +00:00

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