diff --git a/lib/features/email/presentation/action/email_ui_action.dart b/lib/features/email/presentation/action/email_ui_action.dart index 6904ef2fd..5771a3349 100644 --- a/lib/features/email/presentation/action/email_ui_action.dart +++ b/lib/features/email/presentation/action/email_ui_action.dart @@ -1,5 +1,6 @@ import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; @@ -58,4 +59,23 @@ class RefreshThreadDetailAction extends EmailUIAction { @override List get props => [emailChangeResponse]; +} + +class DisposePreviousExpandedEmailAction extends EmailUIAction { + DisposePreviousExpandedEmailAction(this.emailId); + + final EmailId emailId; + + @override + List get props => [emailId]; +} + +class UnsubscribeFromThreadAction extends EmailUIAction { + UnsubscribeFromThreadAction(this.emailId, this.listUnsubscribe); + + final EmailId emailId; + final String listUnsubscribe; + + @override + List get props => [emailId, listUnsubscribe]; } \ No newline at end of file diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 2d53fb675..15227e698 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -123,6 +123,7 @@ import 'package:tmail_ui_user/features/manage_account/domain/usecases/create_new import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_all_identities_interactor.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/extensions/datetime_extension.dart'; import 'package:tmail_ui_user/features/search/email/presentation/search_email_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/action/thread_detail_ui_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/mark_collapsed_email_unread_success.dart'; @@ -397,6 +398,12 @@ class SingleEmailController extends BaseController with AppLoaderMixin { action.emailActionType, action.presentationEmail, ); + } else if (action is DisposePreviousExpandedEmailAction) { + if (_currentEmailId == null || _currentEmailId != action.emailId) return; + for (var worker in obxListeners) { + worker.dispose(); + } + Get.delete(tag: action.emailId.id.value); } else if (action is CloseEmailInThreadDetailAction) { if (_currentEmailId == null) return; closeEmailView(context: currentContext); @@ -404,6 +411,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { worker.dispose(); } Get.delete(tag: _currentEmailId!.id.value); + } else if (action is UnsubscribeFromThreadAction) { + if (_currentEmailId == null || action.emailId != _currentEmailId) return; + _handleUnsubscribe(action.listUnsubscribe); } })); @@ -662,6 +672,14 @@ class SingleEmailController extends BaseController with AppLoaderMixin { emailUnsubscribe.value = null; } } + if (currentEmail?.threadId != null && + currentEmail?.id == mailboxDashBoardController.selectedEmail.value?.id) { + mailboxDashBoardController.dispatchThreadDetailUIAction( + LoadThreadDetailAfterSelectedEmailAction( + currentEmail!.threadId!, + ) + ); + } } void _getEmailContentSuccess(GetEmailContentSuccess success) { @@ -727,6 +745,14 @@ class SingleEmailController extends BaseController with AppLoaderMixin { if ((_threadDetailController?.emailIdsPresentation.keys.length ?? 0) > 1 == true) { _jumpScrollViewToTopOfEmail(); } + if (currentEmail?.threadId != null && + currentEmail?.id == mailboxDashBoardController.selectedEmail.value?.id) { + mailboxDashBoardController.dispatchThreadDetailUIAction( + LoadThreadDetailAfterSelectedEmailAction( + currentEmail!.threadId!, + ) + ); + } } void _jumpScrollViewToTopOfEmail() { diff --git a/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart b/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart index 6cbac2748..041b55f24 100644 --- a/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart +++ b/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart @@ -24,6 +24,10 @@ class GetEmailsByIdsSuccess extends UIState { List get props => [presentationEmails, updateCurrentThreadDetail]; } +class PreloadEmailsByIdsSuccess extends GetEmailsByIdsSuccess { + PreloadEmailsByIdsSuccess(super.presentationEmails); +} + class GetEmailsByIdsFailure extends FeatureFailure { GetEmailsByIdsFailure({ super.exception, diff --git a/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart b/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart index 857196f0a..6903c0475 100644 --- a/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart +++ b/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart @@ -21,6 +21,10 @@ class GetThreadByIdSuccess extends UIState { List get props => [emailIds, updateCurrentThreadDetail]; } +class PreloadEmailIdsInThreadSuccess extends GetThreadByIdSuccess { + PreloadEmailIdsInThreadSuccess(super.emailIds); +} + class GetThreadByIdFailure extends FeatureFailure { GetThreadByIdFailure({ super.exception, diff --git a/lib/features/thread_detail/presentation/action/thread_detail_ui_action.dart b/lib/features/thread_detail/presentation/action/thread_detail_ui_action.dart index 668a0f624..43b233d03 100644 --- a/lib/features/thread_detail/presentation/action/thread_detail_ui_action.dart +++ b/lib/features/thread_detail/presentation/action/thread_detail_ui_action.dart @@ -38,4 +38,13 @@ class EmailMovedAction extends ThreadDetailUIAction { @override List get props => [emailId, originalMailboxId, targetMailboxId]; +} + +class LoadThreadDetailAfterSelectedEmailAction extends ThreadDetailUIAction { + LoadThreadDetailAfterSelectedEmailAction(this.threadId); + + final ThreadId threadId; + + @override + List get props => [threadId]; } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart b/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart index fcbfc44ec..640553726 100644 --- a/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart +++ b/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart @@ -1,4 +1,3 @@ -import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; @@ -6,31 +5,30 @@ extension HandleGetEmailIdsByThreadIdSuccess on ThreadDetailController { void handleGetEmailIdsByThreadIdSuccess( GetThreadByIdSuccess success, ) { - final newEmailsInThreadDetail = []; - if (success.emailIds.isNotEmpty) { - if (success.updateCurrentThreadDetail) { - newEmailsInThreadDetail.addAll(success - .emailIds - .where( - (emailId) => !emailIdsPresentation.keys.contains(emailId), - ) - ); - emailIdsPresentation - ..removeWhere( - (key, value) => !success.emailIds.contains(key), - ) - ..addAll(Map.fromEntries( - newEmailsInThreadDetail.map((emailId) => MapEntry(emailId, null)), - )); - } else { - emailIdsPresentation.value = Map.fromEntries(success.emailIds.map( - (emailId) => MapEntry(emailId, null), - )); - } - } else if (mailboxDashBoardController.selectedEmail.value?.id != null) { - emailIdsPresentation.value = { - mailboxDashBoardController.selectedEmail.value!.id!: null, - }; + if (success.emailIds.isEmpty) { + return; } + + final allEmailIds = success.emailIds; + if (success.updateCurrentThreadDetail) { + final newEmailIds = allEmailIds.where( + (emailId) => !emailIdsPresentation.keys.contains(emailId), + ); + emailIdsPresentation + ..removeWhere((key, _) => !allEmailIds.contains(key)) + ..addEntries( + newEmailIds.map((emailId) => MapEntry(emailId, null)), + ); + return; + } + + final selectedEmail = mailboxDashBoardController.selectedEmail.value; + final selectedEmailId = selectedEmail?.id; + emailIdsPresentation.value = { + for (final id in allEmailIds) + id: id == selectedEmailId + ? emailIdsPresentation[id] ?? selectedEmail + : null, + }; } } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart b/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart index 29c33d6ad..318a0b2b6 100644 --- a/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart +++ b/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart @@ -38,7 +38,7 @@ extension HandleGetEmailsByIdsSuccess on ThreadDetailController { ); } - if (!isLoadMore) return; + if (_skipScrollJump(isLoadMore)) return; final currentExpandedEmailIndex = currentExpandedEmailId.value == null ? -1 @@ -72,4 +72,7 @@ extension HandleGetEmailsByIdsSuccess on ThreadDetailController { }); } } + + bool _skipScrollJump(bool isLoadMore) => + !isLoadMore || emailIdsPresentation.length == 1; } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart b/lib/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart index ead2226db..e619c8af3 100644 --- a/lib/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart +++ b/lib/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart @@ -1,9 +1,18 @@ +import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/email_in_thread_status.dart'; +import 'package:model/extensions/email_extension.dart'; +import 'package:model/extensions/keyword_identifier_extension.dart'; import 'package:model/extensions/list_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; extension HandleRefreshThreadDetailAction on ThreadDetailController { void handleRefreshThreadDetailAction( @@ -30,33 +39,58 @@ extension HandleRefreshThreadDetailAction on ThreadDetailController { } final currentThreadId = mailboxDashBoardController.selectedEmail.value?.threadId; - final threadIdsEmailCreated = action - .emailChangeResponse - .created - ?.listThreadIds ?? []; - final threadIdsEmailUpdated = action - .emailChangeResponse - .updated - ?.listThreadIds ?? []; - final updatedThreadIds = threadIdsEmailCreated + threadIdsEmailUpdated; - final destroyedEmailIds = action.emailChangeResponse.destroyed ?? []; + if (currentThreadId == null) return; + final emailIdsDestroyed = Set.from( + action.emailChangeResponse.destroyed ?? const []); - if (session != null && - accountId != null && - sentMailboxId != null && - ownEmailAddress != null && - currentThreadId != null && ( - updatedThreadIds.contains(currentThreadId) || - emailIdsPresentation.keys.any(destroyedEmailIds.contains))) { - consumeState(getThreadByIdInteractor.execute( - currentThreadId, - session!, - accountId!, - sentMailboxId!, - ownEmailAddress!, - updateCurrentThreadDetail: true, - selectedEmailId: mailboxDashBoardController.selectedEmail.value?.id, - )); + final emailsCreated = action.emailChangeResponse.created + ?.where((email) => email.threadId == currentThreadId) + .map((email) => email.toPresentationEmail().copyWith( + emailInThreadStatus: EmailInThreadStatus.collapsed, + )) + .toList(growable: false) ?? const []; + + final emailsUpdated = action.emailChangeResponse.updated + ?.where((email) => email.threadId == currentThreadId) + .map((email) => email.toPresentationEmail()) + .toList(growable: false) ?? const []; + + final afterRefreshedEmailIds = ThreadDetailPresentationUtils.refreshEmailIds( + original: emailIdsPresentation.keys.toList(), + created: emailsCreated.map((email) => email.id).whereNotNull().toList(), + destroyed: emailIdsDestroyed.toList(), + ); + final afterRefreshedEmails = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: emailIdsPresentation.values.whereNotNull().toList(), + created: emailsCreated, + updated: emailsUpdated, + destroyed: emailIdsDestroyed.toList(), + ); + + if (afterRefreshedEmailIds.isEmpty) { + closeThreadDetailAction(currentContext); + return; + } + + consumeState(Stream.value(Right(GetThreadByIdSuccess( + afterRefreshedEmailIds, + updateCurrentThreadDetail: true, + )))); + consumeState(Stream.value(Right(GetEmailsByIdsSuccess( + afterRefreshedEmails, + updateCurrentThreadDetail: true, + )))); + + final updatedExpandedEmail = afterRefreshedEmails.firstWhereOrNull( + (email) => email.id == currentExpandedEmailId.value, + ); + if (updatedExpandedEmail?.keywords?[KeyWordIdentifierExtension.unsubscribeMail] == true) { + mailboxDashBoardController.dispatchEmailUIAction( + UnsubscribeFromThreadAction( + currentExpandedEmailId.value!, + updatedExpandedEmail?.listUnsubscribe ?? '', + ), + ); } } } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart b/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart index 7b2dda054..0fe9bc6c3 100644 --- a/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart +++ b/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart @@ -9,34 +9,18 @@ import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_d extension InitializeThreadDetailEmails on ThreadDetailController { void initializeThreadDetailEmails(GetThreadByIdSuccess success) { - final threadDetailEnabled = isThreadDetailEnabled; - final selectedEmail = mailboxDashBoardController.selectedEmail.value; - if (!threadDetailEnabled && - selectedEmail != null && - !success.updateCurrentThreadDetail) { - consumeState(Stream.value(Right(GetEmailsByIdsSuccess([selectedEmail])))); - return; - } - - final existingEmailIds = emailIdsPresentation.keys.toList(); final selectedEmailId = mailboxDashBoardController.selectedEmail.value?.id; + if (skipLoadThreadMetaData( + selectedEmailId: selectedEmailId, + updateCurrentThreadDetail: success.updateCurrentThreadDetail, + )) return; List emailIdsToLoadMetaData = []; - if (success.updateCurrentThreadDetail) { - final nonNullEmailIds = emailIdsPresentation.entries - .where((entry) => entry.value != null) - .map((entry) => entry.key) - .toList(); - final newEmailIds = success.emailIds.where( - (emailId) => !existingEmailIds.contains(emailId), - ); - emailIdsToLoadMetaData = [...nonNullEmailIds, ...newEmailIds]; - } else { - emailIdsToLoadMetaData = ThreadDetailPresentationUtils.getFirstLoadEmailIds( - existingEmailIds, - selectedEmailId: selectedEmailId, - ); - } + emailIdsToLoadMetaData = ThreadDetailPresentationUtils.getFirstLoadEmailIds( + emailIdsPresentation.keys.toList(), + selectedEmailId: selectedEmailId, + ); + emailIdsToLoadMetaData.remove(selectedEmailId); if (accountId == null || session == null) { consumeState(Stream.value(Left(GetEmailsByIdsFailure( @@ -45,6 +29,8 @@ extension InitializeThreadDetailEmails on ThreadDetailController { )))); return; } + if (_currentThreadOnlyContainsSelectedEmail(selectedEmailId)) return; + consumeState(getEmailsByIdsInteractor.execute( session!, accountId!, @@ -56,4 +42,19 @@ extension InitializeThreadDetailEmails on ThreadDetailController { updateCurrentThreadDetail: success.updateCurrentThreadDetail, )); } + + bool _currentThreadOnlyContainsSelectedEmail(EmailId? selectedEmailId) { + return selectedEmailId != null && + emailIdsPresentation.length == 1 && + emailIdsPresentation.keys.contains(selectedEmailId); + } + + bool skipLoadThreadMetaData({ + EmailId? selectedEmailId, + bool updateCurrentThreadDetail = false, + }) { + return selectedEmailId == null || + !isThreadDetailEnabled || + updateCurrentThreadDetail; + } } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart b/lib/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart new file mode 100644 index 000000000..029d2bd31 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart @@ -0,0 +1,39 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter/widgets.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension ThreadDetailOnSelectedEmailUpdated on ThreadDetailController { + void onSelectedEmailUpdated( + PresentationEmail? selectedEmail, + GetThreadByIdInteractor getThreadByIdInteractor, + BuildContext? context, + ) { + if (selectedEmail?.id == null) { + closeThreadDetailAction(context); + return; + } + + if (currentExpandedEmailId.value != null) { + mailboxDashBoardController.dispatchEmailUIAction( + DisposePreviousExpandedEmailAction( + currentExpandedEmailId.value!, + ), + ); + } + + _preloadSelectedEmail(selectedEmail!); + } + + void _preloadSelectedEmail(PresentationEmail selectedEmail) { + consumeState(Stream.fromIterable([ + Right(PreloadEmailIdsInThreadSuccess([selectedEmail.id!])), + Right(PreloadEmailsByIdsSuccess([selectedEmail])), + ])); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/thread_detail_controller.dart b/lib/features/thread_detail/presentation/thread_detail_controller.dart index 817eb2dc5..2c9734c3f 100644 --- a/lib/features/thread_detail/presentation/thread_detail_controller.dart +++ b/lib/features/thread_detail/presentation/thread_detail_controller.dart @@ -41,7 +41,6 @@ import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_ import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/action/thread_detail_ui_action.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_detail_status_interactor.dart'; -import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_email_moved_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart'; @@ -49,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_refresh_thread_detail_action.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/refresh_thread_detail_on_setting_changed.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/model/thread_detail_setting_status.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -146,32 +146,11 @@ class ThreadDetailController extends BaseController { }); downloadProgressState.stream.listen(handleDownloadProgressState); ever(mailboxDashBoardController.selectedEmail, (presentationEmail) async { - if (presentationEmail?.threadId == null) { - closeThreadDetailAction(currentContext); - return; - } - - if (!isThreadDetailEnabled && presentationEmail?.id != null) { - consumeState(Stream.value(Right(GetThreadByIdSuccess([ - presentationEmail!.id!, - ])))); - return; - } - - if (session != null && - accountId != null && - sentMailboxId != null && - ownEmailAddress != null) { - scrollController = ScrollController(); - consumeState(_getEmailIdsByThreadIdInteractor.execute( - presentationEmail!.threadId!, - session!, - accountId!, - sentMailboxId!, - ownEmailAddress!, - selectedEmailId: presentationEmail.id, - )); - } + onSelectedEmailUpdated( + presentationEmail, + _getEmailIdsByThreadIdInteractor, + currentContext, + ); }); ever(mailboxDashBoardController.threadDetailUIAction, (action) { if (action is UpdatedEmailKeywordsAction) { @@ -191,6 +170,18 @@ class ThreadDetailController extends BaseController { consumeState(_getThreadDetailStatusInteractor.execute()); } else if (action is EmailMovedAction) { handleEmailMovedAction(action); + } else if (action is LoadThreadDetailAfterSelectedEmailAction) { + if (_validateLoadThread(action)) { + scrollController = ScrollController(); + consumeState(_getEmailIdsByThreadIdInteractor.execute( + action.threadId, + session!, + accountId!, + sentMailboxId!, + ownEmailAddress!, + selectedEmailId: mailboxDashBoardController.selectedEmail.value?.id, + )); + } } // Reset [threadDetailUIAction] to original value mailboxDashBoardController.dispatchThreadDetailUIAction( @@ -208,6 +199,16 @@ class ThreadDetailController extends BaseController { }); } + bool _validateLoadThread(LoadThreadDetailAfterSelectedEmailAction action) { + return mailboxDashBoardController.selectedEmail.value?.threadId != null && + action.threadId == mailboxDashBoardController.selectedEmail.value?.threadId && + session != null && + accountId != null && + sentMailboxId != null && + ownEmailAddress != null && + isThreadDetailEnabled; + } + void reset() { emailIdsPresentation.clear(); scrollController?.dispose(); diff --git a/lib/features/thread_detail/presentation/thread_detail_view.dart b/lib/features/thread_detail/presentation/thread_detail_view.dart index 6ccb317ec..38835edac 100644 --- a/lib/features/thread_detail/presentation/thread_detail_view.dart +++ b/lib/features/thread_detail/presentation/thread_detail_view.dart @@ -164,6 +164,7 @@ class ThreadDetailView extends GetWidget { return Obx(() { if (showLoadingView(controller.viewState.value) && + controller.emailIdsPresentation.isEmpty && controller.responsiveUtils.isTabletLarge(context)) { return controller.getThreadDetailLoadingView( isResponsiveDesktop: false, diff --git a/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart b/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart index 8adf35599..59000fd2f 100644 --- a/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart +++ b/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart @@ -1,7 +1,9 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/presentation_email.dart'; class ThreadDetailPresentationUtils { const ThreadDetailPresentationUtils._(); @@ -54,4 +56,38 @@ class ThreadDetailPresentationUtils { emailIds.length, ); } + + static List refreshEmailIds({ + required List original, + required List created, + required List destroyed, + }) { + return [ + ...original.whereNot(destroyed.contains), + ...created, + ]; + } + + static List refreshPresentationEmails({ + required List original, + required List created, + required List updated, + required List destroyed, + }) { + return [ + ...original + .whereNot((email) => destroyed.contains(email.id)) + .map((email) { + final updatedInOriginal = updated.firstWhereOrNull( + (updatedEmail) => updatedEmail.id == email.id, + ); + + return email.copyWith( + keywords: updatedInOriginal?.keywords, + mailboxIds: updatedInOriginal?.mailboxIds, + ); + }), + ...created, + ]; + } } \ No newline at end of file diff --git a/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart b/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart index 703422d85..a4939fc85 100644 --- a/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart +++ b/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart @@ -4,6 +4,7 @@ import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:model/email/email_in_thread_status.dart'; import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; @@ -18,55 +19,155 @@ import 'handle_get_email_ids_by_thread_id_success_test.mocks.dart'; ]) void main() { final threadDetailController = MockThreadDetailController(); + final mailboxDashBoardController = MockMailboxDashBoardController(); group('handle get email ids by thread id success test:', () { test( - 'should assign emailIds with result from success ' - 'when result from success is not empty', + 'should not change emailIdsPresentation ' + 'when GetThreadByIdSuccess.success.emailIds is empty', () { // arrange - final success = GetThreadByIdSuccess([ - EmailId(Id('1')), - EmailId(Id('2')), - ]); - final mailboxDashBoardController = MockMailboxDashBoardController(); - when(threadDetailController.emailIdsPresentation).thenReturn( - {}.obs, - ); - when(mailboxDashBoardController.selectedEmail).thenReturn( - Rxn(PresentationEmail(id: EmailId(Id('1')))), - ); - when(threadDetailController.mailboxDashBoardController).thenReturn(mailboxDashBoardController); + final initialEmailIdsPresentation = { + EmailId(Id('1')): PresentationEmail(), + }; + when(threadDetailController.emailIdsPresentation) + .thenReturn(initialEmailIdsPresentation.obs); // act - threadDetailController.handleGetEmailIdsByThreadIdSuccess(success); - - // assert - expect(threadDetailController.emailIdsPresentation.keys, success.emailIds); - }); - - test( - 'should assign emailIds with result from mailbox dashboard controller ' - 'when result from success is empty ' - 'and mailbox dashboard controller selected email is not null', - () { - // arrange - final mailboxDashBoardController = MockMailboxDashBoardController(); - final success = GetThreadByIdSuccess([]); - when(mailboxDashBoardController.selectedEmail).thenReturn( - Rxn(PresentationEmail(id: EmailId(Id('1')))), + threadDetailController.handleGetEmailIdsByThreadIdSuccess( + GetThreadByIdSuccess([]), ); - when(threadDetailController.emailIdsPresentation).thenReturn( - {}.obs, - ); - when(threadDetailController.mailboxDashBoardController).thenReturn(mailboxDashBoardController); - - // act - threadDetailController.handleGetEmailIdsByThreadIdSuccess(success); // assert expect( - threadDetailController.emailIdsPresentation.keys, - [mailboxDashBoardController.selectedEmail.value!.id!], + threadDetailController.emailIdsPresentation, + initialEmailIdsPresentation, + ); + }); + + test( + 'should remove email id from emailIdsPresentation ' + 'which is not in GetThreadByIdSuccess.success.emailIds ' + 'and add new email ids from GetThreadByIdSuccess.success.emailIds ' + 'when GetThreadByIdSuccess.success.emailIds is not empty ' + 'and updateCurrentThreadDetail is true', + () { + // arrange + final initialEmailIdsPresentation = { + EmailId(Id('1')): PresentationEmail(), + EmailId(Id('2')): null, + }; + when(threadDetailController.emailIdsPresentation) + .thenReturn(initialEmailIdsPresentation.obs); + + // act + threadDetailController.handleGetEmailIdsByThreadIdSuccess( + GetThreadByIdSuccess( + [ + EmailId(Id('1')), + EmailId(Id('3')), + EmailId(Id('4')), + ], + updateCurrentThreadDetail: true, + ), + ); + + // assert + expect( + threadDetailController.emailIdsPresentation, + { + EmailId(Id('1')): PresentationEmail(), + EmailId(Id('3')): null, + EmailId(Id('4')): null, + }, + ); + }); + + test( + 'should add all email ids from GetThreadByIdSuccess.success.emailIds ' + 'except selected email id ' + 'and keep selected email id in emailIdsPresentation ' + 'when GetThreadByIdSuccess.success.emailIds is not empty ' + 'and updateCurrentThreadDetail is false ' + 'and selected email is available in emailIdsPresentation', + () { + // arrange + final existedSelectedEmailId = PresentationEmail( + id: EmailId(Id('1')), + emailInThreadStatus: EmailInThreadStatus.expanded, + ); + final initialEmailIdsPresentation = { + EmailId(Id('1')): existedSelectedEmailId, + }; + when(threadDetailController.emailIdsPresentation) + .thenReturn(initialEmailIdsPresentation.obs); + when(threadDetailController.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail) + .thenReturn(Rxn(PresentationEmail(id: EmailId(Id('1'))))); + + // act + threadDetailController.handleGetEmailIdsByThreadIdSuccess( + GetThreadByIdSuccess( + [ + EmailId(Id('1')), + EmailId(Id('3')), + EmailId(Id('4')), + ], + updateCurrentThreadDetail: false, + ), + ); + + // assert + expect( + threadDetailController.emailIdsPresentation, + { + EmailId(Id('1')): existedSelectedEmailId, + EmailId(Id('3')): null, + EmailId(Id('4')): null, + }, + ); + }); + + test( + 'should add all email ids from GetThreadByIdSuccess.success.emailIds ' + 'except selected email id ' + 'and take selected email from mailbox dashboard controller ' + 'when GetThreadByIdSuccess.success.emailIds is not empty ' + 'and updateCurrentThreadDetail is false ' + 'and selected email is not available in emailIdsPresentation', + () { + // arrange + final selectedEmailId = PresentationEmail(id: EmailId(Id('1'))); + final initialEmailIdsPresentation = { + EmailId(Id('1')): null, + }; + when(threadDetailController.emailIdsPresentation) + .thenReturn(initialEmailIdsPresentation.obs); + when(threadDetailController.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail) + .thenReturn(Rxn(PresentationEmail(id: EmailId(Id('1'))))); + + // act + threadDetailController.handleGetEmailIdsByThreadIdSuccess( + GetThreadByIdSuccess( + [ + EmailId(Id('1')), + EmailId(Id('3')), + EmailId(Id('4')), + ], + updateCurrentThreadDetail: false, + ), + ); + + // assert + expect( + threadDetailController.emailIdsPresentation, + { + EmailId(Id('1')): selectedEmailId, + EmailId(Id('3')): null, + EmailId(Id('4')): null, + }, ); }); }); diff --git a/test/features/thread_detail/presentation/extension/handle_get_thread_by_id_failure_test.dart b/test/features/thread_detail/presentation/extension/handle_get_thread_by_id_failure_test.dart new file mode 100644 index 000000000..fec906ad8 --- /dev/null +++ b/test/features/thread_detail/presentation/extension/handle_get_thread_by_id_failure_test.dart @@ -0,0 +1,90 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get_rx/get_rx.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_thread_by_id_failure.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +import 'handle_get_thread_by_id_failure_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockThreadDetailController controller; + late MockMailboxDashBoardController mailboxDashBoardController; + + setUp(() { + controller = MockThreadDetailController(); + mailboxDashBoardController = MockMailboxDashBoardController(); + }); + + group('handle get thread by id failure test', () { + test( + 'should not call consumeState ' + 'when updateCurrentThreadDetail is true', + () { + // arrange + final failure = GetThreadByIdFailure(updateCurrentThreadDetail: true); + + // act + controller.handleGetThreadByIdFailure(failure); + + // assert + verifyNever(controller.consumeState(any)); + }); + + test( + 'should consumeState GetEmailsByIdsSuccess with selected email ' + 'when updateCurrentThreadDetail is false ' + 'and selected email is not null', + () { + // arrange + final failure = GetThreadByIdFailure(updateCurrentThreadDetail: false); + final selectedEmail = PresentationEmail(id: EmailId(Id('1'))); + when(controller.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail) + .thenReturn(Rxn(selectedEmail)); + + // act + controller.handleGetThreadByIdFailure(failure); + + // assert + final streamsConsumed = (verify( + controller.consumeState(captureAny), + ).captured as List).first as Stream?; + expect( + streamsConsumed, + emitsInOrder([ + Right(GetEmailsByIdsSuccess([selectedEmail])), + ]), + ); + }); + + test( + 'should show retry toast ' + 'when updateCurrentThreadDetail is false ' + 'and selected email is null', + () { + // arrange + final failure = GetThreadByIdFailure(updateCurrentThreadDetail: false); + when(controller.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail).thenReturn(Rxn()); + + // act + controller.handleGetThreadByIdFailure(failure); + + // assert + verify(controller.showRetryToast(failure)); + verifyNever(controller.consumeState(any)); + }); + }); +} \ No newline at end of file diff --git a/test/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action_test.dart b/test/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action_test.dart index 23b2256a6..7f3239858 100644 --- a/test/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action_test.dart +++ b/test/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action_test.dart @@ -1,24 +1,20 @@ -import 'package:core/presentation/state/success.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; -import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:model/email/presentation_email.dart'; -import 'package:model/extensions/session_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/thread/data/model/email_change_response.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.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/thread_detail_controller.dart'; -import '../../../../fixtures/account_fixtures.dart'; -import '../../../../fixtures/session_fixtures.dart'; import 'handle_refresh_thread_detail_action_test.mocks.dart'; @GenerateNiceMocks([ @@ -31,9 +27,6 @@ void main() { late MockMailboxDashBoardController mailboxDashBoardController; late MockGetThreadByIdInteractor getThreadByIdInteractor; - final sentMailboxId = MailboxId(Id('sent')); - final ownEmailAddress = SessionFixtures.aliceSession.getOwnEmailAddress(); - setUp(() { threadDetailController = MockThreadDetailController(); mailboxDashBoardController = MockMailboxDashBoardController(); @@ -42,275 +35,93 @@ void main() { group('handle refresh thread detail action test', () { test( - 'should call getThreadByIdInteractor.execute ' - 'when refreshThreadDetailAction is called ' - 'and list email created contains currentThreadId ' - 'and isThreadDetailEnabled is true', - () { - // arrange - final emailId = EmailId(Id('1')); - final threadId = ThreadId(Id('some-id')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - created: [Email(id: emailId, threadId: threadId)], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail(id: emailId, threadId: threadId))); - when(threadDetailController.session) - .thenReturn(SessionFixtures.aliceSession); - when(threadDetailController.accountId) - .thenReturn(AccountFixtures.aliceAccountId); - when(threadDetailController.sentMailboxId).thenReturn(sentMailboxId); - when(threadDetailController.ownEmailAddress).thenReturn(ownEmailAddress); - when(threadDetailController.isThreadDetailEnabled).thenReturn(true); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verify(getThreadByIdInteractor.execute( - threadId, - SessionFixtures.aliceSession, - AccountFixtures.aliceAccountId, - sentMailboxId, - ownEmailAddress, - updateCurrentThreadDetail: true, - selectedEmailId: emailId, - )).called(1); - }); + 'Should consume GetThreadByIdSuccess when isThreadDetailEnabled is false', + () async { + // arrange + final emailId = EmailId(Id('1')); + final action = RefreshThreadDetailAction(EmailChangeResponse( + updated: [Email(id: emailId)], + )); + when(threadDetailController.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail) + .thenReturn(Rxn(PresentationEmail(id: emailId))); + when(threadDetailController.isThreadDetailEnabled).thenReturn(false); + + // act + threadDetailController.handleRefreshThreadDetailAction( + action, + getThreadByIdInteractor, + ); + + // assert + final capturedStates = verify( + threadDetailController.consumeState(captureAny) + ).captured; + + expect(capturedStates, hasLength(1)); + final stateStream = capturedStates.first as Stream; + final state = await stateStream.last; + expect(state, isA()); + expect((state as Right).value, isA()); + }, + ); test( - 'should call getThreadByIdInteractor.execute ' - 'when refreshThreadDetailAction is called ' - 'and list email updated contains currentThreadId ' - 'and isThreadDetailEnabled is true', - () { - // arrange - final emailId = EmailId(Id('1')); - final threadId = ThreadId(Id('some-id')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - updated: [Email(id: emailId, threadId: threadId)], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail(id: emailId, threadId: threadId))); - when(threadDetailController.session) - .thenReturn(SessionFixtures.aliceSession); - when(threadDetailController.accountId) - .thenReturn(AccountFixtures.aliceAccountId); - when(threadDetailController.sentMailboxId).thenReturn(sentMailboxId); - when(threadDetailController.ownEmailAddress).thenReturn(ownEmailAddress); - when(threadDetailController.isThreadDetailEnabled).thenReturn(true); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verify(getThreadByIdInteractor.execute( - threadId, - SessionFixtures.aliceSession, - AccountFixtures.aliceAccountId, - sentMailboxId, - ownEmailAddress, - updateCurrentThreadDetail: true, - selectedEmailId: emailId, - )).called(1); - }); + 'Should consume both success states when isThreadDetailEnabled is true with mixed email changes', + () async { + // arrange + final currentThreadId = ThreadId(Id('current-thread')); + final createdEmailId = EmailId(Id('created-email')); + final updatedEmailId = EmailId(Id('updated-email')); + final destroyedEmailId = EmailId(Id('destroyed-email')); + + final action = RefreshThreadDetailAction(EmailChangeResponse( + created: [Email(id: createdEmailId, threadId: currentThreadId)], + updated: [Email(id: updatedEmailId, threadId: currentThreadId)], + destroyed: [destroyedEmailId], + )); - test( - 'should call getThreadByIdInteractor.execute ' - 'when refreshThreadDetailAction is called ' - 'and list email destroyed contains any of emailId in current thread ' - 'and isThreadDetailEnabled is true', - () { - // arrange - final emailId = EmailId(Id('1')); - final threadId = ThreadId(Id('some-id')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - destroyed: [emailId], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail(id: emailId, threadId: threadId))); - when(threadDetailController.session) - .thenReturn(SessionFixtures.aliceSession); - when(threadDetailController.accountId) - .thenReturn(AccountFixtures.aliceAccountId); - when(threadDetailController.sentMailboxId).thenReturn(sentMailboxId); - when(threadDetailController.ownEmailAddress).thenReturn(ownEmailAddress); - when(threadDetailController.emailIdsPresentation).thenReturn({ - emailId: null - }.obs); - when(threadDetailController.isThreadDetailEnabled).thenReturn(true); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verify(getThreadByIdInteractor.execute( - threadId, - SessionFixtures.aliceSession, - AccountFixtures.aliceAccountId, - sentMailboxId, - ownEmailAddress, - updateCurrentThreadDetail: true, - selectedEmailId: emailId, - )).called(1); - }); + when(threadDetailController.mailboxDashBoardController) + .thenReturn(mailboxDashBoardController); + when(mailboxDashBoardController.selectedEmail) + .thenReturn(Rxn(PresentationEmail(threadId: currentThreadId))); + when(threadDetailController.emailIdsPresentation).thenReturn(RxMap.of({ + destroyedEmailId: PresentationEmail(id: destroyedEmailId), + updatedEmailId: PresentationEmail(id: updatedEmailId), + })); + when(threadDetailController.isThreadDetailEnabled).thenReturn(true); + when(threadDetailController.currentExpandedEmailId).thenReturn(Rxn()); - test( - 'should consume GetThreadByIdSuccess with selected email id ' - 'when refreshThreadDetailAction is called ' - 'and isThreadDetailEnabled is false ' - 'and selected email id not null ' - 'and list email updated contains selected email id', - () async { - // arrange - final emailId = EmailId(Id('1')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - updated: [Email(id: emailId)], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail(id: emailId))); - when(threadDetailController.isThreadDetailEnabled).thenReturn(false); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verifyNever(getThreadByIdInteractor.execute( - any, - any, - any, - any, - any, - updateCurrentThreadDetail: anyNamed('updateCurrentThreadDetail'), - selectedEmailId: anyNamed('selectedEmailId'), - )); - final args = verify( - threadDetailController.consumeState(captureAny), - ).captured.first as Stream; - final state = await args.last; - expect( - state, - Right(GetThreadByIdSuccess( - [emailId], - updateCurrentThreadDetail: true, - )), - ); - }); + // act + threadDetailController.handleRefreshThreadDetailAction( + action, + getThreadByIdInteractor, + ); - test( - 'should not call getThreadByIdInteractor.execute ' - 'and not consume GetThreadByIdSuccess with selected email id' - 'when refreshThreadDetailAction is called ' - 'and list email created does not contain currentThreadId ' - 'and list email updated does not contain currentThreadId ' - 'and list email destroyed does not contain any of emailId in current thread ' - 'and isThreadDetailEnabled is true', - () async { - // arrange - final emailId = EmailId(Id('1')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - created: [ - Email(id: EmailId(Id('2')), threadId: ThreadId(Id('some-id-2'))), - ], - updated: [ - Email(id: EmailId(Id('3')), threadId: ThreadId(Id('some-id-3'))), - ], - destroyed: [ - EmailId(Id('4')), - ], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail())); - when(threadDetailController.session) - .thenReturn(SessionFixtures.aliceSession); - when(threadDetailController.accountId) - .thenReturn(AccountFixtures.aliceAccountId); - when(threadDetailController.sentMailboxId).thenReturn(sentMailboxId); - when(threadDetailController.ownEmailAddress).thenReturn(ownEmailAddress); - when(threadDetailController.emailIdsPresentation).thenReturn({ - emailId: null, - }.obs); - when(threadDetailController.viewState).thenReturn(Rx(Right(UIState.idle))); - when(threadDetailController.isThreadDetailEnabled).thenReturn(true); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verifyNever(getThreadByIdInteractor.execute( - any, - any, - any, - any, - any, - updateCurrentThreadDetail: anyNamed('updateCurrentThreadDetail'), - selectedEmailId: anyNamed('selectedEmailId'), - )); - verifyNever(threadDetailController.consumeState(any)); - }); - - test( - 'should not call getThreadByIdInteractor.execute ' - 'and not consume GetThreadByIdSuccess with selected email id' - 'when refreshThreadDetailAction is called ' - 'and isThreadDetailEnabled is false ' - 'and updated email ids does not contain selected email id', - () async { - // arrange - final emailId = EmailId(Id('1')); - final action = RefreshThreadDetailAction(EmailChangeResponse( - updated: [ - Email(id: EmailId(Id('3')), threadId: ThreadId(Id('some-id-3'))), - ], - )); - when(threadDetailController.mailboxDashBoardController) - .thenReturn(mailboxDashBoardController); - when(mailboxDashBoardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail(id: emailId))); - when(threadDetailController.isThreadDetailEnabled).thenReturn(false); - - // act - threadDetailController.handleRefreshThreadDetailAction( - action, - getThreadByIdInteractor, - ); - - // assert - verifyNever(getThreadByIdInteractor.execute( - any, - any, - any, - any, - any, - updateCurrentThreadDetail: anyNamed('updateCurrentThreadDetail'), - selectedEmailId: anyNamed('selectedEmailId'), - )); - verifyNever(threadDetailController.consumeState(any)); - }); + // assert + final capturedStates = verify( + threadDetailController.consumeState(captureAny) + ).captured; + + expect(capturedStates, hasLength(2)); + + // Verify GetThreadByIdSuccess + final firstState = await (capturedStates.first as Stream).last; + expect(firstState, isA()); + final threadSuccess = (firstState as Right).value as GetThreadByIdSuccess; + expect(threadSuccess.emailIds, containsAll([createdEmailId, updatedEmailId])); + + // Verify GetEmailsByIdsSuccess + final secondState = await (capturedStates[1] as Stream).last; + expect(secondState, isA()); + final emailsSuccess = (secondState as Right).value as GetEmailsByIdsSuccess; + expect(emailsSuccess.presentationEmails, hasLength(2)); + expect(emailsSuccess.presentationEmails.map((e) => e.id), containsAll([ + createdEmailId, + updatedEmailId, + ])); + }, + ); }); -} \ No newline at end of file +} diff --git a/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart b/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart index da3f59de1..37b5a1a02 100644 --- a/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart +++ b/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart @@ -33,7 +33,7 @@ void main() { mailboxDashboardController = MockMailboxDashBoardController(); }); - test('initializeThreadDetailEmails should call getEmailsByIdsInteractor with 3 elements', () async { + test('initializeThreadDetailEmails should call getEmailsByIdsInteractor with 2 elements', () async { // Arrange final emailIds = [ EmailId(Id('email1')), @@ -63,7 +63,7 @@ void main() { when(threadDetailController.mailboxDashBoardController) .thenReturn(mailboxDashboardController); when(mailboxDashboardController.selectedEmail) - .thenReturn(Rxn(PresentationEmail())); + .thenReturn(Rxn(PresentationEmail(id: emailIds[0]))); // Act threadDetailController.initializeThreadDetailEmails( @@ -78,7 +78,7 @@ void main() { properties: captureAnyNamed('properties'), )).captured; - expect((captured[2] as List).length, 3); + expect((captured[2] as List).length, 2); }); }); } diff --git a/test/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated_test.dart b/test/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated_test.dart new file mode 100644 index 000000000..1f6def54b --- /dev/null +++ b/test/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated_test.dart @@ -0,0 +1,94 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get_rx/get_rx.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +import '../../../../fixtures/account_fixtures.dart'; +import '../../../../fixtures/session_fixtures.dart'; +import 'thread_detail_on_selected_email_updated_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + late MockThreadDetailController threadDetailController; + late MockGetThreadByIdInteractor getThreadByIdInteractor; + late MockMailboxDashBoardController mailboxDashboardController; + + setUp(() { + threadDetailController = MockThreadDetailController(); + getThreadByIdInteractor = MockGetThreadByIdInteractor(); + mailboxDashboardController = MockMailboxDashBoardController(); + when(threadDetailController.session) + .thenReturn(SessionFixtures.aliceSession); + when(threadDetailController.accountId) + .thenReturn(AccountFixtures.aliceAccountId); + when(threadDetailController.additionalProperties) + .thenReturn(Properties.empty()); + when(threadDetailController.mailboxDashBoardController) + .thenReturn(mailboxDashboardController); + }); + + group('thread detail on selected email updated test:', () { + test( + 'should reset thread detail controller ' + 'when selected email is null', + () { + // act + threadDetailController.onSelectedEmailUpdated( + null, + getThreadByIdInteractor, + null, + ); + + // assert + verify(threadDetailController.reset()).called(1); + }); + + test( + 'should not reset thread detail controller ' + 'and consume PreloadEmailIdsInThreadSuccess and PreloadEmailsByIdsSuccess ' + 'when selected email and its id is not null', + () async { + // arrange + final selectedEmail = PresentationEmail( + id: EmailId(Id('1')), + threadId: ThreadId(Id('1')), + ); + when(threadDetailController.currentExpandedEmailId).thenReturn(Rxn()); + + // act + threadDetailController.onSelectedEmailUpdated( + selectedEmail, + getThreadByIdInteractor, + null, + ); + + // assert + verifyNever(threadDetailController.reset()); + final streamsConsumed = (verify( + threadDetailController.consumeState(captureAny), + ).captured as List).first as Stream?; + expect( + streamsConsumed, + emitsInOrder([ + Right(PreloadEmailIdsInThreadSuccess([selectedEmail.id!])), + Right(PreloadEmailsByIdsSuccess([selectedEmail])), + ]), + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart b/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart index 7b9382807..0d9b8f234 100644 --- a/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart +++ b/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart @@ -1,6 +1,9 @@ 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:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart'; void main() { @@ -322,5 +325,172 @@ void main() { ); }); }); + + group('refreshEmailIds', () { + test('should return original emails when no created or destroyed', () { + final original = [EmailId(Id('1')), EmailId(Id('2'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: [], + destroyed: [], + ), + original, + ); + }); + + test('should remove destroyed emails from original', () { + final original = [EmailId(Id('1')), EmailId(Id('2')), EmailId(Id('3'))]; + final destroyed = [EmailId(Id('2'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: [], + destroyed: destroyed, + ), + [EmailId(Id('1')), EmailId(Id('3'))], + ); + }); + + test('should add created emails to original', () { + final original = [EmailId(Id('1'))]; + final created = [EmailId(Id('2')), EmailId(Id('3'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: created, + destroyed: [], + ), + [EmailId(Id('1')), EmailId(Id('2')), EmailId(Id('3'))], + ); + }); + + test('should combine created and destroyed operations', () { + final original = [EmailId(Id('1')), EmailId(Id('2')), EmailId(Id('3'))]; + final created = [EmailId(Id('4'))]; + final destroyed = [EmailId(Id('2'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: created, + destroyed: destroyed, + ), + [EmailId(Id('1')), EmailId(Id('3')), EmailId(Id('4'))], + ); + }); + + test('should ignore destroyed emails not present in original', () { + final original = [EmailId(Id('1'))]; + final destroyed = [EmailId(Id('99'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: [], + destroyed: destroyed, + ), + original, + ); + }); + + test('should handle duplicate created emails', () { + final original = [EmailId(Id('1'))]; + final created = [EmailId(Id('2')), EmailId(Id('2'))]; + expect( + ThreadDetailPresentationUtils.refreshEmailIds( + original: original, + created: created, + destroyed: [], + ), + [EmailId(Id('1')), EmailId(Id('2')), EmailId(Id('2'))], + ); + }); + }); + + group('refreshPresentationEmails', () { + final email1 = PresentationEmail(id: EmailId(Id('1')), keywords: {}, mailboxIds: {}); + final email2 = PresentationEmail(id: EmailId(Id('2')), keywords: {}, mailboxIds: {}); + final email3 = PresentationEmail(id: EmailId(Id('3')), keywords: {}, mailboxIds: {}); + + final updatedEmail2 = PresentationEmail( + id: EmailId(Id('2')), + keywords: {KeyWordIdentifier('updated'): true}, + mailboxIds: {MailboxId(Id('mailbox')): true} + ); + + test('should combine non-destroyed originals with updates and created emails', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [email1, email2], + created: [email3], + updated: [updatedEmail2], + destroyed: [EmailId(Id('1'))], + ); + + expect(result, [ + email2.copyWith( + keywords: updatedEmail2.keywords, + mailboxIds: updatedEmail2.mailboxIds, + ), + email3, + ]); + }); + + test('should handle empty destroyed and updated lists', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [email1], + created: [email2], + updated: [], + destroyed: [], + ); + + expect(result, [email1, email2]); + }); + + test('should exclude all destroyed emails', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [email1, email2], + created: [email3], + updated: [], + destroyed: [EmailId(Id('1')), EmailId(Id('2'))], + ); + + expect(result, [email3]); + }); + + test('should apply updates to remaining original emails', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [email1, email2], + created: [], + updated: [updatedEmail2], + destroyed: [], + ); + + expect(result, [ + email1, + updatedEmail2, + ]); + }); + + test('should handle empty original list', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [], + created: [email1, email2], + updated: [], + destroyed: [], + ); + + expect(result, [email1, email2]); + }); + + test('should handle emails being both destroyed and updated', () { + final result = ThreadDetailPresentationUtils.refreshPresentationEmails( + original: [email1], + created: [], + updated: [email1.copyWith(keywords: {KeyWordIdentifier('test'): true})], + destroyed: [EmailId(Id('1'))], + ); + + expect(result, isEmpty); + }); + }); }); }