Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'message_swipe_reply.dart';
import 'agent_reply_handoff.dart';
import 'huddle_timeline.dart';
import '../../shared/widgets/modal_presentation.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -132,7 +133,7 @@ class _MessageBubble extends HookConsumerWidget {
);
}

return NotificationListener<MessageGalleryScrollNotification>(
final content = NotificationListener<MessageGalleryScrollNotification>(
onNotification: (notification) {
mediaOffset.value = notification.offset;
return true;
Expand Down Expand Up @@ -342,6 +343,7 @@ class _MessageBubble extends HookConsumerWidget {
),
animate: typingHandoff,
child: MessageContent(
key: swipeContentKey,
content: message.content,
bubble: true,
hasReactions:
Expand Down Expand Up @@ -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,
);
}
}

Expand Down
234 changes: 234 additions & 0 deletions mobile/lib/features/channels/message_swipe_reply.dart
Original file line number Diff line number Diff line change
@@ -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,
),
],
);
},
),
),
);
}
}
1 change: 1 addition & 0 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -310,6 +311,7 @@ class _ThreadMessage extends HookConsumerWidget {
),
),
MessageContent(
key: swipeContentKey,
content: message.content,
bubble: true,
hasReactions:
Expand Down Expand Up @@ -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,
);
}
}
2 changes: 2 additions & 0 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ part 'channel_detail_page_test/loading_review_tests.dart';
part 'channel_detail_page_test/presence_tests.dart';
part 'channel_detail_page_test/action_row_tests.dart';
part 'channel_detail_page_test/bubble_tests.dart';
part 'channel_detail_page_test/swipe_reply_tests.dart';
part 'channel_detail_page_test/review_avatar_tests.dart';
part 'channel_detail_page_test/retention_tests.dart';

Expand Down Expand Up @@ -568,6 +569,7 @@ void main() {
presenceTests();
actionRowTests();
bubbleLayoutTests();
swipeReplyTests();
reviewAvatarTests();
channelRetentionTests();
setUp(() async {
Expand Down
Loading
Loading