diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 0dc87d033e..a59c22f0ca 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -18,7 +18,6 @@ import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar import 'package:jmap_dart_client/jmap/mail/calendar/properties/calendar_organizer.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mdn/disposition.dart'; import 'package:jmap_dart_client/jmap/mdn/mdn.dart'; import 'package:model/error_type_handler/unknown_uri_exception.dart'; @@ -65,6 +64,7 @@ import 'package:tmail_ui_user/features/email/presentation/bindings/calendar_even import 'package:tmail_ui_user/features/email/presentation/bindings/mdn_interactor_bindings.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/extensions/handle_email_action_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/handle_mail_action_by_shortcut_action_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/handle_open_attachment_list_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/update_attendance_status_extension.dart'; @@ -82,7 +82,6 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_download_attachment_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_preview_attachment_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart'; -import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/create_new_rule_filter_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart'; @@ -223,7 +222,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } else if (success is MarkAsEmailReadSuccess) { _handleMarkAsEmailReadCompleted(success); } else if (success is MarkAsStarEmailSuccess) { - _markAsEmailStarSuccess(success); + markAsEmailStarSuccess(success); } else if (success is GetAllIdentitiesSuccess) { _getAllIdentitiesSuccess(success); } else if (success is SendReceiptToSenderSuccess) { @@ -783,30 +782,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } } - void _markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { - final newKeywords = { - KeyWordIdentifier.emailFlagged: - success.markStarAction == MarkStarAction.markStar, - }; - - final newEmail = currentEmail?.updateKeywords(newKeywords); - final emailId = newEmail?.id; - if (emailId == null) return; - - if (PlatformInfo.isMobile && !isThreadDetailEnabled) { - mailboxDashBoardController.selectedEmail.value?.resyncKeywords(newKeywords); - } else { - _threadDetailController?.emailIdsPresentation[emailId] = newEmail; - } - - mailboxDashBoardController.updateEmailFlagByEmailIds( - [emailId], - markStarAction: success.markStarAction, - ); - - toastManager.showMessageSuccess(success); - } - void handleEmailAction( PresentationEmail presentationEmail, EmailActionType actionType, diff --git a/lib/features/email/presentation/extensions/email_extension.dart b/lib/features/email/presentation/extensions/email_extension.dart index 123e24c5af..60cdd8e02b 100644 --- a/lib/features/email/presentation/extensions/email_extension.dart +++ b/lib/features/email/presentation/extensions/email_extension.dart @@ -1,11 +1,13 @@ import 'package:core/utils/app_logger.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/extensions/email_extension.dart'; import 'package:model/extensions/list_email_header_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/smime_signature_constant.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; extension EmailExtension on Email { @@ -39,7 +41,7 @@ extension EmailExtension on Email { bool fromMe(String ownEmailAddress) { return from?.any( - (emailAdress) => emailAdress.email == ownEmailAddress + (emailAddress) => emailAddress.email == ownEmailAddress ) == true; } @@ -50,7 +52,18 @@ extension EmailExtension on Email { ...bcc ?? {}, }; return recipients.any( - (emailAdress) => emailAdress.email == ownEmailAddress + (emailAddress) => emailAddress.email == ownEmailAddress ) == true; } + + Email toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { + return copyWith( + keywords: remove + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/extensions/email_loaded_extension.dart b/lib/features/email/presentation/extensions/email_loaded_extension.dart new file mode 100644 index 0000000000..3e461f31b1 --- /dev/null +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -0,0 +1,22 @@ +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/email_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; + +extension EmailLoadedExtension on EmailLoaded { + EmailLoaded toggleEmailKeyword({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool remove, + }) { + if (emailCurrent == null || emailCurrent!.id != emailId) { + return this; + } + return copyWith( + emailCurrent: emailCurrent!.toggleKeyword( + keyword: keyword, + remove: remove, + ), + ); + } +} diff --git a/lib/features/email/presentation/extensions/handle_email_action_extension.dart b/lib/features/email/presentation/extensions/handle_email_action_extension.dart new file mode 100644 index 0000000000..80985b6f4b --- /dev/null +++ b/lib/features/email/presentation/extensions/handle_email_action_extension.dart @@ -0,0 +1,84 @@ +import 'package:core/utils/platform_info.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:model/email/mark_star_action.dart'; +import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; +import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/email_loaded_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentation_email_map_extension.dart'; + +extension HandleEmailActionExtension on SingleEmailController { + void markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { + _autoSyncStarToSelectedEmailOnMemory( + markStarAction: success.markStarAction, + emailId: success.emailId, + starKeyword: KeyWordIdentifier.emailFlagged, + ); + + toastManager.showMessageSuccess(success); + } + + void _autoSyncStarToSelectedEmailOnMemory({ + required MarkStarAction markStarAction, + required EmailId emailId, + required KeyWordIdentifier starKeyword, + }) { + _updateStarInEmailOnMemory( + emailId: emailId, + starKeyword: starKeyword, + markStarAction: markStarAction, + isMobileThreadDisabled: PlatformInfo.isMobile && !isThreadDetailEnabled, + ); + } + + void _updateStarInEmailOnMemory({ + required MarkStarAction markStarAction, + required EmailId emailId, + required KeyWordIdentifier starKeyword, + required bool isMobileThreadDisabled, + }) { + final remove = markStarAction == MarkStarAction.unMarkStar; + + if (isMobileThreadDisabled) { + final selectedEmail = mailboxDashBoardController.selectedEmail.value; + if (selectedEmail?.id == emailId) { + mailboxDashBoardController.selectedEmail.value = + selectedEmail?.toggleKeyword(keyword: starKeyword, remove: remove); + } + } else { + final controller = threadDetailController; + if (controller != null) { + controller.emailIdsPresentation.value = + controller.emailIdsPresentation.toggleEmailKeywordById( + emailId: emailId, + keyword: starKeyword, + remove: remove, + ); + + controller.emailsInThreadDetailInfo.value = + controller.emailsInThreadDetailInfo.toggleEmailKeywordById( + emailId: emailId, + keyword: starKeyword, + remove: remove, + ); + } + } + + final emailLoaded = currentEmailLoaded.value; + if (emailLoaded != null && emailLoaded.emailCurrent?.id == emailId) { + currentEmailLoaded.value = emailLoaded.toggleEmailKeyword( + emailId: emailId, + keyword: starKeyword, + remove: remove, + ); + } + + mailboxDashBoardController.updateEmailFlagByEmailIds( + [emailId], + markStarAction: markStarAction, + ); + } +} diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart index 385a4c61fe..2085068061 100644 --- a/lib/features/email/presentation/extensions/presentation_email_extension.dart +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -1,8 +1,10 @@ import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/list_email_address_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; extension PresentationEmailExtension on PresentationEmail { ({ @@ -153,4 +155,15 @@ extension PresentationEmailExtension on PresentationEmail { replyTo: [], ); } + + PresentationEmail toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { + return copyWith( + keywords: remove + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/model/email_loaded.dart b/lib/features/email/presentation/model/email_loaded.dart index 15d942d79e..0d0175be99 100644 --- a/lib/features/email/presentation/model/email_loaded.dart +++ b/lib/features/email/presentation/model/email_loaded.dart @@ -17,6 +17,20 @@ class EmailLoaded with EquatableMixin { this.emailCurrent, }); + EmailLoaded copyWith({ + String? htmlContent, + List? attachments, + List? inlineImages, + Email? emailCurrent, + }) { + return EmailLoaded( + htmlContent: htmlContent ?? this.htmlContent, + attachments: attachments ?? this.attachments, + inlineImages: inlineImages ?? this.inlineImages, + emailCurrent: emailCurrent ?? this.emailCurrent, + ); + } + SMimeSignatureStatus? get sMimeStatus => emailCurrent?.sMimeStatus; @override 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 67ebc80126..04a12c1e8d 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -455,12 +455,14 @@ class MailboxDashBoardController extends ReloadableController success.markStarAction, success.countMarkStarSuccess, success.emailIds, + isThread: success.isThread, ); } else if (success is MarkAsStarMultipleEmailHasSomeEmailFailure) { _markAsStarMultipleEmailSuccess( success.markStarAction, success.countMarkStarSuccess, success.successEmailIds, + isThread: success.isThread, ); } else if (success is MoveMultipleEmailToMailboxAllSuccess || success is MoveMultipleEmailToMailboxHasSomeEmailFailure) { @@ -1271,10 +1273,11 @@ class MailboxDashBoardController extends ReloadableController void _markAsStarMultipleEmailSuccess( MarkStarAction markStarAction, int countMarkStarSuccess, - List emailIds, - ) { + List emailIds, { + bool isThread = false, + }) { updateEmailFlagByEmailIds(emailIds, markStarAction: markStarAction); - if (currentOverlayContext != null && currentContext != null) { + if (!isThread && currentOverlayContext != null && currentContext != null) { final message = markStarAction == MarkStarAction.unMarkStar ? AppLocalizations.of(currentContext!).marked_unstar_multiple_item(countMarkStarSuccess) : AppLocalizations.of(currentContext!).marked_star_multiple_item(countMarkStarSuccess); diff --git a/lib/features/thread/data/extensions/map_keywords_extension.dart b/lib/features/thread/data/extensions/map_keywords_extension.dart index 3b6598c0a9..51a4171d93 100644 --- a/lib/features/thread/data/extensions/map_keywords_extension.dart +++ b/lib/features/thread/data/extensions/map_keywords_extension.dart @@ -1,7 +1,16 @@ - import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; -extension MapKeywordsExtension on Map { +extension MapKeywordsExtension on Map? { + Map toMapString() => Map.fromIterables( + this?.keys.map((keyword) => keyword.value) ?? const [], + this?.values ?? const [], + ); + + Map withKeyword(KeyWordIdentifier keyword) { + return Map.from(this ?? {})..[keyword] = true; + } - Map toMapString() => Map.fromIterables(keys.map((keyword) => keyword.value), values); -} \ No newline at end of file + Map withoutKeyword(KeyWordIdentifier keyword) { + return Map.from(this ?? {})..remove(keyword); + } +} diff --git a/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart b/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart index c7c5cecabf..d389383d10 100644 --- a/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart +++ b/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart @@ -3,21 +3,28 @@ import 'package:core/presentation/state/success.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:model/email/mark_star_action.dart'; -class LoadingMarkAsStarMultipleEmailAll extends UIState {} +class LoadingMarkAsStarMultipleEmailAll extends LoadingState {} class MarkAsStarMultipleEmailAllSuccess extends UIState { final int countMarkStarSuccess; final MarkStarAction markStarAction; final List emailIds; + final bool isThread; MarkAsStarMultipleEmailAllSuccess( this.countMarkStarSuccess, this.markStarAction, - this.emailIds, - ); + this.emailIds, { + this.isThread = false, + }); @override - List get props => [countMarkStarSuccess, markStarAction, emailIds]; + List get props => [ + countMarkStarSuccess, + markStarAction, + emailIds, + isThread, + ]; } class MarkAsStarMultipleEmailAllFailure extends FeatureFailure { @@ -33,22 +40,32 @@ class MarkAsStarMultipleEmailHasSomeEmailFailure extends UIState { final int countMarkStarSuccess; final MarkStarAction markStarAction; final List successEmailIds; + final bool isThread; MarkAsStarMultipleEmailHasSomeEmailFailure( this.countMarkStarSuccess, this.markStarAction, - this.successEmailIds, - ); + this.successEmailIds, { + this.isThread = false, + }); @override - List get props => [countMarkStarSuccess, markStarAction, successEmailIds]; + List get props => [ + countMarkStarSuccess, + markStarAction, + successEmailIds, + isThread, + ]; } class MarkAsStarMultipleEmailFailure extends FeatureFailure { final MarkStarAction markStarAction; - MarkAsStarMultipleEmailFailure(this.markStarAction, dynamic exception) : super(exception: exception); + MarkAsStarMultipleEmailFailure( + this.markStarAction, + dynamic exception, + ) : super(exception: exception); @override List get props => [markStarAction, exception]; -} \ No newline at end of file +} diff --git a/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart b/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart index 980a1ff967..111a4ecf8a 100644 --- a/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart +++ b/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart @@ -17,8 +17,9 @@ class MarkAsStarMultipleEmailInteractor { Session session, AccountId accountId, List emailIds, - MarkStarAction markStarAction - ) async* { + MarkStarAction markStarAction, { + bool isThread = false, + }) async* { try { yield Right(LoadingMarkAsStarMultipleEmailAll()); @@ -29,6 +30,7 @@ class MarkAsStarMultipleEmailInteractor { emailIds.length, markStarAction, result.emailIdsSuccess, + isThread: isThread, )); } else if (result.emailIdsSuccess.isEmpty) { yield Left(MarkAsStarMultipleEmailAllFailure(markStarAction)); @@ -37,6 +39,7 @@ class MarkAsStarMultipleEmailInteractor { result.emailIdsSuccess.length, markStarAction, result.emailIdsSuccess, + isThread: isThread, )); } } catch (e) { diff --git a/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart new file mode 100644 index 0000000000..766b2ce183 --- /dev/null +++ b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart @@ -0,0 +1,16 @@ +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; + +extension EmailInThreadDetailInfoExtension on EmailInThreadDetailInfo { + EmailInThreadDetailInfo toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { + return copyWith( + keywords: remove + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); + } +} diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index d33fdea0d6..b72789e6f8 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -1,10 +1,61 @@ import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; -extension ListEmailInThreadDetailInfoExtension on List { +extension ListEmailInThreadDetailInfoExtension + on List { List emailIdsToDisplay(bool isSentMailbox) => isSentMailbox - ? map((email) => email.emailId).toList() - : where((email) => email.isValidToDisplay) - .map((email) => email.emailId) + ? map((emailInfo) => emailInfo.emailId).toList() + : where((emailInfo) => emailInfo.isValidToDisplay) + .map((emailInfo) => emailInfo.emailId) .toList(); -} \ No newline at end of file + + List toggleEmailKeywords({ + required KeyWordIdentifier keyword, + required bool remove, + }) { + return map((emailInfo) => emailInfo.toggleKeyword( + keyword: keyword, + remove: remove, + )).toList(); + } + + List toggleEmailKeywordByIds({ + required List targetIds, + required KeyWordIdentifier keyword, + required bool remove, + }) { + // Always return a new list to keep consistent semantics + if (targetIds.isEmpty) { + return toList(); + } + + final targetSet = targetIds.toSet(); + return map((emailInfo) { + if (!targetSet.contains(emailInfo.emailId)) return emailInfo; + return emailInfo.toggleKeyword(keyword: keyword, remove: remove); + }).toList(); + } + + List toggleEmailKeywordById({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool remove, + }) { + final result = toList(); // ensure new list instance + + for (var i = 0; i < result.length; i++) { + final emailInfo = result[i]; + if (emailInfo.emailId == emailId) { + result[i] = emailInfo.toggleKeyword( + keyword: keyword, + remove: remove, + ); + break; // stop once found + } + } + + return result; + } +} diff --git a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart new file mode 100644 index 0000000000..7f55bc824e --- /dev/null +++ b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart @@ -0,0 +1,55 @@ +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; + +extension PresentationEmailMapExtension on Map { + Map toggleEmailKeywords({ + required KeyWordIdentifier keyword, + required bool remove, + }) { + return map((id, email) { + if (email == null) { + return MapEntry(id, email); + } + return MapEntry( + id, + email.toggleKeyword(keyword: keyword, remove: remove), + ); + }); + } + + Map toggleEmailKeywordByIds({ + required List ids, + required KeyWordIdentifier keyword, + required bool remove, + }) { + final targetSet = ids.toSet(); + + return map((id, email) { + if (!targetSet.contains(id) || email == null) { + return MapEntry(id, email); + } + return MapEntry( + id, + email.toggleKeyword(keyword: keyword, remove: remove), + ); + }); + } + + Map toggleEmailKeywordById({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool remove, + }) { + return map((id, email) { + if (id != emailId || email == null) { + return MapEntry(id, email); + } + return MapEntry( + id, + email.toggleKeyword(keyword: keyword, remove: remove), + ); + }); + } +} diff --git a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart new file mode 100644 index 0000000000..6cf053e3ac --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart @@ -0,0 +1,78 @@ +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:model/email/mark_star_action.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/thread/domain/state/mark_as_star_multiple_email_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentation_email_map_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +typedef OnUpdateMapEmailIds = Map Function( + Map, +); + +typedef OnUpdateListEmailInfo = List Function( + List, +); + +extension HandleThreadActionSuccess on ThreadDetailController { + void handleMarkThreadAsStarredSuccess(Success success) { + mailboxDashBoardController.consumeState(Stream.value(Right(success))); + + switch (success) { + case MarkAsStarMultipleEmailAllSuccess(): + _handleMarkAll(success.markStarAction); + case MarkAsStarMultipleEmailHasSomeEmailFailure(): + _handleMarkPartial( + success.markStarAction, + success.successEmailIds, + ); + default: + break; + } + } + + void _updateEmailStates({ + required OnUpdateMapEmailIds updateIds, + required OnUpdateListEmailInfo updateDetails, + }) { + emailIdsPresentation.value = updateIds(emailIdsPresentation); + emailsInThreadDetailInfo.value = updateDetails(emailsInThreadDetailInfo); + } + + void _handleMarkAll(MarkStarAction action) { + final remove = action == MarkStarAction.unMarkStar; + _updateEmailStates( + updateIds: (current) => current.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + updateDetails: (current) => current.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + ); + } + + void _handleMarkPartial( + MarkStarAction action, + List successEmailIds, + ) { + final remove = action == MarkStarAction.unMarkStar; + _updateEmailStates( + updateIds: (current) => current.toggleEmailKeywordByIds( + ids: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + updateDetails: (current) => current.toggleEmailKeywordByIds( + targetIds: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + ); + } +} diff --git a/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart b/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart index 53a13b82b8..eb68a8eab9 100644 --- a/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart +++ b/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart @@ -67,6 +67,7 @@ extension OnThreadDetailActionClick on ThreadDetailController { threadDetailActionType == EmailActionType.markAsStarred ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + isThread: true, )); break; case EmailActionType.deletePermanently: diff --git a/lib/features/thread_detail/presentation/thread_detail_controller.dart b/lib/features/thread_detail/presentation/thread_detail_controller.dart index 9468fab0ee..b47c5afc2b 100644 --- a/lib/features/thread_detail/presentation/thread_detail_controller.dart +++ b/lib/features/thread_detail/presentation/thread_detail_controller.dart @@ -48,6 +48,7 @@ import 'package:tmail_ui_user/features/thread_detail/presentation/extension/hand import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mark_multiple_emails_read_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_thread_action_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/mark_collapsed_email_unread_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/quick_create_rule_from_collapsed_email_success.dart'; @@ -227,6 +228,7 @@ class ThreadDetailController extends BaseController { void reset() { emailIdsPresentation.clear(); + emailsInThreadDetailInfo.clear(); scrollController?.dispose(); scrollController = null; currentExpandedEmailId.value = null; @@ -259,6 +261,9 @@ class ThreadDetailController extends BaseController { ); } else if (success is CreateNewRuleFilterSuccess) { quickCreateRuleFromCollapsedEmailSuccess(success); + } else if (success is MarkAsStarMultipleEmailAllSuccess || + success is MarkAsStarMultipleEmailHasSomeEmailFailure) { + handleMarkThreadAsStarredSuccess(success); } else { super.handleSuccessViewState(success); } diff --git a/model/lib/extensions/presentation_email_extension.dart b/model/lib/extensions/presentation_email_extension.dart index 404bc245b4..7ad9b0ea10 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -204,11 +204,6 @@ extension PresentationEmailExtension on PresentationEmail { MailboxId? get firstMailboxIdAvailable => mailboxIds?.entries.firstWhereOrNull((element) => element.value)?.key; - void resyncKeywords(Map newKeywords) { - keywords?.addAll(newKeywords); - keywords?.removeWhere((key, value) => !value); - } - bool get isDeletePermanentlyEnabled { return mailboxContain?.isTrash ?? mailboxContain?.isSpam ?? false; } diff --git a/test/features/email/presentation/extensions/email_extension_test.dart b/test/features/email/presentation/extensions/email_extension_test.dart index 7c6891c3c2..0d9206445f 100644 --- a/test/features/email/presentation/extensions/email_extension_test.dart +++ b/test/features/email/presentation/extensions/email_extension_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_header.dart'; import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/email_property.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; @@ -276,4 +277,93 @@ void main() { expect(email.sMimeStatus, SMimeSignatureStatus.notSigned); }); }); + + group('EmailExtension.toggleKeyword', () { + final keyword = KeyWordIdentifier.emailSeen; + + test( + 'Email(keywords: null).toggleKeyword(x, false) ' + '=> keywords contains x:true', + () { + final email = Email(keywords: null); + + final result = email.toggleKeyword(keyword: keyword, remove: false); + + expect(result.keywords, isNotNull); + expect(result.keywords!.length, 1); + expect(result.keywords![keyword], true); + }, + ); + + test( + 'Email(keywords: {x:true}).toggleKeyword(x, true) ' + '=> keywords does not contain x', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword: keyword, remove: true); + + expect(result.keywords, isNotNull); + expect(result.keywords!.containsKey(keyword), false); + }, + ); + + test( + 'Idempotency: remove keyword when already absent ' + '=> keywords unchanged', + () { + final email = Email( + keywords: { + KeyWordIdentifier.emailFlagged: true, + }, + ); + + final result = email.toggleKeyword(keyword: keyword, remove: true); + + expect(result.keywords!.length, 1); + expect( + result.keywords!.containsKey(KeyWordIdentifier.emailFlagged), + true, + ); + expect(result.keywords!.containsKey(keyword), false); + }, + ); + + test( + 'Idempotency: add keyword when already present ' + '=> keyword remains true, no duplication', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword: keyword, remove: false); + + expect(result.keywords!.length, 1); + expect(result.keywords![keyword], true); + }, + ); + + test( + 'toggleKeyword returns new Email instance ' + '(immutability)', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword: keyword, remove: true); + + expect(identical(email, result), false); + }, + ); + }); } diff --git a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart new file mode 100644 index 0000000000..546eff254a --- /dev/null +++ b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; + +void main() { + group('MapKeywordsExtension - withKeyword()', () { + test('adds keyword to empty map', () { + final map = {}; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withKeyword(keyword); + + expect(result.length, 1); + expect(result[keyword], true); + + // Ensure original map unchanged + expect(map.containsKey(keyword), false); + }); + + test('adds keyword to non-empty map', () { + final map = { + KeyWordIdentifier("\$flagged"): true, + }; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withKeyword(keyword); + + expect(result.length, 2); + expect(result[keyword], true); + }); + + test('overwrites existing keyword value to true', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: false, + }; + + final result = map.withKeyword(keyword); + + expect(result.length, 1); + expect(result[keyword], true); + expect(map[keyword], false); // original map not mutated + }); + + test('does not mutate original map', () { + final keyword = KeyWordIdentifier("\$flagged"); + final map = { + keyword: false, + }; + + final result = map.withKeyword(keyword); + + expect(result[keyword], true); + expect(map[keyword], false); + }); + }); + + group('MapKeywordsExtension - withoutKeyword()', () { + test('removes existing keyword', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: true, + KeyWordIdentifier("\$flagged"): false, + }; + + final result = map.withoutKeyword(keyword); + + expect(result.containsKey(keyword), false); + expect(result.length, 1); + }); + + test('removing non-existing keyword keeps map unchanged', () { + final map = { + KeyWordIdentifier("\$flagged"): true, + }; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withoutKeyword(keyword); + + expect(result.length, 1); + expect(result, map); // equal content + expect(identical(result, map), false); // but not the same instance + }); + + test('remove keyword from empty map returns empty map', () { + final map = {}; + final keyword = KeyWordIdentifier("\$junk"); + + final result = map.withoutKeyword(keyword); + + expect(result.isEmpty, true); + }); + + test('does not mutate original map', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: true, + }; + + final result = map.withoutKeyword(keyword); + + expect(result.containsKey(keyword), false); + expect(map.containsKey(keyword), true); // original not mutated + }); + }); + + group('MapKeywordsExtension – nullable receiver', () { + final keyword = KeyWordIdentifier.emailSeen; + + test( + 'null.withKeyword(x) returns new map containing x:true', + () { + Map? keywords; + + final result = keywords.withKeyword(keyword); + + expect(result, isNotNull); + expect(result.length, 1); + expect(result[keyword], true); + }, + ); + + test( + 'null.withoutKeyword(x) returns empty map (no crash)', + () { + Map? keywords; + + final result = keywords.withoutKeyword(keyword); + + expect(result, isNotNull); + expect(result.isEmpty, true); + }, + ); + + test( + 'null.withoutKeyword(x) is idempotent', + () { + Map? keywords; + + final result1 = keywords.withoutKeyword(keyword); + final result2 = result1.withoutKeyword(keyword); + + expect(result2.isEmpty, true); + }, + ); + + test( + 'null.withKeyword(x) followed by withoutKeyword(x) ' + 'returns empty map', + () { + Map? keywords; + + final result = keywords.withKeyword(keyword).withoutKeyword(keyword); + + expect(result.isEmpty, true); + }, + ); + }); +}