TF-3334 Use queue to handle multiple refresh changes mailbox from incoming websocket

This commit is contained in:
dab246
2024-12-23 12:24:50 +07:00
committed by Dat H. Pham
parent 6063f4b832
commit dfd622d7ec
19 changed files with 707 additions and 154 deletions
+1
View File
@@ -16,6 +16,7 @@ export 'presentation/extensions/string_extension.dart';
export 'presentation/extensions/tap_down_details_extension.dart';
export 'domain/extensions/media_type_extension.dart';
export 'presentation/extensions/map_extensions.dart';
export 'presentation/extensions/either_view_state_extension.dart';
// Exceptions
export 'domain/exceptions/download_file_exception.dart';
@@ -0,0 +1,12 @@
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:dartz/dartz.dart';
extension EitherViewStateExtension on Either<Failure, Success> {
dynamic foldSuccessWithResult<T>() {
return fold(
(failure) => failure,
(success) => success is T ? success as T : null,
);
}
}
+14 -14
View File
@@ -143,20 +143,7 @@ abstract class BaseController extends GetxController
void onData(Either<Failure, Success> newState) {
viewState.value = newState;
viewState.value.fold(
(failure) {
if (failure is FeatureFailure) {
final isUrgentException = validateUrgentException(failure.exception);
if (isUrgentException) {
handleUrgentException(failure: failure, exception: failure.exception);
} else {
handleFailureViewState(failure);
}
} else {
handleFailureViewState(failure);
}
},
handleSuccessViewState);
viewState.value.fold(onDataFailureViewState, handleSuccessViewState);
}
void onError(dynamic error, StackTrace stackTrace) {
@@ -272,6 +259,19 @@ abstract class BaseController extends GetxController
}
}
void onDataFailureViewState(Failure failure) {
if (failure is FeatureFailure) {
final isUrgentException = validateUrgentException(failure.exception);
if (isUrgentException) {
handleUrgentException(failure: failure, exception: failure.exception);
} else {
handleFailureViewState(failure);
}
} else {
handleFailureViewState(failure);
}
}
void handleFailureViewState(Failure failure) async {
logError('$runtimeType::handleFailureViewState():Failure = $failure');
if (failure is LogoutOidcFailure) {
@@ -104,8 +104,7 @@ abstract class BaseMailboxController extends BaseController {
teamMailboxesTree.value = tupleTree.value3;
}
Future<void> syncAllMailboxWithDisplayName(BuildContext context) async {
log("BaseMailboxController::syncAllMailboxWithDisplayName");
void syncAllMailboxWithDisplayName(BuildContext context) {
final syncedMailbox = allMailboxes
.map((mailbox) => mailbox.withDisplayName(mailbox.getDisplayName(context)))
.toList();
@@ -112,12 +112,12 @@ class DestinationPickerController extends BaseMailboxController {
await buildTree(success.mailboxList.listSubscribedMailboxesAndDefaultMailboxes);
}
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
} else if (success is RefreshChangesAllMailboxSuccess) {
await refreshTree(success.mailboxList.listSubscribedMailboxesAndDefaultMailboxes);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
} else if (success is SearchMailboxSuccess) {
_searchMailboxSuccess(success);
@@ -1711,57 +1711,54 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
void _rejectCalendarEventAction(EmailId emailId) {
if (_rejectCalendarEventInteractor == null
|| _displayingEventBlobId == null
|| mailboxDashBoardController.accountId.value == null
|| mailboxDashBoardController.sessionCurrent == null
|| mailboxDashBoardController.sessionCurrent
!.validateCalendarEventCapability(mailboxDashBoardController.accountId.value!)
.isAvailable == false
|| accountId == null
|| session == null
|| session!.validateCalendarEventCapability(accountId!).isAvailable == false
) {
consumeState(Stream.value(Left(CalendarEventRejectFailure())));
} else {
consumeState(_rejectCalendarEventInteractor!.execute(
mailboxDashBoardController.accountId.value!,
accountId!,
{_displayingEventBlobId!},
emailId,
mailboxDashBoardController.sessionCurrent!.getLanguageForCalendarEvent(
session!.getLanguageForCalendarEvent(
LocalizationService.getLocaleFromLanguage(),
mailboxDashBoardController.accountId.value!)));
accountId!,
),
));
}
}
void _maybeCalendarEventAction(EmailId emailId) {
if (_maybeCalendarEventInteractor == null
|| _displayingEventBlobId == null
|| mailboxDashBoardController.accountId.value == null
|| mailboxDashBoardController.sessionCurrent == null
|| mailboxDashBoardController.sessionCurrent
!.validateCalendarEventCapability(mailboxDashBoardController.accountId.value!)
.isAvailable == false
|| accountId == null
|| session == null
|| session!.validateCalendarEventCapability(accountId!).isAvailable == false
) {
consumeState(Stream.value(Left(CalendarEventMaybeFailure())));
} else {
consumeState(_maybeCalendarEventInteractor!.execute(
mailboxDashBoardController.accountId.value!,
accountId!,
{_displayingEventBlobId!},
emailId,
mailboxDashBoardController.sessionCurrent!.getLanguageForCalendarEvent(
session!.getLanguageForCalendarEvent(
LocalizationService.getLocaleFromLanguage(),
mailboxDashBoardController.accountId.value!)));
accountId!,
),
));
}
}
void calendarEventSuccess(CalendarEventReplySuccess success) {
final session = mailboxDashBoardController.sessionCurrent;
final accountId = mailboxDashBoardController.accountId.value;
if (session == null || accountId == null) {
consumeState(Stream.value(Left(StoreEventAttendanceStatusFailure(exception: NotFoundSessionException()))));
return;
}
consumeState(_storeEventAttendanceStatusInteractor.execute(
session,
accountId,
session!,
accountId!,
success.emailId,
success.getEventActionType()
));
@@ -1839,16 +1836,15 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
}
Future<void> previewPDFFileAction(BuildContext context, Attachment attachment) async {
final accountId = mailboxDashBoardController.accountId.value;
final downloadUrl = mailboxDashBoardController.sessionCurrent
?.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl);
if (accountId == null || downloadUrl == null) {
if (accountId == null || session == null) {
appToast.showToastErrorMessage(
context,
AppLocalizations.of(context).noPreviewAvailable);
return;
}
final downloadUrl = session!.getDownloadUrl(
jmapUrl: dynamicUrlInterceptors.jmapUrl,
);
await Get.generalDialog(
barrierColor: Colors.black.withOpacity(0.8),
@@ -1856,7 +1852,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
return PointerInterceptor(
child: PDFViewer(
attachment: attachment,
accountId: accountId,
accountId: accountId!,
downloadUrl: downloadUrl,
downloadAction: _downloadPDFFile,
printAction: _printPDFFile,
@@ -1876,7 +1872,10 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
}
final listEmailAddressMailTo = listEmailAddressAttendees
.where((emailAddress) => emailAddress.emailAddress.isNotEmpty && emailAddress.emailAddress != mailboxDashBoardController.sessionCurrent?.username.value)
.where((emailAddress) {
return emailAddress.emailAddress.isNotEmpty &&
emailAddress.emailAddress != session?.username.value;
})
.toSet()
.toList();
@@ -64,6 +64,8 @@ import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_ma
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart';
import 'package:tmail_ui_user/features/search/mailbox/presentation/search_mailbox_bindings.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
@@ -94,6 +96,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
MailboxId? _newFolderId;
NavigationRouter? _navigationRouter;
WebSocketQueueHandler? _webSocketQueueHandler;
final _openMailboxEventController = StreamController<OpenMailboxViewEvent>();
final mailboxListScrollController = ScrollController();
@@ -128,6 +131,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
@override
void onInit() {
_registerObxStreamListener();
_initWebSocketQueueHandler();
super.onInit();
}
@@ -145,6 +149,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
void onClose() {
_openMailboxEventController.close();
mailboxListScrollController.dispose();
_webSocketQueueHandler?.dispose();
super.onClose();
}
@@ -153,8 +158,6 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
super.handleSuccessViewState(success);
if (success is GetAllMailboxSuccess) {
_handleGetAllMailboxSuccess(success);
} else if (success is RefreshChangesAllMailboxSuccess) {
_handleRefreshChangesAllMailboxSuccess(success);
} else if (success is CreateNewMailboxSuccess) {
_createNewMailboxSuccess(success);
} else if (success is DeleteMultipleMailboxAllSuccess) {
@@ -181,8 +184,6 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
_renameMailboxFailure(failure);
} else if (failure is DeleteMultipleMailboxFailure) {
_deleteMailboxFailure(failure);
} else if (failure is RefreshChangesAllMailboxFailure) {
_clearNewFolderId();
}
}
@@ -204,13 +205,6 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
if (PlatformInfo.isIOS) {
_updateMailboxIdsBlockNotificationToKeychain(success.mailboxList);
}
} else if (success is RefreshChangesAllMailboxSuccess) {
_selectSelectedMailboxDefault();
mailboxDashBoardController.refreshSpamReportBanner();
if (_newFolderId != null) {
_redirectToNewFolder();
}
}
});
}
@@ -258,6 +252,13 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
});
}
void _initWebSocketQueueHandler() {
_webSocketQueueHandler = WebSocketQueueHandler(
processMessageCallback: _handleWebSocketMessage,
onErrorCallback: onError,
);
}
void _initCollapseMailboxCategories() {
if (kIsWeb && currentContext != null
&& (responsiveUtils.isMobile(currentContext!) || responsiveUtils.isTablet(currentContext!))) {
@@ -283,17 +284,66 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
if (accountId == null ||
session == null ||
currentMailboxState == null ||
newState == currentMailboxState) {
newState == null) {
_newFolderId = null;
return;
}
refreshMailboxChanges(
session!,
accountId!,
currentMailboxState!,
properties: MailboxConstants.propertiesDefault,
);
_webSocketQueueHandler?.enqueue(WebSocketMessage(newState: newState));
}
Future<void> _handleWebSocketMessage(WebSocketMessage message) async {
try {
if (currentMailboxState == message.newState) {
log('MailboxController::_handleWebSocketMessage:Skipping redundant state: ${message.newState}');
return Future.value();
}
final refreshViewState = await refreshAllMailboxInteractor!.execute(
session!,
accountId!,
currentMailboxState!,
properties: MailboxConstants.propertiesDefault,
).last;
final refreshState = refreshViewState
.foldSuccessWithResult<RefreshChangesAllMailboxSuccess>();
if (refreshState is RefreshChangesAllMailboxSuccess) {
await _handleRefreshChangeMailboxSuccess(refreshState);
} else {
_clearNewFolderId();
onDataFailureViewState(refreshState);
}
} catch (e, stackTrace) {
logError('MailboxController::_processMailboxStateQueue:Error processing state: $e');
onError(e, stackTrace);
}
if (currentMailboxState != null) {
_webSocketQueueHandler?.removeMessagesUpToCurrent(currentMailboxState!.value);
}
}
Future<void> _handleRefreshChangeMailboxSuccess(RefreshChangesAllMailboxSuccess success) async {
currentMailboxState = success.currentMailboxState;
log('MailboxController::_handleRefreshChangeMailboxSuccess:currentMailboxState: $currentMailboxState');
final listMailboxDisplayed = success
.mailboxList
.listSubscribedMailboxesAndDefaultMailboxes;
await refreshTree(listMailboxDisplayed);
if (currentContext != null) {
syncAllMailboxWithDisplayName(currentContext!);
}
_setMapMailbox();
_setOutboxMailbox();
_selectSelectedMailboxDefault();
mailboxDashBoardController.refreshSpamReportBanner();
if (_newFolderId != null) {
_redirectToNewFolder();
}
}
void _setMapMailbox() {
@@ -1078,7 +1128,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
final listMailboxDisplayed = success.mailboxList.listSubscribedMailboxesAndDefaultMailboxes;
await buildTree(listMailboxDisplayed);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
_setMapMailbox();
_setOutboxMailbox();
@@ -1105,18 +1155,6 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM
mailboxIds: mailboxIdsBlockNotification);
}
void _handleRefreshChangesAllMailboxSuccess(RefreshChangesAllMailboxSuccess success) async {
currentMailboxState = success.currentMailboxState;
log('MailboxController::_handleRefreshChangesAllMailboxSuccess:currentMailboxState: $currentMailboxState');
final listMailboxDisplayed = success.mailboxList.listSubscribedMailboxesAndDefaultMailboxes;
await refreshTree(listMailboxDisplayed);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
}
_setMapMailbox();
_setOutboxMailbox();
}
void _unsubscribeMailboxAction(MailboxId mailboxId) {
if (session != null && accountId != null) {
final subscribeRequest = generateSubscribeRequest(
@@ -243,6 +243,7 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo
PresentationMailbox? outboxMailbox;
ComposerArguments? composerArguments;
List<Identity>? _identities;
jmap.State? _currentEmailState;
ScrollController? listSearchFilterScrollController;
StreamSubscription? _pendingSharedFileInfoSubscription;
StreamSubscription? _receivingFileSharingStreamSubscription;
@@ -2930,6 +2931,12 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo
accountId: accountId.value!);
}
void setCurrentEmailState(jmap.State? newState) {
_currentEmailState = newState;
}
jmap.State? get currentEmailState => _currentEmailState;
@override
void onClose() {
if (PlatformInfo.isWeb) {
@@ -2957,6 +2964,7 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo
mapMailboxById = {};
mapDefaultMailboxIdByRole = {};
WebSocketController.instance.onClose();
_currentEmailState = null;
super.onClose();
}
}
@@ -73,7 +73,7 @@ class MailboxVisibilityController extends BaseMailboxController {
currentMailboxState = success.currentMailboxState;
await refreshTree(success.mailboxList);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
} else if (success is SubscribeMailboxSuccess) {
_subscribeMailboxSuccess(success);
@@ -99,7 +99,7 @@ class MailboxVisibilityController extends BaseMailboxController {
await buildTree(mailboxList);
dispatchState(Right(BuildTreeMailboxVisibilitySuccess()));
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
}
@@ -5,6 +5,7 @@ import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/platform_info.dart';
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:fcm/model/type_name.dart';
import 'package:flutter/material.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
@@ -18,6 +19,7 @@ import 'package:tmail_ui_user/features/push_notification/presentation/controller
import 'package:tmail_ui_user/features/push_notification/presentation/extensions/state_change_extension.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/listener/email_change_listener.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/utils/fcm_utils.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
@@ -36,6 +38,7 @@ class WebSocketController extends PushBaseController {
Timer? _webSocketPingTimer;
StreamSubscription? _webSocketSubscription;
AppLifecycleListener? _appLifecycleListener;
Debouncer<StateChange?>? _stateChangeDebouncer;
@override
void handleFailureViewState(Failure failure) {
@@ -105,8 +108,9 @@ class WebSocketController extends PushBaseController {
_webSocketSubscription?.cancel();
_webSocketChannel = null;
_webSocketPingTimer?.cancel();
_stateChangeDebouncer?.cancel();
}
void _handleWebSocketConnectionSuccess(WebSocketConnectionSuccess success) {
log('WebSocketController::_handleWebSocketConnectionSuccess(): $success');
_cleanUpWebSocketResources();
@@ -117,6 +121,7 @@ class WebSocketController extends PushBaseController {
_pingWebSocket();
}
_listenToWebSocket();
_initStateChangeDeouncerTimer();
}
void _handleWebSocketConnectionRetry() {
@@ -150,14 +155,7 @@ class WebSocketController extends PushBaseController {
try {
final stateChange = StateChange.fromJson(data);
final mapTypeState = stateChange.getMapTypeState(accountId!);
mappingTypeStateToAction(
mapTypeState,
accountId!,
emailChangeListener: EmailChangeListener.instance,
mailboxChangeListener: MailboxChangeListener.instance,
session!.username,
session: session);
_stateChangeDebouncer?.value = stateChange;
} catch (e) {
logError('WebSocketController::_listenToWebSocket(): Data is not StateChange');
}
@@ -173,4 +171,31 @@ class WebSocketController extends PushBaseController {
},
);
}
void _initStateChangeDeouncerTimer() {
_stateChangeDebouncer = Debouncer<StateChange?>(
const Duration(milliseconds: FcmUtils.durationMessageComing),
initialValue: null,
);
_stateChangeDebouncer?.values.listen(_handleStateChange);
}
void _handleStateChange(StateChange? stateChange) {
try {
if (stateChange == null || accountId == null || session == null) return;
final mapTypeState = stateChange.getMapTypeState(accountId!);
mappingTypeStateToAction(
mapTypeState,
accountId!,
emailChangeListener: EmailChangeListener.instance,
mailboxChangeListener: MailboxChangeListener.instance,
session!.username,
session: session,
);
} catch (e) {
logError('WebSocketController::_handleStateChange:Exception = $e');
}
}
}
@@ -0,0 +1,13 @@
import 'package:equatable/equatable.dart';
import 'package:jmap_dart_client/jmap/core/state.dart' as jmap;
class WebSocketMessage with EquatableMixin {
final jmap.State newState;
WebSocketMessage({required this.newState});
String get id => newState.value;
@override
List<Object?> get props => [newState];
}
@@ -0,0 +1,121 @@
import 'dart:async';
import 'dart:collection';
import 'package:core/utils/app_logger.dart';
import 'package:flutter/cupertino.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart';
typedef ProcessMessageCallback = Future<void> Function(WebSocketMessage message);
typedef OnErrorCallback = void Function(dynamic error, StackTrace stackTrace);
class WebSocketQueueHandler {
static const int _maxQueueSize = 128;
static const int _maxProcessedIdsSize = 128;
final Queue<WebSocketMessage> _messageQueue = Queue<WebSocketMessage>();
final Queue<String> _processedMessageIds = Queue<String>();
Completer<void>? _processingLock;
final _queueController = StreamController<WebSocketMessage>.broadcast();
final ProcessMessageCallback processMessageCallback;
final OnErrorCallback? onErrorCallback;
WebSocketQueueHandler({
required this.processMessageCallback,
this.onErrorCallback,
}) {
_queueController.stream.listen((_) {
_processQueue();
});
}
void enqueue(WebSocketMessage message) {
if (isMessageProcessed(message.id)) {
log('WebSocketQueueHandler::enqueue:Message ${message.id} already processed, skipping');
return;
}
if (queueSize >= _maxQueueSize) {
log('WebSocketQueueHandler::enqueue:Queue full, removing oldest message');
_messageQueue.removeFirst();
}
_messageQueue.add(message);
_queueController.add(message);
}
Future<void> _processQueue() async {
if (_processingLock != null) {
return;
}
_processingLock = Completer<void>();
try {
while (queueSize > 0) {
final message = _messageQueue.removeFirst();
try {
await processMessageCallback(message);
} catch (e, stackTrace) {
logError('WebSocketQueueHandler::_processQueue:Error processing message ${message.id}: $e');
onErrorCallback?.call(e, stackTrace);
} finally {
_addToProcessedMessages(message.id);
}
}
} finally {
_processingLock?.complete();
_processingLock = null;
if (queueSize > 0) {
scheduleMicrotask(() => _queueController.add(_messageQueue.first));
}
}
}
void _addToProcessedMessages(String messageId) {
if (_processedMessageIds.length >= _maxProcessedIdsSize) {
_processedMessageIds.removeFirst();
}
_processedMessageIds.add(messageId);
}
void removeMessagesUpToCurrent(String messageId) {
final isCurrentStateExist = _messageQueue
.any((message) => message.id == messageId);
if (!isCurrentStateExist) {
log('WebSocketQueueHandler::removeMessagesUpToCurrent:Current state $messageId not found in the queue.');
return;
}
while (queueSize > 0) {
final removedMessage = _messageQueue.removeFirst();
if (removedMessage.id == messageId) {
break;
}
}
log('WebSocketQueueHandler::removeMessagesUpToCurrent:Updated Queue: $queueSize');
}
@visibleForTesting
Future<void> waitForEmpty() async {
while (_messageQueue.isNotEmpty || _processingLock != null) {
if (_processingLock != null) {
await _processingLock!.future;
}
await Future.delayed(const Duration(milliseconds: 100));
}
}
int get queueSize => _messageQueue.length;
bool isMessageProcessed(String messageId) => _processedMessageIds.contains(messageId);
void dispose() {
_queueController.close();
}
}
@@ -127,7 +127,7 @@ class RulesFilterCreatorController extends BaseMailboxController {
if (success is GetAllMailboxSuccess) {
await buildTree(success.mailboxList);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
} else if (success is GetAllRulesSuccess) {
log('RulesFilterCreatorController::handleSuccessViewState():GetAllRulesSuccess: ${success.rules}');
@@ -1,4 +1,5 @@
import 'package:core/presentation/extensions/either_view_state_extension.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/presentation/utils/keyboard_utils.dart';
@@ -10,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:jmap_dart_client/jmap/core/session/session.dart';
import 'package:jmap_dart_client/jmap/core/state.dart' as jmap;
import 'package:jmap_dart_client/jmap/core/unsigned_int.dart';
import 'package:jmap_dart_client/jmap/core/utc_date.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
@@ -50,6 +52,8 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/sear
import 'package:tmail_ui_user/features/manage_account/presentation/extensions/datetime_extension.dart';
import 'package:tmail_ui_user/features/network_connection/presentation/network_connection_controller.dart'
if (dart.library.html) 'package:tmail_ui_user/features/network_connection/presentation/web_network_connection_controller.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart';
import 'package:tmail_ui_user/features/search/email/domain/state/refresh_changes_search_email_state.dart';
import 'package:tmail_ui_user/features/search/email/domain/usecases/refresh_changes_search_email_interactor.dart';
import 'package:tmail_ui_user/features/search/email/presentation/model/search_more_state.dart';
@@ -105,6 +109,7 @@ class SearchEmailController extends BaseController
late Worker dashBoardActionWorker;
late SearchMoreState searchMoreState;
late bool canSearchMore;
WebSocketQueueHandler? _webSocketQueueHandler;
PresentationMailbox? get currentMailbox => mailboxDashBoardController.selectedMailbox.value;
@@ -145,6 +150,7 @@ class SearchEmailController extends BaseController
_initializeDebounceTimeTextSearchChange();
_initializeTextInputFocus();
_initWorkerListener();
_initWebSocketQueueHandler();
}
@override
@@ -165,8 +171,6 @@ class SearchEmailController extends BaseController
searchMoreState = SearchMoreState.waiting;
} else if (success is SearchMoreEmailSuccess) {
_searchMoreEmailsSuccess(success);
} else if (success is RefreshChangesSearchEmailSuccess) {
_refreshChangesSearchEmailsSuccess(success);
} else if (success is SearchingState) {
resultSearchViewState.value = Right(success);
}
@@ -252,40 +256,88 @@ class SearchEmailController extends BaseController
mailboxDashBoardController.emailUIAction,
(action) {
if (action is RefreshChangeEmailAction) {
_refreshEmailChanges();
_refreshEmailChanges(newState: action.newState);
}
},
);
}
void _refreshEmailChanges({jmap.State? newState}) {
log('SearchEmailController::_refreshEmailChanges(): newState: $newState');
if (accountId == null ||
session == null ||
mailboxDashBoardController.currentEmailState == null ||
newState == null ||
searchIsRunning.isFalse) {
return;
}
_webSocketQueueHandler?.enqueue(WebSocketMessage(newState: newState));
}
void _initWebSocketQueueHandler() {
_webSocketQueueHandler = WebSocketQueueHandler(
processMessageCallback: _handleWebSocketMessage,
onErrorCallback: onError,
);
}
Future<void> _handleWebSocketMessage(WebSocketMessage message) async {
try {
if (mailboxDashBoardController.currentEmailState == null ||
mailboxDashBoardController.currentEmailState == message.newState) {
log('SearchEmailController::_handleWebSocketMessage:Skipping redundant state: ${message.newState}');
return Future.value();
}
final limit = listResultSearch.isNotEmpty
? UnsignedInt(listResultSearch.length)
: ThreadConstants.defaultLimit;
if (limit.value > ThreadConstants.maximumEmailQueryLimit &&
resultSearchScrollController.hasClients) {
resultSearchScrollController.jumpTo(0);
}
_updateSimpleSearchFilter(
beforeOption: const None(),
positionOption: option(searchEmailFilter.value.sortOrderType.isScrollByPosition(), 0),
);
final searchViewState = await _refreshChangesSearchEmailInteractor.execute(
session!,
accountId!,
limit: limit,
position: searchEmailFilter.value.position,
sort: searchEmailFilter.value.sortOrderType.getSortOrder().toNullable(),
filter: searchEmailFilter.value.mappingToEmailFilterCondition(),
properties: EmailUtils.getPropertiesForEmailGetMethod(session!, accountId!),
).last;
final searchState = searchViewState
.foldSuccessWithResult<RefreshChangesSearchEmailSuccess>();
if (searchState is RefreshChangesSearchEmailSuccess) {
_handleRefreshChangesSearchEmailsSuccess(searchState);
}
} catch (e, stackTrace) {
logError('SearchEmailController::_handleWebSocketMessage:Error processing state: $e');
onError(e, stackTrace);
} finally {
if (mailboxDashBoardController.currentEmailState != null) {
_webSocketQueueHandler?.removeMessagesUpToCurrent(
mailboxDashBoardController.currentEmailState!.value);
}
}
}
void _onSearchTextInputListener() {
if (textInputSearchFocus.hasFocus) {
searchIsRunning.value = false;
}
}
void _refreshEmailChanges() {
if (searchIsRunning.isTrue && session != null && accountId != null) {
final limit = listResultSearch.isNotEmpty
? UnsignedInt(listResultSearch.length)
: ThreadConstants.defaultLimit;
_updateSimpleSearchFilter(
beforeOption: const None(),
positionOption: option(searchEmailFilter.value.sortOrderType.isScrollByPosition(), 0)
);
consumeState(_refreshChangesSearchEmailInteractor.execute(
session!,
accountId!,
limit: limit,
position: searchEmailFilter.value.position,
sort: searchEmailFilter.value.sortOrderType.getSortOrder().toNullable(),
filter: searchEmailFilter.value.mappingToEmailFilterCondition(),
properties: EmailUtils.getPropertiesForEmailGetMethod(session!, accountId!),
));
}
}
void _refreshChangesSearchEmailsSuccess(RefreshChangesSearchEmailSuccess success) {
void _handleRefreshChangesSearchEmailsSuccess(RefreshChangesSearchEmailSuccess success) {
final resultEmailSearchList = success.emailList
.map((email) => email.toSearchPresentationEmail(mailboxDashBoardController.mapMailboxById))
.toList();
@@ -306,8 +358,8 @@ class SearchEmailController extends BaseController
return _getAllRecentSearchLatestInteractor
.execute(pattern: pattern)
.then((result) => result.fold(
(failure) => <RecentSearch>[],
(success) => success is GetAllRecentSearchLatestSuccess
(failure) => <RecentSearch>[],
(success) => success is GetAllRecentSearchLatestSuccess
? success.listRecentSearch
: <RecentSearch>[]));
}
@@ -136,13 +136,13 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa
currentMailboxState = success.currentMailboxState;
await buildTree(success.mailboxList);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
} else if (success is RefreshChangesAllMailboxSuccess) {
currentMailboxState = success.currentMailboxState;
await refreshTree(success.mailboxList);
if (currentContext != null) {
await syncAllMailboxWithDisplayName(currentContext!);
syncAllMailboxWithDisplayName(currentContext!);
}
searchMailboxAction();
} else if (success is SearchMailboxSuccess) {
@@ -5,6 +5,7 @@ import 'package:model/email/email_property.dart';
class ThreadConstants {
static const maxCountEmails = 20;
static const maximumEmailQueryLimit = 256;
static final defaultLimit = UnsignedInt(maxCountEmails);
static final propertiesDefault = Properties({
EmailProperty.id,
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:core/presentation/extensions/either_view_state_extension.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/utils/app_logger.dart';
@@ -32,6 +33,8 @@ import 'package:tmail_ui_user/features/manage_account/domain/state/create_new_ru
import 'package:tmail_ui_user/features/manage_account/domain/usecases/create_new_email_rule_filter_interactor.dart';
import 'package:tmail_ui_user/features/network_connection/presentation/network_connection_controller.dart'
if (dart.library.html) 'package:tmail_ui_user/features/network_connection/presentation/web_network_connection_controller.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart';
import 'package:tmail_ui_user/features/rules_filter_creator/presentation/model/rules_filter_creator_arguments.dart';
import 'package:tmail_ui_user/features/search/email/presentation/search_email_bindings.dart';
import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart';
@@ -90,12 +93,12 @@ class ThreadController extends BaseController with EmailActionController {
bool canLoadMore = false;
bool canSearchMore = false;
MailboxId? _currentMemoryMailboxId;
jmap.State? _currentEmailState;
final ScrollController listEmailController = ScrollController();
final FocusNode focusNodeKeyBoard = FocusNode();
final latestEmailSelectedOrUnselected = Rxn<PresentationEmail>();
@visibleForTesting
bool isListEmailScrollViewJumping = false;
WebSocketQueueHandler? _webSocketQueueHandler;
StreamSubscription<html.Event>? _resizeBrowserStreamSubscription;
@@ -128,6 +131,7 @@ class ThreadController extends BaseController with EmailActionController {
if (PlatformInfo.isWeb) {
_registerBrowserResizeListener();
}
_initWebSocketQueueHandler();
super.onInit();
}
@@ -140,7 +144,6 @@ class ThreadController extends BaseController with EmailActionController {
@override
void onClose() {
_currentMemoryMailboxId = null;
_currentEmailState = null;
listEmailController.dispose();
focusNodeKeyBoard.dispose();
if (PlatformInfo.isWeb) {
@@ -154,8 +157,6 @@ class ThreadController extends BaseController with EmailActionController {
super.handleSuccessViewState(success);
if (success is GetAllEmailSuccess) {
_getAllEmailSuccess(success);
} else if (success is RefreshChangesAllEmailSuccess) {
_refreshChangesAllEmailSuccess(success);
} else if (success is LoadMoreEmailsSuccess) {
_loadMoreEmailsSuccess(success);
} else if (success is SearchEmailSuccess) {
@@ -231,6 +232,13 @@ class ThreadController extends BaseController with EmailActionController {
});
}
void _initWebSocketQueueHandler() {
_webSocketQueueHandler = WebSocketQueueHandler(
processMessageCallback: _handleWebSocketMessage,
onErrorCallback: onError,
);
}
void _resetLoadingMore() {
if (loadingMoreStatus.value == LoadingMoreStatus.running) {
loadingMoreStatus.value = LoadingMoreStatus.idle;
@@ -383,8 +391,8 @@ class ThreadController extends BaseController with EmailActionController {
log('ThreadController::_getAllEmailSuccess: GetAllForMailboxId = ${success.currentMailboxId?.asString} | SELECTED_MAILBOX_ID = ${selectedMailboxId?.asString} | SELECTED_MAILBOX_NAME = ${selectedMailbox?.name?.name}');
return;
}
_currentEmailState = success.currentEmailState;
log('ThreadController::_getAllEmailSuccess():COUNT = ${success.emailList.length} | EMAIL_STATE = $_currentEmailState');
mailboxDashBoardController.setCurrentEmailState(success.currentEmailState);
log('ThreadController::_getAllEmailSuccess():COUNT = ${success.emailList.length} | EMAIL_STATE = ${mailboxDashBoardController.currentEmailState}');
final newListEmail = success.emailList.syncPresentationEmail(
mapMailboxById: mailboxDashBoardController.mapMailboxById,
selectedMailbox: selectedMailbox,
@@ -413,8 +421,7 @@ class ThreadController extends BaseController with EmailActionController {
log('ThreadController::_refreshChangesAllEmailSuccess: RefreshedMailboxId = ${success.currentMailboxId?.asString} | SELECTED_MAILBOX_ID = ${selectedMailboxId?.asString} | SELECTED_MAILBOX_NAME = ${selectedMailbox?.name?.name}');
return;
}
_currentEmailState = success.currentEmailState;
mailboxDashBoardController.setCurrentEmailState(success.currentEmailState);
log('ThreadController::_refreshChangesAllEmailSuccess: COUNT = ${success.emailList.length}');
final emailsBeforeChanges = mailboxDashBoardController.emailsInCurrentMailbox;
final emailsAfterChanges = success.emailList;
@@ -515,31 +522,112 @@ class ThreadController extends BaseController with EmailActionController {
void _refreshEmailChanges({jmap.State? newState}) {
log('ThreadController::_refreshEmailChanges(): newState: $newState');
if (searchController.isSearchEmailRunning) {
_searchEmail(limit: limitEmailFetched, needRefreshSearchState: true);
} else {
if (_currentEmailState == null ||
_currentEmailState == newState ||
_session == null ||
_accountId == null) {
return;
if (_accountId == null ||
_session == null ||
mailboxDashBoardController.currentEmailState == null ||
newState == null) {
return;
}
_webSocketQueueHandler?.enqueue(WebSocketMessage(newState: newState));
}
Future<void> _handleWebSocketMessage(WebSocketMessage message) async {
try {
if (mailboxDashBoardController.currentEmailState == null ||
mailboxDashBoardController.currentEmailState == message.newState) {
log('ThreadController::_handleWebSocketMessage:Skipping redundant state: ${message.newState}');
return Future.value();
}
consumeState(_refreshChangesEmailsInMailboxInteractor.execute(
if (searchController.isSearchEmailRunning) {
await _refreshChangeSearchEmail();
} else {
await _refreshChangeListEmail();
}
} catch (e, stackTrace) {
logError('ThreadController::_handleWebSocketMessage:Error processing state: $e');
onError(e, stackTrace);
} finally {
if (mailboxDashBoardController.currentEmailState != null) {
_webSocketQueueHandler?.removeMessagesUpToCurrent(
mailboxDashBoardController.currentEmailState!.value);
}
}
}
Future<void> _refreshChangeSearchEmail() async {
log('ThreadController::_refreshChangeSearchEmail:');
if (limitEmailFetched.value > ThreadConstants.maximumEmailQueryLimit &&
listEmailController.hasClients) {
listEmailController.jumpTo(0);
}
canSearchMore = false;
searchController.updateFilterEmail(
positionOption: option(
_searchEmailFilter.sortOrderType.isScrollByPosition(),
0,
),
beforeOption: const None(),
);
searchController.activateSimpleSearch();
final searchViewState = await _searchEmailInteractor.execute(
_session!,
_accountId!,
limit: limitEmailFetched,
position: _searchEmailFilter.position,
sort: _searchEmailFilter.sortOrderType.getSortOrder().toNullable(),
filter: _searchEmailFilter.mappingToEmailFilterCondition(
moreFilterCondition: _getFilterCondition(),
),
properties: EmailUtils.getPropertiesForEmailGetMethod(
_session!,
_accountId!,
_currentEmailState!,
sort: EmailSortOrderType.mostRecent.getSortOrder().toNullable(),
propertiesCreated: EmailUtils.getPropertiesForEmailGetMethod(
_session!,
_accountId!,
),
propertiesUpdated: ThreadConstants.propertiesUpdatedDefault,
emailFilter: EmailFilter(
filter: _getFilterCondition(mailboxIdSelected: selectedMailboxId),
filterOption: mailboxDashBoardController.filterMessageOption.value,
mailboxId: selectedMailboxId,
),
));
),
needRefreshSearchState: true,
).last;
final searchState = searchViewState
.foldSuccessWithResult<SearchEmailSuccess>();
if (searchState is SearchEmailSuccess) {
_searchEmailsSuccess(searchState);
} else {
mailboxDashBoardController.updateRefreshAllEmailState(
Left(RefreshAllEmailFailure()));
canSearchMore = false;
mailboxDashBoardController.emailsInCurrentMailbox.clear();
onDataFailureViewState(searchState);
}
}
Future<void> _refreshChangeListEmail() async {
log('ThreadController::_refreshChangeListEmail:');
final refreshViewState = await _refreshChangesEmailsInMailboxInteractor.execute(
_session!,
_accountId!,
mailboxDashBoardController.currentEmailState!,
sort: EmailSortOrderType.mostRecent.getSortOrder().toNullable(),
propertiesCreated: EmailUtils.getPropertiesForEmailGetMethod(
_session!,
_accountId!,
),
propertiesUpdated: ThreadConstants.propertiesUpdatedDefault,
emailFilter: EmailFilter(
filter: _getFilterCondition(mailboxIdSelected: selectedMailboxId),
filterOption: mailboxDashBoardController.filterMessageOption.value,
mailboxId: selectedMailboxId,
),
).last;
final refreshState = refreshViewState
.foldSuccessWithResult<RefreshChangesAllEmailSuccess>();
if (refreshState is RefreshChangesAllEmailSuccess) {
_refreshChangesAllEmailSuccess(refreshState);
} else {
onDataFailureViewState(refreshState);
}
}
@@ -1168,12 +1256,10 @@ class ThreadController extends BaseController with EmailActionController {
}
void goToCreateEmailRuleView() async {
final accountId = mailboxDashBoardController.accountId.value;
final session = mailboxDashBoardController.sessionCurrent;
if (accountId != null && session != null) {
if (_accountId != null && _session != null) {
final arguments = RulesFilterCreatorArguments(
accountId,
session,
_accountId!,
_session!,
mailboxDestination: selectedMailbox
);
@@ -1182,7 +1268,7 @@ class ThreadController extends BaseController with EmailActionController {
: await push(AppRoutes.rulesFilterCreator, arguments: arguments);
if (newRuleFilterRequest is CreateNewEmailRuleFilterRequest) {
_createNewRuleFilterAction(accountId, newRuleFilterRequest);
_createNewRuleFilterAction(_accountId!, newRuleFilterRequest);
}
} else {
logError('ThreadController::goToCreateEmailRuleView: Account or Session is NULL');
@@ -0,0 +1,197 @@
import 'dart:collection';
import 'package:flutter_test/flutter_test.dart';
import 'package:jmap_dart_client/jmap/core/state.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart';
class MockWebSocketMessage extends WebSocketMessage {
MockWebSocketMessage(String message)
: super(
newState: State(message),
);
}
void main() {
group('WebSocketQueueHandler::test', () {
late Queue<String> processedMessages;
setUp(() {
processedMessages = Queue<String>();
});
WebSocketQueueHandler createHandler({
required ProcessMessageCallback processMessageCallback,
OnErrorCallback? onErrorCallback,
}) {
return WebSocketQueueHandler(
processMessageCallback: processMessageCallback,
onErrorCallback: onErrorCallback,
);
}
group('Basic Operations', () {
late WebSocketQueueHandler handler;
setUp(() {
handler = createHandler(
processMessageCallback: (message) async {
processedMessages.add(message.id);
},
);
});
tearDown(() => handler.dispose());
test('Should process messages in correct order', () async {
final messages = List.generate(5, (index) => MockWebSocketMessage('$index'));
for (var message in messages) {
handler.enqueue(message);
}
await handler.waitForEmpty();
expect(processedMessages, containsAllInOrder(['0', '1', '2', '3', '4']));
});
test('Should correctly remove messages up to specified ID', () async {
final messages = List.generate(5, (index) => MockWebSocketMessage('$index'));
for (var message in messages) {
handler.enqueue(message);
}
handler.removeMessagesUpToCurrent('2');
expect(handler.queueSize, 2);
await handler.waitForEmpty();
expect(processedMessages.length, 2);
expect(processedMessages.first, '3');
});
});
group('Concurrent Operations', () {
late WebSocketQueueHandler handler;
setUp(() {
handler = createHandler(
processMessageCallback: (message) async {
processedMessages.add(message.id);
},
);
});
tearDown(() => handler.dispose());
test('Should handle concurrent message enqueueing', () async {
final messages = List.generate(5, (index) => MockWebSocketMessage('$index'));
await Future.wait(messages.map((message) => Future(() => handler.enqueue(message))));
await handler.waitForEmpty();
expect(processedMessages, containsAllInOrder(['0', '1', '2', '3', '4']));
});
test('Should maintain order under high concurrency', () async {
final messages = List.generate(10, (index) => MockWebSocketMessage('$index'));
for (var message in messages) {
handler.enqueue(message);
}
await handler.waitForEmpty();
expect(processedMessages, containsAllInOrder(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']));
});
});
group('Error Handling', () {
test('Should continue processing after message failure', () async {
final handler = createHandler(
processMessageCallback: (message) async {
if (message.id == '2') throw Exception('Simulated Failure');
processedMessages.add(message.id);
},
);
handler.enqueue(MockWebSocketMessage('1'));
handler.enqueue(MockWebSocketMessage('2'));
handler.enqueue(MockWebSocketMessage('3'));
await handler.waitForEmpty();
expect(processedMessages, ['1', '3']);
handler.dispose();
});
test('Should handle exception in process callback', () async {
final handler = createHandler(
processMessageCallback: (message) async {
if (message.id == '1') throw Exception('Simulated Failure');
processedMessages.add(message.id);
},
onErrorCallback: (error, stackTrace) {
expect(error, isA<Exception>());
},
);
handler.enqueue(MockWebSocketMessage('1'));
handler.enqueue(MockWebSocketMessage('2'));
await handler.waitForEmpty();
expect(processedMessages, ['2']);
handler.dispose();
});
});
group('Stress Testing', () {
late WebSocketQueueHandler handler;
setUp(() {
handler = createHandler(
processMessageCallback: (message) async {
processedMessages.add(message.id);
},
);
});
tearDown(() => handler.dispose());
test('Should handle large bursts of messages', () async {
handler = createHandler(
processMessageCallback: (message) async {
processedMessages.add(message.id);
},
);
const int burstSize = 1000;
for (int i = 0; i < burstSize; i++) {
await Future.delayed(const Duration(milliseconds: 10));
handler.enqueue(MockWebSocketMessage('$i'));
}
await handler.waitForEmpty();
expect(processedMessages.length, burstSize);
expect(processedMessages, List.generate(burstSize, (i) => '$i'));
});
test('Should handle interleaved slow and fast messages', () async {
handler.enqueue(MockWebSocketMessage('1'));
Future.delayed(const Duration(milliseconds: 10), () {
handler.enqueue(MockWebSocketMessage('2'));
});
await handler.waitForEmpty();
expect(processedMessages, ['1', '2']);
});
});
});
}
@@ -286,6 +286,7 @@ void main() {
when(mockMailboxDashBoardController.emailUIAction).thenReturn(Rxn(null));
when(mockMailboxDashBoardController.viewState).thenReturn(Rx(Right(UIState.idle)));
when(mockMailboxDashBoardController.filterMessageOption).thenReturn(Rx(FilterMessageOption.all));
when(mockMailboxDashBoardController.currentEmailState).thenReturn(State('old-state'));
when(mockSearchController.searchState).thenReturn(Rx(SearchState.initial()));
when(mockSearchController.isAdvancedSearchViewOpen).thenReturn(RxBool(false));
when(mockSearchController.isSearchEmailRunning).thenReturn(true);