feat(mobile): add swipe-to-reply message gesture (#8436)

## Summary
Swipe right on channel and thread messages to open the existing reply
destination. Adds a centred accent progress ring, Reply label, threshold
haptic/pulse, drag resistance, cancellation, and reduced-motion support.
Retreating below readiness re-arms feedback.

Includes a separate formatting-only fix required by main’s updated Dart
formatter.

### Related issue
Related message styling: #8281. No matching swipe-to-reply issue found.

### Testing
- Full pre-push checks passed, including mobile analysis/tests and
file-size checks.
- Widget coverage: routing, repeated threshold crossings, cancellation,
resistance, alignment, and centred scaling.
- Release preview installed on iPhone with the existing
profile/community; Kenny tested and confirmed the interaction.

Draft: native preview verification predates the rebase onto main’s
Flutter upgrade. No native gesture recording attached.

---------

Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-18 18:19:21 -04:00
committed by GitHub
parent 7c1a8967e0
commit bbb5183f33
8 changed files with 552 additions and 66 deletions

View File

@@ -1,3 +1,4 @@
import 'message_swipe_reply.dart';
import 'agent_reply_handoff.dart';
import 'huddle_timeline.dart';
import '../../shared/widgets/modal_presentation.dart';

View File

@@ -40,6 +40,7 @@ class _MessageBubble extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final swipeContentKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
final hasLocalReplies = ref.watch(
threadLocalRepliesProvider(
@@ -132,7 +133,7 @@ class _MessageBubble extends HookConsumerWidget {
);
}
return NotificationListener<MessageGalleryScrollNotification>(
final content = NotificationListener<MessageGalleryScrollNotification>(
onNotification: (notification) {
mediaOffset.value = notification.offset;
return true;
@@ -342,6 +343,7 @@ class _MessageBubble extends HookConsumerWidget {
),
animate: typingHandoff,
child: MessageContent(
key: swipeContentKey,
content: message.content,
bubble: true,
hasReactions:
@@ -468,6 +470,16 @@ class _MessageBubble extends HookConsumerWidget {
),
),
);
return MessageSwipeReply(
contentKey: swipeContentKey,
message: message,
allMessages: allMessages,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: content,
);
}
}

View File

@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:math' show sin, pi;
import 'package:flutter/material.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 'thread_detail_page.dart';
import 'timeline_message.dart';
/// Reveals the existing reply action as a message is dragged to the right.
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 didHaptic = useRef(false);
final cancelled = useRef(false);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final messages = allMessages;
if (messages == null || message.isSystem) 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: GestureDetector(
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
onHorizontalDragStart: (_) {
final contentBox = contentKey.currentContext?.findRenderObject();
final layoutBox = layoutKey.currentContext?.findRenderObject();
if (contentBox is RenderBox && layoutBox is RenderBox) {
contentCenter.value = layoutBox
.globalToLocal(
contentBox.localToGlobal(contentBox.size.center(Offset.zero)),
)
.dy;
}
offset.stop();
pulse.stop();
pulse.value = 0;
offset.value = 0;
dragDistance.value = 0;
didHaptic.value = false;
cancelled.value = false;
},
onHorizontalDragUpdate: (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;
}
},
onHorizontalDragCancel: reset,
onHorizontalDragEnd: (_) {
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);
final accent = context.appColors.accent;
final filledForeground =
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
? Colors.white
: Colors.black;
return Stack(
key: layoutKey,
clipBehavior: Clip.none,
alignment: Alignment.centerLeft,
children: [
if (progress > 0)
Positioned(
left: 0,
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,
),
],
);
},
),
),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'message_swipe_reply.dart';
import '../../shared/widgets/ios_scroll_to_top.dart';
import 'message_footer_layout.dart';
import 'reply_summary_link.dart';

View File

@@ -41,6 +41,7 @@ class _ThreadMessage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final swipeContentKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
final pk = message.pubkey.toLowerCase();
final ownMessage = currentPubkey?.toLowerCase() == pk;
@@ -133,7 +134,7 @@ class _ThreadMessage extends HookConsumerWidget {
alpha: _landingHighlightOpacity * highlightProgress,
);
return NotificationListener<MessageGalleryScrollNotification>(
final content = NotificationListener<MessageGalleryScrollNotification>(
onNotification: (notification) {
mediaOffset.value = notification.offset;
return true;
@@ -310,6 +311,7 @@ class _ThreadMessage extends HookConsumerWidget {
),
),
MessageContent(
key: swipeContentKey,
content: message.content,
bubble: true,
hasReactions:
@@ -434,5 +436,15 @@ class _ThreadMessage extends HookConsumerWidget {
),
),
);
return MessageSwipeReply(
contentKey: swipeContentKey,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: content,
);
}
}