diff --git a/packages/neon/neon_talk/lib/l10n/en.arb b/packages/neon/neon_talk/lib/l10n/en.arb index f5b0d95c735..901d30538a8 100644 --- a/packages/neon/neon_talk/lib/l10n/en.arb +++ b/packages/neon/neon_talk/lib/l10n/en.arb @@ -15,6 +15,8 @@ "roomWriteMessage": "Write a message...", "roomMessageAddEmoji": "Add emoji to message", "roomMessageSend": "Send message", + "roomMessageReply": "Reply", + "roomMessageReaction": "Add reaction", "reactionsAddNew": "Add a new reaction", "reactionsLoading": "Loading reactions", "roomsCreateNew": "Create new room" diff --git a/packages/neon/neon_talk/lib/l10n/localizations.dart b/packages/neon/neon_talk/lib/l10n/localizations.dart index 801bbb2f860..8e0535e51aa 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations.dart @@ -149,6 +149,18 @@ abstract class TalkLocalizations { /// **'Send message'** String get roomMessageSend; + /// No description provided for @roomMessageReply. + /// + /// In en, this message translates to: + /// **'Reply'** + String get roomMessageReply; + + /// No description provided for @roomMessageReaction. + /// + /// In en, this message translates to: + /// **'Add reaction'** + String get roomMessageReaction; + /// No description provided for @reactionsAddNew. /// /// In en, this message translates to: diff --git a/packages/neon/neon_talk/lib/l10n/localizations_en.dart b/packages/neon/neon_talk/lib/l10n/localizations_en.dart index 075a75b6904..0529469b802 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations_en.dart @@ -47,6 +47,12 @@ class TalkLocalizationsEn extends TalkLocalizations { @override String get roomMessageSend => 'Send message'; + @override + String get roomMessageReply => 'Reply'; + + @override + String get roomMessageReaction => 'Add reaction'; + @override String get reactionsAddNew => 'Add a new reaction'; diff --git a/packages/neon/neon_talk/lib/src/blocs/room.dart b/packages/neon/neon_talk/lib/src/blocs/room.dart index e4623ff7d59..04998f29cd6 100644 --- a/packages/neon/neon_talk/lib/src/blocs/room.dart +++ b/packages/neon/neon_talk/lib/src/blocs/room.dart @@ -36,6 +36,12 @@ abstract class TalkRoomBloc implements InteractiveBloc { /// Loads the emoji reactions for the [message]. void loadReactions(spreed.$ChatMessageInterface message); + /// Sets a [chatMessage] as the message to [replyTo]. + void setReplyChatMessage(spreed.$ChatMessageInterface chatMessage); + + /// Removes the current [replyTo] chat message. + void removeReplyChatMessage(); + /// The current room data. BehaviorSubject> get room; @@ -49,6 +55,9 @@ abstract class TalkRoomBloc implements InteractiveBloc { /// Map of emoji reactions for the [messages]. BehaviorSubject>>> get reactions; + + /// Current chat message to reply to. + BehaviorSubject get replyTo; } class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @@ -164,6 +173,9 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @override final reactions = BehaviorSubject.seeded(BuiltMap()); + @override + final replyTo = BehaviorSubject.seeded(null); + @override void dispose() { pollLoop = false; @@ -173,6 +185,7 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { unawaited(messages.close()); unawaited(lastCommonRead.close()); unawaited(reactions.close()); + unawaited(replyTo.close()); super.dispose(); } @@ -214,10 +227,14 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @override Future sendMessage(String message) async { + final replyToId = replyTo.value?.id; + replyTo.add(null); + await wrapAction( () async { final response = await account.client.spreed.chat.sendMessage( message: message, + replyTo: replyToId, token: token, ); @@ -294,6 +311,16 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { ); } + @override + void setReplyChatMessage(spreed.$ChatMessageInterface chatMessage) { + replyTo.add(chatMessage); + } + + @override + void removeReplyChatMessage() { + replyTo.add(null); + } + void updateLastCommonRead(String? header) { if (header != null) { final id = int.parse(header); diff --git a/packages/neon/neon_talk/lib/src/pages/room.dart b/packages/neon/neon_talk/lib/src/pages/room.dart index 96bb09bd0de..536555907a3 100644 --- a/packages/neon/neon_talk/lib/src/pages/room.dart +++ b/packages/neon/neon_talk/lib/src/pages/room.dart @@ -162,7 +162,7 @@ class _TalkRoomPageState extends State { onRefresh: bloc.refresh, sliver: SliverPadding( padding: const EdgeInsets.symmetric( - horizontal: 20, + horizontal: 10, ), sliver: sliver, ), diff --git a/packages/neon/neon_talk/lib/src/widgets/message.dart b/packages/neon/neon_talk/lib/src/widgets/message.dart index 31cf60cd3a5..fedb0d98671 100644 --- a/packages/neon/neon_talk/lib/src/widgets/message.dart +++ b/packages/neon/neon_talk/lib/src/widgets/message.dart @@ -4,7 +4,9 @@ import 'package:intl/intl.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/theme.dart'; import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; import 'package:neon_talk/l10n/localizations.dart'; +import 'package:neon_talk/src/blocs/room.dart'; import 'package:neon_talk/src/widgets/actor_avatar.dart'; import 'package:neon_talk/src/widgets/reactions.dart'; import 'package:neon_talk/src/widgets/read_indicator.dart'; @@ -334,7 +336,7 @@ class TalkParentMessage extends StatelessWidget { } /// Displays a comment chat message including voice messages, recorded audio and video and reactions. -class TalkCommentMessage extends StatelessWidget { +class TalkCommentMessage extends StatefulWidget { /// Creates a new Talk comment message. const TalkCommentMessage({ required this.chatMessage, @@ -356,24 +358,32 @@ class TalkCommentMessage extends StatelessWidget { /// {@macro TalkMessage.isParent} final bool isParent; + @override + State createState() => _TalkCommentMessageState(); +} + +class _TalkCommentMessageState extends State { + bool hoverState = false; + bool menuOpen = false; + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final date = DateTimeUtils.fromSecondsSinceEpoch( tz.UTC, - chatMessage.timestamp, + widget.chatMessage.timestamp, ); tz.TZDateTime? previousDate; - if (previousChatMessage != null) { + if (widget.previousChatMessage != null) { previousDate = DateTimeUtils.fromSecondsSinceEpoch( tz.UTC, - previousChatMessage!.timestamp, + widget.previousChatMessage!.timestamp, ); } - final separateMessages = chatMessage.actorId != previousChatMessage?.actorId || - previousChatMessage?.messageType == spreed.MessageType.system || + final separateMessages = widget.chatMessage.actorId != widget.previousChatMessage?.actorId || + widget.previousChatMessage?.messageType == spreed.MessageType.system || previousDate == null || date.difference(previousDate) > const Duration(minutes: 3); @@ -382,14 +392,14 @@ class TalkCommentMessage extends StatelessWidget { Widget? time; if (separateMessages) { displayName = Text( - getActorDisplayName(TalkLocalizations.of(context), chatMessage), + getActorDisplayName(TalkLocalizations.of(context), widget.chatMessage), style: textTheme.labelSmall, ); - if (!isParent) { + if (!widget.isParent) { avatar = TalkActorAvatar( - actorId: chatMessage.actorId, - actorType: chatMessage.actorType, + actorId: widget.chatMessage.actorId, + actorType: widget.chatMessage.actorType, ); time = Tooltip( @@ -403,19 +413,19 @@ class TalkCommentMessage extends StatelessWidget { } Widget? parent; - if (chatMessage + if (widget.chatMessage case spreed.ChatMessageWithParent( parent: final p, messageType: != spreed.MessageType.commentDeleted, - ) when p != null) { + ) when p != null && !widget.isParent) { parent = TalkParentMessage( parentChatMessage: p, - lastCommonRead: lastCommonRead, + lastCommonRead: widget.lastCommonRead, ); } double topMargin; - if (isParent) { + if (widget.isParent) { topMargin = 5; } else if (separateMessages) { topMargin = 20; @@ -425,21 +435,21 @@ class TalkCommentMessage extends StatelessWidget { Widget text = Text.rich( buildChatMessage( - chatMessage: chatMessage, - isPreview: isParent, - style: isParent || chatMessage.messageType == spreed.MessageType.commentDeleted + chatMessage: widget.chatMessage, + isPreview: widget.isParent, + style: widget.isParent || widget.chatMessage.messageType == spreed.MessageType.commentDeleted ? textTheme.bodySmall : textTheme.bodyMedium, ), - maxLines: isParent ? 1 : null, - overflow: isParent ? TextOverflow.ellipsis : TextOverflow.visible, + maxLines: widget.isParent ? 1 : null, + overflow: widget.isParent ? TextOverflow.ellipsis : TextOverflow.visible, ); - if (!isParent && chatMessage.messageType != spreed.MessageType.commentDeleted) { + if (!widget.isParent && widget.chatMessage.messageType != spreed.MessageType.commentDeleted) { text = SelectionArea( child: text, ); } - if (chatMessage.messageType == spreed.MessageType.commentDeleted) { + if (widget.chatMessage.messageType == spreed.MessageType.commentDeleted) { text = Row( children: [ Icon( @@ -457,10 +467,10 @@ class TalkCommentMessage extends StatelessWidget { final account = NeonProvider.of(context); Widget? readIndicator; - if (lastCommonRead != null && account.username == chatMessage.actorId) { + if (widget.lastCommonRead != null && account.username == widget.chatMessage.actorId) { readIndicator = TalkReadIndicator( - chatMessage: chatMessage, - lastCommonRead: lastCommonRead!, + chatMessage: widget.chatMessage, + lastCommonRead: widget.lastCommonRead!, ); } @@ -476,9 +486,9 @@ class TalkCommentMessage extends StatelessWidget { ), if (parent != null) parent, text, - if (!isParent && chatMessage.messageType != spreed.MessageType.commentDeleted) + if (!widget.isParent && widget.chatMessage.reactions.isNotEmpty) TalkReactions( - chatMessage: chatMessage, + chatMessage: widget.chatMessage, ), ] .intersperse( @@ -489,28 +499,53 @@ class TalkCommentMessage extends StatelessWidget { .toList(), ); - if (!isParent) { - message = Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - width: 40, - child: avatar, - ), - ), - Expanded( - child: message, + if (!widget.isParent) { + message = MouseRegion( + onEnter: (_) { + setState(() { + hoverState = true; + }); + }, + onExit: (_) { + setState(() { + hoverState = false; + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: (hoverState || menuOpen) ? Theme.of(context).colorScheme.surfaceDim : null, ), - Padding( - padding: const EdgeInsets.only(left: 10), - child: SizedBox( - width: 14, - child: readIndicator, - ), + padding: const EdgeInsets.all(5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + width: 40, + child: avatar, + ), + ), + Expanded( + child: message, + ), + Padding( + padding: const EdgeInsets.only(left: 5), + child: SizedBox( + width: 14, + child: readIndicator, + ), + ), + SizedBox.square( + dimension: 32, + child: widget.chatMessage.messageType != spreed.MessageType.commentDeleted && (hoverState || menuOpen) + ? _buildPopupMenuButton(widget.chatMessage) + : null, + ), + ], ), - ], + ), ); } @@ -522,4 +557,71 @@ class TalkCommentMessage extends StatelessWidget { child: message, ); } + + Widget? _buildPopupMenuButton(spreed.$ChatMessageInterface chatMessage) { + final children = [ + if (chatMessage.messageType != spreed.MessageType.commentDeleted) + MenuItemButton( + leadingIcon: const Icon(Icons.add_reaction_outlined), + onPressed: () async { + final reaction = await showDialog( + context: context, + builder: (context) => const NeonEmojiPickerDialog(), + ); + if (reaction == null) { + return; + } + + if (!context.mounted) { + return; + } + + // ignore: use_build_context_synchronously + NeonProvider.of(context).addReaction(chatMessage, reaction); + }, + child: Text(TalkLocalizations.of(context).roomMessageReaction), + ), + if (chatMessage.isReplyable) + MenuItemButton( + leadingIcon: const Icon(Icons.reply), + child: Text(TalkLocalizations.of(context).roomMessageReply), + onPressed: () { + setState(() { + menuOpen = false; + }); + + NeonProvider.of(context).setReplyChatMessage(chatMessage); + }, + ), + ]; + + if (children.isEmpty) { + return null; + } + + return MenuAnchor( + menuChildren: children, + onOpen: () { + setState(() { + menuOpen = true; + }); + }, + onClose: () { + setState(() { + menuOpen = false; + }); + }, + builder: (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.more_vert), + ), + ); + } } diff --git a/packages/neon/neon_talk/lib/src/widgets/message_input.dart b/packages/neon/neon_talk/lib/src/widgets/message_input.dart index 69644c8725b..1692c01415a 100644 --- a/packages/neon/neon_talk/lib/src/widgets/message_input.dart +++ b/packages/neon/neon_talk/lib/src/widgets/message_input.dart @@ -8,6 +8,7 @@ import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; import 'package:neon_talk/l10n/localizations.dart'; import 'package:neon_talk/src/blocs/room.dart'; +import 'package:neon_talk/src/widgets/message.dart'; import 'package:nextcloud/spreed.dart' as spreed; /// Widget for displaying the emoji button, text input and send button. @@ -53,6 +54,12 @@ class _TalkMessageInputState extends State { super.initState(); bloc = NeonProvider.of(context); + + bloc.replyTo.listen((replyTo) { + if (replyTo != null) { + focusNode.requestFocus(); + } + }); } @override @@ -99,7 +106,39 @@ class _TalkMessageInputState extends State { ); } - return TypeAheadField<_Suggestion>( + final replyTo = StreamBuilder( + stream: bloc.replyTo, + builder: (context, replyToSnapshot) { + if (!replyToSnapshot.hasData) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + const SizedBox.square( + dimension: 40, + ), + Expanded( + child: TalkParentMessage( + parentChatMessage: replyToSnapshot.requireData!, + lastCommonRead: null, + ), + ), + IconButton( + onPressed: () { + bloc.removeReplyChatMessage(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + ); + }, + ); + + final inputField = TypeAheadField<_Suggestion>( direction: VerticalDirection.up, hideOnEmpty: true, debounceDuration: const Duration(milliseconds: 50), @@ -178,6 +217,14 @@ class _TalkMessageInputState extends State { ), loadingBuilder: (context) => const NeonLinearProgressIndicator(), ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + replyTo, + inputField, + ], + ); } Widget buildResult(BuildContext context, _Suggestion suggestion) { diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_deleted.png b/packages/neon/neon_talk/test/goldens/message_comment_message_deleted.png index 65a277c9570..0de31f39a69 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_deleted.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_deleted.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_other.png b/packages/neon/neon_talk/test/goldens/message_comment_message_other.png index da63ab3c1f4..613d35c4b20 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_other.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_other.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_self.png b/packages/neon/neon_talk/test/goldens/message_comment_message_self.png index 10323ca971a..74e781b2a22 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_self.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_self.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_actor.png b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_actor.png index 400cbfcebfc..817191c5dde 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_actor.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_actor.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_system_message.png b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_system_message.png index 400cbfcebfc..817191c5dde 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_system_message.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_system_message.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_time.png b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_time.png index 400cbfcebfc..817191c5dde 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_time.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_time.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_with_parent.png b/packages/neon/neon_talk/test/goldens/message_comment_message_with_parent.png index 7bd6d57a1f3..4764bab0b29 100644 Binary files a/packages/neon/neon_talk/test/goldens/message_comment_message_with_parent.png and b/packages/neon/neon_talk/test/goldens/message_comment_message_with_parent.png differ diff --git a/packages/neon/neon_talk/test/goldens/room_page_messages.png b/packages/neon/neon_talk/test/goldens/room_page_messages.png index a7e1069a1fa..f0a3517cdf2 100644 Binary files a/packages/neon/neon_talk/test/goldens/room_page_messages.png and b/packages/neon/neon_talk/test/goldens/room_page_messages.png differ diff --git a/packages/neon/neon_talk/test/goldens/room_page_reply.png b/packages/neon/neon_talk/test/goldens/room_page_reply.png new file mode 100644 index 00000000000..9831325b1aa Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/room_page_reply.png differ diff --git a/packages/neon/neon_talk/test/message_input_test.dart b/packages/neon/neon_talk/test/message_input_test.dart index 2709ea0ce3c..21969a85257 100644 --- a/packages/neon/neon_talk/test/message_input_test.dart +++ b/packages/neon/neon_talk/test/message_input_test.dart @@ -57,6 +57,7 @@ void main() { FakeNeonStorage.setup(); bloc = MockRoomBloc(); + when(() => bloc.replyTo).thenAnswer((_) => BehaviorSubject.seeded(null)); }); testWidgets('Cupertino no emoji button', (tester) async { diff --git a/packages/neon/neon_talk/test/message_test.dart b/packages/neon/neon_talk/test/message_test.dart index 0b171c33bde..0b91193054b 100644 --- a/packages/neon/neon_talk/test/message_test.dart +++ b/packages/neon/neon_talk/test/message_test.dart @@ -1,4 +1,5 @@ import 'package:built_collection/built_collection.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -23,6 +24,7 @@ import 'package:nextcloud/spreed.dart' as spreed; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; @@ -546,7 +548,7 @@ void main() { ), ); expect(find.byType(TalkParentMessage), findsOne); - expect(find.byType(TalkReactions), findsOne); + expect(find.byType(TalkReactions), findsNothing); expect(find.byType(SelectionArea), findsOne); await expectLater( find.byType(TalkCommentMessage).first, @@ -701,6 +703,104 @@ void main() { ); }); }); + + group('Menu', () { + late Account account; + late spreed.ChatMessage chatMessage; + late TalkRoomBloc roomBloc; + + setUp(() { + account = MockAccount(); + when(() => account.id).thenReturn(''); + when(() => account.username).thenReturn('test'); + when(() => account.client).thenReturn(NextcloudClient(Uri.parse(''))); + + chatMessage = MockChatMessage(); + when(() => chatMessage.timestamp).thenReturn(0); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorType).thenReturn(spreed.ActorType.users); + when(() => chatMessage.actorDisplayName).thenReturn('test'); + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => chatMessage.message).thenReturn('abc'); + when(() => chatMessage.reactions).thenReturn(BuiltMap()); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.id).thenReturn(0); + when(() => chatMessage.isReplyable).thenReturn(true); + + roomBloc = MockRoomBloc(); + when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + }); + + testWidgets('Add reaction', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + ], + child: TalkCommentMessage( + chatMessage: chatMessage, + lastCommonRead: 0, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(TalkCommentMessage))); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + await tester.runAsync(() async { + await tester.tap(find.byIcon(Icons.add_reaction_outlined)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tag_faces)); + await tester.pumpAndSettle(); + await tester.tap(find.text('😂')); + await tester.pumpAndSettle(); + + verify(() => roomBloc.addReaction(chatMessage, '😂')).called(1); + }); + }); + + testWidgets('Reply', (tester) async { + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + ], + child: TalkCommentMessage( + chatMessage: chatMessage, + lastCommonRead: 0, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(TalkCommentMessage))); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + await tester.runAsync(() async { + await tester.tap(find.byIcon(Icons.reply)); + await tester.pumpAndSettle(); + + verify(() => roomBloc.setReplyChatMessage(chatMessage)).called(1); + }); + }); + }); }); group('buildRichObjectParameter', () { diff --git a/packages/neon/neon_talk/test/room_bloc_test.dart b/packages/neon/neon_talk/test/room_bloc_test.dart index bd0a9d7fc39..b525f9de7c5 100644 --- a/packages/neon/neon_talk/test/room_bloc_test.dart +++ b/packages/neon/neon_talk/test/room_bloc_test.dart @@ -97,18 +97,29 @@ Account mockTalkAccount() { ); } }, - 'post': (match, queryParameters) => Response( - json.encode({ - 'ocs': { - 'meta': {'status': '', 'statuscode': 0}, - 'data': getChatMessage(id: messageCount++), - }, - }), - 201, - headers: { - 'x-chat-last-common-read': '1', + 'post': (match, queryParameters) { + final replyTo = queryParameters['replyTo']?.firstOrNull; + + return Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': getChatMessage( + id: messageCount++, + parent: replyTo != null + ? getChatMessage( + id: int.parse(replyTo), + ) + : null, + ), }, - ), + }), + 201, + headers: { + 'x-chat-last-common-read': '1', + }, + ); + }, }, RegExp(r'/ocs/v2\.php/apps/spreed/api/v1/reaction/abcd/[0-9]+'): { 'post': (match, queryParameters) { @@ -303,6 +314,40 @@ void main() { verify(() => talkBloc.updateRoom(any())).called(3); }); + test('Reply', () async { + final message = MockChatMessage(); + when(() => message.id).thenReturn(1); + + expect( + roomBloc.messages.transformResult((e) => BuiltList(e.map((m) => m.parent?.id))), + emitsInOrder([ + Result>.loading(), + Result.success(BuiltList([null, null, null])), + Result.success(BuiltList([message.id, null, null, null])), + ]), + ); + + expect( + roomBloc.replyTo, + emitsInOrder([ + null, + message, + null, + message, + null, + ]), + ); + + // The delay is necessary to avoid a race condition with loading twice at the same time + await Future.delayed(const Duration(milliseconds: 1)); + + roomBloc + ..setReplyChatMessage(message) + ..removeReplyChatMessage() + ..setReplyChatMessage(message) + ..sendMessage(''); + }); + test('addReaction', () async { expect( roomBloc.messages.transformResult((e) => BuiltList>(e.map((m) => m.reactions))), diff --git a/packages/neon/neon_talk/test/room_page_test.dart b/packages/neon/neon_talk/test/room_page_test.dart index f8859954a0a..d90ca7342c3 100644 --- a/packages/neon/neon_talk/test/room_page_test.dart +++ b/packages/neon/neon_talk/test/room_page_test.dart @@ -53,6 +53,7 @@ void main() { when(() => bloc.messages) .thenAnswer((_) => BehaviorSubject.seeded(Result.success(BuiltList()))); when(() => bloc.lastCommonRead).thenAnswer((_) => BehaviorSubject.seeded(0)); + when(() => bloc.replyTo).thenAnswer((_) => BehaviorSubject.seeded(null)); }); testWidgets('Status message', (tester) async { @@ -210,4 +211,50 @@ void main() { expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing); await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/room_page_read_only.png')); }); + + testWidgets('Reply', (tester) async { + final replyTo = BehaviorSubject.seeded(null); + + when(() => bloc.replyTo).thenAnswer((_) => replyTo); + + final account = MockAccount(); + when(() => account.client).thenReturn(NextcloudClient(Uri.parse(''))); + + await tester.pumpWidgetWithAccessibility( + TestApp( + localizationsDelegates: TalkLocalizations.localizationsDelegates, + supportedLocales: TalkLocalizations.supportedLocales, + appThemes: const [ + TalkTheme(), + ], + providers: [ + Provider.value(value: account), + NeonProvider.value(value: bloc), + ], + child: const TalkRoomPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TalkParentMessage), findsNothing); + + final message = MockChatMessage(); + when(() => message.messageType).thenReturn(spreed.MessageType.comment); + when(() => message.timestamp).thenReturn(0); + when(() => message.actorId).thenReturn('test'); + when(() => message.actorDisplayName).thenReturn('test'); + when(() => message.message).thenReturn('abc'); + when(() => message.messageParameters).thenReturn(BuiltMap()); + + replyTo.add(message); + await tester.pumpAndSettle(); + + expect(find.byType(TalkParentMessage), findsOne); + await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/room_page_reply.png')); + + await tester.tap(find.byIcon(Icons.close)); + verify(() => bloc.removeReplyChatMessage()).called(1); + + unawaited(replyTo.close()); + }); }