From 9c211c05ef9c65bed20d29ec5691f2382e52ebd2 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 3 Feb 2026 19:20:52 +0700 Subject: [PATCH] feat: Auto sync label from web socket --- fcm/lib/model/type_name.dart | 1 + .../batch_set_email_processing_mixin.dart | 4 +- .../email/data/network/email_api.dart | 12 +- .../handle_label_websocket_extension.dart | 71 +++++++ .../labels/presentation/label_controller.dart | 49 ++++- .../presentation/utils/label_utils.dart | 24 +++ .../mailbox_dashboard_controller.dart | 14 +- ...push_notification_state_change_action.dart | 29 +++ .../controller/fcm_message_controller.dart | 2 + .../controller/push_base_controller.dart | 29 ++- .../controller/web_socket_controller.dart | 4 +- .../listener/label_change_listener.dart | 38 ++++ .../thread/data/network/thread_api.dart | 3 +- ...batch_set_email_processing_mixin_test.dart | 6 +- .../base/mixin/mail_api_mixin_test.dart | 3 +- .../label/apply_label_changes_test.dart | 173 ++++++++++++++++++ .../controller/push_base_controller_test.dart | 98 +++++++++- 17 files changed, 536 insertions(+), 24 deletions(-) create mode 100644 lib/features/labels/presentation/extensions/handle_label_websocket_extension.dart create mode 100644 lib/features/push_notification/presentation/listener/label_change_listener.dart create mode 100644 test/features/label/apply_label_changes_test.dart diff --git a/fcm/lib/model/type_name.dart b/fcm/lib/model/type_name.dart index 89879245c..963a97368 100644 --- a/fcm/lib/model/type_name.dart +++ b/fcm/lib/model/type_name.dart @@ -5,6 +5,7 @@ class TypeName with EquatableMixin { static const mailboxType = TypeName('Mailbox'); static const emailType = TypeName('Email'); static const emailDelivery = TypeName('EmailDelivery'); + static const labelType = TypeName('Label'); final String value; diff --git a/lib/features/base/mixin/batch_set_email_processing_mixin.dart b/lib/features/base/mixin/batch_set_email_processing_mixin.dart index ba5673e1c..dd7622040 100644 --- a/lib/features/base/mixin/batch_set_email_processing_mixin.dart +++ b/lib/features/base/mixin/batch_set_email_processing_mixin.dart @@ -15,13 +15,15 @@ import 'package:jmap_dart_client/jmap/mail/email/set/set_email_response.dart'; import 'package:model/extensions/list_id_extension.dart'; import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/mail_api_mixin.dart'; +import 'package:tmail_ui_user/features/base/mixin/session_mixin.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; typedef OnGeneratePatchObjectUpdates = Map Function( List batchIds, ); -mixin BatchSetEmailProcessingMixin on HandleSetErrorMixin, MailAPIMixin { +mixin BatchSetEmailProcessingMixin + on HandleSetErrorMixin, SessionMixin, MailAPIMixin { Future< ({ List emailIdsSuccess, diff --git a/lib/features/email/data/network/email_api.dart b/lib/features/email/data/network/email_api.dart index 76f64efaf..43e5d3352 100644 --- a/lib/features/email/data/network/email_api.dart +++ b/lib/features/email/data/network/email_api.dart @@ -56,6 +56,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:tmail_ui_user/features/base/mixin/batch_set_email_processing_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/mail_api_mixin.dart'; +import 'package:tmail_ui_user/features/base/mixin/session_mixin.dart'; import 'package:tmail_ui_user/features/composer/domain/exceptions/set_method_exception.dart'; import 'package:tmail_ui_user/features/composer/domain/model/email_request.dart'; import 'package:tmail_ui_user/features/download/domain/model/download_source_view.dart'; @@ -70,11 +71,12 @@ import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:uri/uri.dart'; import 'package:uuid/uuid.dart'; -class EmailAPI with - HandleSetErrorMixin, - MailAPIMixin, - BatchSetEmailProcessingMixin { - +class EmailAPI + with + HandleSetErrorMixin, + SessionMixin, + MailAPIMixin, + BatchSetEmailProcessingMixin { final HttpClient _httpClient; final DownloadManager _downloadManager; final DioClient _dioClient; diff --git a/lib/features/labels/presentation/extensions/handle_label_websocket_extension.dart b/lib/features/labels/presentation/extensions/handle_label_websocket_extension.dart new file mode 100644 index 000000000..e17adcf78 --- /dev/null +++ b/lib/features/labels/presentation/extensions/handle_label_websocket_extension.dart @@ -0,0 +1,71 @@ +import 'package:core/presentation/extensions/either_view_state_extension.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:labels/extensions/list_label_extension.dart'; +import 'package:tmail_ui_user/features/labels/domain/state/get_label_changes_state.dart'; +import 'package:tmail_ui_user/features/labels/presentation/label_controller.dart'; +import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_message.dart'; + +extension HandleLabelWebsocketExtension on LabelController { + void refreshLabelChanges({required jmap.State newState}) { + if (accountId == null || + session == null || + currentLabelState == null || + currentLabelState == newState || + isLabelSettingEnabled.isFalse) { + return; + } + + webSocketQueueHandler?.enqueue(WebSocketMessage(newState: newState)); + } + + Future handleWebSocketMessage(WebSocketMessage message) async { + try { + final refreshViewState = await getLabelChangesInteractor! + .execute( + session!, + accountId!, + currentLabelState!, + ) + .last; + + final refreshState = + refreshViewState.foldSuccessWithResult(); + + if (refreshState is GetLabelChangesSuccess) { + await _handleGetLabelChangesSuccess(refreshState); + } else { + onDataFailureViewState(refreshState); + } + } catch (e, stackTrace) { + logWarning( + 'HandleLabelWebsocketExtension::handleWebSocketMessage: Exception $e'); + onError(e, stackTrace); + } + + if (currentLabelState != null) { + webSocketQueueHandler + ?.removeMessagesUpToCurrent(currentLabelState!.value); + } + } + + Future _handleGetLabelChangesSuccess( + GetLabelChangesSuccess success, + ) async { + final result = success.changesResult; + + setCurrentLabelState(result.newState); + + LabelUtils.applyLabelChanges( + currentLabels: labels, + created: result.createdLabels, + updated: result.updatedLabels, + destroyedIds: result.destroyedLabelIds, + ); + + labels.sortByAlphabetically(); + labels.refresh(); + } +} diff --git a/lib/features/labels/presentation/label_controller.dart b/lib/features/labels/presentation/label_controller.dart index d34445400..111c3e28d 100644 --- a/lib/features/labels/presentation/label_controller.dart +++ b/lib/features/labels/presentation/label_controller.dart @@ -1,10 +1,11 @@ import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; import 'package:core/utils/app_logger.dart'; -import 'package:dartz/dartz.dart'; +import 'package:dartz/dartz.dart' hide State; 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'; import 'package:labels/extensions/list_label_extension.dart'; import 'package:labels/model/label.dart'; import 'package:labels/utils/labels_constants.dart'; @@ -19,12 +20,15 @@ import 'package:tmail_ui_user/features/labels/domain/usecases/create_new_label_i import 'package:tmail_ui_user/features/labels/domain/usecases/edit_label_interactor.dart'; import 'package:tmail_ui_user/features/labels/domain/usecases/delete_a_label_interactor.dart'; import 'package:tmail_ui_user/features/labels/domain/usecases/get_all_label_interactor.dart'; +import 'package:tmail_ui_user/features/labels/domain/usecases/get_label_changes_interactor.dart'; import 'package:tmail_ui_user/features/labels/presentation/extensions/handle_label_action_type_extension.dart'; +import 'package:tmail_ui_user/features/labels/presentation/extensions/handle_label_websocket_extension.dart'; import 'package:tmail_ui_user/features/labels/presentation/label_interactor_bindings.dart'; import 'package:tmail_ui_user/features/labels/presentation/mixin/label_context_menu_mixin.dart'; import 'package:tmail_ui_user/features/labels/presentation/widgets/create_new_label_modal.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_label_setting_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_label_setting_state_interactor.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:tmail_ui_user/main/exceptions/logic_exception.dart'; import 'package:tmail_ui_user/main/routes/dialog_router.dart'; @@ -40,12 +44,26 @@ class LabelController extends BaseController with LabelContextMenuMixin { GetLabelSettingStateInteractor? _getLabelSettingStateInteractor; EditLabelInteractor? _editLabelInteractor; DeleteALabelInteractor? _deleteALabelInteractor; + GetLabelChangesInteractor? _getLabelChangesInteractor; + + WebSocketQueueHandler? _webSocketQueueHandler; + State? _currentLabelState; + AccountId? _accountId; + Session? _session; + + @override + void onInit() { + _initWebSocketQueueHandler(); + super.onInit(); + } bool isLabelCapabilitySupported(Session session, AccountId accountId) { return LabelsConstants.labelsCapability.isSupported(session, accountId); } - void checkLabelSettingState(AccountId accountId) { + void checkLabelSettingState(Session session, AccountId accountId) { + _session = session; + _accountId = accountId; _getLabelSettingStateInteractor = getBinding(); if (_getLabelSettingStateInteractor != null) { @@ -66,12 +84,34 @@ class LabelController extends BaseController with LabelContextMenuMixin { _createNewLabelInteractor = getBinding(); _editLabelInteractor = getBinding(); _deleteALabelInteractor = getBinding(); + _getLabelChangesInteractor = getBinding(); } EditLabelInteractor? get editLabelInteractor => _editLabelInteractor; DeleteALabelInteractor? get deleteALabelInteractor => _deleteALabelInteractor; + GetLabelChangesInteractor? get getLabelChangesInteractor => + _getLabelChangesInteractor; + + WebSocketQueueHandler? get webSocketQueueHandler => + _webSocketQueueHandler; + + AccountId? get accountId => _accountId; + + Session? get session => _session; + + State? get currentLabelState => _currentLabelState; + + void setCurrentLabelState(State? newState) => _currentLabelState = newState; + + void _initWebSocketQueueHandler() { + _webSocketQueueHandler = WebSocketQueueHandler( + processMessageCallback: handleWebSocketMessage, + onErrorCallback: onError, + ); + } + void getAllLabels(AccountId accountId) { if (_getAllLabelInteractor == null) return; @@ -177,6 +217,11 @@ class LabelController extends BaseController with LabelContextMenuMixin { _editLabelInteractor = null; _deleteALabelInteractor = null; _getLabelSettingStateInteractor = null; + _webSocketQueueHandler?.dispose(); + _webSocketQueueHandler = null; + _currentLabelState = null; + _accountId = null; + _session = null; super.onClose(); } } diff --git a/lib/features/labels/presentation/utils/label_utils.dart b/lib/features/labels/presentation/utils/label_utils.dart index f90ead98a..3204520a0 100644 --- a/lib/features/labels/presentation/utils/label_utils.dart +++ b/lib/features/labels/presentation/utils/label_utils.dart @@ -243,4 +243,28 @@ class LabelUtils { return name; } } + + static void applyLabelChanges({ + required List