Files
buzz/mobile/lib/features/channels/message_swipe_reply.dart
Tom Brow ad5699d05a fix(mobile): start no threads in Solo chats (#9123)
Agents in a Solo chat always answer at the chat's top level. On mobile
you can still start a thread in Solo (Reply action, swipe-to-reply,
media reply) or reply inside an existing one, so the agent's answer
lands outside the thread you wrote in. Desktop already avoids this: Solo
shows existing threads but starts none and has no thread composer.

This matches desktop on mobile:

- No Reply quick action, swipe-to-reply or media reply in Solo chats.
- Existing Solo threads still open from their reply summary, read-only
(no composer).
- Every other channel is unchanged.

### Demo

Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right
swipe on an agent message, then long-press for the actions.

Before (main): the swipe shows the reply circle and opens a thread with
a composer; Reply is in the actions.


https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906

After (this branch): the swipe does nothing; the actions have no Reply.


https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e

### Validation

Added tests: a Solo chat's message neither swipes to reply nor offers
Reply in its actions, and a Solo thread opens without a composer.
Existing swipe-reply and Solo channel tests pass, along with the full
mobile suite and analyzer.

Simulator run on an iPhone 17 Pro, signed in to a real community: in an
existing Solo chat, a right swipe on an agent message no longer moves or
opens a thread, and the long-press actions show no Reply (Copy link,
Remind me and the rest are unchanged). Team-channel Reply is covered by
the existing swipe-reply tests, not this run.

A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat,
compared main and this branch: on main the swipe opens a thread with a
composer and the actions list Reply; on this branch neither does (see
Demo). The Demo's "Follow thread" action still appears in a Solo chat's
actions on both builds; this PR does not change it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:58:53 -08:00

288 lines
12 KiB
Dart

import 'dart:async';
import 'dart:math' show min, sin, pi;
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/buzz_icons.dart';
import '../../shared/theme/theme.dart';
import 'channels_provider.dart';
import 'thread_detail_page.dart';
import 'message_reply_drag_target.dart';
import 'timeline_message.dart';
/// Reveals the existing reply action as a message is dragged to the left.
///
/// Leftward keeps rightward drags free for the iOS swipe-back gesture.
class MessageSwipeReply extends HookConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final Widget child;
/// The visible content, excluding author metadata and reply/reaction footers.
final GlobalKey contentKey;
const MessageSwipeReply({
super.key,
required this.message,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.child,
required this.contentKey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offset = useAnimationController(upperBound: double.infinity);
final pulse = useAnimationController(
duration: const Duration(milliseconds: 180),
);
final dragDistance = useRef(0.0);
final layoutKey = useMemoized(GlobalKey.new);
final contentCenter = useState<double?>(null);
final feedbackLeft = useState(0.0);
final dragTarget = useRef<Rect?>(null);
final didHaptic = useRef(false);
final cancelled = useRef(false);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final messages = allMessages;
final startsThreads = ref.watch(channelStartsThreadsProvider(channelId));
if (messages == null || message.isSystem || !startsThreads) return child;
void reset() {
pulse.stop();
pulse.value = 0;
if (reducedMotion) {
offset.value = 0;
} else {
unawaited(
offset.animateWith(
ScrollSpringSimulation(
const SpringDescription(mass: 1, stiffness: 500, damping: 36),
offset.value,
0,
0,
),
),
);
}
}
// Flutter ends an accepted drag on PointerCancel too. Preserve the raw
// cancellation signal so an interrupted gesture cannot open a reply.
return Listener(
onPointerCancel: (_) => cancelled.value = true,
child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
gestures: {
_MessageReplyDragRecognizer:
GestureRecognizerFactoryWithHandlers<_MessageReplyDragRecognizer>(
_MessageReplyDragRecognizer.new,
(recognizer) => recognizer
..canStart = (position) {
final bounds = messageReplyDragTargetBounds(
contentKey,
position,
);
dragTarget.value = bounds?.shift(Offset(offset.value, 0));
return bounds != null;
}
..onStart = (_) {
final bounds = dragTarget.value;
final layoutBox = layoutKey.currentContext
?.findRenderObject();
if (bounds != null && layoutBox is RenderBox) {
// Sit just past the touched bubble's trailing edge, but
// stay on screen beside bubbles that reach the row's end;
// the bubble covers the feedback until it slides away.
final trailing = layoutBox
.globalToLocal(bounds.topRight)
.dx;
feedbackLeft.value = min(
trailing + Grid.xxs,
layoutBox.size.width - Grid.lg - Grid.xxs,
);
contentCenter.value = layoutBox
.globalToLocal(bounds.center)
.dy;
}
offset.stop();
pulse.stop();
pulse.value = 0;
offset.value = 0;
dragDistance.value = 0;
didHaptic.value = false;
cancelled.value = false;
}
..onUpdate = (details) {
dragDistance.value = (dragDistance.value - details.delta.dx)
.clamp(0.0, double.infinity);
// Keep pulling beyond readiness, with increasing rubber-band resistance.
final excess = dragDistance.value - 72;
offset.value = excess <= 0
? dragDistance.value
: 72 + 40 * excess / (excess + 80);
if (offset.value >= 72 && !didHaptic.value) {
didHaptic.value = true;
unawaited(HapticFeedback.mediumImpact());
if (!reducedMotion) {
unawaited(pulse.forward(from: 0));
}
}
if (offset.value < 72) {
didHaptic.value = false;
pulse.stop();
pulse.value = 0;
}
}
..onCancel = reset
..onEnd = (_) {
final reply = !cancelled.value && offset.value >= 72;
reset();
if (!reply) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
},
child: AnimatedBuilder(
animation: Listenable.merge([offset, pulse]),
child: child,
builder: (context, child) {
final progress = (offset.value / 72).clamp(0.0, 1.0);
// Follows the chosen message color, like the user's own bubbles.
final chatColors = ChatColors.of(context);
final accent = chatColors.outgoing;
final filledForeground = chatColors.foreground;
return Stack(
key: layoutKey,
clipBehavior: Clip.none,
alignment: Alignment.centerLeft,
children: [
if (progress > 0)
Positioned(
left: feedbackLeft.value,
top: contentCenter.value ?? 0,
width: Grid.lg,
child: FractionalTranslation(
translation: const Offset(0, -0.5),
child: ExcludeSemantics(
child: Opacity(
opacity: progress,
child: Transform.scale(
scale: reducedMotion
? 1
: (0.5 + 0.5 * progress) *
(1 + 0.12 * sin(pi * pulse.value)),
alignment: Alignment.center,
child: SizedBox(
key: const ValueKey('swipe-reply-feedback'),
width: Grid.lg,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: Grid.lg,
child: DecoratedBox(
key: const ValueKey('swipe-reply-circle'),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: progress == 1
? accent
: Colors.transparent,
),
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 2,
strokeAlign: -1,
color: accent,
backgroundColor: Colors.transparent,
),
Icon(
BuzzIcons.messageSquareReply,
size: 22,
color: progress == 1
? filledForeground
: context.colors.primary,
),
],
),
),
),
const SizedBox(height: Grid.quarter),
Text(
'Reply',
textAlign: TextAlign.center,
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
),
Transform.translate(
offset: Offset(-offset.value, 0),
child: child,
),
],
);
},
),
),
);
}
}
// Reject outside starts before entering the gesture arena, leaving navigation
// and other row interactions free to recognize those drags. Only a leftward
// drag can win, so a rightward swipe-back starting on a message still pops.
class _MessageReplyDragRecognizer extends HorizontalDragGestureRecognizer {
late bool Function(Offset) canStart;
@override
bool isPointerAllowed(PointerEvent event) =>
canStart(event.position) && super.isPointerAllowed(event);
@override
bool hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
double? deviceTouchSlop,
) =>
globalDistanceMoved < 0 &&
super.hasSufficientGlobalDistanceToAccept(
pointerDeviceKind,
deviceTouchSlop,
);
}