From d339dd8ed26073beb60ab490dd391cd542370875 Mon Sep 17 00:00:00 2001 From: Dat Vu Date: Mon, 25 Nov 2024 18:24:28 +0700 Subject: [PATCH 01/28] TF-3298 Fix blink when refresh email list (#3299) --- .../data/extensions/list_email_extension.dart | 23 ++- .../thread/data/network/thread_api.dart | 49 ++--- ...ting_list_email_by_order_id_list_test.dart | 54 ----- .../extensions/list_email_extension_test.dart | 166 +++++++++++++++ .../thread/data/network/thread_api_test.dart | 195 ++++++++++++++++++ 5 files changed, 399 insertions(+), 88 deletions(-) delete mode 100644 test/features/email/sorting_list_email_by_order_id_list_test.dart create mode 100644 test/features/thread/data/extensions/list_email_extension_test.dart diff --git a/lib/features/thread/data/extensions/list_email_extension.dart b/lib/features/thread/data/extensions/list_email_extension.dart index 12aebbbb7f..9d4a22b5e4 100644 --- a/lib/features/thread/data/extensions/list_email_extension.dart +++ b/lib/features/thread/data/extensions/list_email_extension.dart @@ -17,23 +17,24 @@ extension ListEmailExtension on List { }; } - List sortingByOrderOfIdList(List ids) { - if (ids.length != length) { - return this; - } + List sortEmailsById(List referenceIds) { + final indexMap = { + for (var i = 0; i < referenceIds.length; i++) + referenceIds[i]: i + }; sort((email1, email2) { - final id1 = email1.id?.id; - final id2 = email2.id?.id; + final emailId1 = email1.id?.id; + final emailId2 = email2.id?.id; - if (id1 == null || id2 == null) { - return 0; + if (emailId1 == null || emailId2 == null) { + return emailId1 == null ? 1 : -1; } - final index1 = ids.indexWhere((id) => id == id1); - final index2 = ids.indexWhere((id) => id == id2); + final indexEmail1 = indexMap[emailId1] ?? double.maxFinite; + final indexEmail2 = indexMap[emailId2] ?? double.maxFinite; - return index1.compareTo(index2); + return indexEmail1.compareTo(indexEmail2); }); return this; diff --git a/lib/features/thread/data/network/thread_api.dart b/lib/features/thread/data/network/thread_api.dart index 863309f1cc..bdb0b57d47 100644 --- a/lib/features/thread/data/network/thread_api.dart +++ b/lib/features/thread/data/network/thread_api.dart @@ -20,7 +20,6 @@ import 'package:jmap_dart_client/jmap/mail/email/get/get_email_method.dart'; import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart'; import 'package:jmap_dart_client/jmap/mail/email/query/query_email_method.dart'; import 'package:jmap_dart_client/jmap/mail/email/query/query_email_response.dart'; -import 'package:model/extensions/list_email_extension.dart'; import 'package:tmail_ui_user/features/thread/data/extensions/list_email_extension.dart'; import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet.dart'; import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet_get_method.dart'; @@ -91,24 +90,33 @@ class ThreadAPI { QueryEmailResponse.deserialize, ); - List? emailList; + final emailList = sortEmails( + getEmailResponse: responseOfGetEmailMethod, + queryEmailResponse: responseOfQueryEmailMethod, + ); - if (responseOfGetEmailMethod?.list.isNotEmpty == true && - responseOfQueryEmailMethod?.ids.isNotEmpty == true) { - log('ThreadAPI::getAllEmail: QUERY_EMAIL_IDS = ${responseOfQueryEmailMethod?.ids}'); - final listSortedEmail = responseOfGetEmailMethod!.list - .sortingByOrderOfIdList(responseOfQueryEmailMethod!.ids.toList()); - emailList = listSortedEmail; - } else { - emailList = responseOfGetEmailMethod?.list; - } - log('ThreadAPI::getAllEmail: EMAIL_DISPLAYED_IDS = ${emailList?.listEmailIds}'); return EmailsResponse( emailList: emailList, state: responseOfGetEmailMethod?.state, ); } + List? sortEmails({ + GetEmailResponse? getEmailResponse, + QueryEmailResponse? queryEmailResponse, + }) { + final listEmails = getEmailResponse?.list; + final listIds = queryEmailResponse?.ids.toList(); + + if (listEmails?.isNotEmpty != true || listIds?.isNotEmpty != true) { + return listEmails; + } + + final listSortedEmails = listEmails!.sortEmailsById(listIds!); + + return listSortedEmails; + } + Future searchEmails( Session session, AccountId accountId, @@ -166,21 +174,16 @@ class ThreadAPI { .build() .execute(); - final emailResultList = result.parse( + final responseOfGetEmailMethod = result.parse( getEmailInvocation.methodCallId, GetEmailResponse.deserialize); final responseOfQueryEmailMethod = result.parse( queryEmailInvocation.methodCallId, QueryEmailResponse.deserialize); - List? sortedEmailList; - - if (emailResultList?.list.isNotEmpty == true && - responseOfQueryEmailMethod?.ids.isNotEmpty == true) { - sortedEmailList = emailResultList!.list - .sortingByOrderOfIdList(responseOfQueryEmailMethod!.ids.toList()); - } else { - sortedEmailList = emailResultList?.list; - } + final sortedEmailList = sortEmails( + getEmailResponse: responseOfGetEmailMethod, + queryEmailResponse: responseOfQueryEmailMethod, + ); final searchSnippets = _getSearchSnippetsFromResponse( result, @@ -188,7 +191,7 @@ class ThreadAPI { ); return SearchEmailsResponse( emailList: sortedEmailList, - state: emailResultList?.state, + state: responseOfGetEmailMethod?.state, searchSnippets: searchSnippets); } diff --git a/test/features/email/sorting_list_email_by_order_id_list_test.dart b/test/features/email/sorting_list_email_by_order_id_list_test.dart deleted file mode 100644 index ab17742d2e..0000000000 --- a/test/features/email/sorting_list_email_by_order_id_list_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:jmap_dart_client/jmap/core/id.dart'; -import 'package:jmap_dart_client/jmap/mail/email/email.dart'; -import 'package:tmail_ui_user/features/thread/data/extensions/list_email_extension.dart'; - -void main() { - group('sorting_list_email_by_order_id_list test', () { - test('sortingByOrderOfIdList method should return an ordered list of ids when of the same length', () { - List ids = [ - Id('a'), - Id('b'), - Id('c'), - Id('d'), - Id('e') - ]; - List emails = [ - Email(id: EmailId(Id('a'))), - Email(id: EmailId(Id('c'))), - Email(id: EmailId(Id('e'))), - Email(id: EmailId(Id('d'))), - Email(id: EmailId(Id('b'))) - ]; - - List sortedEmails = emails.sortingByOrderOfIdList(ids); - - expect( - sortedEmails.map((e) => e.id?.id.value), - equals(['a', 'b', 'c', 'd', 'e']) - ); - }); - - test('sortingByOrderOfIdList method should return the original list when the length of the two lists is different', () { - List ids = [ - Id('a'), - Id('b'), - Id('c'), - ]; - List emails = [ - Email(id: EmailId(Id('a'))), - Email(id: EmailId(Id('c'))), - Email(id: EmailId(Id('e'))), - Email(id: EmailId(Id('d'))), - Email(id: EmailId(Id('b'))) - ]; - - List sortedEmails = emails.sortingByOrderOfIdList(ids); - - expect( - sortedEmails.map((e) => e.id?.id.value), - equals(['a', 'c', 'e', 'd', 'b']) - ); - }); - }); -} \ No newline at end of file diff --git a/test/features/thread/data/extensions/list_email_extension_test.dart b/test/features/thread/data/extensions/list_email_extension_test.dart new file mode 100644 index 0000000000..bb34142cd4 --- /dev/null +++ b/test/features/thread/data/extensions/list_email_extension_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/extensions/email_id_extensions.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/list_email_extension.dart'; + +void main() { + group('ListEmailExtension::sortEmailsById::test', () { + test('Sort the full list', () { + final referenceIds = [ + Id('id1'), + Id('id2'), + Id('id3'), + Id('id4'), + ]; + + final emails = [ + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id4'))), + Email(id: EmailId(Id('id2'))), + ]; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + ['id1', 'id2', 'id3', 'id4'], + ); + }); + + test('Emails list has more elements than referenceIds', () { + final referenceIds = [ + Id('id1'), + Id('id2'), + Id('id3'), + ]; + + final emails = [ + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id4'))), + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id5'))), + Email(id: EmailId(Id('id2'))), + ]; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + ['id1', 'id2', 'id3', 'id4', 'id5'], + ); + }); + + test('Emails list has fewer elements than referenceIds', () { + final referenceIds = [ + Id('id1'), + Id('id2'), + Id('id3'), + Id('id4'), + ]; + + final emails = [ + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id1'))), + ]; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + ['id1', 'id3'], + ); + }); + + test('Emails list is empty', () { + final referenceIds = [ + Id('id1'), + Id('id2'), + Id('id3'), + ]; + + final emails = []; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + [], + ); + }); + + test('ReferenceIds list is empty', () { + final referenceIds = []; + + final emails = [ + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id4'))), + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id5'))), + ]; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + ['id3', 'id4', 'id1', 'id5'], + ); + }); + + test('Both lists are empty', () { + final referenceIds = []; + final emails = []; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + [], + ); + }); + + test('Emails have ids that do not match referenceIds', () { + final referenceIds = [ + Id('id1'), + Id('id2'), + ]; + + final emails = [ + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id4'))), + Email(id: EmailId(Id('id5'))), + ]; + + final result = emails.sortEmailsById(referenceIds); + + expect( + result.map((e) => e.id!.asString).toList(), + ['id3', 'id4', 'id5'], + ); + }); + + test('should keep emails with null IDs in their original order at the end', () { + // Arrange + final referenceIds = [ + Id('id2'), + Id('id1'), + ]; + + final emails = [ + Email(id: EmailId(Id('id1'))), + Email(id: null), + Email(id: EmailId(Id('id2'))), + ]; + + // Act + final sortedEmails = emails.sortEmailsById(referenceIds); + + // Assert + expect( + sortedEmails.map((e) => e.id?.asString).toList(), + ['id2', 'id1', null], + ); + }); + }); +} diff --git a/test/features/thread/data/network/thread_api_test.dart b/test/features/thread/data/network/thread_api_test.dart index 1a236dab08..8bf3a05f4c 100644 --- a/test/features/thread/data/network/thread_api_test.dart +++ b/test/features/thread/data/network/thread_api_test.dart @@ -2,13 +2,18 @@ import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/error/method/error_method_response.dart'; import 'package:jmap_dart_client/jmap/core/filter/filter.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart'; +import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart'; +import 'package:jmap_dart_client/jmap/mail/email/query/query_email_response.dart'; import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet.dart'; +import 'package:model/extensions/email_id_extensions.dart'; import 'package:tmail_ui_user/features/thread/data/network/thread_api.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_email.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_emails_response.dart'; @@ -16,6 +21,31 @@ import 'package:tmail_ui_user/features/thread/domain/model/search_emails_respons import '../../../../fixtures/account_fixtures.dart'; import '../../../../fixtures/session_fixtures.dart'; +class MockGetEmailResponse extends GetEmailResponse { + final List emailList; + + MockGetEmailResponse(this.emailList) : super( + AccountId(Id('abc')), + State('123'), + emailList, + [], + ); +} + +class MockQueryEmailResponse extends QueryEmailResponse { + final Set idList; + + MockQueryEmailResponse(this.idList) : super( + AccountId(Id('abc')), + State('123'), + false, + UnsignedInt(0), + idList, + UnsignedInt(0), + UnsignedInt(0), + ); +} + void main() { final baseOption = BaseOptions(method: 'POST'); final dio = Dio(baseOption)..options.baseUrl = 'http://domain.com/jmap'; @@ -228,5 +258,170 @@ void main() { ); }); }); + + group('sortEmails::test', () { + test('Should returns emails as is when emailList is empty', () { + final getEmailResponse = MockGetEmailResponse([]); + final queryEmailResponse = MockQueryEmailResponse({ + Id('id1'), + Id('id2'), + }); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect(result, []); + }); + + test('Should returns emails as is when idList is empty', () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + ]); + final queryEmailResponse = MockQueryEmailResponse({}); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect(result, [ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + ]); + }); + + test('Sorts emails according to idList', () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + Email(id: EmailId(Id('id3'))), + ]); + final queryEmailResponse = MockQueryEmailResponse({ + Id('id2'), + Id('id3'), + Id('id1'), + }); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect( + result?.map((e) => e.id!.asString).toList(), + ['id2', 'id3', 'id1'], + ); + }); + + test('Should returns null if getEmailResponse is null', () { + const getEmailResponse = null; + final queryEmailResponse = MockQueryEmailResponse({Id('id1')}); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect(result, isNull); + }); + + test('Should returns emails as is when queryEmailResponse is null', () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + ]); + const queryEmailResponse = null; + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect( + result, + [ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + ], + ); + }); + + test('Should remain in original order when the emailList contains emails whose id does not appear in idList', () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + Email(id: EmailId(Id('id3'))), + ]); + final queryEmailResponse = MockQueryEmailResponse({ + Id('id4'), + Id('id5'), + }); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect( + result?.map((e) => e.id!.asString).toList(), + ['id1', 'id2', 'id3'], + ); + }); + + test( + 'Should still be sorted according to the ids that are present in emailList\n' + 'when idList contains ids that do not match any in emailList', + () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + ]); + final queryEmailResponse = MockQueryEmailResponse({ + Id('id1'), + Id('id2'), + Id('id3'), + }); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect( + result?.map((e) => e.id!.asString).toList(), + ['id1', 'id2'], + ); + }); + + test( + 'When both emailList and idList have ids that do not match\n' + 'only the emails whose ids appear in both lists are sorted according to the order in idList\n' + 'and emails that are not in idList are preserved in their original positions in the final list', + () { + final getEmailResponse = MockGetEmailResponse([ + Email(id: EmailId(Id('id1'))), + Email(id: EmailId(Id('id2'))), + Email(id: EmailId(Id('id3'))), + Email(id: EmailId(Id('id4'))), + ]); + final queryEmailResponse = MockQueryEmailResponse({ + Id('id3'), + Id('id1'), + }); + + final result = threadApi.sortEmails( + getEmailResponse: getEmailResponse, + queryEmailResponse: queryEmailResponse, + ); + + expect( + result?.map((e) => e.id!.asString).toList(), + ['id3', 'id1', 'id2', 'id4'], + ); + }); + }); }); } \ No newline at end of file From e50a23d0f1a1b14e806c202c8e4c3629e1751674 Mon Sep 17 00:00:00 2001 From: DatDang Date: Wed, 27 Nov 2024 09:57:02 +0700 Subject: [PATCH 02/28] Update TMail backend docker to memory-1.0.0 --- backend-docker/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-docker/docker-compose.yaml b/backend-docker/docker-compose.yaml index 36f851616e..c42db448ca 100644 --- a/backend-docker/docker-compose.yaml +++ b/backend-docker/docker-compose.yaml @@ -2,7 +2,7 @@ version: "3" services: tmail-backend: - image: linagora/tmail-backend:memory-branch-master + image: linagora/tmail-backend:memory-1.0.0 container_name: tmail-backend volumes: - ./jwt_publickey:/root/conf/jwt_publickey From 1a58542abd0176a93886f102ced087df848347a9 Mon Sep 17 00:00:00 2001 From: DatDang Date: Mon, 18 Nov 2024 16:08:32 +0700 Subject: [PATCH 03/28] TF-3265 Fix double scrolling composer --- pubspec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 86c17e2dc2..f232125162 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1171,10 +1171,10 @@ packages: description: path: "." ref: main - resolved-ref: "3274d7fe8c711e7466890b6a3948e9708877ca4a" + resolved-ref: "2459882d3fb6c3223186c3b50e54e0e22cc29d09" url: "https://github.com/linagora/html-editor-enhanced.git" source: git - version: "3.1.1" + version: "3.1.2" http: dependency: transitive description: From c4757351b73ff7d9bdbae981338778774359f69f Mon Sep 17 00:00:00 2001 From: dab246 Date: Sat, 23 Nov 2024 02:43:53 +0700 Subject: [PATCH 04/28] TF-3291 Fix [SEARCH] No results if you add > 1 address in the From field --- .../presentation/model/search/search_email_filter.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart index cb6b3f5628..d5b4846e0c 100644 --- a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart +++ b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart @@ -127,7 +127,7 @@ class SearchEmailFilter with EquatableMixin, OptionParamMixin { ..._generateFilterFromToField(), if (from.length > 1) LogicFilterOperator( - Operator.AND, + Operator.OR, from.map((e) => EmailFilterCondition(from: e)).toSet(), ), if (notKeyword.length > 1) From 2128a6ea2b6a0405c9c1d2a3ced7247533098eec Mon Sep 17 00:00:00 2001 From: Dat Dang Date: Thu, 28 Nov 2024 17:46:01 +0700 Subject: [PATCH 05/28] TF-3264 Fix composer drag drop all modes (#3279) --- .../composer/presentation/composer_controller.dart | 12 ++++++++++++ .../composer/presentation/composer_view_web.dart | 6 ++++++ .../presentation/view/web/web_editor_view.dart | 8 ++++++++ .../presentation/widgets/web/web_editor_widget.dart | 5 ++++- pubspec.lock | 2 +- 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index 4e843153dd..c09f569a42 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -202,6 +202,7 @@ class ComposerController extends BaseController SignatureStatus _identityContentOnOpenPolicy = SignatureStatus.editedAvailable; int? _savedEmailDraftHash; bool _restoringSignatureButton = false; + GlobalKey? responsiveContainerKey; @visibleForTesting bool get restoringSignatureButton => _restoringSignatureButton; @@ -232,6 +233,7 @@ class ComposerController extends BaseController void onInit() { super.onInit(); if (PlatformInfo.isWeb) { + responsiveContainerKey = GlobalKey(); richTextWebController = getBinding(); } else { richTextMobileTabletController = getBinding(); @@ -276,6 +278,7 @@ class ComposerController extends BaseController richTextMobileTabletController = null; } _identityContentOnOpenPolicy = SignatureStatus.editedAvailable; + responsiveContainerKey = null; super.onClose(); } @@ -1493,6 +1496,9 @@ class ComposerController extends BaseController } void displayScreenTypeComposerAction(ScreenDisplayMode displayMode) async { + if (screenDisplayMode.value == ScreenDisplayMode.minimize) { + _isEmailBodyLoaded = false; + } if (richTextWebController != null && screenDisplayMode.value != ScreenDisplayMode.minimize) { final textCurrent = await richTextWebController!.editorController.getText(); richTextWebController!.editorController.setText(textCurrent); @@ -2190,6 +2196,12 @@ class ComposerController extends BaseController } } + void handleOnDragOverHtmlEditorWeb(List? types) { + if (types.validateFilesTransfer) { + mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.active; + } + } + void onLocalFileDropZoneListener({ required BuildContext context, required DropDoneDetails details, diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 907f9eb386..1fc90ffdbd 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -186,6 +186,7 @@ class ComposerView extends GetWidget { child: Padding( padding: ComposerStyle.mobileEditorPadding, child: Obx(() => WebEditorView( + key: controller.responsiveContainerKey, editorController: controller.richTextWebController!.editorController, arguments: controller.composerArguments.value, contentViewState: controller.emailContentsViewState.value, @@ -199,6 +200,7 @@ class ComposerView extends GetWidget { width: constraints.maxWidth, height: constraints.maxHeight, onDragEnter: controller.handleOnDragEnterHtmlEditorWeb, + onDragOver: controller.handleOnDragOverHtmlEditorWeb, onPasteImageSuccessAction: (listFileUpload) => controller.handleOnPasteImageSuccessAction( context: context, maxWidth: constraintsEditor.maxWidth, @@ -448,6 +450,7 @@ class ComposerView extends GetWidget { padding: ComposerStyle.desktopEditorPadding, child: Obx(() { return WebEditorView( + key: controller.responsiveContainerKey, editorController: controller.richTextWebController!.editorController, arguments: controller.composerArguments.value, contentViewState: controller.emailContentsViewState.value, @@ -461,6 +464,7 @@ class ComposerView extends GetWidget { width: constraints.maxWidth, height: constraints.maxHeight, onDragEnter: controller.handleOnDragEnterHtmlEditorWeb, + onDragOver: controller.handleOnDragOverHtmlEditorWeb, onPasteImageSuccessAction: (listFileUpload) => controller.handleOnPasteImageSuccessAction( context: context, maxWidth: constraintsEditor.maxWidth, @@ -728,6 +732,7 @@ class ComposerView extends GetWidget { child: Padding( padding: ComposerStyle.tabletEditorPadding, child: Obx(() => WebEditorView( + key: controller.responsiveContainerKey, editorController: controller.richTextWebController!.editorController, arguments: controller.composerArguments.value, contentViewState: controller.emailContentsViewState.value, @@ -741,6 +746,7 @@ class ComposerView extends GetWidget { width: constraints.maxWidth, height: constraints.maxHeight, onDragEnter: controller.handleOnDragEnterHtmlEditorWeb, + onDragOver: controller.handleOnDragOverHtmlEditorWeb, onPasteImageSuccessAction: (listFileUpload) => controller.handleOnPasteImageSuccessAction( context: context, maxWidth: constraintsBody.maxWidth, diff --git a/lib/features/composer/presentation/view/web/web_editor_view.dart b/lib/features/composer/presentation/view/web/web_editor_view.dart index f04efac66e..641ed8a41d 100644 --- a/lib/features/composer/presentation/view/web/web_editor_view.dart +++ b/lib/features/composer/presentation/view/web/web_editor_view.dart @@ -31,6 +31,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { final double? width; final double? height; final OnDragEnterListener? onDragEnter; + final OnDragOverListener? onDragOver; final OnPasteImageSuccessAction? onPasteImageSuccessAction; final OnPasteImageFailureAction? onPasteImageFailureAction; final OnInitialContentLoadComplete? onInitialContentLoadComplete; @@ -51,6 +52,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { this.width, this.height, this.onDragEnter, + this.onDragOver, this.onPasteImageSuccessAction, this.onPasteImageFailureAction, this.onInitialContentLoadComplete, @@ -80,6 +82,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, @@ -108,6 +111,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, @@ -136,6 +140,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, @@ -171,6 +176,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, @@ -202,6 +208,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, @@ -224,6 +231,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { width: width, height: height, onDragEnter: onDragEnter, + onDragOver: onDragOver, onPasteImageSuccessAction: onPasteImageSuccessAction, onPasteImageFailureAction: onPasteImageFailureAction, onInitialContentLoadComplete: onInitialContentLoadComplete, diff --git a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart index 83dc646f58..6ab658703a 100644 --- a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart @@ -13,6 +13,7 @@ typedef OnMouseDownEditorAction = Function(BuildContext context); typedef OnEditorSettingsChange = Function(EditorSettings settings); typedef OnEditorTextSizeChanged = Function(int? size); typedef OnDragEnterListener = Function(List? types); +typedef OnDragOverListener = Function(List? types); typedef OnPasteImageSuccessAction = Function(List listFileUpload); typedef OnPasteImageFailureAction = Function( List? listFileUpload, @@ -35,6 +36,7 @@ class WebEditorWidget extends StatefulWidget { final double? width; final double? height; final OnDragEnterListener? onDragEnter; + final OnDragOverListener? onDragOver; final OnPasteImageSuccessAction? onPasteImageSuccessAction; final OnPasteImageFailureAction? onPasteImageFailureAction; final OnInitialContentLoadComplete? onInitialContentLoadComplete; @@ -54,6 +56,7 @@ class WebEditorWidget extends StatefulWidget { this.width, this.height, this.onDragEnter, + this.onDragOver, this.onPasteImageSuccessAction, this.onPasteImageFailureAction, this.onInitialContentLoadComplete, @@ -135,7 +138,6 @@ class _WebEditorState extends State { valueListenable: _htmlEditorHeight, builder: (context, height, _) { return HtmlEditor( - key: Key('web_editor_$height'), controller: _editorController, htmlEditorOptions: HtmlEditorOptions( shouldEnsureVisible: true, @@ -190,6 +192,7 @@ class _WebEditorState extends State { HtmlUtils.lineHeight100Percent.name ), onDragEnter: widget.onDragEnter, + onDragOver: widget.onDragOver, onDragLeave: (_) {}, onImageUpload: widget.onPasteImageSuccessAction, onImageUploadError: widget.onPasteImageFailureAction, diff --git a/pubspec.lock b/pubspec.lock index f232125162..ba1806e676 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1171,7 +1171,7 @@ packages: description: path: "." ref: main - resolved-ref: "2459882d3fb6c3223186c3b50e54e0e22cc29d09" + resolved-ref: "2021c2ab42160217821fa26e7fedaacb465a10f4" url: "https://github.com/linagora/html-editor-enhanced.git" source: git version: "3.1.2" From 9c3954a73803a0f1921926f86b9761156430dc2f Mon Sep 17 00:00:00 2001 From: Dat Dang Date: Thu, 28 Nov 2024 17:49:12 +0700 Subject: [PATCH 06/28] Delegate cache control from Flutter to browser (#3289) --- .../adr/0055-remove-flutter-service-worker.md | 28 +++++++++++++++ web/index.html | 36 ++++++++++++++----- 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 docs/adr/0055-remove-flutter-service-worker.md diff --git a/docs/adr/0055-remove-flutter-service-worker.md b/docs/adr/0055-remove-flutter-service-worker.md new file mode 100644 index 0000000000..0a52766416 --- /dev/null +++ b/docs/adr/0055-remove-flutter-service-worker.md @@ -0,0 +1,28 @@ +# 55. Remove Flutter Service Worker + +Date: 2024-11-26 + +## Status + +Accepted + +## Context + +- Flutter Service Worker have many issues regarding updating cache resources + - https://github.com/flutter/flutter/issues/104509 + - https://github.com/flutter/flutter/issues/63500 +- Flutter is moving away from Flutter Service Worker + - https://github.com/flutter/flutter/issues/156910 + +## Decision + +- We remove the Flutter Service Worker and let the browser handle the cache by: + - Remove the Flutter Service Worker initialization + - Remove the existing Flutter Service Worker registration + +## Consequences + +- Twake Mail web now will validate cache with the server every time it is loaded + - If the status code is 200, new resources will be fetched + - If the status code is 304, old resources will be used +- All of the existing service workers will be removed, even if it is not Flutter Service Worker diff --git a/web/index.html b/web/index.html index 7fc5db2536..8b28cd82ca 100644 --- a/web/index.html +++ b/web/index.html @@ -104,15 +104,33 @@ loadLanguageResources().finally(initialTmailApp); - _flutter.loader.load({ - serviceWorkerSettings: { - serviceWorkerVersion: {{flutter_service_worker_version}}, - }, - onEntrypointLoaded: async function(engineInitializer) { - const appRunner = await engineInitializer.initializeEngine(); - await appRunner.runApp(); - } - }); + if ('serviceWorker' in navigator) { + navigator + .serviceWorker + .getRegistrations() + .then(async function(registrations) { + try { + await Promise.all(registrations.map(function(registration) { + return registration.unregister(); + })); + } catch (error) { + console.log('[Twake Mail] Error unregistering service worker: ', error); + } + _flutter.loader.load({ + onEntrypointLoaded: async function(engineInitializer) { + const appRunner = await engineInitializer.initializeEngine(); + await appRunner.runApp(); + } + }); + }); + } else { + _flutter.loader.load({ + onEntrypointLoaded: async function(engineInitializer) { + const appRunner = await engineInitializer.initializeEngine(); + await appRunner.runApp(); + } + }); + } From eaee8a0c08e3200b06f967783cf79c06a0bd4355 Mon Sep 17 00:00:00 2001 From: dab246 Date: Sat, 23 Nov 2024 11:09:32 +0700 Subject: [PATCH 07/28] TF-3292 Fix [SEARCH] If I filter emails by date and then sort them by relevance, the filter isn't applied --- docs/adr/0052-logic-sort-order-in-search.md | 2 +- .../model/search/search_email_filter.dart | 8 ++------ .../email/presentation/search_email_controller.dart | 12 +++++++++--- .../thread/presentation/thread_controller.dart | 10 +++++----- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/adr/0052-logic-sort-order-in-search.md b/docs/adr/0052-logic-sort-order-in-search.md index d90b87dfcc..0fddafc81d 100644 --- a/docs/adr/0052-logic-sort-order-in-search.md +++ b/docs/adr/0052-logic-sort-order-in-search.md @@ -79,7 +79,7 @@ Accepted Brief the logic flows when click `Sort Order` in search: -- To sort by `Subject`, `Sender` or `Relevance` we will have to use the `position` property and ignore `before` and `after` in `conditions` of `filter` +- To sort by `Subject`, `Sender` or `Relevance` we will have to use the `position` property of `filter` ```json { diff --git a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart index d5b4846e0c..9547295385 100644 --- a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart +++ b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart @@ -99,16 +99,12 @@ class SearchEmailFilter with EquatableMixin, OptionParamMixin { ? text?.value.trim() : null, inMailbox: mailbox?.mailboxId, - after: sortOrderType.isScrollByPosition() - ? null - : emailReceiveTimeType.getAfterDate(startDate), + after: emailReceiveTimeType.getAfterDate(startDate), hasAttachment: !hasAttachment ? null : hasAttachment, subject: subject?.trim().isNotEmpty == true ? subject?.trim() : null, - before: sortOrderType.isScrollByPosition() - ? null - : emailReceiveTimeType.getBeforeDate(endDate, before), + before: emailReceiveTimeType.getBeforeDate(endDate, before), from: from.length == 1 ? from.first : null, diff --git a/lib/features/search/email/presentation/search_email_controller.dart b/lib/features/search/email/presentation/search_email_controller.dart index 6871b68711..a6f79a9a53 100644 --- a/lib/features/search/email/presentation/search_email_controller.dart +++ b/lib/features/search/email/presentation/search_email_controller.dart @@ -219,7 +219,9 @@ class SearchEmailController extends BaseController currentSearchText.value = value; _updateSimpleSearchFilter( textOption: option(value.isNotEmpty, SearchQuery(value)), - beforeOption: const None(), + beforeOption: !searchEmailFilter.value.sortOrderType.isScrollByPosition() + ? const None() + : null, positionOption: option(searchEmailFilter.value.sortOrderType.isScrollByPosition(), 0) ); if (value.isNotEmpty && session != null && accountId != null) { @@ -296,7 +298,9 @@ class SearchEmailController extends BaseController ? UnsignedInt(listResultSearch.length) : ThreadConstants.defaultLimit; _updateSimpleSearchFilter( - beforeOption: const None(), + beforeOption: !searchEmailFilter.value.sortOrderType.isScrollByPosition() + ? const None() + : null, positionOption: option(searchEmailFilter.value.sortOrderType.isScrollByPosition(), 0) ); consumeState(_refreshChangesSearchEmailInteractor.execute( @@ -390,7 +394,9 @@ class SearchEmailController extends BaseController _updateSimpleSearchFilter( positionOption: option(searchEmailFilter.value.sortOrderType.isScrollByPosition(), 0), - beforeOption: const None(), + beforeOption: !searchEmailFilter.value.sortOrderType.isScrollByPosition() + ? const None() + : null, ); consumeState(_searchEmailInteractor.execute( diff --git a/lib/features/thread/presentation/thread_controller.dart b/lib/features/thread/presentation/thread_controller.dart index d58b75886a..e6ad7cedae 100644 --- a/lib/features/thread/presentation/thread_controller.dart +++ b/lib/features/thread/presentation/thread_controller.dart @@ -821,7 +821,10 @@ class ThreadController extends BaseController with EmailActionController { searchController.updateFilterEmail( positionOption: option(_searchEmailFilter.sortOrderType.isScrollByPosition(), 0), - beforeOption: const None()); + beforeOption: !_searchEmailFilter.sortOrderType.isScrollByPosition() + ? const None() + : null, + ); searchController.activateSimpleSearch(); @@ -895,10 +898,7 @@ class ThreadController extends BaseController with EmailActionController { if (_searchEmailFilter.sortOrderType.isScrollByPosition()) { final nextPosition = mailboxDashBoardController.emailsInCurrentMailbox.length; log('ThreadController::_searchMoreEmails:nextPosition: $nextPosition'); - searchController.updateFilterEmail( - positionOption: Some(nextPosition), - beforeOption: const None() - ); + searchController.updateFilterEmail(positionOption: Some(nextPosition)); } else if (_searchEmailFilter.sortOrderType == EmailSortOrderType.oldest) { searchController.updateFilterEmail(startDateOption: optionOf(lastEmail?.receivedAt)); } else { From e59fc1fa7e21e11d14dddd19cf801125cee32787 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 27 Nov 2024 12:31:03 +0700 Subject: [PATCH 08/28] TF-3292 Write integration test for search emails by date and then sort them by relevance --- integration_test/robots/search_robot.dart | 18 ++++ ...ime_and_sort_order_relevance_scenario.dart | 101 ++++++++++++++++++ ...te_time_and_sort_order_relevance_test.dart | 44 ++++++++ .../model/search/email_receive_time_type.dart | 33 ++++-- .../email/presentation/search_email_view.dart | 3 +- 5 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 integration_test/scenarios/search_email_by_date_time_and_sort_order_relevance_scenario.dart create mode 100644 integration_test/tests/search/search_email_by_date_time_and_sort_order_relevance_test.dart diff --git a/integration_test/robots/search_robot.dart b/integration_test/robots/search_robot.dart index 549ad0f605..cd98d39721 100644 --- a/integration_test/robots/search_robot.dart +++ b/integration_test/robots/search_robot.dart @@ -39,4 +39,22 @@ class SearchRobot extends CoreRobot { await $.waitUntilVisible($(AppLocalizations().showingResultsFor)); await $(AppLocalizations().showingResultsFor).tap(); } + + Future scrollToDateTimeButtonFilter() async { + await $.scrollUntilVisible( + finder: $(#mobile_dateTime_search_filter_button), + view: $(#search_filter_list_view), + scrollDirection: AxisDirection.right, + delta: 300, + ); + } + + Future openDateTimeBottomDialog() async { + await $(#mobile_dateTime_search_filter_button).tap(); + } + + Future selectDateTime(String dateTimeType) async { + await $(find.text(dateTimeType)).tap(); + await $.pump(const Duration(seconds: 2)); + } } \ No newline at end of file diff --git a/integration_test/scenarios/search_email_by_date_time_and_sort_order_relevance_scenario.dart b/integration_test/scenarios/search_email_by_date_time_and_sort_order_relevance_scenario.dart new file mode 100644 index 0000000000..22a1e1295a --- /dev/null +++ b/integration_test/scenarios/search_email_by_date_time_and_sort_order_relevance_scenario.dart @@ -0,0 +1,101 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/search_email_view.dart'; +import 'package:tmail_ui_user/features/thread/presentation/widgets/email_tile_builder.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +import '../base/base_scenario.dart'; +import '../models/provisioning_email.dart'; +import '../robots/search_robot.dart'; +import '../robots/thread_robot.dart'; +import '../utils/scenario_utils_mixin.dart'; +import 'login_with_basic_auth_scenario.dart'; + +class SearchEmailByDatetimeAndSortOrderRelevanceScenario extends BaseScenario + with ScenarioUtilsMixin { + + const SearchEmailByDatetimeAndSortOrderRelevanceScenario( + super.$, + { + required this.loginWithBasicAuthScenario, + required this.queryString, + required this.listProvisioningEmail, + } + ); + + final LoginWithBasicAuthScenario loginWithBasicAuthScenario; + final String queryString; + final List listProvisioningEmail; + + @override + Future execute() async { + await loginWithBasicAuthScenario.execute(); + + await provisionEmail(listProvisioningEmail); + await $.pumpAndSettle(); + + final threadRobot = ThreadRobot($); + await threadRobot.openSearchView(); + await _expectSearchViewVisible(); + + final searchRobot = SearchRobot($); + await searchRobot.enterQueryString(queryString); + await _expectSuggestionSearchListViewVisible(); + + await searchRobot.scrollToDateTimeButtonFilter(); + await _expectDateTimeSearchFilterButtonVisible(); + + await Future.delayed(const Duration(seconds: 2)); + + await searchRobot.openDateTimeBottomDialog(); + await _expectDateTimeFilterContextMenuVisible(); + + final appLocalizations = AppLocalizations(); + await searchRobot.selectDateTime( + EmailReceiveTimeType.last7Days.getTitleByAppLocalizations(appLocalizations), + ); + await _expectSearchResultEmailListVisible(); + + await Future.delayed(const Duration(seconds: 2)); + + await searchRobot.openSortOrderBottomDialog(); + await _expectSortFilterContextMenuVisible(); + await searchRobot.selectSortOrder( + EmailSortOrderType.relevance.getTitleByAppLocalizations(appLocalizations), + ); + await _expectSearchResultEmailListVisible(); + + await _expectEmailListDisplayedCorrectly(); + } + + + Future _expectSearchViewVisible() async { + await expectViewVisible($(SearchEmailView)); + } + + Future _expectSuggestionSearchListViewVisible() async { + await expectViewVisible($(#suggestion_search_list_view)); + } + + Future _expectDateTimeSearchFilterButtonVisible() async { + await expectViewVisible($(#mobile_dateTime_search_filter_button)); + } + + Future _expectDateTimeFilterContextMenuVisible() async { + await expectViewVisible($(#date_time_filter_context_menu)); + } + + Future _expectSearchResultEmailListVisible() async { + await expectViewVisible($(#search_email_list_notification_listener)); + } + + Future _expectSortFilterContextMenuVisible() async { + await expectViewVisible($(#sort_filter_context_menu)); + } + + Future _expectEmailListDisplayedCorrectly() async { + expect(find.byType(EmailTileBuilder), findsNWidgets(listProvisioningEmail.length)); + } +} \ No newline at end of file diff --git a/integration_test/tests/search/search_email_by_date_time_and_sort_order_relevance_test.dart b/integration_test/tests/search/search_email_by_date_time_and_sort_order_relevance_test.dart new file mode 100644 index 0000000000..6fd8e39875 --- /dev/null +++ b/integration_test/tests/search/search_email_by_date_time_and_sort_order_relevance_test.dart @@ -0,0 +1,44 @@ +import '../../base/test_base.dart'; +import '../../models/provisioning_email.dart'; +import '../../scenarios/login_with_basic_auth_scenario.dart'; +import '../../scenarios/search_email_by_date_time_and_sort_order_relevance_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: 'Should see list email displayed by date time `Last 7 days` and sort order `Relevance` when search email successfully', + test: ($) async { + const username = String.fromEnvironment('USERNAME'); + const password = String.fromEnvironment('PASSWORD'); + const hostUrl = String.fromEnvironment('BASIC_AUTH_URL'); + const email = String.fromEnvironment('BASIC_AUTH_EMAIL'); + + final loginWithBasicAuthScenario = LoginWithBasicAuthScenario( + $, + username: username, + password: password, + hostUrl: hostUrl, + email: email, + ); + + const queryString = 'relevance'; + const listUsername = ['Alice', 'Brian', 'Charlotte', 'David', 'Emma']; + + final listProvisioningEmail = listUsername + .map((username) => ProvisioningEmail( + toEmail: '${username.toLowerCase()}@example.com', + subject: queryString, + content: '$queryString to user $username', + )) + .toList(); + + final searchEmailByDatetimeAndSortOrderRelevanceScenario = SearchEmailByDatetimeAndSortOrderRelevanceScenario( + $, + loginWithBasicAuthScenario: loginWithBasicAuthScenario, + queryString: queryString, + listProvisioningEmail: listProvisioningEmail, + ); + + await searchEmailByDatetimeAndSortOrderRelevanceScenario.execute(); + }, + ); +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart b/lib/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart index b54d7bcf60..7f16167b66 100644 --- a/lib/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart +++ b/lib/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart @@ -13,26 +13,41 @@ enum EmailReceiveTimeType { customRange; String getTitle(BuildContext context, {DateTime? startDate, DateTime? endDate}) { + return getTitleByAppLocalizations( + AppLocalizations.of(context), + startDate: startDate, + endDate: endDate, + ); + } + + String getTitleByAppLocalizations( + AppLocalizations appLocalizations, + { + DateTime? startDate, + DateTime? endDate, + } + ) { switch(this) { case EmailReceiveTimeType.allTime: - return AppLocalizations.of(context).allTime; + return appLocalizations.allTime; case EmailReceiveTimeType.last7Days: - return AppLocalizations.of(context).last7Days; + return appLocalizations.last7Days; case EmailReceiveTimeType.last30Days: - return AppLocalizations.of(context).last30Days; + return appLocalizations.last30Days; case EmailReceiveTimeType.last6Months: - return AppLocalizations.of(context).last6Months; + return appLocalizations.last6Months; case EmailReceiveTimeType.lastYear: - return AppLocalizations.of(context).lastYears; + return appLocalizations.lastYears; case EmailReceiveTimeType.customRange: if (startDate != null && endDate != null) { final startDateString = startDate.formatDate(pattern: 'yyyy-dd-MM'); final endDateString = endDate.formatDate(pattern: 'yyyy-dd-MM'); - return AppLocalizations.of(context).dateRangeAdvancedSearchFilter( - startDateString, - endDateString); + return appLocalizations.dateRangeAdvancedSearchFilter( + startDateString, + endDateString, + ); } else { - return AppLocalizations.of(context).customRange; + return appLocalizations.customRange; } } } diff --git a/lib/features/search/email/presentation/search_email_view.dart b/lib/features/search/email/presentation/search_email_view.dart index 264f741c80..262e6deb75 100644 --- a/lib/features/search/email/presentation/search_email_view.dart +++ b/lib/features/search/email/presentation/search_email_view.dart @@ -355,7 +355,8 @@ class SearchEmailView extends GetWidget context, receiveTime ) - ) + ), + key: const Key('date_time_filter_context_menu'), ); } From c5b1e878f4a9ff7b15d4f14acdd33d3e032e483f Mon Sep 17 00:00:00 2001 From: dab246 Date: Sat, 23 Nov 2024 03:52:01 +0700 Subject: [PATCH 09/28] TF-3294 Fix BLUE-BAR mail to attendees duplicated recipients --- .../controller/single_email_controller.dart | 19 ++--- .../calendar_attendee_extension.dart | 12 ++++ .../calendar_organizer_extension.dart | 12 ++++ .../list_email_address_extension.dart | 9 +++ .../list_email_address_extension_test.dart | 71 +++++++++++++++++++ 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 lib/features/email/presentation/extensions/calendar_attendee_extension.dart create mode 100644 lib/features/email/presentation/extensions/calendar_organizer_extension.dart create mode 100644 model/test/extensions/list_email_address_extension_test.dart diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index e40e78fca3..928c8e2dfe 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -77,6 +77,8 @@ import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action import 'package:tmail_ui_user/features/email/presentation/bindings/calendar_event_interactor_bindings.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/attachment_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_attendee_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_organizer_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/blob_calendar_event.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; @@ -1853,19 +1855,20 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void handleMailToAttendees(CalendarOrganizer? organizer, List? attendees) { - final listEmailAddressAttendees = attendees - ?.map((attendee) => EmailAddress(attendee.name?.name, attendee.mailto?.mailAddress.value)) - .toList() ?? []; + List listEmailAddressAttendees = []; if (organizer != null) { - listEmailAddressAttendees.add(EmailAddress(organizer.name, organizer.mailto?.value)); + listEmailAddressAttendees.add(organizer.toEmailAddress()); } - final listEmailAddressMailTo = listEmailAddressAttendees - .where((emailAddress) => emailAddress.emailAddress.isNotEmpty && emailAddress.emailAddress != mailboxDashBoardController.sessionCurrent?.username.value) - .toSet() - .toList(); + final listEmailAddress = attendees + ?.map((attendee) => attendee.toEmailAddress()) + .toList() ?? []; + listEmailAddressAttendees.addAll(listEmailAddress); + + final username = mailboxDashBoardController.sessionCurrent?.username.value ?? ''; + final listEmailAddressMailTo = listEmailAddressAttendees.removeInvalidEmails(username); log('SingleEmailController::handleMailToAttendees: listEmailAddressMailTo = $listEmailAddressMailTo'); mailboxDashBoardController.goToComposer( ComposerArguments.fromMailtoUri(listEmailAddress: listEmailAddressMailTo) diff --git a/lib/features/email/presentation/extensions/calendar_attendee_extension.dart b/lib/features/email/presentation/extensions/calendar_attendee_extension.dart new file mode 100644 index 0000000000..8233445363 --- /dev/null +++ b/lib/features/email/presentation/extensions/calendar_attendee_extension.dart @@ -0,0 +1,12 @@ + +import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar_attendee.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; + +extension CalendarAttendeeExtension on CalendarAttendee { + EmailAddress toEmailAddress() { + return EmailAddress( + name?.name, + mailto?.mailAddress.value, + ); + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/extensions/calendar_organizer_extension.dart b/lib/features/email/presentation/extensions/calendar_organizer_extension.dart new file mode 100644 index 0000000000..173345b5c0 --- /dev/null +++ b/lib/features/email/presentation/extensions/calendar_organizer_extension.dart @@ -0,0 +1,12 @@ + +import 'package:jmap_dart_client/jmap/mail/calendar/properties/calendar_organizer.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; + +extension CalendarOrganizerExtension on CalendarOrganizer { + EmailAddress toEmailAddress() { + return EmailAddress( + name, + mailto?.value, + ); + } +} \ No newline at end of file diff --git a/model/lib/extensions/list_email_address_extension.dart b/model/lib/extensions/list_email_address_extension.dart index 84a9465bf3..2678b9efcd 100644 --- a/model/lib/extensions/list_email_address_extension.dart +++ b/model/lib/extensions/list_email_address_extension.dart @@ -38,4 +38,13 @@ extension SetEmailAddressExtension on Set? { extension ListEmailAddressExtension on List { Set asSetAddress() => map((emailAddress) => emailAddress.emailAddress).toSet(); + + List removeInvalidEmails(String username) { + final Set seenEmails = {}; + return where((email) { + if (email.emailAddress.isEmpty) return false; + if (email.emailAddress == username) return false; + return seenEmails.add(email.emailAddress); + }).toList(); + } } \ No newline at end of file diff --git a/model/test/extensions/list_email_address_extension_test.dart b/model/test/extensions/list_email_address_extension_test.dart new file mode 100644 index 0000000000..3319d60933 --- /dev/null +++ b/model/test/extensions/list_email_address_extension_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:model/extensions/list_email_address_extension.dart'; + +void main() { + group('ListEmailAddressExtension::removeInvalidEmails::test', () { + test('SHOULD remove email addresses that are empty', () { + final emails = [ + EmailAddress('Alice', 'alice@example.com'), + EmailAddress('Bob', ''), + EmailAddress('Charlie', 'charlie@example.com'), + ]; + + final validEmails = emails.removeInvalidEmails('bob@example.com'); + + expect(validEmails.length, 2); + expect(validEmails[0].emailAddress, 'alice@example.com'); + expect(validEmails[1].emailAddress, 'charlie@example.com'); + }); + + test('SHOULD remove email addresses that match the provided username', () { + final emails = [ + EmailAddress('Alice', 'alice@example.com'), + EmailAddress('Bob', 'bob@example.com'), + EmailAddress('Charlie', 'charlie@example.com'), + ]; + + final validEmails = emails.removeInvalidEmails('bob@example.com'); + + expect(validEmails.length, 2); + expect(validEmails[0].emailAddress, 'alice@example.com'); + expect(validEmails[1].emailAddress, 'charlie@example.com'); + }); + + test('SHOULD remove duplicate email addresses, keeping only the first occurrence', () { + final emails = [ + EmailAddress('Alice', 'alice@example.com'), + EmailAddress('Bob', 'bob@example.com'), + EmailAddress('Charlie', 'alice@example.com'), + EmailAddress('David', 'david@example.com'), + ]; + + final validEmails = emails.removeInvalidEmails('bob@example.com'); + + expect(validEmails.length, 2); + expect(validEmails[0].emailAddress, 'alice@example.com'); + expect(validEmails[1].emailAddress, 'david@example.com'); + }); + + test('SHOULD return an empty list if all email addresses are invalid', () { + final emails = [ + EmailAddress('Alice', ''), + EmailAddress('Bob', 'bob@example.com'), + EmailAddress('Charlie', 'bob@example.com'), + ]; + + final validEmails = emails.removeInvalidEmails('bob@example.com'); + + expect(validEmails.isEmpty, true); + }); + + test('SHOULD return an empty list for an empty input list', () { + final emails = []; + + final validEmails = emails.removeInvalidEmails('bob@example.com'); + + expect(validEmails.isEmpty, true); + }); + }); +} From 6fff59ac6f5fa33496d8350edbbb1c3bfaf8991f Mon Sep 17 00:00:00 2001 From: dab246 Date: Sat, 23 Nov 2024 04:09:11 +0700 Subject: [PATCH 10/28] TF-3295 Fix BLUE-BAR attendees mail addresses are not clickable --- lib/features/email/presentation/email_view.dart | 1 + .../extensions/calendar_organier_extension.dart | 11 +++++++++++ .../widgets/calendar_event/attendee_widget.dart | 10 ++++++++++ .../calendar_event_information_widget.dart | 5 +++++ .../event_attendee_detail_widget.dart | 16 +++++++++++++--- .../widgets/calendar_event/organizer_widget.dart | 12 +++++++++++- 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 lib/features/email/presentation/extensions/calendar_organier_extension.dart diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index 507ac40c6c..3f111a6209 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -361,6 +361,7 @@ class EmailView extends GetWidget { calendarEventReplying: controller.calendarEventProcessing, presentationEmail: controller.currentEmail, onMailtoAttendeesAction: controller.handleMailToAttendees, + openEmailAddressDetailAction: controller.openEmailAddressDialog, )), if (_validateDisplayEventActionBanner( context: context, diff --git a/lib/features/email/presentation/extensions/calendar_organier_extension.dart b/lib/features/email/presentation/extensions/calendar_organier_extension.dart new file mode 100644 index 0000000000..6b567f50dc --- /dev/null +++ b/lib/features/email/presentation/extensions/calendar_organier_extension.dart @@ -0,0 +1,11 @@ + +import 'package:jmap_dart_client/jmap/mail/calendar/properties/calendar_organizer.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; + +extension CalendarOrganierExtension on CalendarOrganizer { + EmailAddress toEmailAddress() { + return EmailAddress( + name, mailto?.value, + ); + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/calendar_event/attendee_widget.dart b/lib/features/email/presentation/widgets/calendar_event/attendee_widget.dart index 8834905b8a..8c5af940f1 100644 --- a/lib/features/email/presentation/widgets/calendar_event/attendee_widget.dart +++ b/lib/features/email/presentation/widgets/calendar_event/attendee_widget.dart @@ -1,17 +1,22 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar_attendee.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_attendee_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/attendee_widget_styles.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; class AttendeeWidget extends StatelessWidget { final CalendarAttendee attendee; final List listAttendees; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; const AttendeeWidget({ super.key, required this.attendee, required this.listAttendees, + this.openEmailAddressDetailAction, }); @override @@ -34,6 +39,7 @@ class AttendeeWidget extends StatelessWidget { fontSize: AttendeeWidgetStyles.textSize, fontWeight: FontWeight.w500 ), + recognizer: TapGestureRecognizer()..onTap = () => _onClickMailAddress(context) ), if (listAttendees.last != attendee) const TextSpan(text: ', '), @@ -41,4 +47,8 @@ class AttendeeWidget extends StatelessWidget { ) ); } + + void _onClickMailAddress(BuildContext context) { + openEmailAddressDetailAction?.call(context, attendee.toEmailAddress()); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/calendar_event/calendar_event_information_widget.dart b/lib/features/email/presentation/widgets/calendar_event/calendar_event_information_widget.dart index df32490e6d..798a476208 100644 --- a/lib/features/email/presentation/widgets/calendar_event/calendar_event_information_widget.dart +++ b/lib/features/email/presentation/widgets/calendar_event/calendar_event_information_widget.dart @@ -15,6 +15,7 @@ import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/event_location_information_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/event_time_information_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/event_title_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; @@ -30,6 +31,7 @@ class CalendarEventInformationWidget extends StatelessWidget { final bool calendarEventReplying; final PresentationEmail? presentationEmail; final OnMailtoAttendeesAction? onMailtoAttendeesAction; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; final _responsiveUtils = Get.find(); @@ -42,6 +44,7 @@ class CalendarEventInformationWidget extends StatelessWidget { this.onOpenComposerAction, this.presentationEmail, this.onMailtoAttendeesAction, + this.openEmailAddressDetailAction, }); @override @@ -130,6 +133,7 @@ class CalendarEventInformationWidget extends StatelessWidget { child: EventAttendeeDetailWidget( attendees: calendarEvent.participants ?? [], organizer: calendarEvent.organizer, + openEmailAddressDetailAction: openEmailAddressDetailAction, ), ), if (calendarEvent.isDisplayedEventReplyAction) @@ -215,6 +219,7 @@ class CalendarEventInformationWidget extends StatelessWidget { child: EventAttendeeDetailWidget( attendees: calendarEvent.participants ?? [], organizer: calendarEvent.organizer, + openEmailAddressDetailAction: openEmailAddressDetailAction, ), ), if (calendarEvent.isDisplayedEventReplyAction) diff --git a/lib/features/email/presentation/widgets/calendar_event/event_attendee_detail_widget.dart b/lib/features/email/presentation/widgets/calendar_event/event_attendee_detail_widget.dart index af963b92cd..3f59e08b32 100644 --- a/lib/features/email/presentation/widgets/calendar_event/event_attendee_detail_widget.dart +++ b/lib/features/email/presentation/widgets/calendar_event/event_attendee_detail_widget.dart @@ -9,6 +9,7 @@ import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/hide_all_attendees_button_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/organizer_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/see_all_attendees_button_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; class EventAttendeeDetailWidget extends StatefulWidget { @@ -17,11 +18,13 @@ class EventAttendeeDetailWidget extends StatefulWidget { final List attendees; final CalendarOrganizer? organizer; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; const EventAttendeeDetailWidget({ super.key, required this.attendees, - required this.organizer + required this.organizer, + this.openEmailAddressDetailAction, }); @override @@ -61,10 +64,17 @@ class _EventAttendeeDetailWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.organizer != null) - OrganizerWidget(organizer: widget.organizer!), + OrganizerWidget( + organizer: widget.organizer!, + openEmailAddressDetailAction: widget.openEmailAddressDetailAction + ), if (_attendeesDisplayed.isNotEmpty) ..._attendeesDisplayed - .map((attendee) => AttendeeWidget(attendee: attendee, listAttendees: _attendeesDisplayed)) + .map((attendee) => AttendeeWidget( + attendee: attendee, + listAttendees: _attendeesDisplayed, + openEmailAddressDetailAction: widget.openEmailAddressDetailAction + )) .toList(), if (!_isShowAllAttendee) Padding( diff --git a/lib/features/email/presentation/widgets/calendar_event/organizer_widget.dart b/lib/features/email/presentation/widgets/calendar_event/organizer_widget.dart index 6ed9f4ae5a..ec60abd918 100644 --- a/lib/features/email/presentation/widgets/calendar_event/organizer_widget.dart +++ b/lib/features/email/presentation/widgets/calendar_event/organizer_widget.dart @@ -1,16 +1,21 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/mail/calendar/properties/calendar_organizer.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_organier_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/organizer_widget_styles.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; class OrganizerWidget extends StatelessWidget { final CalendarOrganizer organizer; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; const OrganizerWidget({ super.key, - required this.organizer + required this.organizer, + this.openEmailAddressDetailAction, }); @override @@ -33,6 +38,7 @@ class OrganizerWidget extends StatelessWidget { fontSize: OrganizerWidgetStyles.textSize, fontWeight: FontWeight.w500 ), + recognizer: TapGestureRecognizer()..onTap = () => _onClickMailAddress(context) ), TextSpan(text: '(${AppLocalizations.of(context).organizer})'), const TextSpan(text: ', '), @@ -40,4 +46,8 @@ class OrganizerWidget extends StatelessWidget { ) ); } + + void _onClickMailAddress(BuildContext context) { + openEmailAddressDetailAction?.call(context, organizer.toEmailAddress()); + } } \ No newline at end of file From 3a5f91b1a5a0795d8ff2e7631e4472ba65e06942 Mon Sep 17 00:00:00 2001 From: dab246 Date: Sat, 23 Nov 2024 02:54:28 +0700 Subject: [PATCH 11/28] TF-3296 Fix [SEARCH] Can't apply the "Starred" filter in search --- .../presentation/controller/search_controller.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart index be158dbfe1..accfd2b128 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart @@ -150,10 +150,15 @@ class SearchController extends BaseController with DateRangePickerMixin { } } + final listHasKeyword = listFilterOnSuggestionForm.contains(QuickSearchFilter.starred) + ? {KeyWordIdentifier.emailFlagged.value} + : null; + updateFilterEmail( emailReceiveTimeTypeOption: Some(receiveTime), hasAttachmentOption: Some(hasAttachment), - fromOption: Some(listFromAddress) + fromOption: Some(listFromAddress), + hasKeywordOption: optionOf(listHasKeyword), ); clearFilterSuggestion(); From 9202925fc5adacb84ef9f42cc99f5151f0aacef7 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 2 Dec 2024 13:21:46 +0700 Subject: [PATCH 12/28] fixup! TF-3296 Fix [SEARCH] Can't apply the "Starred" filter in search --- .../presentation/controller/mailbox_dashboard_controller.dart | 4 ++++ .../presentation/model/search/search_email_filter.dart | 3 +++ 2 files changed, 7 insertions(+) diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 9a532c1230..9681e4dc95 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -765,6 +765,10 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo ? Some({queryString}) : null); + if (searchController.searchEmailFilter.value.isContainFlagged) { + filterMessageOption.value = FilterMessageOption.starred; + } + FocusManager.instance.primaryFocus?.unfocus(); dispatchAction(StartSearchEmailAction()); diff --git a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart index 9547295385..121c0abdaf 100644 --- a/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart +++ b/lib/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart @@ -7,6 +7,7 @@ import 'package:jmap_dart_client/jmap/core/filter/filter_operator.dart'; import 'package:jmap_dart_client/jmap/core/filter/operator/logic_filter_operator.dart'; import 'package:jmap_dart_client/jmap/core/utc_date.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/prefix_email_address.dart'; import 'package:model/extensions/email_filter_condition_extension.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart'; @@ -195,6 +196,8 @@ class SearchEmailFilter with EquatableMixin, OptionParamMixin { (mailbox != null && mailbox?.id != PresentationMailbox.unifiedMailbox.id) || hasAttachment; + bool get isContainFlagged => hasKeyword.contains(KeyWordIdentifier.emailFlagged.value); + @override List get props => [ from, From fe2062d8a17e05f39e323dc589a4e3fb4de61d7f Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 3 Dec 2024 11:55:11 +0700 Subject: [PATCH 13/28] TF-3315 Fix TMail web could not display embedded table correctly --- ...ndardize_html_sanitizing_transformers.dart | 3 + ...ize_html_sanitizing_transformers_test.dart | 165 ++++++++++-------- 2 files changed, 93 insertions(+), 75 deletions(-) diff --git a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart index e9e5b8c76b..43ac2acbae 100644 --- a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart @@ -20,6 +20,9 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { 'style', 'body', 'section', + 'google-sheets-html-origin', + 'colgroup', + 'col', ]; const StandardizeHtmlSanitizingTransformers(); diff --git a/core/test/utils/standardize_html_sanitizing_transformers_test.dart b/core/test/utils/standardize_html_sanitizing_transformers_test.dart index 5910914dc4..c915740ffb 100644 --- a/core/test/utils/standardize_html_sanitizing_transformers_test.dart +++ b/core/test/utils/standardize_html_sanitizing_transformers_test.dart @@ -6,67 +6,80 @@ void main() { group('StandardizeHtmlSanitizingTransformers::test', () { const transformer = StandardizeHtmlSanitizingTransformers(); const htmlEscape = HtmlEscape(); + const listHTMLTags = [ + 'div', + 'span', + 'p', + 'a', + 'i', + 'table', + 'font', + 'u', + 'center', + 'style', + 'section', + 'google-sheets-html-origin', + ]; + const listOnEventAttributes = [ + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseover', + 'mouseout', + 'mouseup', + 'load', + 'unload', + 'loadstart', + 'loadeddata', + 'loadedmetadata', + 'playing', + 'show', + 'error', + 'message', + 'focus', + 'focusin', + 'focusout', + 'keydown', + 'keypress', + 'keyup', + 'input', + 'ended', + 'drag', + 'drop', + 'dragstart', + 'dragover', + 'dragleave', + 'dragend', + 'dragenter', + 'beforeunload', + 'beforeprint', + 'afterprint', + 'blur', + 'click', + 'change', + 'contextmenu', + 'cut', + 'copy', + 'dblclick', + 'abort', + 'durationchange', + 'progress', + 'resize', + 'reset', + 'scroll', + 'seeked', + 'select', + 'submit', + 'toggle', + 'volumechange', + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + ]; test('SHOULD remove all `on*` attributes tag', () { - const listOnEventAttributes = [ - 'mousedown', - 'mouseenter', - 'mouseleave', - 'mousemove', - 'mouseover', - 'mouseout', - 'mouseup', - 'load', - 'unload', - 'loadstart', - 'loadeddata', - 'loadedmetadata', - 'playing', - 'show', - 'error', - 'message', - 'focus', - 'focusin', - 'focusout', - 'keydown', - 'keydpress', - 'keydup', - 'input', - 'ended', - 'drag', - 'drop', - 'dragstart', - 'dragover', - 'dragleave', - 'dragend', - 'dragenter', - 'beforeunload', - 'beforeprint', - 'afterprint', - 'blur', - 'click', - 'change', - 'contextmenu', - 'cut', - 'copy', - 'dblclick', - 'abort', - 'durationchange', - 'progress', - 'resize', - 'reset', - 'scroll', - 'seeked', - 'select', - 'submit', - 'toggle', - 'volumechange', - 'touchstart', - 'touchmove', - 'touchend', - 'touchcancel' - ]; - for (var i = 0; i < listOnEventAttributes.length; i++) { final inputHtml = ''; final result = transformer.process(inputHtml, htmlEscape); @@ -76,22 +89,6 @@ void main() { }); test('SHOULD remove all `on*` attributes for any tags', () { - const listOnEventAttributes = [ - 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseover', - 'mouseout', 'mouseup', 'load', 'unload', 'loadstart', 'loadeddata', - 'loadedmetadata', 'playing', 'show', 'error', 'message', 'focus', - 'focusin', 'focusout', 'keydown', 'keypress', 'keyup', 'input', 'ended', - 'drag', 'drop', 'dragstart', 'dragover', 'dragleave', 'dragend', 'dragenter', - 'beforeunload', 'beforeprint', 'afterprint', 'blur', 'click', 'change', - 'contextmenu', 'cut', 'copy', 'dblclick', 'abort', 'durationchange', - 'progress', 'resize', 'reset', 'scroll', 'seeked', 'select', 'submit', - 'toggle', 'volumechange', 'touchstart', 'touchmove', 'touchend', 'touchcancel' - ]; - - const listHTMLTags = [ - 'div', 'span', 'p', 'a', 'u', 'i', 'table', 'section' - ]; - for (var tag in listHTMLTags) { for (var event in listOnEventAttributes) { final inputHtml = '<$tag on$event="javascript:alert(1)">'; @@ -102,6 +99,24 @@ void main() { } }); + test('SHOULD remove all `on*` attributes for `colgroup` tag', () { + for (var event in listOnEventAttributes) { + final inputHtml = '
'; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('
')); + } + }); + + test('SHOULD remove all `on*` attributes for `col` tag', () { + for (var event in listOnEventAttributes) { + final inputHtml = '
'; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('
')); + } + }); + test('SHOULD remove attributes of IMG tag WHEN they are invalid', () { const inputHtml = ''; final result = transformer.process(inputHtml, htmlEscape); From ed0b14c35c86558f1ac6e4809cf374bbd90750eb Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Mon, 21 Oct 2024 16:29:26 +0200 Subject: [PATCH 14/28] TF-3189 new option to enable/disable subaddressing for a personal folder --- assets/images/ic_subaddressing_allow.svg | 3 ++ assets/images/ic_subaddressing_disallow.svg | 8 +++ .../presentation/resources/image_paths.dart | 2 + .../upgrade_hive_database_steps_v13.dart | 17 ++++++ lib/features/caching/caching_manager.dart | 6 +++ .../caching/config/hive_cache_config.dart | 2 + .../data/datasource/mailbox_datasource.dart | 3 ++ .../mailbox_cache_datasource_impl.dart | 6 +++ .../mailbox_datasource_impl.dart | 8 +++ .../extensions/mailbox_cache_extension.dart | 3 +- .../data/extensions/mailbox_extension.dart | 3 +- .../mailbox/data/model/mailbox_cache.dart | 5 ++ .../mailbox/data/network/mailbox_api.dart | 53 ++++++++++++++++++ .../repository/mailbox_repository_impl.dart | 6 +++ .../domain/constants/mailbox_constants.dart | 3 +- .../null_session_or_accountid_exception.dart | 8 +++ .../set_mailbox_rights_exception.dart | 8 +++ .../domain/model/mailbox_right_request.dart | 23 ++++++++ .../model/mailbox_subaddressing_action.dart | 4 ++ .../domain/repository/mailbox_repository.dart | 3 ++ .../state/subaddressing_mailbox_state.dart | 37 +++++++++++++ .../usecases/subaddressing_interactor.dart | 36 +++++++++++++ .../presentation/mailbox_bindings.dart | 3 ++ .../presentation/mailbox_controller.dart | 50 +++++++++++++++++ .../mixin/mailbox_widget_mixin.dart | 4 ++ .../presentation/model/mailbox_actions.dart | 14 ++++- lib/l10n/intl_messages.arb | 30 +++++++++++ lib/main/localizations/app_localizations.dart | 31 +++++++++++ model/lib/extensions/mailbox_extension.dart | 3 ++ .../presentation_mailbox_extension.dart | 9 ++++ model/lib/mailbox/mailbox_constants.dart | 2 + model/lib/mailbox/mailbox_property.dart | 1 + model/lib/mailbox/presentation_mailbox.dart | 3 ++ .../update_rights_for_subaddressing_test.dart | 54 +++++++++++++++++++ .../mailbox_dashboard_controller_test.dart | 4 ++ .../mailbox_dashboard_view_widget_test.dart | 4 ++ 36 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 assets/images/ic_subaddressing_allow.svg create mode 100644 assets/images/ic_subaddressing_disallow.svg create mode 100644 lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart create mode 100644 lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart create mode 100644 lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart create mode 100644 lib/features/mailbox/domain/model/mailbox_right_request.dart create mode 100644 lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart create mode 100644 lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart create mode 100644 lib/features/mailbox/domain/usecases/subaddressing_interactor.dart create mode 100644 model/lib/mailbox/mailbox_constants.dart create mode 100644 test/features/mailbox/data/update_rights_for_subaddressing_test.dart diff --git a/assets/images/ic_subaddressing_allow.svg b/assets/images/ic_subaddressing_allow.svg new file mode 100644 index 0000000000..b9162f3b1e --- /dev/null +++ b/assets/images/ic_subaddressing_allow.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_subaddressing_disallow.svg b/assets/images/ic_subaddressing_disallow.svg new file mode 100644 index 0000000000..f3d19f3d34 --- /dev/null +++ b/assets/images/ic_subaddressing_disallow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 52b6013b14..d20c5d6058 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -221,6 +221,8 @@ class ImagePaths { String get icBadSignature => _getImagePath('ic_bad_signature.svg'); String get icDeleteSelection => _getImagePath('ic_delete_selection.svg'); String get icLogoTwakeWelcome => _getImagePath('ic_logo_twake_welcome.svg'); + String get icSubaddressingAllow => _getImagePath('ic_subaddressing_allow.svg'); + String get icSubaddressingDisallow => _getImagePath('ic_subaddressing_disallow.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart b/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart new file mode 100644 index 0000000000..481b8ef406 --- /dev/null +++ b/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart @@ -0,0 +1,17 @@ + +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_database_steps.dart'; +import 'package:tmail_ui_user/features/caching/caching_manager.dart'; + +class UpgradeHiveDatabaseStepsV13 extends UpgradeDatabaseSteps { + + final CachingManager _cachingManager; + + UpgradeHiveDatabaseStepsV13(this._cachingManager); + + @override + Future onUpgrade(int oldVersion, int newVersion) async { + if (oldVersion > 0 && oldVersion < newVersion && newVersion == 13) { + await _cachingManager.clearMailboxCache(); + } + } +} \ No newline at end of file diff --git a/lib/features/caching/caching_manager.dart b/lib/features/caching/caching_manager.dart index 613751f07a..fb22bc36cd 100644 --- a/lib/features/caching/caching_manager.dart +++ b/lib/features/caching/caching_manager.dart @@ -118,6 +118,12 @@ class CachingManager { ], eagerError: true); } + Future clearMailboxCache() { + return Future.wait([ + _mailboxCacheClient.clearAllData(), + ], eagerError: true); + } + Future storeCacheVersion(int newVersion) async { log('CachingManager::storeCacheVersion():newVersion = $newVersion'); return _hiveCacheVersionClient.storeVersion(newVersion); diff --git a/lib/features/caching/config/hive_cache_config.dart b/lib/features/caching/config/hive_cache_config.dart index 7d4d2e56fb..5ae303e82d 100644 --- a/lib/features/caching/config/hive_cache_config.dart +++ b/lib/features/caching/config/hive_cache_config.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart' as path_provider; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v10.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v11.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v12.dart'; +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v13.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v7.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; import 'package:tmail_ui_user/features/caching/config/cache_version.dart'; @@ -69,6 +70,7 @@ class HiveCacheConfig { await UpgradeHiveDatabaseStepsV10(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV11(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV12(cachingManager).onUpgrade(oldVersion, newVersion); + await UpgradeHiveDatabaseStepsV13(cachingManager).onUpgrade(oldVersion, newVersion); if (oldVersion != newVersion) { await cachingManager.storeCacheVersion(newVersion); diff --git a/lib/features/mailbox/data/datasource/mailbox_datasource.dart b/lib/features/mailbox/data/datasource/mailbox_datasource.dart index 4b9f30c7ef..dd781a6185 100644 --- a/lib/features/mailbox/data/datasource/mailbox_datasource.dart +++ b/lib/features/mailbox/data/datasource/mailbox_datasource.dart @@ -18,6 +18,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; @@ -49,6 +50,8 @@ abstract class MailboxDataSource { Future> subscribeMultipleMailbox(Session session, AccountId accountId, SubscribeMultipleMailboxRequest subscribeRequest); + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request); + Future> createDefaultMailbox(Session session, AccountId accountId, List listRole); Future setRoleDefaultMailbox(Session session, AccountId accountId, List listMailbox); diff --git a/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart b/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart index b52e767b24..03cf6c6aff 100644 --- a/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart +++ b/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart @@ -21,6 +21,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; @@ -97,6 +98,11 @@ class MailboxCacheDataSourceImpl extends MailboxDataSource { throw UnimplementedError(); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + throw UnimplementedError(); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { throw UnimplementedError(); diff --git a/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart b/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart index 062e7effa3..1dbb9c9f4d 100644 --- a/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart +++ b/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; @@ -121,6 +122,13 @@ class MailboxDataSourceImpl extends MailboxDataSource { }).catchError(_exceptionThrower.throwException); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + return Future.sync(() async { + return await mailboxAPI.handleMailboxRightRequest(session, accountId, request); + }).catchError(_exceptionThrower.throwException); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { return Future.sync(() async { diff --git a/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart b/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart index 84652e1126..594eb3e3b9 100644 --- a/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart +++ b/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart @@ -20,7 +20,8 @@ extension MailboxCacheExtension on MailboxCache { unreadThreads: unreadThreads != null ? UnreadThreads(UnsignedInt(unreadThreads!)) : null, myRights: myRights?.toMailboxRights(), isSubscribed: isSubscribed != null ? IsSubscribed(isSubscribed!) : null, - namespace: namespace != null ? Namespace(namespace!) : null + namespace: namespace != null ? Namespace(namespace!) : null, + rights: rights != null ? Map?>.from(rights!) : null, ); } } \ No newline at end of file diff --git a/lib/features/mailbox/data/extensions/mailbox_extension.dart b/lib/features/mailbox/data/extensions/mailbox_extension.dart index 6735803fe1..965d606a79 100644 --- a/lib/features/mailbox/data/extensions/mailbox_extension.dart +++ b/lib/features/mailbox/data/extensions/mailbox_extension.dart @@ -17,7 +17,8 @@ extension MailboxExtension on Mailbox { unreadThreads: unreadThreads?.value.value.round(), myRights: myRights?.toMailboxRightsCache(), isSubscribed: isSubscribed?.value, - namespace: namespace?.value + namespace: namespace?.value, + rights: rights?.map((key, value) => MapEntry(key, value)), ); } } \ No newline at end of file diff --git a/lib/features/mailbox/data/model/mailbox_cache.dart b/lib/features/mailbox/data/model/mailbox_cache.dart index 05fe3e005d..4463e7e450 100644 --- a/lib/features/mailbox/data/model/mailbox_cache.dart +++ b/lib/features/mailbox/data/model/mailbox_cache.dart @@ -48,6 +48,9 @@ class MailboxCache extends HiveObject with EquatableMixin { @HiveField(12) final String? namespace; + @HiveField(13) + final Map?>? rights; + MailboxCache( this.id, { @@ -63,6 +66,7 @@ class MailboxCache extends HiveObject with EquatableMixin { this.isSubscribed, this.lastOpened, this.namespace, + this.rights } ); @@ -80,5 +84,6 @@ class MailboxCache extends HiveObject with EquatableMixin { myRights, isSubscribed, namespace, + rights ]; } \ No newline at end of file diff --git a/lib/features/mailbox/data/network/mailbox_api.dart b/lib/features/mailbox/data/network/mailbox_api.dart index 462b72ebc9..5d53321209 100644 --- a/lib/features/mailbox/data/network/mailbox_api.dart +++ b/lib/features/mailbox/data/network/mailbox_api.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:flutter/material.dart' hide State; +import 'package:get/get.dart'; import 'package:jmap_dart_client/http/http_client.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; @@ -26,20 +28,24 @@ import 'package:jmap_dart_client/jmap/mail/mailbox/query/query_mailbox_method.da import 'package:jmap_dart_client/jmap/mail/mailbox/set/set_mailbox_method.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/set/set_mailbox_response.dart'; import 'package:model/error_type_handler/set_method_error_handler_mixin.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; import 'package:model/model.dart'; import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart'; import 'package:tmail_ui_user/features/composer/domain/exceptions/set_method_exception.dart'; import 'package:tmail_ui_user/features/mailbox/data/model/mailbox_change_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/mailbox_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_method_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/extensions/list_mailbox_id_extension.dart'; import 'package:tmail_ui_user/features/mailbox/domain/extensions/role_extension.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; @@ -401,6 +407,53 @@ class MailboxAPI with HandleSetErrorMixin { return listMailboxIdSubscribe ?? []; } + List _updateRightsForSubaddressing(MailboxSubaddressingAction action, List? currentRights) { + final updatedRights = List.from(currentRights ?? []); + + if (action == MailboxSubaddressingAction.allow) { + updatedRights.addIf(!updatedRights.contains(postingRight), postingRight); + } else { + updatedRights.remove(postingRight); + } + + return updatedRights; + } + + @visibleForTesting + List updateRightsForSubaddressing(MailboxSubaddressingAction action, List? currentRights) + => _updateRightsForSubaddressing(action, currentRights); + + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) async { + final setMailboxMethod = SetMailboxMethod(accountId) + ..addUpdates({ + request.mailboxId.id : PatchObject({ + 'sharedWith/$anyoneIdentifier': _updateRightsForSubaddressing(request.subaddressingAction, request.currentRights?[anyoneIdentifier]) + }) + }); + + final requestBuilder = JmapRequestBuilder(httpClient, ProcessingInvocation()); + + final setMailboxInvocation = requestBuilder.invocation(setMailboxMethod); + + final capabilities = setMailboxMethod.requiredCapabilities + .toCapabilitiesSupportTeamMailboxes(session, accountId); + + final response = await (requestBuilder + ..usings(capabilities)) + .build() + .execute(); + + final setMailboxResponse = response.parse( + setMailboxInvocation.methodCallId, + SetMailboxResponse.deserialize); + + if (setMailboxResponse?.updated?.containsKey(request.mailboxId.id) ?? false) { + return true; + } else { + throw SetMailboxRightsException(); + } + } + Future> createDefaultMailbox( Session session, AccountId accountId, diff --git a/lib/features/mailbox/data/repository/mailbox_repository_impl.dart b/lib/features/mailbox/data/repository/mailbox_repository_impl.dart index a0fde06d72..8e28f45913 100644 --- a/lib/features/mailbox/data/repository/mailbox_repository_impl.dart +++ b/lib/features/mailbox/data/repository/mailbox_repository_impl.dart @@ -28,6 +28,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_respons import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/repository/mailbox_repository.dart'; @@ -250,6 +251,11 @@ class MailboxRepositoryImpl extends MailboxRepository { return mapDataSource[DataSourceType.network]!.subscribeMultipleMailbox(session, accountId, subscribeRequest); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + return mapDataSource[DataSourceType.network]!.handleMailboxRightRequest(session, accountId, request); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { return mapDataSource[DataSourceType.network]!.createDefaultMailbox(session, accountId, listRole); diff --git a/lib/features/mailbox/domain/constants/mailbox_constants.dart b/lib/features/mailbox/domain/constants/mailbox_constants.dart index 36f7d97bf3..36b221ba85 100644 --- a/lib/features/mailbox/domain/constants/mailbox_constants.dart +++ b/lib/features/mailbox/domain/constants/mailbox_constants.dart @@ -16,7 +16,8 @@ class MailboxConstants { MailboxProperty.unreadEmails, MailboxProperty.unreadThreads, MailboxProperty.myRights, - MailboxProperty.namespace + MailboxProperty.namespace, + MailboxProperty.rights }); static final List defaultMailboxRoles = [ diff --git a/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart b/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart new file mode 100644 index 0000000000..2e9bc3e7bb --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart @@ -0,0 +1,8 @@ + +class NullSessionOrAccountIdException implements Exception { + + NullSessionOrAccountIdException(); + + @override + String toString() => 'NullSessionOrAccountIdException: session and accountId should not be null'; +} diff --git a/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart b/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart new file mode 100644 index 0000000000..211c4b3ce8 --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart @@ -0,0 +1,8 @@ + +class SetMailboxRightsException implements Exception { + + SetMailboxRightsException(); + + @override + String toString() => 'Failed to update mailbox rights.'; +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/model/mailbox_right_request.dart b/lib/features/mailbox/domain/model/mailbox_right_request.dart new file mode 100644 index 0000000000..cf09072c2e --- /dev/null +++ b/lib/features/mailbox/domain/model/mailbox_right_request.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; + +class MailboxRightRequest with EquatableMixin { + + final MailboxSubaddressingAction subaddressingAction; + final MailboxId mailboxId; + final Map?>? currentRights; + + MailboxRightRequest( + this.mailboxId, + this.currentRights, + this.subaddressingAction + ); + + @override + List get props => [ + mailboxId, + currentRights, + subaddressingAction, + ]; +} diff --git a/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart b/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart new file mode 100644 index 0000000000..cef83f61e0 --- /dev/null +++ b/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart @@ -0,0 +1,4 @@ +enum MailboxSubaddressingAction { + allow, + disallow, +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/repository/mailbox_repository.dart b/lib/features/mailbox/domain/repository/mailbox_repository.dart index a3f40851e4..4b9e319ed8 100644 --- a/lib/features/mailbox/domain/repository/mailbox_repository.dart +++ b/lib/features/mailbox/domain/repository/mailbox_repository.dart @@ -19,6 +19,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; abstract class MailboxRepository { Stream getAllMailbox(Session session, AccountId accountId, {Properties? properties}); @@ -46,6 +47,8 @@ abstract class MailboxRepository { Future> subscribeMultipleMailbox(Session session, AccountId accountId, SubscribeMultipleMailboxRequest subscribeRequest); + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request); + Future> createDefaultMailbox(Session session, AccountId accountId, List listRole); Future setRoleDefaultMailbox(Session session, AccountId accountId, List listMailbox); diff --git a/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart b/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart new file mode 100644 index 0000000000..44a1ccb5f4 --- /dev/null +++ b/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart @@ -0,0 +1,37 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:tmail_ui_user/features/base/state/ui_action_state.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; + +class LoadingSubaddressingMailbox extends LoadingState {} + +class SubaddressingSuccess extends UIActionState { + final MailboxId mailboxId; + final MailboxSubaddressingAction subaddressingAction; + + SubaddressingSuccess( + this.mailboxId, + this.subaddressingAction, + { + jmap.State? currentEmailState, + jmap.State? currentMailboxState, + } + ) : super(currentEmailState, currentMailboxState); + + @override + List get props => [ + mailboxId, + subaddressingAction, + ...super.props + ]; +} + +class SubaddressingFailure extends FeatureFailure { + + SubaddressingFailure(dynamic exception) : super(exception: exception); + + SubaddressingFailure.withException(Exception exception) + : super(exception: exception); +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart b/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart new file mode 100644 index 0000000000..30361e4a5f --- /dev/null +++ b/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart @@ -0,0 +1,36 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/repository/mailbox_repository.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/state/subaddressing_mailbox_state.dart'; + +class SubaddressingInteractor { + final MailboxRepository _mailboxRepository; + + SubaddressingInteractor(this._mailboxRepository); + + Stream> execute(Session session, AccountId accountId, MailboxRightRequest mailboxRightRequest) async* { + try { + yield Right(LoadingSubaddressingMailbox()); + + final currentMailboxState = await _mailboxRepository.getMailboxState(session, accountId); + + final result = await _mailboxRepository.handleMailboxRightRequest(session, accountId, mailboxRightRequest); + + if (result) { + yield Right(SubaddressingSuccess( + mailboxRightRequest.mailboxId, + currentMailboxState: currentMailboxState, + mailboxRightRequest.subaddressingAction)); + } else { + yield Left(SubaddressingFailure(null)); + } + + } catch (exception) { + yield Left(SubaddressingFailure(exception)); + } + } +} \ No newline at end of file diff --git a/lib/features/mailbox/presentation/mailbox_bindings.dart b/lib/features/mailbox/presentation/mailbox_bindings.dart index 2458018923..7e71a757c8 100644 --- a/lib/features/mailbox/presentation/mailbox_bindings.dart +++ b/lib/features/mailbox/presentation/mailbox_bindings.dart @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/get_all_mailbox_i import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -56,6 +57,7 @@ class MailboxBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), Get.find(), Get.find(), Get.find(), @@ -106,6 +108,7 @@ class MailboxBindings extends BaseBindings { Get.lazyPut(() => MoveMailboxInteractor(Get.find())); Get.lazyPut(() => SubscribeMailboxInteractor(Get.find())); Get.lazyPut(() => SubscribeMultipleMailboxInteractor(Get.find())); + Get.lazyPut(() => SubaddressingInteractor(Get.find())); Get.lazyPut(() => CreateDefaultMailboxInteractor(Get.find())); } diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index b0501879e4..dc278e217d 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -32,11 +32,14 @@ import 'package:tmail_ui_user/features/email/presentation/model/composer_argumen import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/mailbox/domain/constants/mailbox_constants.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_action_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_request.dart'; @@ -49,6 +52,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/state/move_mailbox_state.d import 'package:tmail_ui_user/features/mailbox/domain/state/refresh_all_mailboxes_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/refresh_changes_all_mailboxes_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/rename_mailbox_state.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/state/subaddressing_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/subscribe_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/subscribe_multiple_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_default_mailbox_interactor.dart'; @@ -58,6 +62,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/get_all_mailbox_i import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/action/mailbox_ui_action.dart'; @@ -100,6 +105,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM final MoveMailboxInteractor _moveMailboxInteractor; final SubscribeMailboxInteractor _subscribeMailboxInteractor; final SubscribeMultipleMailboxInteractor _subscribeMultipleMailboxInteractor; + final SubaddressingInteractor _subaddressingInteractor; final CreateDefaultMailboxInteractor _createDefaultMailboxInteractor; IOSSharingManager? _iosSharingManager; @@ -125,6 +131,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM this._moveMailboxInteractor, this._subscribeMailboxInteractor, this._subscribeMultipleMailboxInteractor, + this._subaddressingInteractor, this._createDefaultMailboxInteractor, TreeBuilder treeBuilder, VerifyNameInteractor verifyNameInteractor, @@ -186,6 +193,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _handleUnsubscribeMultipleMailboxAllSuccess(success); } else if (success is SubscribeMultipleMailboxHasSomeSuccess) { _handleUnsubscribeMultipleMailboxHasSomeSuccess(success); + } else if (success is SubaddressingSuccess) { + _handleSubaddressingSuccess(success); } else if (success is CreateDefaultMailboxAllSuccess) { _refreshMailboxChanges(currentMailboxState: success.currentMailboxState); } @@ -204,6 +213,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _clearNewFolderId(); } else if (failure is CreateDefaultMailboxFailure) { _refreshMailboxChanges(currentMailboxState: failure.currentMailboxState); + } else if (failure is SubaddressingFailure) { + _handleSubaddressingFailure(failure); } } @@ -1068,6 +1079,10 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM case MailboxActions.disableMailbox: _unsubscribeMailboxAction(mailbox.id); break; + case MailboxActions.allowSubaddressing: + case MailboxActions.disallowSubaddressing: + _handleSubaddressingAction(mailbox.id, mailbox.rights, actions); + break; case MailboxActions.emptyTrash: emptyTrashAction(context, mailbox, mailboxDashBoardController); break; @@ -1338,6 +1353,41 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM } } + void _handleSubaddressingAction(MailboxId mailboxId, Map?>? currentRights, MailboxActions subaddressingAction) { + final accountId = mailboxDashBoardController.accountId.value; + final session = mailboxDashBoardController.sessionCurrent; + + if (session != null && accountId != null) { + final allowSubaddressingRequest = MailboxRightRequest( + mailboxId, + currentRights, + subaddressingAction == MailboxActions.allowSubaddressing ? MailboxSubaddressingAction.allow : MailboxSubaddressingAction.disallow + ); + + consumeState(_subaddressingInteractor.execute(session, accountId, allowSubaddressingRequest)); + } else { + _handleSubaddressingFailure(SubaddressingFailure.withException(NullSessionOrAccountIdException())); + } + + popBack(); + } + + void _handleSubaddressingFailure(SubaddressingFailure failure) { + if (currentOverlayContext != null && currentContext != null) { + final messageError = AppLocalizations.of(currentContext!).toastMessageSubaddressingFailure; + appToast.showToastErrorMessage(currentOverlayContext!, messageError); + } + } + + void _handleSubaddressingSuccess(SubaddressingSuccess success) { + appToast.showToastSuccessMessage( + currentOverlayContext!, + success.subaddressingAction == MailboxSubaddressingAction.allow + ? AppLocalizations.of(currentContext!).toastMessageAllowSubaddressingSuccess + : AppLocalizations.of(currentContext!).toastMessageDisallowSubaddressingSuccess, + ); + } + void _mailboxListScrollControllerListener() { _handleScrollTop(); _handleScrollBottom(); diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f39c778dee..f7320b6299 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -60,6 +60,10 @@ mixin MailboxWidgetMixin { MailboxActions.markAsRead, MailboxActions.move, MailboxActions.rename, + if (mailbox.isSubaddressingAllowed) + MailboxActions.disallowSubaddressing + else + MailboxActions.allowSubaddressing, if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else diff --git a/lib/features/mailbox/presentation/model/mailbox_actions.dart b/lib/features/mailbox/presentation/model/mailbox_actions.dart index 722d55baac..2cb018b006 100644 --- a/lib/features/mailbox/presentation/model/mailbox_actions.dart +++ b/lib/features/mailbox/presentation/model/mailbox_actions.dart @@ -24,7 +24,9 @@ enum MailboxActions { emptySpam, newSubfolder, confirmMailSpam, - recoverDeletedMessages; + recoverDeletedMessages, + allowSubaddressing, + disallowSubaddressing; } extension MailboxActionsExtension on MailboxActions { @@ -74,6 +76,10 @@ extension MailboxActionsExtension on MailboxActions { return AppLocalizations.of(context).confirmAllEmailHereAreSpam; case MailboxActions.recoverDeletedMessages: return AppLocalizations.of(context).recoverDeletedMessages; + case MailboxActions.allowSubaddressing: + return AppLocalizations.of(context).allowSubaddressing; + case MailboxActions.disallowSubaddressing: + return AppLocalizations.of(context).disallowSubaddressing; default: return ''; } @@ -109,6 +115,10 @@ extension MailboxActionsExtension on MailboxActions { return imagePaths.icMarkAsRead; case MailboxActions.recoverDeletedMessages: return imagePaths.icRecoverDeletedMessages; + case MailboxActions.allowSubaddressing: + return imagePaths.icSubaddressingAllow; + case MailboxActions.disallowSubaddressing: + return imagePaths.icSubaddressingDisallow; default: return ''; } @@ -183,6 +193,8 @@ extension MailboxActionsExtension on MailboxActions { case MailboxActions.emptySpam: case MailboxActions.confirmMailSpam: case MailboxActions.recoverDeletedMessages: + case MailboxActions.allowSubaddressing: + case MailboxActions.disallowSubaddressing: return ContextMenuItemState.activated; case MailboxActions.markAsRead: return mailbox.countUnReadEmailsAsString.isNotEmpty diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index d4d794eb14..d7b560b4ef 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -2326,6 +2326,36 @@ "placeholders_order": [], "placeholders": {} }, + "allowSubaddressing": "Allow subaddressing", + "@allowSubaddressing": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "disallowSubaddressing": "Disallow subaddressing", + "@disallowSubaddressing": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageAllowSubaddressingSuccess": "You have successfully allowed subaddressing for this folder", + "@toastMessageAllowSubaddressingSuccess": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageDisallowSubaddressingSuccess": "You have successfully disallowed subaddressing for this folder", + "@toastMessageDisallowSubaddressingSuccess": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageSubaddressingFailure": "There was an error dealing with the request", + "@toastMessageSubaddressingFailure": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "requestReadReceipt": "Request read receipt", "@requestReadReceipt": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 9dac836f6b..c02e63f313 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -2403,6 +2403,36 @@ class AppLocalizations { name: 'selectParentFolder'); } + String get allowSubaddressing { + return Intl.message( + 'Allow subaddressing', + name: 'allowSubaddressing'); + } + + String get disallowSubaddressing { + return Intl.message( + 'Disallow subaddressing', + name: 'disallowSubaddressing'); + } + + String get toastMessageAllowSubaddressingSuccess { + return Intl.message( + 'You have successfully allowed subaddressing for this folder', + name: 'toastMessageAllowSubaddressingSuccess'); + } + + String get toastMessageDisallowSubaddressingSuccess { + return Intl.message( + 'You have successfully disallowed subaddressing for this folder', + name: 'toastMessageDisallowSubaddressingSuccess'); + } + + String get toastMessageSubaddressingFailure { + return Intl.message( + 'There was an error dealing with the request', + name: 'toastMessageSubaddressingFailure'); + } + String get requestReadReceipt { return Intl.message( 'Request read receipt', @@ -2772,6 +2802,7 @@ class AppLocalizations { 'This folder is already displayed in your primary folder', name: 'toastMessageShowFolderSuccess'); } + String get folderVisibility { return Intl.message( 'Folder visibility', diff --git a/model/lib/extensions/mailbox_extension.dart b/model/lib/extensions/mailbox_extension.dart index 44dcefa265..542325beaf 100644 --- a/model/lib/extensions/mailbox_extension.dart +++ b/model/lib/extensions/mailbox_extension.dart @@ -32,6 +32,7 @@ extension MailboxExtension on Mailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } @@ -49,6 +50,7 @@ extension MailboxExtension on Mailbox { myRights: updatedProperties.contain(MailboxProperty.myRights) ? newMailbox.myRights : myRights, isSubscribed: updatedProperties.contain(MailboxProperty.isSubscribed) ? newMailbox.isSubscribed : isSubscribed, namespace: updatedProperties.contain(MailboxProperty.namespace) ? newMailbox.namespace : namespace, + rights: updatedProperties.contain(MailboxProperty.rights) ? newMailbox.rights : rights, ); } @@ -66,6 +68,7 @@ extension MailboxExtension on Mailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } } \ No newline at end of file diff --git a/model/lib/extensions/presentation_mailbox_extension.dart b/model/lib/extensions/presentation_mailbox_extension.dart index 6b1f57d360..eb58c78aa7 100644 --- a/model/lib/extensions/presentation_mailbox_extension.dart +++ b/model/lib/extensions/presentation_mailbox_extension.dart @@ -2,6 +2,7 @@ import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/namespace.dart'; import 'package:model/mailbox/mailbox_state.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; import 'package:model/mailbox/select_mode.dart'; extension PresentationMailboxExtension on PresentationMailbox { @@ -49,6 +50,8 @@ extension PresentationMailboxExtension on PresentationMailbox { bool get isSubscribedMailbox => isSubscribed != null && isSubscribed?.value == true; + bool get isSubaddressingAllowed => rights != null && rights?[anyoneIdentifier]?.contains(postingRight) == true; + bool get allowedToDisplayCountOfUnreadEmails => !(isTrash || isSpam || isDrafts || isTemplates || isSent) && countUnreadEmails > 0; bool get allowedToDisplayCountOfTotalEmails => (isTrash || isSpam || isDrafts) && countTotalEmails > 0; @@ -100,6 +103,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -121,6 +125,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -142,6 +147,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: newMailboxState, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -159,6 +165,7 @@ extension PresentationMailboxExtension on PresentationMailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } @@ -180,6 +187,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -201,6 +209,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } } \ No newline at end of file diff --git a/model/lib/mailbox/mailbox_constants.dart b/model/lib/mailbox/mailbox_constants.dart new file mode 100644 index 0000000000..ae75773f98 --- /dev/null +++ b/model/lib/mailbox/mailbox_constants.dart @@ -0,0 +1,2 @@ +const String anyoneIdentifier = 'anyone'; +const String postingRight = 'p'; diff --git a/model/lib/mailbox/mailbox_property.dart b/model/lib/mailbox/mailbox_property.dart index 6fc1346278..9e152e95bb 100644 --- a/model/lib/mailbox/mailbox_property.dart +++ b/model/lib/mailbox/mailbox_property.dart @@ -12,4 +12,5 @@ class MailboxProperty { static const String myRights = 'myRights'; static const String isSubscribed = 'isSubscribed'; static const String namespace = 'namespace'; + static const String rights = 'rights'; } \ No newline at end of file diff --git a/model/lib/mailbox/presentation_mailbox.dart b/model/lib/mailbox/presentation_mailbox.dart index a810e1037c..56f9290115 100644 --- a/model/lib/mailbox/presentation_mailbox.dart +++ b/model/lib/mailbox/presentation_mailbox.dart @@ -48,6 +48,7 @@ class PresentationMailbox with EquatableMixin { final MailboxState? state; final Namespace? namespace; final String? displayName; + final Map?>? rights; PresentationMailbox( this.id, @@ -67,6 +68,7 @@ class PresentationMailbox with EquatableMixin { this.state = MailboxState.activated, this.namespace, this.displayName, + this.rights } ); @@ -88,5 +90,6 @@ class PresentationMailbox with EquatableMixin { state, namespace, displayName, + rights ]; } \ No newline at end of file diff --git a/test/features/mailbox/data/update_rights_for_subaddressing_test.dart b/test/features/mailbox/data/update_rights_for_subaddressing_test.dart new file mode 100644 index 0000000000..2c5398b4e9 --- /dev/null +++ b/test/features/mailbox/data/update_rights_for_subaddressing_test.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:tmail_ui_user/features/mailbox/data/network/mailbox_api.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + group('MailboxAPI.updateRightsForSubaddressing tests', () { + const postingRight = 'p'; + final mailboxAPI = MailboxAPI(HttpClient(Dio()), const Uuid()); + + test('should add postingRight when action is allow and currentRights is empty', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, []); + expect(result, contains(postingRight)); + expect(result.length, 1); + }); + + test('should add postingRight when action is allow and currentRights does not contain postingRight', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, ['r', 'w']); + expect(result, contains(postingRight)); + expect(result.length, 3); + }); + + test('should not add postingRight if action is allow and postingRight already present', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, ['r', postingRight, 'w']); + expect(result.where((right) => right == postingRight).length, 1); + expect(result.length, 3); + }); + + test('should remove postingRight if present and action is disallow', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, ['r', postingRight, 'w']); + expect(result, isNot(contains(postingRight))); + expect(result.length, 2); + }); + + test('should do nothing if postingRight is not present and action is disallow', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, ['r', 'w']); + expect(result, isNot(contains(postingRight))); + expect(result.length, 2); + }); + + test('should return empty list if action is disallow and currentRights is null', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, null); + expect(result, isEmpty); + }); + + test('should return list with only postingRight if action is allow and currentRights is null', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, null); + expect(result, contains(postingRight)); + expect(result.length, 1); + }); + }); +} diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index a6045ae75a..bdeedd328f 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -44,6 +44,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/mark_as_mailbox_r import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -146,6 +147,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -248,6 +250,7 @@ void main() { final moveMailboxInteractor = MockMoveMailboxInteractor(); final subscribeMailboxInteractor = MockSubscribeMailboxInteractor(); final subscribeMultipleMailboxInteractor = MockSubscribeMultipleMailboxInteractor(); + final subaddressingInteractor = MockSubaddressingInteractor(); final createDefaultMailboxInteractor = MockCreateDefaultMailboxInteractor(); final treeBuilder = MockTreeBuilder(); final verifyNameInteractor = MockVerifyNameInteractor(); @@ -359,6 +362,7 @@ void main() { moveMailboxInteractor, subscribeMailboxInteractor, subscribeMultipleMailboxInteractor, + subaddressingInteractor, createDefaultMailboxInteractor, treeBuilder, verifyNameInteractor, diff --git a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart index 910f68bc9f..0d022941f9 100644 --- a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart +++ b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart @@ -44,6 +44,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/mark_as_mailbox_r import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -150,6 +151,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -237,6 +239,7 @@ void main() { final moveMailboxInteractor = MockMoveMailboxInteractor(); final subscribeMailboxInteractor = MockSubscribeMailboxInteractor(); final subscribeMultipleMailboxInteractor = MockSubscribeMultipleMailboxInteractor(); + final subaddressingInteractor = MockSubaddressingInteractor(); final createDefaultMailboxInteractor = MockCreateDefaultMailboxInteractor(); final treeBuilder = MockTreeBuilder(); final verifyNameInteractor = MockVerifyNameInteractor(); @@ -352,6 +355,7 @@ void main() { moveMailboxInteractor, subscribeMailboxInteractor, subscribeMultipleMailboxInteractor, + subaddressingInteractor, createDefaultMailboxInteractor, treeBuilder, verifyNameInteractor, From 810e304c1994c39525df467b316094db72d0faba Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 14:29:59 +0100 Subject: [PATCH 15/28] TF-3189 new option to copy a folder's subaddress --- assets/images/ic_copy.svg | 3 + .../presentation/resources/image_paths.dart | 1 + .../base/base_mailbox_controller.dart | 12 ++- .../empty_folder_name_exception.dart | 9 +++ .../invalid_mail_format_exception.dart | 9 +++ .../presentation/mailbox_controller.dart | 29 +++++++ .../mixin/mailbox_widget_mixin.dart | 2 + .../presentation/model/mailbox_actions.dart | 6 ++ .../presentation/model/mailbox_tree.dart | 6 +- lib/l10n/intl_messages.arb | 12 +++ lib/main/localizations/app_localizations.dart | 20 +++++ .../mailbox_dashboard_controller_test.dart | 79 +++++++++++++++++++ 12 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 assets/images/ic_copy.svg create mode 100644 lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart create mode 100644 lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart diff --git a/assets/images/ic_copy.svg b/assets/images/ic_copy.svg new file mode 100644 index 0000000000..558c78aa86 --- /dev/null +++ b/assets/images/ic_copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index d20c5d6058..d320368cb1 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -221,6 +221,7 @@ class ImagePaths { String get icBadSignature => _getImagePath('ic_bad_signature.svg'); String get icDeleteSelection => _getImagePath('ic_delete_selection.svg'); String get icLogoTwakeWelcome => _getImagePath('ic_logo_twake_welcome.svg'); + String get icCopy => _getImagePath('ic_copy.svg'); String get icSubaddressingAllow => _getImagePath('ic_subaddressing_allow.svg'); String get icSubaddressingDisallow => _getImagePath('ic_subaddressing_disallow.svg'); diff --git a/lib/features/base/base_mailbox_controller.dart b/lib/features/base/base_mailbox_controller.dart index 854eca61a7..e1112bbb7a 100644 --- a/lib/features/base/base_mailbox_controller.dart +++ b/lib/features/base/base_mailbox_controller.dart @@ -210,14 +210,18 @@ abstract class BaseMailboxController extends BaseController { return teamMailboxesTree.value.findNode((node) => node.item.id == mailboxId); } - String? findNodePath(MailboxId mailboxId) { - var mailboxNodePath = defaultMailboxTree.value.getNodePath(mailboxId) - ?? personalMailboxTree.value.getNodePath(mailboxId) - ?? teamMailboxesTree.value.getNodePath(mailboxId); + String? findNodePathWithSeparator(MailboxId mailboxId, String pathSeparator) { + var mailboxNodePath = defaultMailboxTree.value.getNodePath(mailboxId, pathSeparator) + ?? personalMailboxTree.value.getNodePath(mailboxId, pathSeparator) + ?? teamMailboxesTree.value.getNodePath(mailboxId, pathSeparator); log('BaseMailboxController::findNodePath():mailboxNodePath: $mailboxNodePath'); return mailboxNodePath; } + String? findNodePath(MailboxId mailboxId) { + return findNodePathWithSeparator(mailboxId, '/'); + } + MailboxNode? findMailboxNodeByRole(Role role) { final mailboxNode = defaultMailboxTree.value.findNode((node) => node.item.role == role); return mailboxNode; diff --git a/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart b/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart new file mode 100644 index 0000000000..5a3ff99ddc --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart @@ -0,0 +1,9 @@ + +class EmptyFolderNameException implements Exception { + final String folderName; + + EmptyFolderNameException(this.folderName); + + @override + String toString() => 'EmptyFolderNameException: Folder name should not be empty: $folderName'; +} diff --git a/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart b/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart new file mode 100644 index 0000000000..bd15b87b2d --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart @@ -0,0 +1,9 @@ + +class InvalidMailFormatException implements Exception { + final String mail; + + InvalidMailFormatException(this.mail); + + @override + String toString() => 'InvalidMailFormatException: $mail'; +} diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index dc278e217d..5cee27cbfa 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -5,6 +5,7 @@ import 'package:core/core.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; @@ -31,6 +32,8 @@ import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state. import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/mailbox/domain/constants/mailbox_constants.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/empty_folder_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_name_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; @@ -1072,6 +1075,14 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM case MailboxActions.openInNewTab: openMailboxInNewTabAction(mailbox); break; + case MailboxActions.copySubaddress: + try{ + final subaddress = getSubaddress(mailboxDashBoardController.userEmail, findNodePathWithSeparator(mailbox.id, '.')!); + copySubaddressAction(context, subaddress); + } catch (error) { + appToast.showToastErrorMessage(context, AppLocalizations.of(context).errorWhileFetchingSubaddress); + } + break; case MailboxActions.disableSpamReport: case MailboxActions.enableSpamReport: mailboxDashBoardController.storeSpamReportStateAction(); @@ -1460,4 +1471,22 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM mailboxDashBoardController.emptySpamFolderAction(spamFolderId: presentationMailbox.id); } } + + void copySubaddressAction(BuildContext context, String subaddress) { + Clipboard.setData(ClipboardData(text: subaddress)); + appToast.showToastSuccessMessage(context, AppLocalizations.of(context).emailSubaddressCopiedToClipboard); + } + + String getSubaddress(String userEmail, String folderName) { + if (folderName.isEmpty) { + throw EmptyFolderNameException(folderName); + } + + final atIndex = userEmail.indexOf('@'); + if (atIndex <= 0 || atIndex == userEmail.length - 1) { + throw InvalidMailFormatException(userEmail); + } + + return '${userEmail.substring(0, atIndex)}+$folderName@${userEmail.substring(atIndex + 1)}'; + } } \ No newline at end of file diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f7320b6299..f97a869ab3 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -64,6 +64,8 @@ mixin MailboxWidgetMixin { MailboxActions.disallowSubaddressing else MailboxActions.allowSubaddressing, + if (mailbox.isSubaddressingAllowed) + MailboxActions.copySubaddress, if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else diff --git a/lib/features/mailbox/presentation/model/mailbox_actions.dart b/lib/features/mailbox/presentation/model/mailbox_actions.dart index 2cb018b006..e010aec1b3 100644 --- a/lib/features/mailbox/presentation/model/mailbox_actions.dart +++ b/lib/features/mailbox/presentation/model/mailbox_actions.dart @@ -16,6 +16,7 @@ enum MailboxActions { markAsRead, selectForRuleAction, openInNewTab, + copySubaddress, disableSpamReport, enableSpamReport, disableMailbox, @@ -50,6 +51,8 @@ extension MailboxActionsExtension on MailboxActions { switch(this) { case MailboxActions.openInNewTab: return AppLocalizations.of(context).openInNewTab; + case MailboxActions.copySubaddress: + return AppLocalizations.of(context).copySubaddress; case MailboxActions.newSubfolder: return AppLocalizations.of(context).newSubfolder; case MailboxActions.disableSpamReport: @@ -89,6 +92,8 @@ extension MailboxActionsExtension on MailboxActions { switch(this) { case MailboxActions.openInNewTab: return imagePaths.icOpenInNewTab; + case MailboxActions.copySubaddress: + return imagePaths.icCopy; case MailboxActions.newSubfolder: return imagePaths.icAddNewFolder; case MailboxActions.disableSpamReport: @@ -181,6 +186,7 @@ extension MailboxActionsExtension on MailboxActions { ContextMenuItemState getContextMenuItemState(PresentationMailbox mailbox) { switch(this) { case MailboxActions.openInNewTab: + case MailboxActions.copySubaddress: case MailboxActions.newSubfolder: case MailboxActions.disableSpamReport: case MailboxActions.enableSpamReport: diff --git a/lib/features/mailbox/presentation/model/mailbox_tree.dart b/lib/features/mailbox/presentation/model/mailbox_tree.dart index 125e6e3c63..c825ddcd69 100644 --- a/lib/features/mailbox/presentation/model/mailbox_tree.dart +++ b/lib/features/mailbox/presentation/model/mailbox_tree.dart @@ -83,7 +83,7 @@ class MailboxTree with EquatableMixin { } } - String? getNodePath(MailboxId mailboxId) { + String? getNodePath(MailboxId mailboxId, String pathSeparator) { final matchedNode = findNode((node) => node.item.id == mailboxId); if (matchedNode == null) { return null; @@ -103,9 +103,9 @@ class MailboxTree with EquatableMixin { break; } if (currentContext != null) { - path = '${parentNode.item.getDisplayName(currentContext!)}/$path'; + path = '${parentNode.item.getDisplayName(currentContext!)}$pathSeparator$path'; } else { - path = '${parentNode.item.name?.name}/$path'; + path = '${parentNode.item.name?.name}$pathSeparator$path'; } parentId = parentNode.item.parentId; } diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index d7b560b4ef..6780fade10 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -764,6 +764,12 @@ "placeholders_order": [], "placeholders": {} }, + "emailSubaddressCopiedToClipboard": "Email subaddress copied to clipboard", + "@emailSubaddressCopiedToClipboard": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "minimize": "Minimize", "@minimize": { "type": "text", @@ -2560,6 +2566,12 @@ "placeholders_order": [], "placeholders": {} }, + "copySubaddress": "Copy subaddress", + "@copySubaddress": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "regards": "Regards", "@regards": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index c02e63f313..0be68b3531 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -787,6 +787,12 @@ class AppLocalizations { name: 'email_address_copied_to_clipboard'); } + String get emailSubaddressCopiedToClipboard { + return Intl.message( + 'Email subaddress copied to clipboard', + name: 'emailSubaddressCopiedToClipboard'); + } + String get minimize { return Intl.message( 'Minimize', @@ -2628,6 +2634,13 @@ class AppLocalizations { ); } + String get copySubaddress { + return Intl.message( + 'Copy subaddress', + name: 'copySubaddress', + ); + } + String get regards { return Intl.message( 'Regards', @@ -2979,6 +2992,13 @@ class AppLocalizations { ); } + String get errorWhileFetchingSubaddress { + return Intl.message( + 'Error while fetching the subaddress', + name: 'errorWhileFetchingSubaddress', + ); + } + String get connectedToTheInternet { return Intl.message( 'Connected to the internet', diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index bdeedd328f..a8b8a0122d 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -36,6 +36,8 @@ import 'package:tmail_ui_user/features/login/domain/usecases/delete_authority_oi import 'package:tmail_ui_user/features/login/domain/usecases/delete_credential_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/get_authenticated_account_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/update_account_cache_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/empty_folder_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_default_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/delete_multiple_mailbox_interactor.dart'; @@ -570,4 +572,81 @@ void main() { expect(spamId, isNull); }); }); + + group('getSubaddress:test', () { + setUp(() { + getEmailsInMailboxInteractor = MockGetEmailsInMailboxInteractor(); + + when(emailReceiveManager.pendingSharedFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); + + Get.put(mailboxDashboardController); + mailboxDashboardController.onReady(); + + mailboxController = MailboxController( + createNewMailboxInteractor, + deleteMultipleMailboxInteractor, + renameMailboxInteractor, + moveMailboxInteractor, + subscribeMailboxInteractor, + subscribeMultipleMailboxInteractor, + subaddressingInteractor, + createDefaultMailboxInteractor, + treeBuilder, + verifyNameInteractor, + getAllMailboxInteractor, + refreshAllMailboxInteractor); + mailboxController.onReady(); + + threadController = ThreadController( + getEmailsInMailboxInteractor, + refreshChangesEmailsInMailboxInteractor, + loadMoreEmailsInMailboxInteractor, + searchEmailInteractor, + searchMoreEmailInteractor, + getEmailByIdInteractor); + Get.put(threadController); + + advancedFilterController = AdvancedFilterController(); + + mailboxDashboardController.sessionCurrent = testSession; + mailboxDashboardController.filterMessageOption.value = FilterMessageOption.all; + mailboxDashboardController.accountId.value = testAccountId; + }); + + test('should return subaddress with valid email and folder name', () { + const String userEmail = 'user@example.com'; + const String folderName = 'folder'; + final result = mailboxController.getSubaddress(userEmail, folderName); + + expect(result, equals('user+folder@example.com')); + }); + + test('should throw an error if empty local part', () { + const userEmail = '@example.com'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if empty folder name', () { + const userEmail = 'user@example.com'; + const folderName = ''; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if empty domain', () { + const userEmail = 'user@'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if absent `@`', () { + const userEmail = 'invalid-email-format'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + }); } From 0f61e3de8bdb8a7738ab261b46e7d300ebc04600 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 12:30:22 +0100 Subject: [PATCH 16/28] TF-3189 subaddressing features only shown if supported by the server --- .../mixin/mailbox_widget_mixin.dart | 63 ++++++++++++----- .../presentation/search_mailbox_view.dart | 10 +++ model/lib/mailbox/mailbox_constants.dart | 1 + .../mailbox_widget_mixin_test.dart | 69 +++++++++++++++++++ 4 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 test/features/mailbox/presentation/mailbox_widget_mixin_test.dart diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f97a869ab3..f313f9d465 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -4,9 +4,11 @@ import 'package:core/utils/direction_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; -import 'package:model/extensions/presentation_mailbox_extension.dart'; -import 'package:model/mailbox/expand_mode.dart'; -import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; +import 'package:model/model.dart'; import 'package:tmail_ui_user/features/base/base_mailbox_controller.dart'; import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -15,6 +17,7 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_action import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_categories.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/widgets/mailbox_bottom_sheet_action_tile_builder.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/app_dashboard/app_list_dashboard_item.dart'; +import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; mixin MailboxWidgetMixin { @@ -51,7 +54,7 @@ mixin MailboxWidgetMixin { ]; } - List _listActionForPersonalMailbox(PresentationMailbox mailbox) { + List _listActionForPersonalMailbox(PresentationMailbox mailbox, bool subaddressingSupported) { return [ if (PlatformInfo.isWeb && mailbox.isSubscribedMailbox) MailboxActions.openInNewTab, @@ -60,12 +63,14 @@ mixin MailboxWidgetMixin { MailboxActions.markAsRead, MailboxActions.move, MailboxActions.rename, - if (mailbox.isSubaddressingAllowed) - MailboxActions.disallowSubaddressing - else - MailboxActions.allowSubaddressing, - if (mailbox.isSubaddressingAllowed) - MailboxActions.copySubaddress, + if (subaddressingSupported) ...[ + if (mailbox.isSubaddressingAllowed) + MailboxActions.disallowSubaddressing + else + MailboxActions.allowSubaddressing, + if (mailbox.isSubaddressingAllowed) + MailboxActions.copySubaddress, + ], if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else @@ -90,12 +95,13 @@ mixin MailboxWidgetMixin { List _listActionForAllMailboxType( PresentationMailbox mailbox, - bool spamReportEnabled + bool spamReportEnabled, + bool subaddressingSupported ) { if (mailbox.isDefault) { return _listActionForDefaultMailbox(mailbox, spamReportEnabled); } else if (mailbox.isPersonal) { - return _listActionForPersonalMailbox(mailbox); + return _listActionForPersonalMailbox(mailbox, subaddressingSupported); } else { return _listActionForTeamMailbox(mailbox); } @@ -107,9 +113,14 @@ mixin MailboxWidgetMixin { PresentationMailbox mailbox, MailboxController controller ) { + final bool subaddressingSupported = isSubaddressingSupported( + controller.mailboxDashBoardController.sessionCurrent, + controller.mailboxDashBoardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, - controller.mailboxDashBoardController.enableSpamReport + controller.mailboxDashBoardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { @@ -180,9 +191,10 @@ mixin MailboxWidgetMixin { List listContextMenuItemAction( PresentationMailbox mailbox, - bool spamReportEnabled + bool spamReportEnabled, + bool subaddressingSupported ) { - final mailboxActionsSupported = _listActionForAllMailboxType(mailbox, spamReportEnabled); + final mailboxActionsSupported = _listActionForAllMailboxType(mailbox, spamReportEnabled, subaddressingSupported); final listContextMenuItemAction = mailboxActionsSupported .map((action) => ContextMenuItemMailboxAction(action, action.getContextMenuItemState(mailbox))) @@ -199,9 +211,14 @@ mixin MailboxWidgetMixin { PresentationMailbox mailbox, MailboxController controller ) { + final bool subaddressingSupported = isSubaddressingSupported( + controller.mailboxDashBoardController.sessionCurrent, + controller.mailboxDashBoardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, - controller.mailboxDashBoardController.enableSpamReport + controller.mailboxDashBoardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { @@ -254,6 +271,20 @@ mixin MailboxWidgetMixin { .toList(); } + static bool isSubaddressingSupported(Session? session, AccountId? accountId) { + if (session == null || accountId == null) { + return false; + } + if (!CapabilityIdentifier.jmapTeamMailboxes.isSupported(session, accountId)) { + return false; + } + + return (session.getCapabilityProperties(accountId, CapabilityIdentifier.jmapTeamMailboxes) + ?.props[0] as Map?) + ?[subaddressingSupported] + ?? false; + } + PopupMenuItem _buildPopupMenuItem( BuildContext context, ImagePaths imagePaths, diff --git a/lib/features/search/mailbox/presentation/search_mailbox_view.dart b/lib/features/search/mailbox/presentation/search_mailbox_view.dart index beeb620712..b388f23fc9 100644 --- a/lib/features/search/mailbox/presentation/search_mailbox_view.dart +++ b/lib/features/search/mailbox/presentation/search_mailbox_view.dart @@ -196,9 +196,14 @@ class SearchMailboxView extends GetWidget } List _listPopupMenuItemAction(BuildContext context, PresentationMailbox mailbox) { + final bool subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported( + controller.dashboardController.sessionCurrent, + controller.dashboardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, controller.dashboardController.enableSpamReport, + subaddressingSupported ); return contextMenuActions .map((action) => _mailboxFocusedMenuItem(context, action, mailbox)) @@ -252,9 +257,14 @@ class SearchMailboxView extends GetWidget PresentationMailbox mailbox, {RelativeRect? position} ) { + final bool subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported( + controller.dashboardController.sessionCurrent, + controller.dashboardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, controller.dashboardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { diff --git a/model/lib/mailbox/mailbox_constants.dart b/model/lib/mailbox/mailbox_constants.dart index ae75773f98..dd945ace5c 100644 --- a/model/lib/mailbox/mailbox_constants.dart +++ b/model/lib/mailbox/mailbox_constants.dart @@ -1,2 +1,3 @@ const String anyoneIdentifier = 'anyone'; const String postingRight = 'p'; +const String subaddressingSupported = "subaddressingSupported"; diff --git a/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart b/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart new file mode 100644 index 0000000000..8fbf9da276 --- /dev/null +++ b/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/account/account.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/capability/default_capability.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart'; + +void main() { + group('MailboxWidgetMixin::isSubaddressingSupported::test', () { + + test( + 'should return true ' + 'when the server advertizes true', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": true})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": true})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, true); + }); + + test( + 'should return false ' + 'when the server advertizes false', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": false})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": false})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, false); + }); + + test( + 'should return false ' + 'when the server advertizes nothing', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, false); + }); + }); +} \ No newline at end of file From d939ffb1cf82f4b96a8f9d1991357b0790060c8f Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 12:25:42 +0100 Subject: [PATCH 17/28] TF-3189 new confirmation popup when enabling subaddressing for a folder --- .../dialog/confirmation_dialog_builder.dart | 18 ++++-- .../base/base_mailbox_controller.dart | 11 +++- .../presentation/mailbox_controller.dart | 62 +++++++++++++++++++ .../widgets/copy_subaddress_widget.dart | 50 +++++++++++++++ lib/l10n/intl_messages.arb | 16 +++++ lib/main/localizations/app_localizations.dart | 14 +++++ 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart diff --git a/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart b/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart index 8cb4d41c75..9730257140 100644 --- a/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart +++ b/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart @@ -12,10 +12,11 @@ class ConfirmDialogBuilder { Key? _key; String _title = ''; - String _content = ''; + String _textContent = ''; String _confirmText = ''; String _cancelText = ''; Widget? _iconWidget; + Widget? _additionalWidgetContent; Color? _colorCancelButton; Color? _colorConfirmButton; TextStyle? _styleTextCancelButton; @@ -63,7 +64,11 @@ class ConfirmDialogBuilder { } void content(String content) { - _content = content; + _textContent = content; + } + + void addWidgetContent(Widget? icon) { + _additionalWidgetContent = icon; } void addIcon(Widget? icon) { @@ -210,11 +215,11 @@ class ConfirmDialogBuilder { ) ) ), - if (_content.isNotEmpty) + if (_textContent.isNotEmpty) Padding( padding: _paddingContent ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Center( - child: Text(_content, + child: Text(_textContent, textAlign: TextAlign.center, style: _styleContent ?? const TextStyle(fontSize: 17.0, color: AppColor.colorMessageDialog) ), @@ -233,6 +238,11 @@ class ConfirmDialogBuilder { ), ), ), + if (_additionalWidgetContent != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _additionalWidgetContent, + ), if (isArrangeActionButtonsVertical) ...[ if (_cancelText.isNotEmpty) diff --git a/lib/features/base/base_mailbox_controller.dart b/lib/features/base/base_mailbox_controller.dart index e1112bbb7a..c914969da9 100644 --- a/lib/features/base/base_mailbox_controller.dart +++ b/lib/features/base/base_mailbox_controller.dart @@ -49,6 +49,11 @@ import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/dialog_router.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +typedef RenameMailboxActionCallback = void Function(PresentationMailbox mailbox, MailboxName newMailboxName); +typedef MovingMailboxActionCallback = void Function(PresentationMailbox mailboxSelected, PresentationMailbox? destinationMailbox); +typedef DeleteMailboxActionCallback = void Function(PresentationMailbox mailbox); +typedef AllowSubaddressingActionCallback = void Function(MailboxId, Map?>?, MailboxActions); + abstract class BaseMailboxController extends BaseController { final TreeBuilder _treeBuilder; final VerifyNameInteractor verifyNameInteractor; @@ -312,7 +317,7 @@ abstract class BaseMailboxController extends BaseController { BuildContext context, PresentationMailbox presentationMailbox, ResponsiveUtils responsiveUtils, { - required Function(PresentationMailbox mailbox, MailboxName newMailboxName) onRenameMailboxAction + required RenameMailboxActionCallback onRenameMailboxAction }) { final listMailboxName = getListMailboxNameInParentMailbox(presentationMailbox); @@ -382,7 +387,7 @@ abstract class BaseMailboxController extends BaseController { BuildContext context, PresentationMailbox mailboxSelected, MailboxDashBoardController dashBoardController, { - required Function(PresentationMailbox mailboxSelected, PresentationMailbox? destinationMailbox) onMovingMailboxAction + required MovingMailboxActionCallback onMovingMailboxAction }) async { final accountId = dashBoardController.accountId.value; final session = dashBoardController.sessionCurrent; @@ -415,7 +420,7 @@ abstract class BaseMailboxController extends BaseController { ResponsiveUtils responsiveUtils, ImagePaths imagePaths, PresentationMailbox presentationMailbox, { - required Function(PresentationMailbox mailbox) onDeleteMailboxAction + required DeleteMailboxActionCallback onDeleteMailboxAction }) { if (responsiveUtils.isLandscapeMobile(context) || responsiveUtils.isPortraitMobile(context)) { (ConfirmationDialogActionSheetBuilder(context) diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index 5cee27cbfa..c1f1370b05 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -77,6 +77,7 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.d import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/open_mailbox_view_event.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_utils.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/widgets/copy_subaddress_widget.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/mailbox_creator_arguments.dart'; import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_mailbox_arguments.dart'; @@ -1091,6 +1092,22 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _unsubscribeMailboxAction(mailbox.id); break; case MailboxActions.allowSubaddressing: + try{ + final subaddress = getSubaddress(mailboxDashBoardController.userEmail, findNodePathWithSeparator(mailbox.id, '.')!); + openConfirmationDialogSubaddressingAction( + context, + responsiveUtils, + imagePaths, + mailbox.id, + mailbox.getDisplayName(context), + subaddress, + mailbox.rights, + onAllowSubaddressingAction: _handleSubaddressingAction + ); + } catch (error) { + appToast.showToastErrorMessage(context, AppLocalizations.of(context).errorWhileFetchingSubaddress); + } + break; case MailboxActions.disallowSubaddressing: _handleSubaddressingAction(mailbox.id, mailbox.rights, actions); break; @@ -1472,6 +1489,51 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM } } + void openConfirmationDialogSubaddressingAction( + BuildContext context, + ResponsiveUtils responsiveUtils, + ImagePaths imagePaths, + MailboxId mailboxId, + String mailboxName, + String subaddress, + Map?>? currentRights, { + required AllowSubaddressingActionCallback onAllowSubaddressingAction + }) { + if (responsiveUtils.isLandscapeMobile(context) || responsiveUtils.isPortraitMobile(context)) { + (ConfirmationDialogActionSheetBuilder(context) + ..messageText(AppLocalizations.of(context).message_confirmation_dialog_allow_subaddressing(mailboxName)) + ..onCancelAction(AppLocalizations.of(context).cancel, () => popBack()) + ..onConfirmAction(AppLocalizations.of(context).allow, () => onAllowSubaddressingAction(mailboxId, currentRights, MailboxActions.allowSubaddressing)) + ).show(); + } else { + Get.dialog( + PointerInterceptor( + child: (ConfirmDialogBuilder(imagePaths) + ..key(const Key('confirm_dialog_subaddressing')) + ..title(AppLocalizations.of(context).allowSubaddressing) + ..styleTitle(const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: AppColor.colorTextButton + )) + ..content(AppLocalizations.of(context).message_confirmation_dialog_allow_subaddressing(mailboxName)) + ..addIcon(SvgPicture.asset(imagePaths.icSubaddressingAllow, width: 64, height: 64)) + ..addWidgetContent(CopySubaddressWidget( + context: context, + imagePath: imagePaths, + subaddress: subaddress, + onCopyButtonAction: () => copySubaddressAction(context, subaddress), + )) + ..colorCancelButton(AppColor.colorContentEmail) + ..onCloseButtonAction(() => popBack()) + ..onConfirmButtonAction(AppLocalizations.of(context).allow, () => onAllowSubaddressingAction(mailboxId, currentRights, MailboxActions.allowSubaddressing)) + ..onCancelButtonAction(AppLocalizations.of(context).cancel, () => popBack()) + ).build()), + barrierColor: AppColor.colorDefaultCupertinoActionSheet, + ); + } + } + void copySubaddressAction(BuildContext context, String subaddress) { Clipboard.setData(ClipboardData(text: subaddress)); appToast.showToastSuccessMessage(context, AppLocalizations.of(context).emailSubaddressCopiedToClipboard); diff --git a/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart b/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart new file mode 100644 index 0000000000..9f4642c95b --- /dev/null +++ b/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart @@ -0,0 +1,50 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; + +typedef OnCopyButtonAction = void Function(); + +class CopySubaddressWidget extends StatelessWidget { + + final BuildContext context; + final ImagePaths imagePath; + final String subaddress; + + final OnCopyButtonAction onCopyButtonAction; + + const CopySubaddressWidget({ + super.key, + required this.context, + required this.imagePath, + required this.subaddress, + required this.onCopyButtonAction + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + subaddress, + style: const TextStyle(fontSize: 17.0, color: AppColor.colorMessageDialog), + ), + ), + TMailButtonWidget.fromIcon( + icon: imagePath.icCopy, + iconSize: 30, + padding: const EdgeInsets.all(3), + backgroundColor: Colors.transparent, + margin: const EdgeInsetsDirectional.only(top: 16, end: 16), + onTapActionCallback: onCopyButtonAction + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 6780fade10..a5d98b2a5f 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -698,6 +698,16 @@ "nameMailbox": {} } }, + "message_confirmation_dialog_allow_subaddressing": "You are about to allow anyone to send emails directly to your folder \"{nameMailbox}\" using:", + "@message_confirmation_dialog_allow_subaddressing": { + "type": "text", + "placeholders_order": [ + "nameMailbox" + ], + "placeholders": { + "nameMailbox": {} + } + }, "renameFolder": "Rename folder", "@renameFolder": { "type": "text", @@ -2338,6 +2348,12 @@ "placeholders_order": [], "placeholders": {} }, + "allow": "Allow", + "@allow": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "disallowSubaddressing": "Disallow subaddressing", "@disallowSubaddressing": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 0be68b3531..424bbbdc6d 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -716,6 +716,14 @@ class AppLocalizations { ); } + String message_confirmation_dialog_allow_subaddressing(String nameMailbox) { + return Intl.message( + 'You are about to allow anyone to send emails directly to your folder "$nameMailbox" using:', + name: 'message_confirmation_dialog_allow_subaddressing', + args: [nameMailbox] + ); + } + String get renameFolder { return Intl.message( 'Rename folder', @@ -2415,6 +2423,12 @@ class AppLocalizations { name: 'allowSubaddressing'); } + String get allow { + return Intl.message( + 'Allow', + name: 'allow'); + } + String get disallowSubaddressing { return Intl.message( 'Disallow subaddressing', From 20c4720c373928fc9e0759e228c219b2be70fe8f Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Wed, 30 Oct 2024 16:02:00 +0100 Subject: [PATCH 18/28] TF-3189 new `reply to` field in the mail composer --- .../utils/scenario_utils_mixin.dart | 1 + .../presentation/composer_controller.dart | 106 +++++++++++++++++- .../composer/presentation/composer_view.dart | 52 ++++++++- .../presentation/composer_view_web.dart | 72 ++++++++++++ .../create_email_request_extension.dart | 4 +- .../email_action_type_extension.dart | 7 ++ .../prefix_email_address_extension.dart | 4 + .../model/create_email_request.dart | 3 + .../presentation/model/saved_email_draft.dart | 3 + .../presentation/styles/composer_style.dart | 6 +- .../widgets/recipient_composer_widget.dart | 15 ++- lib/l10n/intl_messages.arb | 8 +- lib/main/localizations/app_localizations.dart | 7 ++ model/lib/email/prefix_email_address.dart | 3 +- .../presentation_email_extension.dart | 12 +- ..._save_email_to_drafts_interactor_test.dart | 2 + ...te_new_and_send_email_interactor_test.dart | 1 + .../composer_controller_test.dart | 37 ++++++ .../create_email_request_extension_test.dart | 1 + .../model/saved_email_draft_test.dart | 33 +++++- .../presentation_email_extension_test.dart | 43 ++++--- 21 files changed, 376 insertions(+), 44 deletions(-) diff --git a/integration_test/utils/scenario_utils_mixin.dart b/integration_test/utils/scenario_utils_mixin.dart index 76e5b35d9b..c70bfa9b0a 100644 --- a/integration_test/utils/scenario_utils_mixin.dart +++ b/integration_test/utils/scenario_utils_mixin.dart @@ -53,6 +53,7 @@ mixin ScenarioUtilsMixin { toRecipients: {EmailAddress(null, provisioningEmail.toEmail)}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, outboxMailboxId: mailboxDashBoardController.outboxMailbox?.mailboxId, sentMailboxId: mailboxDashBoardController.mapDefaultMailboxIdByRole[PresentationMailbox.roleSent], identity: identity, diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index c09f569a42..751f64c329 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -121,11 +121,13 @@ class ComposerController extends BaseController final toAddressExpandMode = ExpandMode.EXPAND.obs; final ccAddressExpandMode = ExpandMode.EXPAND.obs; final bccAddressExpandMode = ExpandMode.EXPAND.obs; + final replyToAddressExpandMode = ExpandMode.EXPAND.obs; final emailContentsViewState = Rxn>(); final hasRequestReadReceipt = false.obs; final fromRecipientState = PrefixRecipientState.disabled.obs; final ccRecipientState = PrefixRecipientState.disabled.obs; final bccRecipientState = PrefixRecipientState.disabled.obs; + final replyToRecipientState = PrefixRecipientState.disabled.obs; final identitySelected = Rxn(); final listFromIdentities = RxList(); @@ -150,6 +152,7 @@ class ComposerController extends BaseController List listToEmailAddress = []; List listCcEmailAddress = []; List listBccEmailAddress = []; + List listReplyToEmailAddress = []; ContactSuggestionSource _contactSuggestionSource = ContactSuggestionSource.tMailContact; final subjectEmailInputController = LanguageToolController( @@ -158,11 +161,13 @@ class ComposerController extends BaseController final toEmailAddressController = TextEditingController(); final ccEmailAddressController = TextEditingController(); final bccEmailAddressController = TextEditingController(); + final replyToEmailAddressController = TextEditingController(); final searchIdentitiesInputController = TextEditingController(); final GlobalKey keyToEmailTagEditor = GlobalKey(); final GlobalKey keyCcEmailTagEditor = GlobalKey(); final GlobalKey keyBccEmailTagEditor = GlobalKey(); + final GlobalKey keyReplyToEmailTagEditor = GlobalKey(); final GlobalKey headerEditorMobileWidgetKey = GlobalKey(); final GlobalKey identityDropdownKey = GlobalKey(); final double defaultPaddingCoordinateYCursorEditor = 8; @@ -171,10 +176,12 @@ class ComposerController extends BaseController FocusNode? toAddressFocusNode; FocusNode? ccAddressFocusNode; FocusNode? bccAddressFocusNode; + FocusNode? replyToAddressFocusNode; FocusNode? searchIdentitiesFocusNode; FocusNode? toAddressFocusNodeKeyboard; FocusNode? ccAddressFocusNodeKeyboard; FocusNode? bccAddressFocusNodeKeyboard; + FocusNode? replyToAddressFocusNodeKeyboard; StreamSubscription? _subscriptionOnDragEnter; StreamSubscription? _subscriptionOnDragOver; @@ -292,18 +299,23 @@ class ComposerController extends BaseController ccAddressFocusNode = null; bccAddressFocusNode?.dispose(); bccAddressFocusNode = null; + replyToAddressFocusNode?.dispose(); + replyToAddressFocusNode = null; toAddressFocusNodeKeyboard?.dispose(); toAddressFocusNodeKeyboard = null; ccAddressFocusNodeKeyboard?.dispose(); ccAddressFocusNodeKeyboard = null; bccAddressFocusNodeKeyboard?.dispose(); bccAddressFocusNodeKeyboard = null; + replyToAddressFocusNodeKeyboard?.dispose(); + replyToAddressFocusNodeKeyboard = null; searchIdentitiesFocusNode?.dispose(); searchIdentitiesFocusNode = null; subjectEmailInputController.dispose(); toEmailAddressController.dispose(); ccEmailAddressController.dispose(); bccEmailAddressController.dispose(); + replyToEmailAddressController.dispose(); uploadInlineImageWorker.dispose(); dashboardViewStateWorker.dispose(); scrollController.dispose(); @@ -473,6 +485,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -500,16 +513,21 @@ class ComposerController extends BaseController if (bccEmailAddressController.text.isNotEmpty) { keyBccEmailTagEditor.currentState?.closeSuggestionBox(); } + if (replyToEmailAddressController.text.isNotEmpty) { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + } } void createFocusNodeInput() { toAddressFocusNode = FocusNode(); ccAddressFocusNode = FocusNode(); bccAddressFocusNode = FocusNode(); + replyToAddressFocusNode = FocusNode(); searchIdentitiesFocusNode = FocusNode(); toAddressFocusNodeKeyboard = FocusNode(); ccAddressFocusNodeKeyboard = FocusNode(); bccAddressFocusNodeKeyboard = FocusNode(); + replyToAddressFocusNodeKeyboard = FocusNode(); subjectEmailInputFocusNode = FocusNode( onKeyEvent: PlatformInfo.isWeb ? _subjectEmailInputOnKeyListener : null, @@ -816,18 +834,21 @@ class ComposerController extends BaseController listToEmailAddress = List.from(recipients.value1.toSet()); listCcEmailAddress = List.from(recipients.value2.toSet()); listBccEmailAddress = List.from(recipients.value3.toSet()); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } else { listToEmailAddress = List.from(recipients.value1.toSet().filterEmailAddress(userName.value)); listCcEmailAddress = List.from(recipients.value2.toSet().filterEmailAddress(userName.value)); listBccEmailAddress = List.from(recipients.value3.toSet().filterEmailAddress(userName.value)); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } } else { listToEmailAddress = List.from(recipients.value1.toSet()); listCcEmailAddress = List.from(recipients.value2.toSet()); listBccEmailAddress = List.from(recipients.value3.toSet()); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } - if (listToEmailAddress.isNotEmpty || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty) { + if (listToEmailAddress.isNotEmpty || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty || listReplyToEmailAddress.isNotEmpty) { isInitialRecipient.value = true; toAddressExpandMode.value = ExpandMode.COLLAPSE; } @@ -842,6 +863,11 @@ class ComposerController extends BaseController bccAddressExpandMode.value = ExpandMode.COLLAPSE; } + if (listReplyToEmailAddress.isNotEmpty) { + replyToRecipientState.value = PrefixRecipientState.enabled; + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; + } + _updateStatusEmailSendButton(); } @@ -859,6 +885,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: listBccEmailAddress = List.from(newListEmailAddress); break; + case PrefixEmailAddress.replyTo: + listReplyToEmailAddress = List.from(newListEmailAddress); + break; default: break; } @@ -867,8 +896,9 @@ class ComposerController extends BaseController void _updateStatusEmailSendButton() { if (listToEmailAddress.isNotEmpty + || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty - || listCcEmailAddress.isNotEmpty) { + || listReplyToEmailAddress.isNotEmpty) { isEnableEmailSendButton.value = true; } else { isEnableEmailSendButton.value = false; @@ -886,7 +916,8 @@ class ComposerController extends BaseController if (toEmailAddressController.text.isNotEmpty || ccEmailAddressController.text.isNotEmpty - || bccEmailAddressController.text.isNotEmpty) { + || bccEmailAddressController.text.isNotEmpty + || replyToEmailAddressController.text.isNotEmpty) { _collapseAllRecipient(); _autoCreateEmailTag(); } @@ -903,7 +934,7 @@ class ComposerController extends BaseController return; } - final allListEmailAddress = listToEmailAddress + listCcEmailAddress + listBccEmailAddress; + final allListEmailAddress = listToEmailAddress + listCcEmailAddress + listBccEmailAddress + listReplyToEmailAddress; final listEmailAddressInvalid = allListEmailAddress .where((emailAddress) => !EmailUtils.isEmailAddressValid(emailAddress.emailAddress)) .toList(); @@ -915,6 +946,7 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.EXPAND; ccAddressExpandMode.value = ExpandMode.EXPAND; bccAddressExpandMode.value = ExpandMode.EXPAND; + replyToAddressExpandMode.value = ExpandMode.EXPAND; }, showAsBottomSheet: true, title: AppLocalizations.of(context).sending_failed, @@ -1034,6 +1066,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -1245,6 +1278,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, hasReadReceipt: hasRequestReadReceipt.value, @@ -1521,6 +1555,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: bccRecipientState.value = PrefixRecipientState.enabled; break; + case PrefixEmailAddress.replyTo: + replyToRecipientState.value = PrefixRecipientState.enabled; + break; default: break; } @@ -1539,6 +1576,11 @@ class ComposerController extends BaseController bccAddressFocusNode = FocusNode(); bccEmailAddressController.clear(); break; + case PrefixEmailAddress.replyTo: + replyToRecipientState.value = PrefixRecipientState.disabled; + replyToAddressFocusNode = FocusNode(); + replyToEmailAddressController.clear(); + break; default: break; } @@ -1548,12 +1590,14 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.COLLAPSE; ccAddressExpandMode.value = ExpandMode.COLLAPSE; bccAddressExpandMode.value = ExpandMode.COLLAPSE; + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; } void _autoCreateEmailTag() { final inputToEmail = toEmailAddressController.text; final inputCcEmail = ccEmailAddressController.text; final inputBccEmail = bccEmailAddressController.text; + final inputReplyToEmail = replyToEmailAddressController.text; if (inputToEmail.isNotEmpty) { _autoCreateToEmailTag(inputToEmail); @@ -1564,6 +1608,9 @@ class ComposerController extends BaseController if (inputBccEmail.isNotEmpty) { _autoCreateBccEmailTag(inputBccEmail); } + if (inputReplyToEmail.isNotEmpty) { + _autoCreateReplyToEmailTag(inputReplyToEmail); + } } bool _isDuplicatedRecipient(String inputEmail, List listEmailAddress) { @@ -1616,6 +1663,20 @@ class ComposerController extends BaseController }); } + void _autoCreateReplyToEmailTag(String inputEmail) { + if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) { + final emailAddress = EmailAddress(null, inputEmail); + listReplyToEmailAddress.add(emailAddress); + isInitialRecipient.value = true; + isInitialRecipient.refresh(); + _updateStatusEmailSendButton(); + } + keyReplyToEmailTagEditor.currentState?.resetTextField(); + Future.delayed(const Duration(milliseconds: 300), () { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + }); + } + void _closeSuggestionBox() { if (toEmailAddressController.text.isEmpty) { keyToEmailTagEditor.currentState?.closeSuggestionBox(); @@ -1626,6 +1687,9 @@ class ComposerController extends BaseController if (bccEmailAddressController.text.isEmpty) { keyBccEmailTagEditor.currentState?.closeSuggestionBox(); } + if (replyToEmailAddressController.text.isEmpty) { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + } } void showFullEmailAddress(PrefixEmailAddress prefixEmailAddress) { @@ -1642,6 +1706,10 @@ class ComposerController extends BaseController bccAddressExpandMode.value = ExpandMode.EXPAND; bccAddressFocusNode?.requestFocus(); break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.EXPAND; + replyToAddressFocusNode?.requestFocus(); + break; default: break; } @@ -1659,6 +1727,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: bccAddressExpandMode.value = ExpandMode.EXPAND; break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.EXPAND; + break; default: break; } @@ -1691,6 +1762,13 @@ class ComposerController extends BaseController _autoCreateBccEmailTag(inputBccEmail); } break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; + final inputReplyToEmail = replyToEmailAddressController.text; + if (inputReplyToEmail.isNotEmpty) { + _autoCreateReplyToEmailTag(inputReplyToEmail); + } + break; default: break; } @@ -1920,12 +1998,14 @@ class ComposerController extends BaseController return toAddressFocusNode?.hasFocus == true || ccAddressFocusNode?.hasFocus == true || bccAddressFocusNode?.hasFocus == true || + replyToAddressFocusNode?.hasFocus == true || subjectEmailInputFocusNode?.hasFocus == true; } else if (PlatformInfo.isMobile) { final isEditorFocused = (await richTextMobileTabletController?.isEditorFocused) ?? false; return toAddressFocusNode?.hasFocus == true || ccAddressFocusNode?.hasFocus == true || bccAddressFocusNode?.hasFocus == true || + replyToAddressFocusNode?.hasFocus == true || subjectEmailInputFocusNode?.hasFocus == true || isEditorFocused; } @@ -1970,6 +2050,8 @@ class ComposerController extends BaseController return ccAddressFocusNode; } else if (bccRecipientState.value == PrefixRecipientState.enabled) { return bccAddressFocusNode; + } else if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; } else { return subjectEmailInputFocusNode; } @@ -1978,6 +2060,16 @@ class ComposerController extends BaseController FocusNode? getNextFocusOfCcEmailAddress() { if (bccRecipientState.value == PrefixRecipientState.enabled) { return bccAddressFocusNode; + } else if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; + } else { + return subjectEmailInputFocusNode; + } + } + + FocusNode? getNextFocusOfBccEmailAddress() { + if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; } else { return subjectEmailInputFocusNode; } @@ -2033,6 +2125,10 @@ class ComposerController extends BaseController listBccEmailAddress.remove(draggableEmailAddress.emailAddress); bccAddressExpandMode.value = ExpandMode.EXPAND; break; + case PrefixEmailAddress.replyTo: + listReplyToEmailAddress.remove(draggableEmailAddress.emailAddress); + replyToAddressExpandMode.value = ExpandMode.EXPAND; + break; default: break; } @@ -2298,6 +2394,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -2379,6 +2476,7 @@ class ComposerController extends BaseController fromRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; ccRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; bccRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; + replyToRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; } void _handleGetEmailContentFailure(GetEmailContentFailure failure) { diff --git a/lib/features/composer/presentation/composer_view.dart b/lib/features/composer/presentation/composer_view.dart index a7b9974bb4..5b0a52a0ed 100644 --- a/lib/features/composer/presentation/composer_view.dart +++ b/lib/features/composer/presentation/composer_view.dart @@ -130,6 +130,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -190,9 +191,9 @@ class ComposerView extends GetWidget { focusNode: controller.bccAddressFocusNode, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, - nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, onShowFullListEmailAddressAction: controller.showFullEmailAddress, onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, @@ -204,6 +205,33 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), + Obx(() { + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) { + return RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + nextFocusNode: controller.subjectEmailInputFocusNode, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onAddEmailAddressTypeAction: controller.addEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputAction, + ); + } else { + return const SizedBox.shrink(); + } + }), SubjectComposerWidget( focusNode: controller.subjectEmailInputFocusNode, textController: controller.subjectEmailInputController, @@ -295,6 +323,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -349,6 +378,27 @@ class ComposerView extends GetWidget { focusNode: controller.bccAddressFocusNode, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 1fc90ffdbd..c27b1087b3 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -95,6 +95,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -153,6 +154,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfCcEmailAddress(), + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, @@ -345,6 +369,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -403,6 +428,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.desktopRecipientPadding, + margin: ComposerStyle.desktopRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.desktopRecipientPadding, margin: ComposerStyle.desktopRecipientMargin, @@ -628,6 +676,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -686,6 +735,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.tabletRecipientPadding, + margin: ComposerStyle.tabletRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.tabletRecipientPadding, margin: ComposerStyle.tabletRecipientMargin, diff --git a/lib/features/composer/presentation/extensions/create_email_request_extension.dart b/lib/features/composer/presentation/extensions/create_email_request_extension.dart index f6de928bb1..9d16ad83d2 100644 --- a/lib/features/composer/presentation/extensions/create_email_request_extension.dart +++ b/lib/features/composer/presentation/extensions/create_email_request_extension.dart @@ -39,7 +39,9 @@ extension CreateEmailRequestExtension on CreateEmailRequest { } Set createReplyToRecipients() { - if (identity?.replyTo?.isNotEmpty == true) { + if (replyToRecipients.isNotEmpty) { + return replyToRecipients.toSet(); + } else if (identity?.replyTo?.isNotEmpty == true) { return identity!.replyTo!.toSet(); } else { return { session.username.toEmailAddress() }; diff --git a/lib/features/composer/presentation/extensions/email_action_type_extension.dart b/lib/features/composer/presentation/extensions/email_action_type_extension.dart index 5c0a523d15..aaa4341fb8 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -75,6 +75,7 @@ extension EmailActionTypeExtension on EmailActionType { final toEmailAddress = presentationEmail.to.listEmailAddressToString(isFullEmailAddress: true); final ccEmailAddress = presentationEmail.cc.listEmailAddressToString(isFullEmailAddress: true); final bccEmailAddress = presentationEmail.bcc.listEmailAddressToString(isFullEmailAddress: true); + final replyToEmailAddress = presentationEmail.replyTo.listEmailAddressToString(isFullEmailAddress: true); if (subject.isNotEmpty) { headerQuoted = headerQuoted @@ -112,6 +113,12 @@ extension EmailActionTypeExtension on EmailActionType { .append(bccEmailAddress) .addNewLineTag(); } + if (replyToEmailAddress.isNotEmpty) { + headerQuoted = headerQuoted + .append('${AppLocalizations.of(context).reply_to_email_address_prefix}: ') + .append(replyToEmailAddress) + .addNewLineTag(); + } return headerQuoted; default: diff --git a/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart b/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart index b9780fb103..a425c6a62e 100644 --- a/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart +++ b/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart @@ -14,6 +14,8 @@ extension PrefixEmailAddressExtension on PrefixEmailAddress { return AppLocalizations.of(context).cc_email_address_prefix; case PrefixEmailAddress.bcc: return AppLocalizations.of(context).bcc_email_address_prefix; + case PrefixEmailAddress.replyTo: + return AppLocalizations.of(context).reply_to_email_address_prefix; case PrefixEmailAddress.from: return AppLocalizations.of(context).from_email_address_prefix; } @@ -27,6 +29,8 @@ extension PrefixEmailAddressExtension on PrefixEmailAddress { return email.cc?.toList() ?? List.empty(); case PrefixEmailAddress.bcc: return email.bcc?.toList() ?? List.empty(); + case PrefixEmailAddress.replyTo: + return email.replyTo?.toList() ?? List.empty(); case PrefixEmailAddress.from: return email.from?.toList() ?? List.empty(); } diff --git a/lib/features/composer/presentation/model/create_email_request.dart b/lib/features/composer/presentation/model/create_email_request.dart index b3f104cfa0..d6c82e44b3 100644 --- a/lib/features/composer/presentation/model/create_email_request.dart +++ b/lib/features/composer/presentation/model/create_email_request.dart @@ -23,6 +23,7 @@ class CreateEmailRequest with EquatableMixin { final Set toRecipients; final Set ccRecipients; final Set bccRecipients; + final Set replyToRecipients; final Identity? identity; final List? attachments; final Map? inlineAttachments; @@ -47,6 +48,7 @@ class CreateEmailRequest with EquatableMixin { required this.toRecipients, required this.ccRecipients, required this.bccRecipients, + required this.replyToRecipients, this.hasRequestReadReceipt = true, this.identity, this.attachments, @@ -74,6 +76,7 @@ class CreateEmailRequest with EquatableMixin { toRecipients, ccRecipients, bccRecipients, + replyToRecipients, identity, hasRequestReadReceipt, attachments, diff --git a/lib/features/composer/presentation/model/saved_email_draft.dart b/lib/features/composer/presentation/model/saved_email_draft.dart index a39809032d..5056e25e4e 100644 --- a/lib/features/composer/presentation/model/saved_email_draft.dart +++ b/lib/features/composer/presentation/model/saved_email_draft.dart @@ -9,6 +9,7 @@ class SavedEmailDraft with EquatableMixin { final Set toRecipients; final Set ccRecipients; final Set bccRecipients; + final Set replyToRecipients; final List attachments; final Identity? identity; final bool hasReadReceipt; @@ -19,6 +20,7 @@ class SavedEmailDraft with EquatableMixin { required this.toRecipients, required this.ccRecipients, required this.bccRecipients, + required this.replyToRecipients, required this.attachments, required this.identity, required this.hasReadReceipt, @@ -32,6 +34,7 @@ class SavedEmailDraft with EquatableMixin { {0: toRecipients}, {1: ccRecipients}, {2: bccRecipients}, + {3: replyToRecipients}, attachments, identity, hasReadReceipt diff --git a/lib/features/composer/presentation/styles/composer_style.dart b/lib/features/composer/presentation/styles/composer_style.dart index 26bf5e3e15..2e28770de6 100644 --- a/lib/features/composer/presentation/styles/composer_style.dart +++ b/lib/features/composer/presentation/styles/composer_style.dart @@ -155,10 +155,6 @@ class ComposerStyle { } static double getMaxHeightEmailAddressWidget(BuildContext context, BoxConstraints constraints, ResponsiveUtils responsiveUtils) { - if (responsiveUtils.isDesktop(context)) { - return constraints.maxHeight > 0 ? constraints.maxHeight * 0.3 : 150.0; - } else { - return constraints.maxHeight > 0 ? constraints.maxHeight * 0.4 : 150.0; - } + return constraints.maxHeight > 0 ? constraints.maxHeight * 0.4 : 150.0; } } \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index c58c846632..424278affd 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -46,6 +46,7 @@ class RecipientComposerWidget extends StatefulWidget { final PrefixRecipientState fromState; final PrefixRecipientState ccState; final PrefixRecipientState bccState; + final PrefixRecipientState replyToState; final bool? isInitial; final FocusNode? focusNode; final FocusNode? focusNodeKeyboard; @@ -76,6 +77,7 @@ class RecipientComposerWidget extends StatefulWidget { @visibleForTesting this.isTestingForWeb = false, this.ccState = PrefixRecipientState.disabled, this.bccState = PrefixRecipientState.disabled, + this.replyToState = PrefixRecipientState.disabled, this.fromState = PrefixRecipientState.disabled, this.isInitial, this.controller, @@ -353,6 +355,16 @@ class _RecipientComposerWidgetState extends State { margin: RecipientComposerWidgetStyle.recipientMargin, onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.bcc), ), + if (widget.replyToState == PrefixRecipientState.disabled) + TMailButtonWidget.fromText( + key: Key('prefix_${widget.prefix.name}_recipient_reply_to_button'), + text: AppLocalizations.of(context).reply_to_email_address_prefix, + textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle, + backgroundColor: Colors.transparent, + padding: RecipientComposerWidgetStyle.prefixButtonPadding, + margin: RecipientComposerWidgetStyle.recipientMargin, + onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.replyTo), + ), ] else if (PlatformInfo.isMobile) TMailButtonWidget.fromIcon( @@ -395,7 +407,8 @@ class _RecipientComposerWidgetState extends State { bool get _isAllRecipientInputEnabled => widget.fromState == PrefixRecipientState.enabled && widget.ccState == PrefixRecipientState.enabled - && widget.bccState == PrefixRecipientState.enabled; + && widget.bccState == PrefixRecipientState.enabled + && widget.replyToState == PrefixRecipientState.enabled; List get _collapsedListEmailAddress => _isCollapse ? _currentListEmailAddress.sublist(0, 1) diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index a5d98b2a5f..74f755910b 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2024-10-31T13:18:32.336494", + "@@last_modified": "2024-11-25T10:57:37.546143", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -186,6 +186,12 @@ "placeholders_order": [], "placeholders": {} }, + "reply_to_email_address_prefix": "Reply to", + "@reply_to_email_address_prefix": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "cc_email_address_prefix": "Cc", "@cc_email_address_prefix": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 424bbbdc6d..44f25ca989 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -191,6 +191,13 @@ class AppLocalizations { ); } + String get reply_to_email_address_prefix { + return Intl.message( + 'Reply to', + name: 'reply_to_email_address_prefix', + ); + } + String get cc_email_address_prefix { return Intl.message( 'Cc', diff --git a/model/lib/email/prefix_email_address.dart b/model/lib/email/prefix_email_address.dart index 9b93fbec16..13a99d4aa7 100644 --- a/model/lib/email/prefix_email_address.dart +++ b/model/lib/email/prefix_email_address.dart @@ -3,5 +3,6 @@ enum PrefixEmailAddress { from, to, cc, - bcc + bcc, + replyTo } \ No newline at end of file diff --git a/model/lib/extensions/presentation_email_extension.dart b/model/lib/extensions/presentation_email_extension.dart index 0a0fa40266..23d557e304 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -124,27 +124,27 @@ extension PresentationEmailExtension on PresentationEmail { return allEmailAddress.isNotEmpty ? allEmailAddress.join(', ') : ''; } - Tuple3, List, List> generateRecipientsEmailAddressForComposer({ + Tuple4, List, List, List> generateRecipientsEmailAddressForComposer({ required EmailActionType emailActionType, Role? mailboxRole }) { switch(emailActionType) { case EmailActionType.reply: if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(to.asList(), [], []); + return Tuple4(to.asList(), [], [], []); } else { final replyToAddress = replyTo.asList().isNotEmpty ? replyTo.asList() : from.asList(); - return Tuple3(replyToAddress, [], []); + return Tuple4(replyToAddress, [], [], []); } case EmailActionType.replyAll: if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(to.asList(), cc.asList(), bcc.asList()); + return Tuple4(to.asList(), cc.asList(), bcc.asList(), []); } else { final senderReplyToAddress = replyTo.asList().isNotEmpty ? replyTo.asList() : from.asList(); - return Tuple3(to.asList() + senderReplyToAddress, cc.asList(), bcc.asList()); + return Tuple4(to.asList() + senderReplyToAddress, cc.asList(), bcc.asList(), []); } default: - return Tuple3(to.asList(), cc.asList(), bcc.asList()); + return Tuple4(to.asList(), cc.asList(), bcc.asList(), replyTo.asList()); } } diff --git a/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart b/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart index 0869982c1e..5471e6b306 100644 --- a/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart +++ b/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart @@ -52,6 +52,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, draftsEmailId: EmailId(Id('some-id')) ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) @@ -90,6 +91,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) .thenAnswer((_) async => Email()); diff --git a/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart b/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart index 72027e6f49..d7e523de00 100644 --- a/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart +++ b/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart @@ -42,6 +42,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) .thenAnswer((_) async => Email()); diff --git a/test/features/composer/presentation/composer_controller_test.dart b/test/features/composer/presentation/composer_controller_test.dart index fa4e11ed4d..d18e42785b 100644 --- a/test/features/composer/presentation/composer_controller_test.dart +++ b/test/features/composer/presentation/composer_controller_test.dart @@ -293,6 +293,7 @@ void main() { final toRecipient = EmailAddress('to', 'to@linagora.com'); final ccRecipient = EmailAddress('cc', 'cc@linagora.com'); final bccRecipient = EmailAddress('bcc', 'bcc@linagora.com'); + final replyToRecipient = EmailAddress('replyTo', 'replyTo@linagora.com'); final identity = Identity(); final attachment = Attachment(); const alwaysReadReceiptEnabled = true; @@ -318,6 +319,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final state = GetAlwaysReadReceiptSettingSuccess( @@ -329,6 +331,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -357,6 +360,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final state = GetAlwaysReadReceiptSettingFailure(Exception()); @@ -367,6 +371,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -394,6 +399,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -408,6 +414,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -438,6 +445,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockMailboxDashBoardController.composerArguments).thenReturn( ComposerArguments(identities: [identity])); when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -448,6 +456,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -478,6 +487,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity( @@ -495,6 +505,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -525,6 +536,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final identity = Identity( @@ -542,6 +554,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -581,6 +594,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity(id: IdentityId(Id('alice'))); @@ -599,6 +613,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -652,6 +667,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity(id: IdentityId(Id('alice'))); @@ -670,6 +686,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -722,6 +739,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -735,6 +753,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -763,6 +782,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -774,6 +794,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -810,6 +831,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -824,6 +846,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -863,6 +886,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -876,6 +900,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -917,6 +942,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -933,6 +959,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -974,6 +1001,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)),)); @@ -989,6 +1017,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -1028,6 +1057,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); composerController?.identitySelected.value = selectedIdentity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -1047,6 +1077,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -1100,6 +1131,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); composerController?.identitySelected.value = selectedIdentity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -1119,6 +1151,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -1165,6 +1198,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.hasRequestReadReceipt.value = alwaysReadReceiptEnabled; const idenityId = 'some-identity-id'; @@ -1185,6 +1219,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -1214,6 +1249,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); composerController?.hasRequestReadReceipt.value = alwaysReadReceiptEnabled; @@ -1224,6 +1260,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled diff --git a/test/features/composer/presentation/extensions/create_email_request_extension_test.dart b/test/features/composer/presentation/extensions/create_email_request_extension_test.dart index 84aa860197..956c807a29 100644 --- a/test/features/composer/presentation/extensions/create_email_request_extension_test.dart +++ b/test/features/composer/presentation/extensions/create_email_request_extension_test.dart @@ -19,6 +19,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); group('create email request extension test:', () { diff --git a/test/features/composer/presentation/model/saved_email_draft_test.dart b/test/features/composer/presentation/model/saved_email_draft_test.dart index 1544ded790..cbc1dcf8a3 100644 --- a/test/features/composer/presentation/model/saved_email_draft_test.dart +++ b/test/features/composer/presentation/model/saved_email_draft_test.dart @@ -16,6 +16,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -37,7 +38,7 @@ void main() { // arrange const subject = 'subject'; const content = 'content'; - final recipent = EmailAddress('recipent name', 'recipent email'); + final recipient = EmailAddress('recipient name', 'recipient email'); final identity = Identity(); final attachments = []; const hasReadReceipt = false; @@ -45,9 +46,10 @@ void main() { final toSavedEmailDraft = SavedEmailDraft( subject: subject, content: content, - toRecipients: {recipent}, + toRecipients: {recipient}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt @@ -57,8 +59,9 @@ void main() { subject: subject, content: content, toRecipients: {}, - ccRecipients: {recipent}, + ccRecipients: {recipient}, bccRecipients: {}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt @@ -69,21 +72,36 @@ void main() { content: content, toRecipients: {}, ccRecipients: {}, - bccRecipients: {recipent}, + bccRecipients: {recipient}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt ); + + final replyToSavedEmailDraft = SavedEmailDraft( + subject: subject, + content: content, + toRecipients: {}, + ccRecipients: {}, + bccRecipients: {}, + replyToRecipients: {recipient}, + identity: identity, + attachments: attachments, + hasReadReceipt: hasReadReceipt + ); // act final toProps = toSavedEmailDraft.props; final ccProps = ccSavedEmailDraft.props; final bccProps = bccSavedEmailDraft.props; - + final replyToProps = replyToSavedEmailDraft.props; + // assert expect(toProps.hashCode, isNot(ccProps.hashCode)); expect(ccProps.hashCode, isNot(bccProps.hashCode)); - expect(bccProps.hashCode, isNot(toProps.hashCode)); + expect(bccProps.hashCode, isNot(replyToProps.hashCode)); + expect(replyToProps.hashCode, isNot(toProps.hashCode)); }); test( @@ -100,6 +118,7 @@ void main() { toRecipients: listToRecipients, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -125,6 +144,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -136,6 +156,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false diff --git a/test/model/lib/extensions/presentation_email_extension_test.dart b/test/model/lib/extensions/presentation_email_extension_test.dart index f8e964f8b5..42e9a1e639 100644 --- a/test/model/lib/extensions/presentation_email_extension_test.dart +++ b/test/model/lib/extensions/presentation_email_extension_test.dart @@ -15,15 +15,15 @@ void main() { final userEEmailAddress = EmailAddress('User E', 'userE@domain.com'); final replyToEmailAddress = EmailAddress('Reply To', 'replyToThis@domain.com'); - group('GIVEN user A is the sender AND send an email to user B and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [], []); + group('GIVEN user A is the sender AND sends an email to user B and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply', () { + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, to: {userBEmailAddress, userEEmailAddress}, cc: {userCEmailAddress}, - bcc: {userDEmailAddress} + bcc: {userDEmailAddress}, ); final result = emailToReply.generateRecipientsEmailAddressForComposer( @@ -34,10 +34,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply, user C email address to cc, user D email address to bcc', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply, user C email address to cc, user D email address to bcc', () { + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, @@ -54,12 +55,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('GIVEN user B is the sender, SENDER configured the replyTo email AND send an email to user A and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return only replyToEmailAddress email to reply' , () { - final expectedResult = Tuple3([replyToEmailAddress], [], []); + group('GIVEN user B is the sender, SENDER configured the replyTo email AND sends an email to user A and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return only replyToEmailAddress email to reply' , () { + final expectedResult = Tuple4([replyToEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -77,10 +79,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return replyToEmailAddress + user A email + user E email to reply, user C email address to cc, user D email address to bcc', () { - final expectedResult = Tuple3([userAEmailAddress, userEEmailAddress, replyToEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return replyToEmailAddress + user A email + user E email to reply, user C email address to cc, user D email address to bcc', () { + final expectedResult = Tuple4([userAEmailAddress, userEEmailAddress, replyToEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -98,12 +101,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('GIVEN user B is the sender, SENDER does not have the replyTo email AND send an email to user A and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return only user B email to reply', () { - final expectedResult = Tuple3([userBEmailAddress], [], []); + group('GIVEN user B is the sender, SENDER does not have the replyTo email AND sends an email to user A and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return only user B email to reply', () { + final expectedResult = Tuple4([userBEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -120,10 +124,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return user A email + user E email + user B email to reply, user C email to cc, user D email to bcc', () { - final expectedResult = Tuple3([userAEmailAddress, userEEmailAddress, userBEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return user A email + user E email + user B email to reply, user C email to cc, user D email to bcc', () { + final expectedResult = Tuple4([userAEmailAddress, userEEmailAddress, userBEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -140,12 +145,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('Given user A is the sender AND send an email to user B + user E, cc to user C, bcc to user D THEN user B click forward', () { + group('Given user A is the sender AND sends an email to user B + user E, cc to user C, bcc to user D THEN user B clicks forward', () { test('generateRecipientsEmailAddressForComposer SHOULD return user user B email + user E email to reply, user C email to cc, user D email to bcc', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress]); + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, @@ -162,6 +168,7 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); }); From 47b842d2583fc6f4b1de12b9cbcf427e24a9090a Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 15 Nov 2024 14:01:33 +0100 Subject: [PATCH 19/28] TF-3189 composer now correctly encodes subaddresses --- core/lib/utils/mail/mail_address.dart | 128 ++++++++++++++++++ core/test/utils/mail_address_test.dart | 48 +++++++ .../presentation/composer_controller.dart | 45 +++--- .../extensions/mail_address_extension.dart | 17 +++ .../widgets/recipient_composer_widget.dart | 23 ++-- .../email/presentation/utils/email_utils.dart | 3 +- 6 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 lib/features/composer/presentation/extensions/mail_address_extension.dart diff --git a/core/lib/utils/mail/mail_address.dart b/core/lib/utils/mail/mail_address.dart index a791220063..5861b244fb 100644 --- a/core/lib/utils/mail/mail_address.dart +++ b/core/lib/utils/mail/mail_address.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:core/domain/exceptions/address_exception.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/mail/domain.dart'; @@ -23,8 +24,15 @@ class MailAddress with EquatableMixin { final String localPart; final Domain domain; + static const String subaddressingLocalPartDelimiter = '+'; + MailAddress({required this.localPart, required this.domain}); + MailAddress.fromParts({required String localPartWithoutDetails, required String localPartDetails, required this.domain}) : localPart = + localPartDetails.isEmpty + ? localPartWithoutDetails + : '$localPartWithoutDetails$subaddressingLocalPartDelimiter$localPartDetails'; + factory MailAddress.validateAddress(String address) { log('MailAddress::validate: Address = $address'); String localPart; @@ -137,6 +145,89 @@ class MailAddress with EquatableMixin { return localPart; } + String? getLocalPartDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return null; + } + return localPart.substring(separatorPosition + subaddressingLocalPartDelimiter.length); + } + + String getLocalPartWithoutDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return localPart; + } + return localPart.substring(0, separatorPosition); + } + + MailAddress stripDetails() { + return MailAddress(localPart: getLocalPartWithoutDetails(), domain: domain); + } + + // cannot use Uri.encodeComponent because it is meant to be compliant with RFC2396 + // eg `-_.!~*'()` are not encoded, but we want `!*'()` to be + static final _needsNoEncoding = RegExp(r'^[a-zA-Z0-9._~-]+$'); + + // this table is adapted from `_unreserved2396Table` found at + // https://github.com/dart-lang/sdk/blob/58f9beb6d4ec9e93430454bb96c0b8f068d0b0bc/sdk/lib/core/uri.dart#L3382 + static const _customUnreservedTable = [ + // LSB MSB + // | | + 0x0000, // 0x00 - 0x0f 0000000000000000 + 0x0000, // 0x10 - 0x1f 0000000000000000 + // -. + 0x6000, // 0x20 - 0x2f 0000000000000110 + // 0123456789 + 0x03ff, // 0x30 - 0x3f 1111111111000000 + // ABCDEFGHIJKLMNO + 0xfffe, // 0x40 - 0x4f 0111111111111111 + // PQRSTUVWXYZ _ + 0x87ff, // 0x50 - 0x5f 1111111111100001 + // abcdefghijklmno + 0xfffe, // 0x60 - 0x6f 0111111111111111 + // pqrstuvwxyz ~ + 0x47ff, // 0x70 - 0x7f 1111111111100010 + ]; + + // this method is adapted from `_uriEncode()` found at: + // https://github.com/dart-lang/sdk/blob/bb8db16297e6b9994b08ecae6ee1dd45a0be587e/sdk/lib/_internal/wasm/lib/uri_patch.dart#L49 + static String customUriEncode(String text) { + if (_needsNoEncoding.hasMatch(text)) { + return text; + } + + // Encode the string into bytes then generate an ASCII only string + // by percent encoding selected bytes. + StringBuffer result = StringBuffer(''); + var bytes = utf8.encode(text); + for (int byte in bytes) { + if (byte < 128 && + ((_customUnreservedTable[byte >> 4] & (1 << (byte & 0x0f))) != 0)) { + result.writeCharCode(byte); + } else { + const String hexDigits = '0123456789ABCDEF'; + result.write('%'); + result.write(hexDigits[(byte >> 4) & 0x0f]); + result.write(hexDigits[byte & 0x0f]); + } + } + return result.toString(); + } + + String asEncodedString() { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return asString(); + } else { + return MailAddress.fromParts( + localPartWithoutDetails: getLocalPartWithoutDetails(), + localPartDetails: customUriEncode(localPartDetails), + domain: domain + ).asString(); + } + } + @override String toString() { return '$localPart@${domain.asString()}'; @@ -323,6 +414,10 @@ class MailAddress with EquatableMixin { lpSB.write('.'); pos++; lastCharDot = true; + } else if (postChar == subaddressingLocalPartDelimiter) { + // Start of local part details, jump to the `@` + lpSB.write(subaddressingLocalPartDelimiter); + pos = _parseLocalPartDetails(lpSB, address, pos+1); } else if (postChar == '@') { // End of local-part break; @@ -416,6 +511,39 @@ class MailAddress with EquatableMixin { return pos; } + static int _parseLocalPartDetails(StringBuffer localPartSB, String address, int pos) { + StringBuffer localPartDetailsSB = StringBuffer(); + + while (true) { + if (pos >= address.length) { + break; + } + var postChar = address[pos]; + if (postChar == '@') { + // End of local-part-details + break; + } else { + localPartDetailsSB.write(postChar); + pos++; + } + } + + String localPartDetails = localPartDetailsSB.toString(); + if (localPartDetails.isEmpty || localPartDetails.trim().isEmpty) { + throw AddressException("target mailbox name should not be empty"); + } + if (localPartDetails.startsWith('#')) { + throw AddressException("target mailbox name should not start with #"); + } + final forbiddenChars = RegExp(r'[*\r\n]'); + if (forbiddenChars.hasMatch(localPartDetails)) { + throw AddressException("target mailbox name should not contain special characters"); + } + + localPartSB.write(localPartDetails); + return pos; + } + @override List get props => [localPart, domain]; } diff --git a/core/test/utils/mail_address_test.dart b/core/test/utils/mail_address_test.dart index 2265458a8c..2998845ab8 100644 --- a/core/test/utils/mail_address_test.dart +++ b/core/test/utils/mail_address_test.dart @@ -20,6 +20,9 @@ void main() { "Abc.123@example.com", "user+mailbox/department=shipping@example.com", "user+mailbox@example.com", + "user+folder@james.apache.org", + "user+my folder@domain.com", + "user+Dossier d'été@domain.com", "\"Abc@def\"@example.com", "\"Fred Bloggs\"@example.com", "\"Joe.\\Blow\"@example.com", @@ -56,6 +59,10 @@ void main() { "server-dev@#123.apache.org", "server-dev@[127.0.1.1.1]", "server-dev@[127.0.1.-1]", + "user+@domain.com", + "user+ @domain.com", + "user+#folder@domain.com", + "user+test-_.!~*'() @domain.com", "\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it "server-dev\\.@james.apache.org", // jakarta.mail is unable to handle this so we better reject it "a..b@domain.com", @@ -165,5 +172,46 @@ void main() { final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS); expect(mailAddress.toString(), equals(GOOD_ADDRESS)); }); + + test('MailAddress.encodeLocalPartDetails() should work with characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+my folder@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+my%20folder@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should work with many characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+Dossier d'été@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should encode the rights characters', () { + final mailAddress = MailAddress.validateAddress("user+test-_.!~'() @domain.com"); + expect(mailAddress.asEncodedString(), equals("user+test-_.%21~%27%28%29%20@domain.com")); + }); + + test('getLocalPartDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartDetails(), equals("details")); + }); + + test('getLocalPartWithoutDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartWithoutDetails(), equals("user")); + }); + + test('stripDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work with encoded local part', () { + final mailAddress = MailAddress.validateAddress("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work when local part needs encoding', () { + final mailAddress = MailAddress.validateAddress("user+super folder@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + }); } \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index 751f64c329..d90e26d85a 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/composer/presentation/controller/rich_tex import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_identities_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_shared_media_file_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; @@ -1600,16 +1601,16 @@ class ComposerController extends BaseController final inputReplyToEmail = replyToEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } } @@ -1620,10 +1621,9 @@ class ComposerController extends BaseController .contains(inputEmail); } - void _autoCreateToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listToEmailAddress.add(emailAddress); + void _autoCreateToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listToEmailAddress)) { + listToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1635,10 +1635,9 @@ class ComposerController extends BaseController }); } - void _autoCreateCcEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listCcEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listCcEmailAddress.add(emailAddress); + void _autoCreateCcEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listCcEmailAddress)) { + listCcEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1649,10 +1648,9 @@ class ComposerController extends BaseController }); } - void _autoCreateBccEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listBccEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listBccEmailAddress.add(emailAddress); + void _autoCreateBccEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listBccEmailAddress)) { + listBccEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1663,10 +1661,9 @@ class ComposerController extends BaseController }); } - void _autoCreateReplyToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listReplyToEmailAddress.add(emailAddress); + void _autoCreateReplyToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listReplyToEmailAddress)) { + listReplyToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1745,28 +1742,28 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.COLLAPSE; final inputToEmail = toEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } break; case PrefixEmailAddress.cc: ccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputCcEmail = ccEmailAddressController.text; if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } break; case PrefixEmailAddress.bcc: bccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputBccEmail = bccEmailAddressController.text; if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } break; case PrefixEmailAddress.replyTo: replyToAddressExpandMode.value = ExpandMode.COLLAPSE; final inputReplyToEmail = replyToEmailAddressController.text; if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } break; default: diff --git a/lib/features/composer/presentation/extensions/mail_address_extension.dart b/lib/features/composer/presentation/extensions/mail_address_extension.dart new file mode 100644 index 0000000000..fd804db22e --- /dev/null +++ b/lib/features/composer/presentation/extensions/mail_address_extension.dart @@ -0,0 +1,17 @@ +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:core/core.dart'; + +extension MailAddressExtension on MailAddress { + String? get getDisplayName { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return null; + } else { + return '${getLocalPartWithoutDetails()} [${getLocalPartDetails()}]'; + } + } + + EmailAddress asEmailAddress() { + return EmailAddress(getDisplayName, asEncodedString()); + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index 424278affd..80a0163231 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -8,6 +8,7 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/mail/mail_address.dart'; import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -17,6 +18,7 @@ import 'package:model/extensions/email_address_extension.dart'; import 'package:model/mailbox/expand_mode.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/suggestion_email_address.dart'; @@ -220,7 +222,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -304,7 +306,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -505,8 +507,9 @@ class _RecipientComposerWidgetState extends State { SuggestionEmailAddress suggestionEmailAddress, StateSetter stateSetter ) { - if (!_isDuplicatedRecipient(suggestionEmailAddress.emailAddress.emailAddress)) { - stateSetter(() => _currentListEmailAddress.add(suggestionEmailAddress.emailAddress)); + MailAddress mailAddress = MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -515,9 +518,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -526,9 +529,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } _gapBetweenTagChangedAndFindSuggestion = Timer( diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index d4eb188455..5b8576085a 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -77,7 +77,8 @@ class EmailUtils { static bool isEmailAddressValid(String address) { try { - return GetUtils.isEmail(address) && MailAddress.validateAddress(address).asString().isNotEmpty; + MailAddress mailAddress = MailAddress.validateAddress(address); + return GetUtils.isEmail(mailAddress.stripDetails().asString()) && mailAddress.asString().isNotEmpty; } catch(e) { logError('EmailUtils::isEmailAddressValid: Exception = $e'); return false; From de940d68af3305ced3787a667fa1991d867c68a4 Mon Sep 17 00:00:00 2001 From: Dat Dang Date: Fri, 6 Dec 2024 13:05:07 +0700 Subject: [PATCH 20/28] TF-3275 Fix FCM iOS foreground desync (#3314) --- docs/adr/0055-ios-fcm-routing.md | 24 +++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 3 ++ ios/Runner/AppDelegate.swift | 35 +++++++++++++++++++++- ios/TwakeCore/Utils/CoreUtils.swift | 3 ++ ios/TwakeMailNSE/Info.plist | 2 ++ ios/TwakeMailNSE/NotificationService.swift | 8 +++++ 6 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0055-ios-fcm-routing.md diff --git a/docs/adr/0055-ios-fcm-routing.md b/docs/adr/0055-ios-fcm-routing.md new file mode 100644 index 0000000000..09ae4eedf6 --- /dev/null +++ b/docs/adr/0055-ios-fcm-routing.md @@ -0,0 +1,24 @@ +# 55. iOS FCM routing + +Date: 2024-12-05 + +## Status + +Accepted + +## Context + +- Notification from FCM contains `mutable-content: true` +- Those notification will be transfered to Notification Service Extension ([See here](https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications#Configure-the-payload-for-the-remote-notification)) whether the app is in background or foreground +- Due to the app is in foreground, no notification will be shown +- Due to NSE handle the data, FCM on Flutter side cannot handle it + +## Decision + +- We check if the app is in foreground or not + - If the app is in foreground, we will not modify the payload and route the payload to FCM foreground method channel + - If the app is in background or terminated, we will keep the current implementation + +## Consequences + +- Twake Mail iOS app will get latest updates when app is in foreground diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fe3b934ce1..ed11008cf8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -1381,6 +1381,7 @@ F5D4EA082B2ABF090090DDFC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -1427,6 +1428,7 @@ F5D4EA092B2ABF090090DDFC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -1467,6 +1469,7 @@ F5D4EA0A2B2ABF090090DDFC /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index faa9d613d6..82d520753e 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,7 @@ import flutter_local_notifications @objc class AppDelegate: FlutterAppDelegate { var notificationInteractionChannel: FlutterMethodChannel? + var fcmMethodChannel: FlutterMethodChannel? var currentEmailId: String? override func application( @@ -17,6 +18,7 @@ import flutter_local_notifications GeneratedPluginRegistrant.register(with: self) createNotificationInteractionChannel() + createFcmMethodChannel() if let payload = launchOptions?[.remoteNotification] as? [AnyHashable : Any], let emailId = payload[JmapConstants.EMAIL_ID] as? String, @@ -52,6 +54,14 @@ import flutter_local_notifications return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + override func applicationDidEnterBackground(_ application: UIApplication) { + updateApplicationStateInUserDefaults(false) + } + + override func applicationWillTerminate(_ application: UIApplication) { + updateApplicationStateInUserDefaults(false) + } + override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { let sharingIntent = SwiftReceiveSharingIntentPlugin.instance if sharingIntent.hasMatchingSchemePrefix(url: url) { @@ -69,6 +79,7 @@ import flutter_local_notifications override func applicationDidBecomeActive(_ application: UIApplication) { removeAppBadger() + updateApplicationStateInUserDefaults(true) } private func handleEmailAndress(open url: URL) -> URL? { @@ -90,12 +101,19 @@ import flutter_local_notifications willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { TwakeLogger.shared.log(message: "AppDelegate::userNotificationCenter::willPresent::notification: \(notification)") + TwakeLogger.shared.log(message: "AppDelegate::userNotificationCenter::willPresent::notificationContent: \(notification.request.content.userInfo)") if let notificationBadgeCount = notification.request.content.badge?.intValue, notificationBadgeCount > 0 { let newBadgeCount = UIApplication.shared.applicationIconBadgeNumber + notificationBadgeCount TwakeLogger.shared.log(message: "AppDelegate::userNotificationCenter::willPresent:newBadgeCount: \(newBadgeCount)") updateAppBadger(newBadgeCount: newBadgeCount) } - if validateDisplayPushNotification(userInfo: notification.request.content.userInfo) { + if UIApplication.shared.applicationState == .active { + fcmMethodChannel?.invokeMethod( + CoreUtils.FCM_ON_MESSAGE_METHOD_NAME, + arguments: notification.request.content.userInfo) + + completionHandler([]) + } else if validateDisplayPushNotification(userInfo: notification.request.content.userInfo) { completionHandler([.alert, .badge, .sound]) } else { completionHandler([]) @@ -167,4 +185,19 @@ extension AppDelegate { } } } + + private func createFcmMethodChannel() { + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + + self.fcmMethodChannel = FlutterMethodChannel( + name: CoreUtils.FCM_METHOD_CHANNEL_NAME, + binaryMessenger: controller.binaryMessenger + ) + } + + private func updateApplicationStateInUserDefaults(_ appIsActive: Bool) { + let appGroupId = (Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String) ?? "group.\(Bundle.main.bundleIdentifier!)" + let userDefaults = UserDefaults(suiteName: appGroupId) + userDefaults?.set(appIsActive, forKey: CoreUtils.APPLICATION_STATE) + } } diff --git a/ios/TwakeCore/Utils/CoreUtils.swift b/ios/TwakeCore/Utils/CoreUtils.swift index 254d08cda7..007a184450 100644 --- a/ios/TwakeCore/Utils/CoreUtils.swift +++ b/ios/TwakeCore/Utils/CoreUtils.swift @@ -8,6 +8,9 @@ class CoreUtils { static let NOTIFICATION_INTERACTION_CHANNEL_NAME = "notification_interaction_channel" static let CURRENT_EMAIL_ID_IN_NOTIFICATION_CLICK_WHEN_APP_FOREGROUND_OR_BACKGROUND = "current_email_id_in_notification_click_when_app_foreground_or_background" static let CURRENT_EMAIL_ID_IN_NOTIFICATION_CLICK_WHEN_APP_TERMINATED = "current_email_id_in_notification_click_when_app_terminated" + static let FCM_METHOD_CHANNEL_NAME = "plugins.flutter.io/firebase_messaging" + static let FCM_ON_MESSAGE_METHOD_NAME = "Messaging#onMessage" + static let APPLICATION_STATE = "applicationState" func getCurrentDate() -> Date { if #available(iOS 15, *) { diff --git a/ios/TwakeMailNSE/Info.plist b/ios/TwakeMailNSE/Info.plist index 05d36c34d9..d5e4f2a46d 100644 --- a/ios/TwakeMailNSE/Info.plist +++ b/ios/TwakeMailNSE/Info.plist @@ -13,5 +13,7 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + AppGroupId + ${APP_GROUP_ID} diff --git a/ios/TwakeMailNSE/NotificationService.swift b/ios/TwakeMailNSE/NotificationService.swift index e61f93521a..5388ac1a15 100644 --- a/ios/TwakeMailNSE/NotificationService.swift +++ b/ios/TwakeMailNSE/NotificationService.swift @@ -16,6 +16,14 @@ class NotificationService: UNNotificationServiceExtension { handler = contentHandler modifiedContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + let appGroupId = (Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String) ?? "group.\(Bundle.main.bundleIdentifier!)" + let userDefaults = UserDefaults(suiteName: appGroupId) + let isAppActive = userDefaults?.value(forKey: CoreUtils.APPLICATION_STATE) as? Bool + if isAppActive == true { + self.modifiedContent?.userInfo = request.content.userInfo.merging(["data": request.content.userInfo], uniquingKeysWith: {(_, new) in new}) + contentHandler(self.modifiedContent ?? request.content) + } + guard let payloadData = request.content.userInfo as? [String: Any], !keychainController.retrieveSharingSessions().isEmpty else { self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) From 5094fd43dd37816590dec476cfc73c58b80b6c70 Mon Sep 17 00:00:00 2001 From: DatDang Date: Wed, 4 Dec 2024 14:24:39 +0700 Subject: [PATCH 21/28] Hotfix identity creator view bug --- .../presentation/identity_creator_view.dart | 14 +++-- .../presentation/vacation/vacation_view.dart | 51 ++++++++++--------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/lib/features/identity_creator/presentation/identity_creator_view.dart b/lib/features/identity_creator/presentation/identity_creator_view.dart index 2d633c0e0d..f9c8ac6ec7 100644 --- a/lib/features/identity_creator/presentation/identity_creator_view.dart +++ b/lib/features/identity_creator/presentation/identity_creator_view.dart @@ -472,10 +472,16 @@ class IdentityCreatorView extends GetWidget ), ] ), - _buildHtmlEditorWeb( - context, - controller.contentHtmlEditor, - maxWidth), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: 300, + ), + child: _buildHtmlEditorWeb( + context, + controller.contentHtmlEditor, + maxWidth), + ), ], ); } else { diff --git a/lib/features/manage_account/presentation/vacation/vacation_view.dart b/lib/features/manage_account/presentation/vacation/vacation_view.dart index a27451ef6d..8fd9dba198 100644 --- a/lib/features/manage_account/presentation/vacation/vacation_view.dart +++ b/lib/features/manage_account/presentation/vacation/vacation_view.dart @@ -477,30 +477,33 @@ class VacationView extends GetWidget with RichTextButtonMixi Widget _buildMessageHtmlTextEditor(BuildContext context) { if (PlatformInfo.isWeb) { - return html_editor_browser.HtmlEditor( - key: const Key('vacation_message_html_text_editor_web'), - controller: controller.richTextControllerForWeb.editorController, - htmlEditorOptions: html_editor_browser.HtmlEditorOptions( - hint: '', - darkMode: false, - initialText: controller.vacationMessageHtmlText, - spellCheck: true, - customBodyCssStyle: HtmlUtils.customCssStyleHtmlEditor(direction: AppUtils.getCurrentDirection(context)) - ), - htmlToolbarOptions: const html_editor_browser.HtmlToolbarOptions( - toolbarType: html_editor_browser.ToolbarType.hide, - defaultToolbarButtons: []), - otherOptions: const html_editor_browser.OtherOptions(height: 150), - callbacks: html_editor_browser.Callbacks( - onChangeSelection: controller.richTextControllerForWeb.onEditorSettingsChange, - onChangeContent: controller.updateMessageHtmlText, - onFocus: () { - KeyboardUtils.hideKeyboard(context); - Future.delayed(const Duration(milliseconds: 500), () { - controller.richTextControllerForWeb.editorController.setFocus(); - }); - controller.richTextControllerForWeb.closeAllMenuPopup(); - } + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: html_editor_browser.HtmlEditor( + key: const Key('vacation_message_html_text_editor_web'), + controller: controller.richTextControllerForWeb.editorController, + htmlEditorOptions: html_editor_browser.HtmlEditorOptions( + hint: '', + darkMode: false, + initialText: controller.vacationMessageHtmlText, + spellCheck: true, + customBodyCssStyle: HtmlUtils.customCssStyleHtmlEditor(direction: AppUtils.getCurrentDirection(context)) + ), + htmlToolbarOptions: const html_editor_browser.HtmlToolbarOptions( + toolbarType: html_editor_browser.ToolbarType.hide, + defaultToolbarButtons: []), + otherOptions: const html_editor_browser.OtherOptions(height: 150), + callbacks: html_editor_browser.Callbacks( + onChangeSelection: controller.richTextControllerForWeb.onEditorSettingsChange, + onChangeContent: controller.updateMessageHtmlText, + onFocus: () { + KeyboardUtils.hideKeyboard(context); + Future.delayed(const Duration(milliseconds: 500), () { + controller.richTextControllerForWeb.editorController.setFocus(); + }); + controller.richTextControllerForWeb.closeAllMenuPopup(); + } + ), ), ); } else { From aa9999535f592bc252877dc435cf01a539a85750 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Thu, 14 Nov 2024 15:13:56 +0100 Subject: [PATCH 22/28] TF-3219 display limits of email recovery in a yellow banner and filter out invalid suggestions --- .../base/mixin/date_range_picker_mixin.dart | 2 +- .../email_recovery_controller.dart | 11 +- .../model/email_recovery_field.dart | 16 ++- .../model/email_recovery_time_type.dart | 8 ++ .../presentation/model/session_extension.dart | 42 +++++++ .../date_selection_field_web_widget.dart | 4 +- .../email_recovery_form_desktop_builder.dart | 7 ++ .../email_recovery_form_mobile_builder.dart | 5 + .../email_recovery_form_tablet_builder.dart | 7 ++ .../presentation/widgets/limits_banner.dart | 33 ++++++ lib/l10n/intl_messages.arb | 18 ++- lib/main/localizations/app_localizations.dart | 14 +++ pubspec.lock | 8 ++ pubspec.yaml | 2 + .../session_extension_test.dart | 106 ++++++++++++++++++ 15 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 lib/features/email_recovery/presentation/model/session_extension.dart create mode 100644 lib/features/email_recovery/presentation/widgets/limits_banner.dart create mode 100644 test/features/email_recovery/session_extension_test.dart diff --git a/lib/features/base/mixin/date_range_picker_mixin.dart b/lib/features/base/mixin/date_range_picker_mixin.dart index 10f746f815..70ad1744f2 100644 --- a/lib/features/base/mixin/date_range_picker_mixin.dart +++ b/lib/features/base/mixin/date_range_picker_mixin.dart @@ -22,7 +22,7 @@ mixin DateRangePickerMixin { last7daysTitle: AppLocalizations.of(context).last7Days, last30daysTitle: AppLocalizations.of(context).last30Days, last6monthsTitle: AppLocalizations.of(context).last6Months, - lastYearTitle: AppLocalizations.of(context).lastYears, + lastYearTitle: AppLocalizations.of(context).last1Year, initStartDate: initStartDate, initEndDate: initEndDate, autoClose: false, diff --git a/lib/features/email_recovery/presentation/email_recovery_controller.dart b/lib/features/email_recovery/presentation/email_recovery_controller.dart index d48991ddb2..7a48189ea3 100644 --- a/lib/features/email_recovery/presentation/email_recovery_controller.dart +++ b/lib/features/email_recovery/presentation/email_recovery_controller.dart @@ -8,9 +8,7 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/utc_date.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:model/autocomplete/auto_complete_pattern.dart'; -import 'package:model/extensions/email_address_extension.dart'; -import 'package:model/mailbox/expand_mode.dart'; +import 'package:model/model.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/base/base_controller.dart'; @@ -25,6 +23,7 @@ import 'package:tmail_ui_user/features/email_recovery/presentation/controller/in import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_recovery_arguments.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_recovery_field.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_recovery_time_type.dart'; +import 'package:tmail_ui_user/features/email_recovery/presentation/model/session_extension.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/extensions/datetime_extension.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; import 'package:tmail_ui_user/main/utils/app_config.dart'; @@ -34,7 +33,7 @@ class EmailRecoveryController extends BaseController with DateRangePickerMixin { GetAutoCompleteInteractor? _getAutoCompleteInteractor; GetDeviceContactSuggestionsInteractor? _getDeviceContactSuggestionsInteractor; - final deletionDateFieldSelected = EmailRecoveryTimeType.last1Year.obs; + final deletionDateFieldSelected = EmailRecoveryTimeType.last7Days.obs; final receptionDateFieldSelected = EmailRecoveryTimeType.allTime.obs; final startDeletionDate = Rxn(); final endDeletionDate = Rxn(); @@ -348,6 +347,10 @@ class EmailRecoveryController extends BaseController with DateRangePickerMixin { popBack(); } + String getRestorationHorizonAsString() => arguments!.session.getRestorationHorizonAsString(); + + DateTime getRestorationHorizonAsDateTime() => arguments!.session.getRestorationHorizonAsDateTime(); + @override void dispose() { focusManager.subjectFieldFocusNode.removeListener(_onSubjectFieldFocusChanged); diff --git a/lib/features/email_recovery/presentation/model/email_recovery_field.dart b/lib/features/email_recovery/presentation/model/email_recovery_field.dart index fb98b11566..522942eba7 100644 --- a/lib/features/email_recovery/presentation/model/email_recovery_field.dart +++ b/lib/features/email_recovery/presentation/model/email_recovery_field.dart @@ -28,7 +28,7 @@ enum EmailRecoveryField { String getHintText(BuildContext context) { switch (this) { case EmailRecoveryField.deletionDate: - return AppLocalizations.of(context).last1Year; + return AppLocalizations.of(context).last7Days; case EmailRecoveryField.receptionDate: return AppLocalizations.of(context).allTime; case EmailRecoveryField.subject: @@ -40,16 +40,20 @@ enum EmailRecoveryField { } } - List getSupportedTimeTypes() { + List getSupportedTimeTypes(DateTime restorationHorizon) { switch (this) { case EmailRecoveryField.deletionDate: - return [ - EmailRecoveryTimeType.last1Year, + final supportedTypes = [ EmailRecoveryTimeType.last7Days, + EmailRecoveryTimeType.last15Days, EmailRecoveryTimeType.last30Days, EmailRecoveryTimeType.last6Months, - EmailRecoveryTimeType.customRange, - ]; + EmailRecoveryTimeType.last1Year, + ].where((type) => restorationHorizon + .subtract(const Duration(seconds: 2)) // to allow "15 days" if restorationHorizon is exactly 15 + .isBefore(type.toOldestUTCDate()!.value)).toList(); + + return [...supportedTypes, EmailRecoveryTimeType.customRange]; case EmailRecoveryField.receptionDate: return [ EmailRecoveryTimeType.allTime, diff --git a/lib/features/email_recovery/presentation/model/email_recovery_time_type.dart b/lib/features/email_recovery/presentation/model/email_recovery_time_type.dart index a9f553100b..632da6bb29 100644 --- a/lib/features/email_recovery/presentation/model/email_recovery_time_type.dart +++ b/lib/features/email_recovery/presentation/model/email_recovery_time_type.dart @@ -6,6 +6,7 @@ import 'package:jmap_dart_client/jmap/core/utc_date.dart'; enum EmailRecoveryTimeType { allTime, last7Days, + last15Days, last30Days, last6Months, last1Year, @@ -18,6 +19,8 @@ enum EmailRecoveryTimeType { return AppLocalizations.of(context).allTime; case EmailRecoveryTimeType.last7Days: return AppLocalizations.of(context).last7Days; + case EmailRecoveryTimeType.last15Days: + return AppLocalizations.of(context).last15Days; case EmailRecoveryTimeType.last30Days: return AppLocalizations.of(context).last30Days; case EmailRecoveryTimeType.last6Months: @@ -47,6 +50,10 @@ enum EmailRecoveryTimeType { final today = DateTime.now(); final last7Days = today.subtract(const Duration(days: 7)); return last7Days.toUTCDate(); + case EmailRecoveryTimeType.last15Days: + final today = DateTime.now(); + final last15Days = today.subtract(const Duration(days: 15)); + return last15Days.toUTCDate(); case EmailRecoveryTimeType.last30Days: final today = DateTime.now(); final last30Days = today.subtract(const Duration(days: 30)); @@ -68,6 +75,7 @@ enum EmailRecoveryTimeType { UTCDate? toLatestUTCDate() { switch(this) { case EmailRecoveryTimeType.last7Days: + case EmailRecoveryTimeType.last15Days: case EmailRecoveryTimeType.last30Days: case EmailRecoveryTimeType.last6Months: case EmailRecoveryTimeType.last1Year: diff --git a/lib/features/email_recovery/presentation/model/session_extension.dart b/lib/features/email_recovery/presentation/model/session_extension.dart new file mode 100644 index 0000000000..0be0d4b1cc --- /dev/null +++ b/lib/features/email_recovery/presentation/model/session_extension.dart @@ -0,0 +1,42 @@ + +import 'package:duration/duration.dart'; +import 'package:email_recovery/email_recovery/capability_deleted_messages_vault.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/extensions/session_extension.dart'; + +extension SessionExtension on Session { + + String getRestorationHorizonAsString() { + String restorationHorizon = 'restorationHorizon'; + String defaultHorizon = "15 days"; + + return (getCapabilityProperties(accountId, capabilityDeletedMessagesVault) + ?.props[0] as Map?) + ?[restorationHorizon] + ?? defaultHorizon; + } + + Duration getRestorationHorizonAsDuration() { + try { + String horizonWithCorrectFormat = getRestorationHorizonAsString() + .replaceAll(" days", "d") + .replaceAll(" day", "d") + .replaceAll(" hours", "h") + .replaceAll(" hour", "h") + .replaceAll(" minutes", "m") + .replaceAll(" minute", "m") + .replaceAll(" seconds", "s") + .replaceAll(" second", "s") + .replaceAll(" milliseconds", "ms") + .replaceAll(" millisecond", "ms"); + + return parseDuration(horizonWithCorrectFormat, separator: ' '); + } catch (e) { + return const Duration(days: 15); + } + } + + DateTime getRestorationHorizonAsDateTime() { + return DateTime.now().subtract(getRestorationHorizonAsDuration()); + } +} \ No newline at end of file diff --git a/lib/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart b/lib/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart index d5e8acd028..17ce469618 100644 --- a/lib/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart +++ b/lib/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart @@ -18,6 +18,7 @@ class DateSelectionFieldWebWidget extends StatelessWidget { final EmailRecoveryTimeType? recoveryTimeSelected; final OnRecoveryTimeSelected? onRecoveryTimeSelected; final VoidCallback? onTapCalendar; + final DateTime restorationHorizon; const DateSelectionFieldWebWidget({ super.key, @@ -29,6 +30,7 @@ class DateSelectionFieldWebWidget extends StatelessWidget { this.recoveryTimeSelected, this.onRecoveryTimeSelected, this.onTapCalendar, + required this.restorationHorizon }); @override @@ -54,7 +56,7 @@ class DateSelectionFieldWebWidget extends StatelessWidget { endDate: endDate, recoveryTimeSelected: recoveryTimeSelected, onRecoveryTimeSelected: onRecoveryTimeSelected, - items: field.getSupportedTimeTypes(), + items: field.getSupportedTimeTypes(restorationHorizon), ), ), const SizedBox(width: DateSelectionFieldStyles.icCalenderSpace), diff --git a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_desktop_builder.dart b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_desktop_builder.dart index bf12da47f2..0e7567da3a 100644 --- a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_desktop_builder.dart +++ b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_desktop_builder.dart @@ -7,6 +7,7 @@ import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_r import 'package:tmail_ui_user/features/email_recovery/presentation/styles/email_recovery_form_styles.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/check_box_has_attachment_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart'; +import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/limits_banner.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/list_button_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_suggestion_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_widget.dart'; @@ -41,6 +42,10 @@ class EmailRecoveryFormDesktopBuilder extends GetWidget child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + LimitsBanner( + bannerContent: AppLocalizations.of(context).recoverDeletedMessagesBannerContent(controller.getRestorationHorizonAsString()), + ), + const SizedBox(height: 16.0), Obx(() => DateSelectionFieldWebWidget( field: EmailRecoveryField.deletionDate, imagePaths: controller.imagePaths, @@ -50,6 +55,7 @@ class EmailRecoveryFormDesktopBuilder extends GetWidget recoveryTimeSelected: controller.deletionDateFieldSelected.value, onTapCalendar: () => controller.onSelectDeletionDateRange(context), onRecoveryTimeSelected: (type) => controller.onDeletionDateTypeSelected(context, type), + restorationHorizon: controller.getRestorationHorizonAsDateTime(), )), Obx(() => DateSelectionFieldWebWidget( field: EmailRecoveryField.receptionDate, @@ -60,6 +66,7 @@ class EmailRecoveryFormDesktopBuilder extends GetWidget recoveryTimeSelected: controller.receptionDateFieldSelected.value, onTapCalendar: () => controller.onSelectReceptionDateRange(context), onRecoveryTimeSelected: (type) => controller.onReceptionDateTypeSelected(context, type), + restorationHorizon: controller.getRestorationHorizonAsDateTime(), )), TextInputFieldWidget( field: EmailRecoveryField.subject, diff --git a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_mobile_builder.dart b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_mobile_builder.dart index 8abd5307ea..318a312a58 100644 --- a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_mobile_builder.dart +++ b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_mobile_builder.dart @@ -7,6 +7,7 @@ import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_r import 'package:tmail_ui_user/features/email_recovery/presentation/styles/email_recovery_form_styles.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/check_box_has_attachment_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_mobile_widget.dart'; +import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/limits_banner.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/list_button_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_suggestion_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_widget.dart'; @@ -41,6 +42,10 @@ class EmailRecoveryFormMobileBuilder extends GetWidget child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + LimitsBanner( + bannerContent: AppLocalizations.of(context).recoverDeletedMessagesBannerContent(controller.getRestorationHorizonAsString()), + ), + const SizedBox(height: 16.0), Obx(() => DateSelectionFieldMobileWidget( field: EmailRecoveryField.deletionDate, recoveryTimeSelected: controller.deletionDateFieldSelected.value, diff --git a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_tablet_builder.dart b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_tablet_builder.dart index 0ad9f9170b..5d300845c2 100644 --- a/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_tablet_builder.dart +++ b/lib/features/email_recovery/presentation/widgets/email_recovery_form/email_recovery_form_tablet_builder.dart @@ -7,6 +7,7 @@ import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_r import 'package:tmail_ui_user/features/email_recovery/presentation/styles/email_recovery_form_styles.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/check_box_has_attachment_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/date_selection_field/date_selection_field_web_widget.dart'; +import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/limits_banner.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/list_button_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_suggestion_widget.dart'; import 'package:tmail_ui_user/features/email_recovery/presentation/widgets/text_input_field/text_input_field_widget.dart'; @@ -41,6 +42,10 @@ class EmailRecoveryFormTabletBuilder extends GetWidget child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + LimitsBanner( + bannerContent: AppLocalizations.of(context).recoverDeletedMessagesBannerContent(controller.getRestorationHorizonAsString()), + ), + const SizedBox(height: 16.0), Obx(() => DateSelectionFieldWebWidget( field: EmailRecoveryField.deletionDate, imagePaths: controller.imagePaths, @@ -50,6 +55,7 @@ class EmailRecoveryFormTabletBuilder extends GetWidget recoveryTimeSelected: controller.deletionDateFieldSelected.value, onTapCalendar: () => controller.onSelectDeletionDateRange(context), onRecoveryTimeSelected: (type) => controller.onDeletionDateTypeSelected(context, type), + restorationHorizon: controller.getRestorationHorizonAsDateTime(), )), Obx(() => DateSelectionFieldWebWidget( field: EmailRecoveryField.receptionDate, @@ -60,6 +66,7 @@ class EmailRecoveryFormTabletBuilder extends GetWidget recoveryTimeSelected: controller.receptionDateFieldSelected.value, onTapCalendar: () => controller.onSelectReceptionDateRange(context), onRecoveryTimeSelected: (type) => controller.onReceptionDateTypeSelected(context, type), + restorationHorizon: controller.getRestorationHorizonAsDateTime(), )), TextInputFieldWidget( field: EmailRecoveryField.subject, diff --git a/lib/features/email_recovery/presentation/widgets/limits_banner.dart b/lib/features/email_recovery/presentation/widgets/limits_banner.dart new file mode 100644 index 0000000000..e540da1cec --- /dev/null +++ b/lib/features/email_recovery/presentation/widgets/limits_banner.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class LimitsBanner extends StatelessWidget { + final String bannerContent; + + const LimitsBanner({ + super.key, + required this.bannerContent, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), + decoration: BoxDecoration( + color: Colors.yellow[400], + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: Center( + child: Text( + bannerContent, + style: const TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 74f755910b..3f8db3ba9d 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2024-11-25T10:57:37.546143", + "@@last_modified": "2024-12-03T10:03:02.076482", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -1368,6 +1368,12 @@ "placeholders_order": [], "placeholders": {} }, + "last15Days": "Last 15 days", + "@last15Days": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "fromMe": "From me", "@fromMe": { "type": "text", @@ -3598,6 +3604,16 @@ "placeholders_order": [], "placeholders": {} }, + "recoverDeletedMessagesBannerContent": "You can recover messages deleted during the past {period}", + "@recoverDeletedMessagesBannerContent": { + "type": "text", + "placeholders_order": [ + "period" + ], + "placeholders": { + "period": {} + } + }, "deletionDate": "Deletion date", "@deletionDate": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 44f25ca989..aba874376c 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -1395,6 +1395,12 @@ class AppLocalizations { name: 'last7Days'); } + String get last15Days { + return Intl.message( + 'Last 15 days', + name: 'last15Days'); + } + String get fromMe { return Intl.message( 'From me', @@ -3750,6 +3756,14 @@ class AppLocalizations { ); } + String recoverDeletedMessagesBannerContent(String period) { + return Intl.message( + 'You can recover messages deleted during the past $period', + name: 'recoverDeletedMessagesBannerContent', + args: [period] + ); + } + String get deletionDate { return Intl.message( 'Deletion date', diff --git a/pubspec.lock b/pubspec.lock index ba1806e676..e92181aecb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -433,6 +433,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + duration: + dependency: "direct main" + description: + name: duration + sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501" + url: "https://pub.dev" + source: hosted + version: "4.0.3" email_recovery: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index bf0b4a3a2c..3c96165502 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -259,6 +259,8 @@ dependencies: flutter_web_auth_2: 3.1.1 + duration: 4.0.3 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/features/email_recovery/session_extension_test.dart b/test/features/email_recovery/session_extension_test.dart new file mode 100644 index 0000000000..11df36386a --- /dev/null +++ b/test/features/email_recovery/session_extension_test.dart @@ -0,0 +1,106 @@ + +import 'package:email_recovery/email_recovery/capability_deleted_messages_vault.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/account/account.dart'; +import 'package:jmap_dart_client/jmap/core/capability/default_capability.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:tmail_ui_user/features/email_recovery/presentation/model/session_extension.dart'; + +void main() { + + group('session extension test:', () { + test( + 'getRestorationHorizonAsDuration() should return the horizon as a duration when 15 days', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"restorationHorizon": "15 days"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsDuration(); + + // assert + expect(horizon, const Duration(days: 15)); + }); + + test( + 'getRestorationHorizonAsDuration() should return the horizon as a duration when 1 hour', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"restorationHorizon": "1 hour"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsDuration(); + + // assert + expect(horizon, const Duration(hours: 1)); + }); + + test( + 'getRestorationHorizonAsDuration() should return the horizon as a duration when 45 minutes', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"restorationHorizon": "45 minutes"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsDuration(); + + // assert + expect(horizon, const Duration(minutes: 45)); + }); + + test( + 'getRestorationHorizonAsDuration() should return default horizon as a duration when invalid horizon', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"restorationHorizon": "invalid"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsDuration(); + + // assert + expect(horizon, const Duration(days: 15)); + }); + + test( + 'getRestorationHorizonAsString should return the horizon as a String', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"restorationHorizon": "15 days"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsString(); + + // assert + expect(horizon, "15 days"); + }); + + test( + 'getRestorationHorizonAsString should return the default horizon as a String when invalid session attribute', + () { + // arrange + final session = Session({}, {AccountId(Id("1")): Account(AccountName("name"), true, false, + {capabilityDeletedMessagesVault: DefaultCapability({"invalid": "15 days"})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final horizon = session.getRestorationHorizonAsString(); + + // assert + expect(horizon, "15 days"); + }); + }); +} \ No newline at end of file From 159769345707cdf8506f84c75cc481083111642d Mon Sep 17 00:00:00 2001 From: DatDang Date: Mon, 9 Dec 2024 10:56:54 +0700 Subject: [PATCH 23/28] Fix patrol test on Firebase Test Lab --- android/app/build.gradle | 2 +- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2ac5b63a09..26d681eb53 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,5 +100,5 @@ dependencies { implementation 'androidx.work:work-runtime-ktx:2.7.0' implementation 'com.android.support:multidex:1.0.3' implementation 'androidx.window:window:1.0.0' - androidTestUtil "androidx.test:orchestrator:1.5.0" + androidTestUtil "androidx.test:orchestrator:1.5.1" } diff --git a/pubspec.lock b/pubspec.lock index e92181aecb..f5b0f85c6e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1533,10 +1533,10 @@ packages: dependency: "direct dev" description: name: patrol - sha256: ef07b0022f6eabee77655a3cde2364ff57cf22c29018d524476e972a5476724f + sha256: "6ac5e239384282da389a6b78d68aa86d8760ad8d5481d665937c4eb586c0b3dc" url: "https://pub.dev" source: hosted - version: "3.11.1" + version: "3.11.0" patrol_finders: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c96165502..0e1e565f88 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -283,7 +283,7 @@ dev_dependencies: http_mock_adapter: 0.4.2 - patrol: 3.11.1 + patrol: 3.11.0 plugin_platform_interface: 2.1.8 From 11960d74be53b5e482c59d982617598ef19ffa64 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 5 Dec 2024 18:39:43 +0700 Subject: [PATCH 24/28] TF-3312 Add logout confirmation dialog --- lib/features/base/base_controller.dart | 23 ++++++++- lib/features/base/mixin/logout_mixin.dart | 48 +++++++++++++++++++ .../mixin/message_dialog_action_mixin.dart | 20 ++++---- .../mailbox_dashboard_controller.dart | 1 + .../manage_account_dashboard_controller.dart | 2 +- .../menu/manage_account_menu_view.dart | 1 + .../settings/settings_first_level_view.dart | 1 + lib/l10n/intl_messages.arb | 26 +++++++++- lib/main/localizations/app_localizations.dart | 21 ++++++++ 9 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 lib/features/base/mixin/logout_mixin.dart diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index 1c8f904e8a..2b71a36b2e 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -28,6 +28,7 @@ import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:model/model.dart'; import 'package:rule_filter/rule_filter/capability_rule_filter.dart'; import 'package:tmail_ui_user/features/base/before_reconnect_manager.dart'; +import 'package:tmail_ui_user/features/base/mixin/logout_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/popup_context_menu_action_mixin.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; @@ -73,7 +74,8 @@ import 'package:uuid/uuid.dart'; abstract class BaseController extends GetxController with MessageDialogActionMixin, - PopupContextMenuActionMixin { + PopupContextMenuActionMixin, + LogoutMixin { final CachingManager cachingManager = Get.find(); final LanguageCacheManager languageCacheManager = Get.find(); @@ -436,11 +438,28 @@ abstract class BaseController extends GetxController arguments: LoginArguments(LoginFormType.none)); } - void logout(Session? session, AccountId? accountId) async { + void logout( + BuildContext context, + Session? session, + AccountId? accountId, + ) { + if (PlatformInfo.isMobile) { + showLogoutConfirmDialog( + context: context, + username: session?.username.value ?? '', + onConfirmAction: () => _handleLogoutAction(session, accountId), + ); + } else { + _handleLogoutAction(session, accountId); + } + } + + Future _handleLogoutAction(Session? session, AccountId? accountId) async { if (session == null || accountId == null) { await clearDataAndGoToLoginPage(); return; } + _isFcmEnabled = _isFcmActivated(session, accountId); if (isAuthenticatedWithOidc) { consumeState(logoutOidcInteractor.execute()); diff --git a/lib/features/base/mixin/logout_mixin.dart b/lib/features/base/mixin/logout_mixin.dart new file mode 100644 index 0000000000..24b55faf32 --- /dev/null +++ b/lib/features/base/mixin/logout_mixin.dart @@ -0,0 +1,48 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +mixin LogoutMixin implements MessageDialogActionMixin { + void showLogoutConfirmDialog({ + required BuildContext context, + required String username, + required Function? onConfirmAction, + }) { + final appLocalizations = AppLocalizations.of(context); + + showConfirmDialogAction( + context, + '', + appLocalizations.yesLogout, + title: appLocalizations.logoutConfirmation, + alignCenter: true, + outsideDismissible: false, + titleActionButtonMaxLines: 1, + titlePadding: const EdgeInsetsDirectional.only( + start: 24, + top: 24, + end: 24, + bottom: 12, + ), + messageStyle: const TextStyle( + color: AppColor.colorTextBody, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + listTextSpan: [ + TextSpan(text: AppLocalizations.of(context).messageConfirmationLogout), + TextSpan( + text: ' $username', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppColor.colorTextBody, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + const TextSpan(text: '?'), + ], + onConfirmAction: onConfirmAction, + ); + } +} diff --git a/lib/features/base/mixin/message_dialog_action_mixin.dart b/lib/features/base/mixin/message_dialog_action_mixin.dart index 3756683cb4..168ca43c71 100644 --- a/lib/features/base/mixin/message_dialog_action_mixin.dart +++ b/lib/features/base/mixin/message_dialog_action_mixin.dart @@ -37,11 +37,17 @@ mixin MessageDialogActionMixin { PopInvokedCallback? onPopInvoked, bool isArrangeActionButtonsVertical = false, int? titleActionButtonMaxLines, + EdgeInsetsGeometry? titlePadding, } ) async { final responsiveUtils = Get.find(); final imagePaths = Get.find(); + final paddingTitle = titlePadding ?? + (icon != null + ? const EdgeInsetsDirectional.only(top: 24, start: 24, end: 24) + : const EdgeInsetsDirectional.symmetric(horizontal: 24)); + if (alignCenter) { final childWidget = PointerInterceptor( child: (ConfirmDialogBuilder( @@ -57,10 +63,7 @@ mixin MessageDialogActionMixin { ..colorConfirmButton(actionButtonColor ?? AppColor.colorTextButton) ..colorCancelButton(cancelButtonColor ?? AppColor.colorCancelButton) ..marginIcon(icon != null ? (marginIcon ?? const EdgeInsets.only(top: 24)) : null) - ..paddingTitle(icon != null - ? const EdgeInsetsDirectional.only(top: 24, start: 24, end: 24) - : const EdgeInsetsDirectional.symmetric(horizontal: 24) - ) + ..paddingTitle(paddingTitle) ..radiusButton(12) ..paddingButton(paddingButton) ..paddingContent(const EdgeInsets.only(left: 24, right: 24, bottom: 24, top: 12)) @@ -114,10 +117,7 @@ mixin MessageDialogActionMixin { ..widthDialog(responsiveUtils.getSizeScreenWidth(context)) ..colorConfirmButton(actionButtonColor ?? AppColor.colorTextButton) ..colorCancelButton(cancelButtonColor ?? AppColor.colorCancelButton) - ..paddingTitle(icon != null - ? const EdgeInsetsDirectional.only(top: 24, start: 24, end: 24) - : const EdgeInsetsDirectional.symmetric(horizontal: 24) - ) + ..paddingTitle(paddingTitle) ..marginIcon(EdgeInsets.zero) ..paddingContent(const EdgeInsets.only(left: 44, right: 44, bottom: 24, top: 12)) ..marginButton(hasCancelButton ? null : const EdgeInsets.only(bottom: 16, left: 44, right: 44)) @@ -194,9 +194,7 @@ mixin MessageDialogActionMixin { ..colorConfirmButton(actionButtonColor ?? AppColor.colorTextButton) ..colorCancelButton(cancelButtonColor ?? AppColor.colorCancelButton) ..marginIcon(icon != null ? const EdgeInsets.only(top: 24) : null) - ..paddingTitle(icon != null - ? const EdgeInsetsDirectional.only(top: 24, start: 24, end: 24) - : const EdgeInsetsDirectional.symmetric(horizontal: 24)) + ..paddingTitle(paddingTitle) ..marginIcon(EdgeInsets.zero) ..paddingContent(const EdgeInsets.only(left: 44, right: 44, bottom: 24, top: 12)) ..marginButton(hasCancelButton ? null : const EdgeInsets.only(bottom: 16, left: 44, right: 44)) diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 9681e4dc95..c0781ac35e 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -2879,6 +2879,7 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo onLogoutAction: () { popBack(); logout( + context, sessionCurrent, accountId.value ); diff --git a/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart b/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart index ae57d913aa..8bd0e35f75 100644 --- a/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart +++ b/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart @@ -367,7 +367,7 @@ class ManageAccountDashBoardController extends ReloadableController with UserSet sessionCurrent?.username, onLogoutAction: () { popBack(); - logout(sessionCurrent, accountId.value); + logout(context, sessionCurrent, accountId.value); } ) ); diff --git a/lib/features/manage_account/presentation/menu/manage_account_menu_view.dart b/lib/features/manage_account/presentation/menu/manage_account_menu_view.dart index 9c5fe7c8be..7e5b5d1afa 100644 --- a/lib/features/manage_account/presentation/menu/manage_account_menu_view.dart +++ b/lib/features/manage_account/presentation/menu/manage_account_menu_view.dart @@ -103,6 +103,7 @@ class ManageAccountMenuView extends GetWidget { child: InkWell( onTap: () { controller.dashBoardController.logout( + context, controller.dashBoardController.sessionCurrent, controller.dashBoardController.accountId.value ); diff --git a/lib/features/manage_account/presentation/menu/settings/settings_first_level_view.dart b/lib/features/manage_account/presentation/menu/settings/settings_first_level_view.dart index c5de7de208..6c33f15a69 100644 --- a/lib/features/manage_account/presentation/menu/settings/settings_first_level_view.dart +++ b/lib/features/manage_account/presentation/menu/settings/settings_first_level_view.dart @@ -163,6 +163,7 @@ class SettingsFirstLevelView extends GetWidget { AppLocalizations.of(context).sign_out, controller.imagePaths.icSignOut, () => controller.manageAccountDashboardController.logout( + context, controller.manageAccountDashboardController.sessionCurrent, controller.manageAccountDashboardController.accountId.value) ), diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 3f8db3ba9d..51d22613c0 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2024-12-03T10:03:02.076482", + "@@last_modified": "2024-12-05T18:34:48.830922", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -2924,6 +2924,12 @@ "placeholders_order": [], "placeholders": {} }, + "errorWhileFetchingSubaddress": "Error while fetching the subaddress", + "@errorWhileFetchingSubaddress": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "connectedToTheInternet": "Connected to the internet", "@connectedToTheInternet": { "type": "text", @@ -4151,5 +4157,23 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "yesLogout": "Yes, Log out", + "@yesLogout": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "logoutConfirmation": "Logout Confirmation", + "@logoutConfirmation": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageConfirmationLogout": "Do you want to log out of", + "@messageConfirmationLogout": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index aba874376c..ecd67c9ad8 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4359,4 +4359,25 @@ class AppLocalizations { name: 'createTwakeIdFailed', ); } + + String get yesLogout { + return Intl.message( + 'Yes, Log out', + name: 'yesLogout', + ); + } + + String get logoutConfirmation { + return Intl.message( + 'Logout Confirmation', + name: 'logoutConfirmation', + ); + } + + String get messageConfirmationLogout { + return Intl.message( + 'Do you want to log out of', + name: 'messageConfirmationLogout', + ); + } } From 547cfd8d10aea12480db6bd51774166647e05db3 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 5 Dec 2024 18:40:11 +0700 Subject: [PATCH 25/28] TF-3312 Hide system's confirm dialog when logging out --- .../authentication_client/authentication_client_mobile.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart b/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart index e307810b54..eeb1c4451b 100644 --- a/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart +++ b/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart @@ -58,7 +58,8 @@ class AuthenticationClientMobile implements AuthenticationClientBase { idTokenHint: tokenId.uuid, postLogoutRedirectUrl: config.logoutRedirectUrl, discoveryUrl: config.discoveryUrl, - serviceConfiguration: authorizationServiceConfiguration + serviceConfiguration: authorizationServiceConfiguration, + preferEphemeralSession: true, )); log('AuthenticationClientMobile::logoutOidc(): ${endSession?.state}'); return endSession?.state?.isNotEmpty == true; From 27741005641004986def16dd25ff4c4cc7296eb2 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 5 Dec 2024 18:40:33 +0700 Subject: [PATCH 26/28] TF-3312 Change bundle name of Twake Mail --- ios/Runner/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 84f94739ab..d108272922 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - TMail + TwakeMail CFBundlePackageType APPL CFBundleShortVersionString From e5405b56eca3bec1e41b36255743b697de9dc497 Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 6 Dec 2024 09:03:15 +0700 Subject: [PATCH 27/28] fixup! TF-3312 Change bundle name of Twake Mail --- ios/Runner/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d108272922..7f5658898a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - TwakeMail + Twake Mail CFBundlePackageType APPL CFBundleShortVersionString From 6beca9db0d5f741f8120705925c41356cafee12a Mon Sep 17 00:00:00 2001 From: DatDang Date: Tue, 10 Dec 2024 16:09:03 +0700 Subject: [PATCH 28/28] TF-3318 Fix black pixel when clicking on attachment with long name --- .../presentation/widgets/attachment_item_composer_widget.dart | 1 + .../email/presentation/widgets/attachment_item_widget.dart | 1 + .../widgets/attachment_list/attachment_list_item_widget.dart | 1 + .../widgets/feedback_draggable_attachment_item_widget.dart | 1 + 4 files changed, 4 insertions(+) diff --git a/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart b/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart index c86599f572..15dc3090a1 100644 --- a/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart @@ -77,6 +77,7 @@ class AttachmentItemComposerWidget extends StatelessWidget with AppLoaderMixin { maxLines: 1, overflowWidget: const TextOverflowWidget( position: TextOverflowPosition.middle, + clearType: TextOverflowClearType.clipRect, child: Text( '...', style: AttachmentItemComposerWidgetStyle.dotsLabelTextStyle, diff --git a/lib/features/email/presentation/widgets/attachment_item_widget.dart b/lib/features/email/presentation/widgets/attachment_item_widget.dart index 8a9f7ad160..aa724ceb9d 100644 --- a/lib/features/email/presentation/widgets/attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_item_widget.dart @@ -78,6 +78,7 @@ class AttachmentItemWidget extends StatelessWidget { maxLines: 1, overflowWidget: const TextOverflowWidget( position: TextOverflowPosition.middle, + clearType: TextOverflowClearType.clipRect, child: Text( "...", style: AttachmentItemWidgetStyle.dotsLabelTextStyle, diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart index 94467a9032..50b6371218 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart @@ -70,6 +70,7 @@ class AttachmentListItemWidget extends StatelessWidget { maxLines: 1, overflowWidget: const TextOverflowWidget( position: TextOverflowPosition.middle, + clearType: TextOverflowClearType.clipRect, child: Text( "...", style: AttachmentListItemWidgetStyle.dotsLabelTextStyle, diff --git a/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart b/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart index a91a32cf90..b43755c4a7 100644 --- a/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart @@ -52,6 +52,7 @@ class FeedbackDraggableAttachmentItemWidget extends StatelessWidget { maxLines: 1, overflowWidget: const TextOverflowWidget( position: TextOverflowPosition.middle, + clearType: TextOverflowClearType.clipRect, child: Text( '...', style: FeedbackDraggableAttachmentItemWidgetStyle.dotsLabelTextStyle,