From 2f74c5bb9053f7dad7065d2558e7b4aea066ec43 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 30 Oct 2024 11:40:55 +0100 Subject: [PATCH 1/2] refactor(talk_app): Extract message action checks Signed-off-by: provokateurin --- .../packages/talk_app/lib/src/pages/room.dart | 6 +---- .../talk_app/lib/src/utils/helpers.dart | 27 +++++++++++++++++++ .../talk_app/lib/src/widgets/message.dart | 18 +++---------- .../talk_app/lib/src/widgets/reactions.dart | 6 ++--- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart b/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart index 7852157cbc0..09f8f7094fe 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart @@ -6,7 +6,6 @@ import 'package:intl/intl.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; -import 'package:nextcloud/spreed.dart' as spreed; import 'package:talk_app/src/blocs/room.dart'; import 'package:talk_app/src/theme.dart'; import 'package:talk_app/src/utils/helpers.dart'; @@ -176,10 +175,7 @@ class _TalkRoomPageState extends State { ), ); - if (room.readOnly == 0 && - spreed.ParticipantPermission.values - .byBinary(room.permissions) - .contains(spreed.ParticipantPermission.canSendMessageAndShareAndReact)) { + if (canSendMessageAndShareAndReact(room)) { body = Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, diff --git a/packages/neon_framework/packages/talk_app/lib/src/utils/helpers.dart b/packages/neon_framework/packages/talk_app/lib/src/utils/helpers.dart index d227d0416e7..aafbbfbd877 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/utils/helpers.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/utils/helpers.dart @@ -51,3 +51,30 @@ bool hasFeature(BuildContext context, String feature) { return capabilities.features.contains(feature); } + +/// Checks whether the user is allowed to send chat messages in a [room]. +bool canSendMessageAndShareAndReact(spreed.Room room) { + return room.readOnly == 0 && + spreed.ParticipantPermission.values + .byBinary(room.permissions) + .contains(spreed.ParticipantPermission.canSendMessageAndShareAndReact); +} + +/// Checks whether the user is allowed to reply to a [chatMessage] in a [room]. +bool canReplyToMessage(spreed.Room room, spreed.$ChatMessageInterface chatMessage) { + return canSendMessageAndShareAndReact(room) && + chatMessage.messageType != spreed.MessageType.commentDeleted && + chatMessage.isReplyable; +} + +/// Checks whether the user is allowed to edit a [chatMessage] in a [room]. +bool canEditMessage(BuildContext context, spreed.Room room, spreed.$ChatMessageInterface chatMessage) { + return chatMessage.messageType != spreed.MessageType.commentDeleted && + chatMessage.actorId == room.actorId && + hasFeature(context, 'edit-messages'); +} + +/// Checks whether the user is allowed to delete a [chatMessage] in a [room]. +bool canDeleteMessage(spreed.Room room, spreed.$ChatMessageInterface chatMessage) { + return chatMessage.messageType != spreed.MessageType.commentDeleted && chatMessage.actorId == room.actorId; +} diff --git a/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart b/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart index d99d5dc2cd9..05c1da8654b 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart @@ -517,15 +517,8 @@ class _TalkCommentMessageState extends State { spreed.Room room, spreed.$ChatMessageInterface chatMessage, ) { - final readOnly = room.readOnly == 1; - final permissions = spreed.ParticipantPermission.values.byBinary(room.permissions); - final hasChatPermission = permissions.contains(spreed.ParticipantPermission.canSendMessageAndShareAndReact); - return [ - if (widget.chatMessage.messageType != spreed.MessageType.commentDeleted && - chatMessage.isReplyable && - !readOnly && - hasChatPermission) + if (canReplyToMessage(room, chatMessage)) ( icon: const Icon(Icons.add_reaction_outlined), child: Text(TalkLocalizations.of(context).roomMessageReaction), @@ -546,18 +539,13 @@ class _TalkCommentMessageState extends State { NeonProvider.of(context).addReaction(chatMessage, reaction); }, ), - if (widget.chatMessage.messageType != spreed.MessageType.commentDeleted && - chatMessage.isReplyable && - !readOnly && - hasChatPermission) + if (canReplyToMessage(room, chatMessage)) ( icon: const Icon(Icons.reply), child: Text(TalkLocalizations.of(context).roomMessageReply), onPressed: () => NeonProvider.of(context).setReplyChatMessage(chatMessage), ), - if (chatMessage.messageType != spreed.MessageType.commentDeleted && - chatMessage.actorId == room.actorId && - hasFeature(context, 'edit-messages')) + if (canEditMessage(context, room, chatMessage)) ( icon: const Icon(Icons.edit), child: Text(TalkLocalizations.of(context).roomMessageEdit), diff --git a/packages/neon_framework/packages/talk_app/lib/src/widgets/reactions.dart b/packages/neon_framework/packages/talk_app/lib/src/widgets/reactions.dart index 961e22ac4de..2551d4d3f50 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/widgets/reactions.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/widgets/reactions.dart @@ -5,6 +5,7 @@ import 'package:neon_framework/widgets.dart'; import 'package:nextcloud/spreed.dart' as spreed; import 'package:talk_app/l10n/localizations.dart'; import 'package:talk_app/src/blocs/room.dart'; +import 'package:talk_app/src/utils/helpers.dart'; import 'package:talk_app/src/widgets/reactions_overview_dialog.dart'; /// Widget for displaying the current reactions on a chat message including the ability to add and remove reactions. @@ -26,10 +27,7 @@ class TalkReactions extends StatelessWidget { Widget build(BuildContext context) { final bloc = NeonProvider.of(context); - final canUpdateReactions = room.readOnly == 0 && - spreed.ParticipantPermission.values - .byBinary(room.permissions) - .contains(spreed.ParticipantPermission.canSendMessageAndShareAndReact); + final canUpdateReactions = canSendMessageAndShareAndReact(room); const shape = RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(50)), From 1e4928e9a8b66745395c6734e2d8c9959e5b5876 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 30 Oct 2024 11:41:29 +0100 Subject: [PATCH 2/2] feat(talk_app): Implement swipe-to-reply Signed-off-by: provokateurin --- .../packages/talk_app/lib/src/pages/room.dart | 13 +++ .../talk_app/test/room_page_test.dart | 104 ++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart b/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart index 09f8f7094fe..470397152e8 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/pages/room.dart @@ -123,6 +123,19 @@ class _TalkRoomPageState extends State { previousChatMessage: previousMessage, ); + if (canReplyToMessage(room, message)) { + child = Dismissible( + key: Key(message.id.toString()), + confirmDismiss: (_) async { + bloc.setReplyChatMessage(message); + + // We don't use the real dismiss feature as we don't want the widget to be removed from the list + return false; + }, + child: child, + ); + } + if (previousMessage == null || (tz.local.translate(previousMessage.timestamp * 1000) ~/ _millisecondsPerDay) != (tz.local.translate(message.timestamp * 1000) ~/ _millisecondsPerDay)) { diff --git a/packages/neon_framework/packages/talk_app/test/room_page_test.dart b/packages/neon_framework/packages/talk_app/test/room_page_test.dart index ba124538f0f..114be2dba46 100644 --- a/packages/neon_framework/packages/talk_app/test/room_page_test.dart +++ b/packages/neon_framework/packages/talk_app/test/room_page_test.dart @@ -236,4 +236,108 @@ void main() { expect(find.byType(TalkMessageInput), findsNothing); await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/room_page_no_chat_permission.png')); }); + + group('Swipe-to-reply', () { + late spreed.ChatMessageWithParent chatMessage; + + setUp(() { + chatMessage = MockChatMessageWithParent(); + 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); + + when(() => bloc.messages).thenAnswer( + (_) => BehaviorSubject.seeded( + Result.success( + BuiltList([ + chatMessage, + ]), + ), + ), + ); + + when(() => bloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + }); + + testWidgets('Allowed', (tester) async { + final account = MockAccount(); + + 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(), + ), + ); + + expect(find.byType(Dismissible), findsOne); + + await tester.drag(find.byType(TalkCommentMessage), const Offset(1000, 0)); + await tester.pumpAndSettle(); + verify(() => bloc.setReplyChatMessage(chatMessage)).called(1); + }); + + testWidgets('Read-only', (tester) async { + when(() => room.readOnly).thenReturn(1); + + final account = MockAccount(); + + 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(), + ), + ); + + expect(find.byType(Dismissible), findsNothing); + }); + + testWidgets('No permission', (tester) async { + when(() => room.permissions).thenReturn(0); + + final account = MockAccount(); + + 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(), + ), + ); + + expect(find.byType(Dismissible), findsNothing); + }); + }); }