diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 68f0891..c21d7ef 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -117,6 +117,21 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Swipe To Reply", + description: "Swipe a message to the right to reply to it.", + icon: Icons.swipe_right, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.swipeToReply, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(swipeToReply: value)) + .onError(showError), + ), + ), if (!Platform.isAndroid && !Platform.isIOS) .new( title: "Show Minimize Button", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 407aa19..f9198ed 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -13,6 +13,7 @@ class const Settings({ final bool linuxMobileMode = false, final bool showAllInHome = false, final bool showMinimizeButton = false, + final bool swipeToReply = false, final RoomOrder roomOrder = .unreadFirst, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 14e5c94..3ec3d4a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,7 @@ import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/timestamp.dart"; class const EventRenderer( @@ -39,6 +40,9 @@ class const EventRenderer( final int? maxLines, final JumpToEvent? jumpToEvent, final IList Function(Event event)? getEventOptions, + + /// If set, events can be swiped, calling this + final VoidCallback? onSwipe, super.key, }) extends HookConsumerWidget { @override @@ -239,18 +243,21 @@ class const EventRenderer( child: GestureDetector( onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8) - .copyWith(top: isGrouped ? 0 : 8), - child: Wrap( - verticalDirection: .up, - spacing: 6, - crossAxisAlignment: .center, - children: [ - child, - if (child is! MessageRenderer) - Timestamp(event.timestamp), - ], + child: SwipeToReply( + onSwipe: onSwipe, + Padding( + padding: EdgeInsets.symmetric(horizontal: 8) + .copyWith(top: isGrouped ? 0 : 8), + child: Wrap( + verticalDirection: .up, + spacing: 6, + crossAxisAlignment: .center, + children: [ + child, + if (child is! MessageRenderer) + Timestamp(event.timestamp), + ], + ), ), ), ), diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 254c782..8a4c071 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -13,6 +13,7 @@ class const ChatTimeline({ required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, + final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { Widget eventRow( @@ -26,6 +27,7 @@ class const ChatTimeline({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), ), key: key, isHighlighted: highlightedEvent == eventRowId, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index f45a6f2..155e90e 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -7,6 +7,8 @@ import "package:measure_size/measure_size.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; @@ -171,19 +173,30 @@ final class const RoomChat({ return null; }, [jumpRowId]); + void onRelation(int rowId, RelationType type) { + relatedEvent.value = rowId; + relationType.value = type; + composerNode.requestFocus(); + } + IList getEventOptions(Event event) => event.buildEventOptions( context: context, ref: ref, roomId: roomId, userId: userId, - onRelation: (rowId, type) { - relatedEvent.value = rowId; - relationType.value = type; - composerNode.requestFocus(); - }, + onRelation: onRelation, ); + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final canReply = ref.watch( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); + return Scaffold( endDrawer: PinnedEventsDrawer( roomId, @@ -224,6 +237,9 @@ final class const RoomChat({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, highlightedEvent: highlightedEvent.value, composerHeight: composerSize.value, ), diff --git a/lib/widgets/swipe_to_reply.dart b/lib/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..c02b310 --- /dev/null +++ b/lib/widgets/swipe_to_reply.dart @@ -0,0 +1,58 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +class const SwipeToReply( + final Widget child, { + required final VoidCallback? onSwipe, + super.key, +}) extends HookWidget { + static const maxDrag = 64.0; + static const threshold = 0.75; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: Durations.short4); + useListenable(controller); + + if (onSwipe == null) return child; + + final progress = controller.value; + + return GestureDetector( + onHorizontalDragUpdate: (details) { + final previous = controller.value; + controller.value = previous + details.primaryDelta! / maxDrag; + + if (previous < threshold && controller.value >= threshold) { + HapticFeedback.lightImpact(); + } + }, + onHorizontalDragEnd: (_) { + if (controller.value >= threshold) onSwipe!(); + controller.animateBack(0, curve: Curves.easeOut); + }, + onHorizontalDragCancel: () => + controller.animateBack(0, curve: Curves.easeOut), + child: Stack( + alignment: .centerLeft, + children: [ + Positioned( + left: 16, + child: Opacity( + opacity: (progress / threshold).clamp(0, 1), + child: Transform.scale( + scale: progress >= threshold ? 1 : 0.75, + child: const Icon(Icons.reply), + ), + ), + ), + Transform.translate( + offset: .new(progress * maxDrag, 0), + child: child, + ), + ], + ), + ); + } +}