TF-3804 Thread Detail Load selected email in parallel with thread metadata

This commit is contained in:
DatDang
2025-06-20 10:10:24 +07:00
committed by Dat H. Pham
parent 683d407fba
commit 5fd4986c6a
19 changed files with 863 additions and 421 deletions
@@ -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<Object?> get props => [emailChangeResponse];
}
class DisposePreviousExpandedEmailAction extends EmailUIAction {
DisposePreviousExpandedEmailAction(this.emailId);
final EmailId emailId;
@override
List<Object?> get props => [emailId];
}
class UnsubscribeFromThreadAction extends EmailUIAction {
UnsubscribeFromThreadAction(this.emailId, this.listUnsubscribe);
final EmailId emailId;
final String listUnsubscribe;
@override
List<Object?> get props => [emailId, listUnsubscribe];
}
@@ -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<SingleEmailController>(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<SingleEmailController>(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() {
@@ -24,6 +24,10 @@ class GetEmailsByIdsSuccess extends UIState {
List<Object?> get props => [presentationEmails, updateCurrentThreadDetail];
}
class PreloadEmailsByIdsSuccess extends GetEmailsByIdsSuccess {
PreloadEmailsByIdsSuccess(super.presentationEmails);
}
class GetEmailsByIdsFailure extends FeatureFailure {
GetEmailsByIdsFailure({
super.exception,
@@ -21,6 +21,10 @@ class GetThreadByIdSuccess extends UIState {
List<Object> get props => [emailIds, updateCurrentThreadDetail];
}
class PreloadEmailIdsInThreadSuccess extends GetThreadByIdSuccess {
PreloadEmailIdsInThreadSuccess(super.emailIds);
}
class GetThreadByIdFailure extends FeatureFailure {
GetThreadByIdFailure({
super.exception,
@@ -38,4 +38,13 @@ class EmailMovedAction extends ThreadDetailUIAction {
@override
List<Object?> get props => [emailId, originalMailboxId, targetMailboxId];
}
class LoadThreadDetailAfterSelectedEmailAction extends ThreadDetailUIAction {
LoadThreadDetailAfterSelectedEmailAction(this.threadId);
final ThreadId threadId;
@override
List<Object?> get props => [threadId];
}
@@ -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 = <EmailId>[];
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,
};
}
}
@@ -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;
}
@@ -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<EmailId>.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 ?? '',
),
);
}
}
}
@@ -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<EmailId> 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;
}
}
@@ -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])),
]));
}
}
@@ -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();
@@ -164,6 +164,7 @@ class ThreadDetailView extends GetWidget<ThreadDetailController> {
return Obx(() {
if (showLoadingView(controller.viewState.value) &&
controller.emailIdsPresentation.isEmpty &&
controller.responsiveUtils.isTabletLarge(context)) {
return controller.getThreadDetailLoadingView(
isResponsiveDesktop: false,
@@ -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<EmailId> refreshEmailIds({
required List<EmailId> original,
required List<EmailId> created,
required List<EmailId> destroyed,
}) {
return [
...original.whereNot(destroyed.contains),
...created,
];
}
static List<PresentationEmail> refreshPresentationEmails({
required List<PresentationEmail> original,
required List<PresentationEmail> created,
required List<PresentationEmail> updated,
required List<EmailId> 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,
];
}
}