diff --git a/packages/neon/neon_talk/lib/l10n/en.arb b/packages/neon/neon_talk/lib/l10n/en.arb index a8c0a3b4ffd..dbf6c38449f 100644 --- a/packages/neon/neon_talk/lib/l10n/en.arb +++ b/packages/neon/neon_talk/lib/l10n/en.arb @@ -18,6 +18,19 @@ "roomMessageReply": "Reply", "roomMessageReaction": "Add reaction", "roomMessageDelete": "Delete", + "roomMessageEdit": "Edit", + "roomMessageEdited": "edited", + "roomMessageLastEdited": "Last edited by {name} at {time}", + "@roomMessageLastEdited": { + "placeholders": { + "name": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, "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 43d0315ebb3..78c77d23739 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations.dart @@ -167,6 +167,24 @@ abstract class TalkLocalizations { /// **'Delete'** String get roomMessageDelete; + /// No description provided for @roomMessageEdit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get roomMessageEdit; + + /// No description provided for @roomMessageEdited. + /// + /// In en, this message translates to: + /// **'edited'** + String get roomMessageEdited; + + /// No description provided for @roomMessageLastEdited. + /// + /// In en, this message translates to: + /// **'Last edited by {name} at {time}'** + String roomMessageLastEdited(String name, String time); + /// 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 c80d1d704d3..5fbffd304a8 100644 --- a/packages/neon/neon_talk/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_talk/lib/l10n/localizations_en.dart @@ -56,6 +56,17 @@ class TalkLocalizationsEn extends TalkLocalizations { @override String get roomMessageDelete => 'Delete'; + @override + String get roomMessageEdit => 'Edit'; + + @override + String get roomMessageEdited => 'edited'; + + @override + String roomMessageLastEdited(String name, String time) { + return 'Last edited by $name at $time'; + } + @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 93e38080d29..c623d99152b 100644 --- a/packages/neon/neon_talk/lib/src/blocs/room.dart +++ b/packages/neon/neon_talk/lib/src/blocs/room.dart @@ -39,9 +39,15 @@ abstract class TalkRoomBloc implements InteractiveBloc { /// Sets a [chatMessage] as the message to [replyTo]. void setReplyChatMessage(spreed.$ChatMessageInterface chatMessage); + /// Sets a [chatMessage] as the message to [editing]. + void setEditChatMessage(spreed.$ChatMessageInterface chatMessage); + /// Removes the current [replyTo] chat message. void removeReplyChatMessage(); + /// Removes the current [editing] chat message. + void removeEditChatMessage(); + /// Deletes a chat messages. void deleteMessage(spreed.$ChatMessageInterface chatMessage); @@ -61,6 +67,9 @@ abstract class TalkRoomBloc implements InteractiveBloc { /// Current chat message to reply to. BehaviorSubject get replyTo; + + /// Current chat message that is edited. + BehaviorSubject get editing; } class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @@ -182,6 +191,9 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @override final replyTo = BehaviorSubject.seeded(null); + @override + final editing = BehaviorSubject.seeded(null); + @override void dispose() { pollLoop = false; @@ -192,6 +204,7 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { unawaited(lastCommonRead.close()); unawaited(reactions.close()); unawaited(replyTo.close()); + unawaited(editing.close()); super.dispose(); } @@ -234,29 +247,48 @@ 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( - token: token, - $body: spreed.ChatSendMessageRequestApplicationJson( - (b) { - b.message = message; - if (replyToId != null) { - b.replyTo = replyToId; - } - }, - ), - ); + late spreed.ChatMessageWithParent? m; + late String? lastCommonRead; - updateLastCommonRead(response.headers.xChatLastCommonRead); + final editingId = editing.value?.id; + if (editingId != null) { + editing.add(null); - final m = response.body.ocs.data; + final response = await account.client.spreed.chat.editMessage( + token: token, + messageId: editingId, + $body: spreed.ChatEditMessageRequestApplicationJson( + (b) => b.message = message, + ), + ); + + m = response.body.ocs.data; + lastCommonRead = response.headers.xChatLastCommonRead; + } else { + final replyToId = replyTo.value?.id; + replyTo.add(null); + + final response = await account.client.spreed.chat.sendMessage( + token: token, + $body: spreed.ChatSendMessageRequestApplicationJson( + (b) { + b.message = message; + if (replyToId != null) { + b.replyTo = replyToId; + } + }, + ), + ); + + m = response.body.ocs.data; + lastCommonRead = response.headers.xChatLastCommonRead; + } + + updateLastCommonRead(lastCommonRead); if (m != null) { updateLastKnownMessageId(m.id); - prependMessages([m]); } }, @@ -330,6 +362,7 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { @override void setReplyChatMessage(spreed.$ChatMessageInterface chatMessage) { + editing.add(null); replyTo.add(chatMessage); } @@ -338,6 +371,17 @@ class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc { replyTo.add(null); } + @override + void setEditChatMessage(spreed.$ChatMessageInterface chatMessage) { + replyTo.add(null); + editing.add(chatMessage); + } + + @override + void removeEditChatMessage() { + editing.add(null); + } + @override Future deleteMessage(spreed.$ChatMessageInterface chatMessage) async { await wrapAction( diff --git a/packages/neon/neon_talk/lib/src/utils/helpers.dart b/packages/neon/neon_talk/lib/src/utils/helpers.dart index 5afeb5369c6..2706ffec784 100644 --- a/packages/neon/neon_talk/lib/src/utils/helpers.dart +++ b/packages/neon/neon_talk/lib/src/utils/helpers.dart @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/utils.dart'; import 'package:nextcloud/spreed.dart' as spreed; import 'package:nextcloud/utils.dart'; import 'package:timezone/timezone.dart' as tz; @@ -47,3 +50,16 @@ extension $ReactionInterfaceHelpers on spreed.$ReactionInterface { /// Parsed equivalent of [timestamp]. tz.TZDateTime get parsedTimestamp => DateTimeUtils.fromSecondsSinceEpoch(tz.local, timestamp); } + +/// Returns if the Talk [feature] is supported on the instance. +bool hasFeature(BuildContext context, String feature) { + final capabilitiesBloc = NeonProvider.of(context); + final capabilities = capabilitiesBloc + .capabilities.valueOrNull?.data?.capabilities.spreedPublicCapabilities?.spreedPublicCapabilities0?.spreed; + + if (capabilities == null) { + return false; + } + + return capabilities.features.contains(feature); +} diff --git a/packages/neon/neon_talk/lib/src/widgets/message.dart b/packages/neon/neon_talk/lib/src/widgets/message.dart index 05d66fdfa52..4efd5d38546 100644 --- a/packages/neon/neon_talk/lib/src/widgets/message.dart +++ b/packages/neon/neon_talk/lib/src/widgets/message.dart @@ -459,19 +459,40 @@ class _TalkCommentMessageState extends State { final separateMessages = widget.chatMessage.actorId != widget.previousChatMessage?.actorId || widget.previousChatMessage?.messageType == spreed.MessageType.system || previousDate == null || - date.difference(previousDate) > const Duration(minutes: 3); + date.difference(previousDate) > const Duration(minutes: 3) || + widget.chatMessage.lastEditTimestamp != null; - Widget? displayName; + Widget? label; Widget? avatar; Widget? time; if (separateMessages) { - displayName = Text( + label = Text( getActorDisplayName(TalkLocalizations.of(context), widget.chatMessage), style: textTheme.labelLarge!.copyWith( color: labelColor, ), ); + if (widget.chatMessage.lastEditTimestamp != null && widget.chatMessage.lastEditActorDisplayName != null) { + label = Row( + children: [ + label, + Tooltip( + message: TalkLocalizations.of(context).roomMessageLastEdited( + widget.chatMessage.lastEditActorDisplayName!, + DateFormat.yMd().add_jm().format(widget.chatMessage.parsedLastEditTimestamp!), + ), + child: Text( + ' (${TalkLocalizations.of(context).roomMessageEdited})', + style: textTheme.labelLarge!.copyWith( + color: labelColor, + ), + ), + ), + ], + ); + } + if (!widget.isParent) { avatar = TalkActorAvatar( actorId: widget.chatMessage.actorId, @@ -562,7 +583,7 @@ class _TalkCommentMessageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (displayName != null) displayName, + if (label != null) label, if (time != null) time, ], ), @@ -694,6 +715,20 @@ class _TalkCommentMessageState extends State { NeonProvider.of(context).setReplyChatMessage(chatMessage); }, ), + if (chatMessage.messageType != spreed.MessageType.commentDeleted && + chatMessage.actorId == room.actorId && + hasFeature(context, 'edit-messages')) + MenuItemButton( + leadingIcon: const Icon(Icons.edit), + child: Text(TalkLocalizations.of(context).roomMessageEdit), + onPressed: () { + setState(() { + menuOpen = false; + }); + + NeonProvider.of(context).setEditChatMessage(chatMessage); + }, + ), if (chatMessage.messageType != spreed.MessageType.commentDeleted && chatMessage.actorId == room.actorId) MenuItemButton( leadingIcon: const Icon(Icons.delete_forever), 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 e59ebf68ffd..1fcc7c2468c 100644 --- a/packages/neon/neon_talk/lib/src/widgets/message_input.dart +++ b/packages/neon/neon_talk/lib/src/widgets/message_input.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -52,6 +54,8 @@ class _TalkMessageInputState extends State { }, ); late TalkRoomBloc bloc; + late StreamSubscription replyToSubscription; + late StreamSubscription editingSubscription; @override void initState() { @@ -59,15 +63,28 @@ class _TalkMessageInputState extends State { bloc = NeonProvider.of(context); - bloc.replyTo.listen((replyTo) { + replyToSubscription = bloc.replyTo.listen((replyTo) { if (replyTo != null) { focusNode.requestFocus(); } }); + editingSubscription = bloc.editing.listen((editing) { + if (editing != null) { + controller + ..text = editing.message + ..selection = TextSelection( + baseOffset: editing.message.length, + extentOffset: editing.message.length, + ); + focusNode.requestFocus(); + } + }); } @override void dispose() { + unawaited(replyToSubscription.cancel()); + unawaited(editingSubscription.cancel()); controller.dispose(); focusNode.dispose(); super.dispose(); @@ -117,28 +134,31 @@ class _TalkMessageInputState extends State { return const SizedBox(); } - return Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Row( - children: [ - const SizedBox.square( - dimension: 40, - ), - Expanded( - child: TalkParentMessage( - room: widget.room, - parentChatMessage: replyToSnapshot.requireData!, - lastCommonRead: null, - ), - ), - IconButton( - onPressed: () { - bloc.removeReplyChatMessage(); - }, - icon: const Icon(Icons.close), - ), - ], - ), + return buildContextMessage( + chatMessage: replyToSnapshot.requireData!, + onDismiss: bloc.removeReplyChatMessage, + ); + }, + ); + + final editing = StreamBuilder( + stream: bloc.editing, + builder: (context, editingSnapshot) { + if (!editingSnapshot.hasData) { + return const SizedBox(); + } + + return buildContextMessage( + chatMessage: editingSnapshot.requireData!, + onDismiss: () { + bloc.removeEditChatMessage(); + controller + ..text = '' + ..selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + ); + }, ); }, ); @@ -230,6 +250,7 @@ class _TalkMessageInputState extends State { mainAxisSize: MainAxisSize.min, children: [ replyTo, + editing, inputField, ], ); @@ -258,6 +279,35 @@ class _TalkMessageInputState extends State { leading: icon, ); } + + Widget buildContextMessage({ + required spreed.$ChatMessageInterface chatMessage, + required VoidCallback onDismiss, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + const SizedBox.square( + dimension: 40, + ), + Expanded( + child: TalkParentMessage( + room: widget.room, + parentChatMessage: chatMessage, + lastCommonRead: null, + ), + ), + IconButton( + onPressed: () { + onDismiss(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + ); + } } class _Suggestion { diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_separate_edited.png b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_edited.png new file mode 100644 index 00000000000..c45e226e4c9 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/message_comment_message_separate_edited.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_input_edit.png b/packages/neon/neon_talk/test/goldens/message_input_edit.png new file mode 100644 index 00000000000..d4c496864e1 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/message_input_edit.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_input_reply.png b/packages/neon/neon_talk/test/goldens/message_input_reply.png new file mode 100644 index 00000000000..37a705e390d Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/message_input_reply.png differ diff --git a/packages/neon/neon_talk/test/goldens/message_system_message_hide.png b/packages/neon/neon_talk/test/goldens/message_system_message_hide.png deleted file mode 100644 index 1f8704416d3..00000000000 Binary files a/packages/neon/neon_talk/test/goldens/message_system_message_hide.png and /dev/null 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 deleted file mode 100644 index 0c3edf26fa6..00000000000 Binary files a/packages/neon/neon_talk/test/goldens/room_page_reply.png and /dev/null differ diff --git a/packages/neon/neon_talk/test/message_input_test.dart b/packages/neon/neon_talk/test/message_input_test.dart index 3c90e646456..fd3503258d3 100644 --- a/packages/neon/neon_talk/test/message_input_test.dart +++ b/packages/neon/neon_talk/test/message_input_test.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; @@ -13,11 +15,14 @@ import 'package:neon_framework/theme.dart'; import 'package:neon_framework/utils.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:neon_talk/src/widgets/message_input.dart'; import 'package:nextcloud/spreed.dart' as spreed; import 'package:provider/provider.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; import 'testing.dart'; @@ -49,20 +54,44 @@ Account mockTalkAccount() { } void main() { + late Account account; late TalkRoomBloc bloc; late spreed.Room room; + late BehaviorSubject replyTo; + late BehaviorSubject editing; + late ReferencesBloc referencesBloc; setUpAll(() { KeyboardVisibilityTesting.setVisibilityForTesting(true); + + tzdata.initializeTimeZones(); + tz.setLocalLocation(tz.getLocation('Europe/Berlin')); }); setUp(() { FakeNeonStorage.setup(); + account = mockTalkAccount(); + + replyTo = BehaviorSubject.seeded(null); + editing = BehaviorSubject.seeded(null); + bloc = MockRoomBloc(); - when(() => bloc.replyTo).thenAnswer((_) => BehaviorSubject.seeded(null)); + when(() => bloc.room).thenAnswer((_) => BehaviorSubject.seeded(Result.success(room))); + when(() => bloc.replyTo).thenAnswer((_) => replyTo); + when(() => bloc.editing).thenAnswer((_) => editing); room = MockRoom(); + when(() => room.token).thenReturn('token'); + + referencesBloc = MockReferencesBloc(); + when(() => referencesBloc.referenceRegex).thenAnswer((_) => BehaviorSubject.seeded(Result.success(null))); + when(() => referencesBloc.references).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + }); + + tearDown(() { + unawaited(replyTo.close()); + unawaited(editing.close()); }); testWidgets('Cupertino no emoji button', (tester) async { @@ -128,12 +157,6 @@ void main() { }); testWidgets('Mention suggestions', (tester) async { - final account = mockTalkAccount(); - - final room = MockRoom(); - when(() => room.token).thenReturn('token'); - when(() => bloc.room).thenAnswer((_) => BehaviorSubject.seeded(Result.success(room))); - await tester.pumpWidgetWithAccessibility( TestApp( localizationsDelegates: TalkLocalizations.localizationsDelegates, @@ -167,12 +190,6 @@ void main() { }); testWidgets('Multiline', (tester) async { - final account = mockTalkAccount(); - - final room = MockRoom(); - when(() => room.token).thenReturn('token'); - when(() => bloc.room).thenAnswer((_) => BehaviorSubject.seeded(Result.success(room))); - await tester.pumpWidgetWithAccessibility( TestApp( localizationsDelegates: TalkLocalizations.localizationsDelegates, @@ -211,4 +228,78 @@ void main() { verify(() => bloc.sendMessage('123\n456')).called(1); }); + + testWidgets('Reply', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + localizationsDelegates: TalkLocalizations.localizationsDelegates, + supportedLocales: TalkLocalizations.supportedLocales, + providers: [ + NeonProvider.value(value: bloc), + NeonProvider.value(value: referencesBloc), + Provider.value(value: account), + ], + child: TalkMessageInput( + room: room, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TalkParentMessage), findsNothing); + + final chatMessage = MockChatMessage(); + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => chatMessage.timestamp).thenReturn(0); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorDisplayName).thenReturn('test'); + when(() => chatMessage.message).thenReturn('message'); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + + replyTo.add(chatMessage); + await tester.pumpAndSettle(); + expect(find.byType(TalkParentMessage), findsOne); + await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/message_input_reply.png')); + + await tester.tap(find.byIcon(Icons.close)); + verify(() => bloc.removeReplyChatMessage()).called(1); + }); + + testWidgets('Edit', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + localizationsDelegates: TalkLocalizations.localizationsDelegates, + supportedLocales: TalkLocalizations.supportedLocales, + providers: [ + NeonProvider.value(value: bloc), + NeonProvider.value(value: referencesBloc), + Provider.value(value: account), + ], + child: TalkMessageInput( + room: room, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TalkParentMessage), findsNothing); + + final chatMessage = MockChatMessage(); + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => chatMessage.timestamp).thenReturn(0); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorDisplayName).thenReturn('test'); + when(() => chatMessage.message).thenReturn('message'); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + + editing.add(chatMessage); + await tester.pumpAndSettle(); + expect(find.byType(TalkParentMessage), findsOne); + expect(find.text('message'), findsExactly(2)); + await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/message_input_edit.png')); + + await tester.tap(find.byIcon(Icons.close)); + verify(() => bloc.removeEditChatMessage()).called(1); + expect(find.text('message'), findsOne); + }); } diff --git a/packages/neon/neon_talk/test/message_test.dart b/packages/neon/neon_talk/test/message_test.dart index bc1eb2ba8c2..347b466f292 100644 --- a/packages/neon/neon_talk/test/message_test.dart +++ b/packages/neon/neon_talk/test/message_test.dart @@ -20,6 +20,7 @@ import 'package:neon_talk/src/widgets/rich_object/deck_card.dart'; import 'package:neon_talk/src/widgets/rich_object/fallback.dart'; import 'package:neon_talk/src/widgets/rich_object/file.dart'; import 'package:neon_talk/src/widgets/rich_object/mention.dart'; +import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/spreed.dart' as spreed; import 'package:provider/provider.dart'; @@ -42,6 +43,46 @@ Widget wrapWidget({ child: child, ); +core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data buildCapabilities(core.SpreedCapabilities spreedCapabilities) => + core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data( + (b) => b + ..version.update( + (b) => b + ..major = 0 + ..minor = 0 + ..micro = 0 + ..string = '' + ..edition = '' + ..extendedSupport = false, + ) + ..capabilities = ( + commentsCapabilities: null, + coreCapabilities: null, + corePublicCapabilities: null, + davCapabilities: null, + dropAccountCapabilities: null, + filesCapabilities: null, + filesSharingCapabilities: null, + filesTrashbinCapabilities: null, + filesVersionsCapabilities: null, + notesCapabilities: null, + notificationsCapabilities: null, + provisioningApiCapabilities: null, + sharebymailCapabilities: null, + spreedCapabilities: null, + spreedPublicCapabilities: ( + builtListNever: null, + spreedPublicCapabilities0: core.SpreedPublicCapabilities0( + (b) => b.spreed.replace(spreedCapabilities), + ), + ) as core.SpreedPublicCapabilities, + systemtagsCapabilities: null, + themingPublicCapabilities: null, + userStatusCapabilities: null, + weatherStatusCapabilities: null, + ), + ); + void main() { late spreed.Room room; late ReferencesBloc referencesBloc; @@ -816,12 +857,69 @@ void main() { matchesGoldenFile('goldens/message_comment_message_separate_system_message.png'), ); }); + + testWidgets('Edited', (tester) async { + final account = MockAccount(); + when(() => account.id).thenReturn(''); + when(() => account.client).thenReturn(NextcloudClient(Uri.parse(''))); + + final previousChatMessage = MockChatMessage(); + when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => previousChatMessage.timestamp).thenReturn(0); + when(() => previousChatMessage.actorId).thenReturn('test'); + + final 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({'😀': 1, '😊': 23})); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.lastEditTimestamp).thenReturn(0); + when(() => chatMessage.lastEditActorDisplayName).thenReturn('test'); + + final roomBloc = MockRoomBloc(); + when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + ], + child: TalkCommentMessage( + room: room, + chatMessage: chatMessage, + lastCommonRead: null, + previousChatMessage: previousChatMessage, + ), + ), + ); + expect(find.byType(TalkActorAvatar), findsOne); + expect(find.text('1:00 AM'), findsOne); + expect(find.byTooltip('1/1/1970 1:00 AM'), findsOne); + expect(find.text('test'), findsOne); + expect(find.text(' (edited)'), findsOne); + expect(find.byTooltip('Last edited by test at 1/1/1970 1:00 AM'), findsOne); + expect(find.text('abc', findRichText: true), findsOne); + expect(find.byType(TalkReactions), findsOne); + expect(find.byType(SelectionArea), findsOne); + await expectLater( + find.byType(TalkCommentMessage), + matchesGoldenFile('goldens/message_comment_message_separate_edited.png'), + ); + }); }); group('Menu', () { late Account account; late spreed.ChatMessage chatMessage; late TalkRoomBloc roomBloc; + late core.SpreedCapabilities capabilities; + late CapabilitiesBloc capabilitiesBloc; setUp(() { account = MockAccount(); @@ -843,6 +941,54 @@ void main() { roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + + capabilities = core.SpreedCapabilities( + (b) => b + ..features.replace(['edit-messages']) + ..config.update( + (b) => b + ..attachments.update( + (b) => b.allowed = false, + ) + ..call.update( + (b) => b + ..enabled = false + ..breakoutRooms = false + ..recording = false + ..recordingConsent = 0 + ..canUploadBackground = false + ..sipEnabled = false + ..sipDialoutEnabled = false + ..canEnableSip = false, + ) + ..chat.update( + (b) => b + ..maxLength = 0 + ..readPrivacy = 0 + ..hasTranslationProviders = false + ..typingPrivacy = 0, + ) + ..conversations.update( + (b) => b.canCreate = false, + ) + ..previews.update( + (b) => b..maxGifSize = 0, + ) + ..signaling.update( + (b) => b..sessionPingLimit = 0, + ), + ) + ..version = '', + ); + + capabilitiesBloc = MockCapabilitiesBloc(); + when(() => capabilitiesBloc.capabilities).thenAnswer( + (_) => BehaviorSubject.seeded( + Result.success( + buildCapabilities(capabilities), + ), + ), + ); }); group('Add reaction', () { @@ -859,6 +1005,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -903,6 +1050,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -937,6 +1085,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -971,6 +1120,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -1009,6 +1159,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -1041,6 +1192,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -1063,6 +1215,149 @@ void main() { }); }); + group('Edit', () { + testWidgets('Comment self', (tester) async { + when(() => room.readOnly).thenReturn(0); + when(() => room.permissions).thenReturn(spreed.ParticipantPermission.canSendMessageAndShareAndReact.binary); + when(() => room.actorId).thenReturn('test'); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), + ], + child: TalkCommentMessage( + room: room, + 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.edit)); + await tester.pumpAndSettle(); + + verify(() => roomBloc.setEditChatMessage(chatMessage)).called(1); + }); + }); + + testWidgets('Comment other', (tester) async { + when(() => room.readOnly).thenReturn(0); + when(() => room.permissions).thenReturn(spreed.ParticipantPermission.canSendMessageAndShareAndReact.binary); + when(() => room.actorId).thenReturn('other'); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), + ], + child: TalkCommentMessage( + room: room, + 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(); + + expect(find.byIcon(Icons.edit), findsNothing); + }); + + testWidgets('Deleted', (tester) async { + when(() => room.readOnly).thenReturn(0); + when(() => room.permissions).thenReturn(spreed.ParticipantPermission.canSendMessageAndShareAndReact.binary); + + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.commentDeleted); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), + ], + child: TalkCommentMessage( + room: room, + 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(); + + expect(find.byIcon(Icons.more_vert), findsNothing); + }); + + testWidgets('No feature', (tester) async { + when(() => room.readOnly).thenReturn(0); + when(() => room.permissions).thenReturn(spreed.ParticipantPermission.canSendMessageAndShareAndReact.binary); + when(() => room.actorId).thenReturn('test'); + + capabilities = capabilities.rebuild((b) => b.features.clear()); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), + ], + child: TalkCommentMessage( + room: room, + 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(); + + expect(find.byIcon(Icons.edit), findsNothing); + }); + }); + group('Delete', () { testWidgets('Comment self', (tester) async { when(() => room.readOnly).thenReturn(0); @@ -1075,6 +1370,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -1113,6 +1409,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, @@ -1147,6 +1444,7 @@ void main() { Provider.value(value: account), NeonProvider.value(value: roomBloc), NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), ], child: TalkCommentMessage( room: room, diff --git a/packages/neon/neon_talk/test/room_bloc_test.dart b/packages/neon/neon_talk/test/room_bloc_test.dart index bdb379f24f7..100ae132809 100644 --- a/packages/neon/neon_talk/test/room_bloc_test.dart +++ b/packages/neon/neon_talk/test/room_bloc_test.dart @@ -42,7 +42,7 @@ Account mockTalkAccount() { ), }, RegExp(r'/ocs/v2\.php/apps/spreed/api/v1/chat/abcd/([0-9]+)'): { - 'delete': (match, queryParameters) { + 'delete': (match, bodyBytes) { final id = int.parse(match.group(1)!); return Response( @@ -66,6 +66,32 @@ Account mockTalkAccount() { }, ); }, + 'put': (match, bodyBytes) { + final id = int.parse(match.group(1)!); + final data = json.decode(utf8.decode(bodyBytes)) as Map; + final message = data['message'] as String; + + return Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': getChatMessage( + id: messageCount++, + systemMessage: 'message_edited', + messageType: spreed.MessageType.system, + parent: getChatMessage( + id: id, + message: message, + ), + ), + }, + }), + 200, + headers: { + 'content-type': 'application/json', + }, + ); + }, }, RegExp(r'/ocs/v2\.php/apps/spreed/api/v1/chat/abcd'): { 'get': (match, bodyBytes) async { @@ -385,6 +411,14 @@ void main() { null, ]), ); + expect( + roomBloc.editing, + emitsInOrder([ + null, + null, + null, + ]), + ); // The delay is necessary to avoid a race condition with loading twice at the same time await Future.delayed(const Duration(milliseconds: 1)); @@ -396,6 +430,48 @@ void main() { ..sendMessage(''); }); + test('Edit', () async { + final message = MockChatMessage(); + when(() => message.id).thenReturn(1); + + expect( + roomBloc.messages.transformResult((e) => BuiltList(e.map((m) => m.message))), + emitsInOrder([ + Result>.loading(), + Result.success(BuiltList(['', '', ''])), + Result.success(BuiltList(['', 'test', ''])), + ]), + ); + + expect( + roomBloc.editing, + emitsInOrder([ + null, + message, + null, + message, + null, + ]), + ); + expect( + roomBloc.replyTo, + emitsInOrder([ + null, + null, + 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 + ..setEditChatMessage(message) + ..removeEditChatMessage() + ..setEditChatMessage(message) + ..sendMessage('test'); + }); + 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 adcf7935ce1..c43005ca7f6 100644 --- a/packages/neon/neon_talk/test/room_page_test.dart +++ b/packages/neon/neon_talk/test/room_page_test.dart @@ -54,6 +54,7 @@ void main() { .thenAnswer((_) => BehaviorSubject.seeded(Result.success(BuiltList()))); when(() => bloc.lastCommonRead).thenAnswer((_) => BehaviorSubject.seeded(0)); when(() => bloc.replyTo).thenAnswer((_) => BehaviorSubject.seeded(null)); + when(() => bloc.editing).thenAnswer((_) => BehaviorSubject.seeded(null)); referencesBloc = MockReferencesBloc(); when(() => referencesBloc.referenceRegex).thenAnswer((_) => BehaviorSubject.seeded(Result.success(null))); @@ -216,51 +217,4 @@ 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), - NeonProvider.value(value: referencesBloc), - ], - 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()); - }); } diff --git a/packages/neon_framework/lib/blocs.dart b/packages/neon_framework/lib/blocs.dart index f37187246da..375537c17ed 100644 --- a/packages/neon_framework/lib/blocs.dart +++ b/packages/neon_framework/lib/blocs.dart @@ -1,6 +1,7 @@ export 'package:neon_framework/src/bloc/bloc.dart'; export 'package:neon_framework/src/bloc/result.dart'; export 'package:neon_framework/src/blocs/apps.dart'; +export 'package:neon_framework/src/blocs/capabilities.dart'; export 'package:neon_framework/src/blocs/references.dart'; export 'package:neon_framework/src/blocs/timer.dart'; export 'package:neon_framework/src/blocs/user_details.dart'; diff --git a/packages/neon_framework/lib/src/app.dart b/packages/neon_framework/lib/src/app.dart index 8b5b5f3fec5..a7873b91ea0 100644 --- a/packages/neon_framework/lib/src/app.dart +++ b/packages/neon_framework/lib/src/app.dart @@ -10,7 +10,6 @@ import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/l10n/localizations.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/blocs/maintenance_mode.dart'; import 'package:neon_framework/src/blocs/unified_search.dart'; import 'package:neon_framework/src/models/account.dart'; diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index e2c434fa648..2429359339a 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -7,7 +7,6 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/blocs/maintenance_mode.dart'; import 'package:neon_framework/src/blocs/unified_search.dart'; import 'package:neon_framework/src/models/account.dart'; diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index fbff01deb81..ee196a218f7 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -9,7 +9,6 @@ import 'package:neon_framework/models.dart'; import 'package:neon_framework/platform.dart'; import 'package:neon_framework/settings.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/exportable.dart'; diff --git a/packages/neon_framework/lib/src/widgets/dialog.dart b/packages/neon_framework/lib/src/widgets/dialog.dart index 7ffcc733a24..896e339cfa7 100644 --- a/packages/neon_framework/lib/src/widgets/dialog.dart +++ b/packages/neon_framework/lib/src/widgets/dialog.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/relative_time.dart'; diff --git a/packages/neon_framework/lib/src/widgets/drawer.dart b/packages/neon_framework/lib/src/widgets/drawer.dart index 7042c7a8a42..40133537bdc 100644 --- a/packages/neon_framework/lib/src/widgets/drawer.dart +++ b/packages/neon_framework/lib/src/widgets/drawer.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer_destination.dart'; import 'package:neon_framework/src/widgets/error.dart'; diff --git a/packages/neon_framework/test/drawer_test.dart b/packages/neon_framework/test/drawer_test.dart index 5e565c20a63..a740ae61ccf 100644 --- a/packages/neon_framework/test/drawer_test.dart +++ b/packages/neon_framework/test/drawer_test.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer.dart'; diff --git a/packages/neon_framework/test/router_test.dart b/packages/neon_framework/test/router_test.dart index 8c06816df42..7729da6d2c8 100644 --- a/packages/neon_framework/test/router_test.dart +++ b/packages/neon_framework/test/router_test.dart @@ -4,7 +4,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; -import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/router.dart'; import 'package:neon_framework/testing.dart'; import 'package:neon_framework/utils.dart'; diff --git a/packages/nextcloud/test/fixtures/spreed/chat/edit_message.regexp b/packages/nextcloud/test/fixtures/spreed/chat/edit_message.regexp new file mode 100644 index 00000000000..18b515b7475 --- /dev/null +++ b/packages/nextcloud/test/fixtures/spreed/chat/edit_message.regexp @@ -0,0 +1,18 @@ +POST http://localhost/ocs/v2\.php/apps/spreed/api/v4/room +accept: application/json +authorization: Bearer mock +content-type: application/json; charset=utf-8 +ocs-apirequest: true +\{"roomType":3,"invite":"","roomName":"Test","source":"","objectType":"","objectId":""\} +POST http://localhost/ocs/v2\.php/apps/spreed/api/v1/chat/[a-z0-9]{8} +accept: application/json +authorization: Bearer mock +content-type: application/json; charset=utf-8 +ocs-apirequest: true +\{"message":"bla","actorDisplayName":"","referenceId":"","replyTo":0,"silent":false\} +PUT http://localhost/ocs/v2\.php/apps/spreed/api/v1/chat/[a-z0-9]{8}/[0-9]+ +accept: application/json +authorization: Bearer mock +content-type: application/json; charset=utf-8 +ocs-apirequest: true +\{"message":"123"\} \ No newline at end of file diff --git a/packages/nextcloud/test/spreed_test.dart b/packages/nextcloud/test/spreed_test.dart index f814bbb7664..3fa8481b334 100644 --- a/packages/nextcloud/test/spreed_test.dart +++ b/packages/nextcloud/test/spreed_test.dart @@ -267,6 +267,48 @@ void main() { expect(response.body.ocs.data!.messageType, spreed.MessageType.comment); }); + test( + 'Edit message', + () async { + final startTime = DateTime.timestamp(); + final room = await createTestRoom(); + + final messageResponse = await client1.spreed.chat.sendMessage( + token: room.token, + $body: spreed.ChatSendMessageRequestApplicationJson( + (b) => b..message = 'bla', + ), + ); + + final response = await client1.spreed.chat.editMessage( + token: room.token, + messageId: messageResponse.body.ocs.data!.id, + $body: spreed.ChatEditMessageRequestApplicationJson( + (b) => b..message = '123', + ), + ); + expect(response.body.ocs.data.id, isPositive); + expect(response.body.ocs.data.actorType, spreed.ActorType.users); + expect(response.body.ocs.data.actorId, 'user1'); + expect(response.body.ocs.data.actorDisplayName, 'User One'); + expect(response.body.ocs.data.message, 'You edited a message'); + expect(response.body.ocs.data.messageType, spreed.MessageType.system); + expect(response.body.ocs.data.systemMessage, 'message_edited'); + expect(response.body.ocs.data.parent!.id, messageResponse.body.ocs.data!.id); + expect(response.body.ocs.data.parent!.actorType, spreed.ActorType.users); + expect(response.body.ocs.data.parent!.actorId, 'user1'); + expect(response.body.ocs.data.parent!.actorDisplayName, 'User One'); + expect(response.body.ocs.data.parent!.message, '123'); + expect(response.body.ocs.data.parent!.messageType, spreed.MessageType.comment); + expect(response.body.ocs.data.parent!.systemMessage, ''); + expect(response.body.ocs.data.parent!.lastEditTimestamp, closeTo(startTime.secondsSinceEpoch, 10)); + expect(response.body.ocs.data.parent!.lastEditActorId, 'user1'); + expect(response.body.ocs.data.parent!.lastEditActorDisplayName, 'User One'); + expect(response.body.ocs.data.parent!.lastEditActorType, spreed.ActorType.users); + }, + skip: preset.version < Version(19, 0, 0), + ); + group('Get messages', () { test('Directly', () async { final startTime = DateTime.timestamp();