feat: Auto sync label from web socket

This commit is contained in:
dab246
2026-02-03 19:20:52 +07:00
committed by Dat H. Pham
parent c49e48ae1b
commit 9c211c05ef
17 changed files with 536 additions and 24 deletions
@@ -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<Id, PatchObject> Function(
List<EmailId> batchIds,
);
mixin BatchSetEmailProcessingMixin on HandleSetErrorMixin, MailAPIMixin {
mixin BatchSetEmailProcessingMixin
on HandleSetErrorMixin, SessionMixin, MailAPIMixin {
Future<
({
List<EmailId> emailIdsSuccess,
@@ -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;
@@ -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<void> handleWebSocketMessage(WebSocketMessage message) async {
try {
final refreshViewState = await getLabelChangesInteractor!
.execute(
session!,
accountId!,
currentLabelState!,
)
.last;
final refreshState =
refreshViewState.foldSuccessWithResult<GetLabelChangesSuccess>();
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<void> _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();
}
}
@@ -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<GetLabelSettingStateInteractor>();
if (_getLabelSettingStateInteractor != null) {
@@ -66,12 +84,34 @@ class LabelController extends BaseController with LabelContextMenuMixin {
_createNewLabelInteractor = getBinding<CreateNewLabelInteractor>();
_editLabelInteractor = getBinding<EditLabelInteractor>();
_deleteALabelInteractor = getBinding<DeleteALabelInteractor>();
_getLabelChangesInteractor = getBinding<GetLabelChangesInteractor>();
}
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();
}
}
@@ -243,4 +243,28 @@ class LabelUtils {
return name;
}
}
static void applyLabelChanges({
required List<Label> currentLabels,
required List<Label> created,
required List<Label> updated,
required List<Id> destroyedIds,
}) {
final idsToRemove = {
...destroyedIds,
...updated.map((label) => label.id),
};
if (idsToRemove.isNotEmpty) {
currentLabels.removeWhere((label) => idsToRemove.contains(label.id));
}
if (created.isNotEmpty) {
currentLabels.addAll(created);
}
if (updated.isNotEmpty) {
currentLabels.addAll(updated);
}
}
}
@@ -920,7 +920,7 @@ class MailboxDashBoardController extends ReloadableController
);
if (isLabelCapabilitySupported) {
labelController.checkLabelSettingState(currentAccountId);
labelController.checkLabelSettingState(session, currentAccountId);
}
}
@@ -2094,8 +2094,10 @@ class MailboxDashBoardController extends ReloadableController
getServerSetting();
spamReportController.getSpamReportStateAction();
loadAIScribeConfig();
if (isLabelCapabilitySupported && accountId.value != null) {
labelController.checkLabelSettingState(accountId.value!);
if (isLabelCapabilitySupported &&
accountId.value != null &&
sessionCurrent != null) {
labelController.checkLabelSettingState(sessionCurrent!, accountId.value!);
}
}
@@ -2174,8 +2176,10 @@ class MailboxDashBoardController extends ReloadableController
getServerSetting();
spamReportController.getSpamReportStateAction();
loadAIScribeConfig();
if (isLabelCapabilitySupported && accountId.value != null) {
labelController.checkLabelSettingState(accountId.value!);
if (isLabelCapabilitySupported &&
accountId.value != null &&
sessionCurrent != null) {
labelController.checkLabelSettingState(sessionCurrent!, accountId.value!);
}
}
@@ -84,6 +84,35 @@ class StoreMailboxStateToRefreshAction extends PushNotificationStateChangeAction
this.userName
) : super(typeName, newState);
@override
List<Object?> get props => [typeName, newState, accountId, userName];
}
class SynchronizeLabelOnForegroundAction
extends PushNotificationStateChangeAction {
final AccountId accountId;
SynchronizeLabelOnForegroundAction(
TypeName typeName,
jmap.State newState,
this.accountId,
) : super(typeName, newState);
@override
List<Object?> get props => [typeName, newState, accountId];
}
class StoreLabelStateToRefreshAction extends PushNotificationStateChangeAction {
final AccountId accountId;
final UserName userName;
StoreLabelStateToRefreshAction(
TypeName typeName,
jmap.State newState,
this.accountId,
this.userName,
) : super(typeName, newState);
@override
List<Object?> get props => [typeName, newState, accountId, userName];
}
@@ -27,6 +27,7 @@ import 'package:tmail_ui_user/features/push_notification/presentation/controller
import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart';
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/label_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/services/fcm_service.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/utils/fcm_utils.dart';
@@ -195,6 +196,7 @@ class FcmMessageController extends PushBaseController {
userName,
emailChangeListener: EmailChangeListener.instance,
mailboxChangeListener: MailboxChangeListener.instance,
labelChangeListener: LabelChangeListener.instance,
isForeground: false,
session: session);
}
@@ -12,6 +12,7 @@ import 'package:jmap_dart_client/jmap/core/user_name.dart';
import 'package:tmail_ui_user/features/base/action/ui_action.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.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/label_change_listener.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart';
abstract class PushBaseController {
@@ -51,7 +52,8 @@ abstract class PushBaseController {
bool isForeground = true,
Session? session,
required EmailChangeListener emailChangeListener,
required MailboxChangeListener mailboxChangeListener
required MailboxChangeListener mailboxChangeListener,
required LabelChangeListener labelChangeListener,
}) {
log('PushBaseController::mappingTypeStateToAction():mapTypeState: $mapTypeState');
final listTypeName = mapTypeState.keys
@@ -81,6 +83,22 @@ abstract class PushBaseController {
if (listMailboxActions.isNotEmpty) {
mailboxChangeListener.dispatchActions(listMailboxActions);
}
final labelActions = listTypeName
.where((typeName) => typeName == TypeName.labelType)
.map((typeName) => _toPushNotificationAction(
typeName,
accountId,
userName,
mapTypeState,
isForeground,
))
.nonNulls
.toList();
log('PushBaseController::mappingTypeStateToAction():LabelActions: $labelActions');
if (labelActions.isNotEmpty) {
labelChangeListener.dispatchActions(labelActions);
}
}
PushNotificationStateChangeAction? _toPushNotificationAction(
@@ -106,6 +124,15 @@ abstract class PushBaseController {
return isForeground
? SynchronizeMailboxOnForegroundAction(typeName, newState, accountId)
: StoreMailboxStateToRefreshAction(typeName, newState, accountId, userName);
case TypeName.labelType:
return isForeground
? SynchronizeLabelOnForegroundAction(typeName, newState, accountId)
: StoreLabelStateToRefreshAction(
typeName,
newState,
accountId,
userName,
);
}
return null;
}
@@ -23,6 +23,7 @@ import 'package:tmail_ui_user/features/push_notification/domain/usecases/connect
import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart';
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/label_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';
@@ -185,7 +186,7 @@ class WebSocketController extends PushBaseController {
void _enableWebSocketPush() {
log('WebSocketController::_enableWebSocketPush:');
_webSocketChannel?.sink.add(jsonEncode(WebSocketPushEnableRequest.toJson(
dataTypes: [TypeName.emailType, TypeName.mailboxType]
dataTypes: [TypeName.emailType, TypeName.mailboxType, TypeName.labelType]
)));
}
@@ -247,6 +248,7 @@ class WebSocketController extends PushBaseController {
accountId!,
emailChangeListener: EmailChangeListener.instance,
mailboxChangeListener: MailboxChangeListener.instance,
labelChangeListener: LabelChangeListener.instance,
session!.username,
session: session,
);
@@ -0,0 +1,38 @@
import 'package:core/utils/app_logger.dart';
import 'package:jmap_dart_client/jmap/core/state.dart' as jmap;
import 'package:tmail_ui_user/features/base/action/ui_action.dart';
import 'package:tmail_ui_user/features/labels/presentation/extensions/handle_label_websocket_extension.dart';
import 'package:tmail_ui_user/features/labels/presentation/label_controller.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.dart';
import 'package:tmail_ui_user/features/push_notification/presentation/listener/change_listener.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
class LabelChangeListener extends ChangeListener {
LabelController? _labelController;
LabelChangeListener._internal() {
try {
_labelController = getBinding<LabelController>();
} catch (e) {
logError(
'LabelChangeListener::_internal(): IS NOT REGISTERED: ${e.toString()}');
}
}
static final LabelChangeListener _instance = LabelChangeListener._internal();
static LabelChangeListener get instance => _instance;
@override
void dispatchActions(List<Action> actions) {
for (var action in actions) {
if (action is SynchronizeLabelOnForegroundAction) {
_synchronizeLabelOnForegroundAction(action.newState);
}
}
}
void _synchronizeLabelOnForegroundAction(jmap.State newState) {
_labelController?.refreshLabelChanges(newState: newState);
}
}
@@ -26,13 +26,14 @@ import 'package:tmail_ui_user/features/base/mixin/mail_api_mixin.dart';
import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet.dart';
import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet_get_method.dart';
import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet_get_response.dart';
import 'package:tmail_ui_user/features/base/mixin/session_mixin.dart';
import 'package:tmail_ui_user/features/thread/data/extensions/list_email_id_extension.dart';
import 'package:tmail_ui_user/features/thread/data/model/email_change_response.dart';
import 'package:tmail_ui_user/features/thread/domain/model/email_response.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_emails_response.dart';
import 'package:tmail_ui_user/main/error/capability_validator.dart';
class ThreadAPI with HandleSetErrorMixin, MailAPIMixin {
class ThreadAPI with HandleSetErrorMixin, SessionMixin, MailAPIMixin {
final HttpClient httpClient;