[bugfix] TF-2316 Can not delete folder when its children are hidden (#2519)

This commit is contained in:
Bùi Trung Hiếu
2026-04-06 13:18:34 +07:00
committed by GitHub
parent 7cf61ac2f0
commit ea7b9fed33
7 changed files with 785 additions and 371 deletions
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/utils/app_logger.dart';
@@ -5,6 +6,8 @@ import 'package:dartz/dartz.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/mail/mailbox/mailbox.dart';
import 'package:model/extensions/mailbox_extension.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/mailbox/domain/repository/mailbox_repository.dart';
import 'package:tmail_ui_user/features/mailbox/domain/state/delete_multiple_mailbox_state.dart';
@@ -14,24 +17,40 @@ class DeleteMultipleMailboxInteractor {
DeleteMultipleMailboxInteractor(this._mailboxRepository);
Stream<Either<Failure, Success>> execute(
Session session,
AccountId accountId,
Map<MailboxId, List<MailboxId>> mapMailboxIdToDelete,
List<MailboxId> listMailboxIdToDelete
Session session,
AccountId accountId,
List<MailboxId> selectedMailboxIds,
) async* {
try {
yield Right<Failure, Success>(LoadingDeleteMultipleMailboxAll());
final currentMailboxState = await _mailboxRepository.getMailboxState(session, accountId);
final mailboxResponses = await _mailboxRepository.getAllMailbox(session, accountId).toList();
final Set<MailboxId> seenIds = {};
final List<PresentationMailbox> allMailboxes = [];
for (final response in mailboxResponses) {
for (final mailbox in response.mailboxes) {
final presentation = mailbox.toPresentationMailbox();
if (seenIds.add(presentation.id)) {
allMailboxes.add(presentation);
}
}
}
final deleteMap = _buildDeleteMap(selectedMailboxIds, allMailboxes);
final mapMailboxIdToDelete = deleteMap.value1;
final listMailboxIdToDelete = deleteMap.value2;
final listResult = await Future.wait(
mapMailboxIdToDelete.keys.map((mailboxId) {
final mailboxIdsToDelete = mapMailboxIdToDelete[mailboxId]!;
return _mailboxRepository.deleteMultipleMailbox(
session,
accountId,
mailboxIdsToDelete);
})
mapMailboxIdToDelete.keys.map((mailboxId) {
return _mailboxRepository.deleteMultipleMailbox(
session,
accountId,
mapMailboxIdToDelete[mailboxId]!,
);
}),
);
final allSuccess = listResult.every((result) => result.isEmpty);
@@ -39,18 +58,85 @@ class DeleteMultipleMailboxInteractor {
if (allSuccess) {
yield Right<Failure, Success>(DeleteMultipleMailboxAllSuccess(
listMailboxIdToDelete,
currentMailboxState: currentMailboxState));
listMailboxIdToDelete,
currentMailboxState: currentMailboxState,
));
} else if (allFailed) {
yield Left<Failure, Success>(DeleteMultipleMailboxAllFailure());
} else {
yield Right<Failure, Success>(DeleteMultipleMailboxHasSomeSuccess(
listMailboxIdToDelete,
currentMailboxState: currentMailboxState));
listMailboxIdToDelete,
currentMailboxState: currentMailboxState,
));
}
} catch (e) {
logWarning('DeleteMultipleMailboxInteractor::execute(): exception: $e');
yield Left<Failure, Success>(DeleteMultipleMailboxFailure(e));
}
}
}
/// Builds the map of [MailboxId] → ordered delete list for each selected root,
/// plus a flat list of all IDs to delete.
///
/// Uses [allMailboxes] (subscribed and unsubscribed) so hidden subfolders are
/// automatically included — no separate unsubscribed-tree pass is needed.
Tuple2<Map<MailboxId, List<MailboxId>>, List<MailboxId>> _buildDeleteMap(
List<MailboxId> selectedMailboxIds,
List<PresentationMailbox> allMailboxes,
) {
final parentToChildren = _buildParentToChildrenMap(allMailboxes);
final Map<MailboxId, List<MailboxId>> mapDescendantIds = {};
final Set<MailboxId> processedIds = {};
for (final mailboxId in selectedMailboxIds) {
if (processedIds.contains(mailboxId)) continue;
// Collect all descendants (pre-order DFS), then reverse so deepest children
// come first — the server requires children to be deleted before parents.
final descendants = _collectDescendantsPreOrder(mailboxId, parentToChildren);
final deleteOrder = descendants.reversed.toList();
mapDescendantIds[mailboxId] = deleteOrder;
processedIds.addAll(deleteOrder);
}
final allMailboxIds = mapDescendantIds.values.expand((ids) => ids).toList();
return Tuple2(mapDescendantIds, allMailboxIds);
}
Map<MailboxId, List<MailboxId>> _buildParentToChildrenMap(
List<PresentationMailbox> mailboxes,
) {
final Map<MailboxId, List<MailboxId>> parentToChildren = HashMap();
for (final mailbox in mailboxes) {
final parentId = mailbox.parentId;
if (parentId != null) {
parentToChildren.putIfAbsent(parentId, () => []).add(mailbox.id);
}
}
return parentToChildren;
}
/// Pre-order DFS: root first, then children. Caller should reverse the result
/// to get deepest-first deletion order.
List<MailboxId> _collectDescendantsPreOrder(
MailboxId rootId,
Map<MailboxId, List<MailboxId>> parentToChildren,
) {
final result = <MailboxId>[];
final stack = ListQueue<MailboxId>()..addLast(rootId);
while (stack.isNotEmpty) {
final current = stack.removeLast();
result.add(current);
final children = parentToChildren[current];
if (children != null) {
// Push in reverse so the first child is processed first (stack is LIFO).
for (final child in children.reversed) {
stack.addLast(child);
}
}
}
return result;
}
}
@@ -79,7 +79,6 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.d
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/open_mailbox_view_event.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_action_reactor.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_utils.dart';
import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/mailbox_creator_arguments.dart';
import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_mailbox_arguments.dart';
@@ -565,7 +564,7 @@ class MailboxController extends BaseMailboxController
consumeState(Stream.value(Left(GetAllMailboxFailure(NotFoundSessionException()))));
}
}
void _refreshMailboxChanges({required jmap.State newState}) {
log('MailboxController::_refreshMailboxChanges():newState: $newState');
if (accountId == null ||
@@ -1039,18 +1038,10 @@ class MailboxController extends BaseMailboxController
void _deleteMailboxAction(PresentationMailbox presentationMailbox) {
if (session != null && accountId != null) {
final tupleMap = MailboxUtils.generateMapDescendantIdsAndMailboxIdList(
[presentationMailbox],
defaultMailboxTree.value,
personalMailboxTree.value);
final mapDescendantIds = tupleMap.value1;
final listMailboxId = tupleMap.value2;
consumeState(_deleteMultipleMailboxInteractor.execute(
session!,
accountId!,
mapDescendantIds,
listMailboxId,
[presentationMailbox.id],
));
} else {
_deleteMailboxFailure(DeleteMultipleMailboxFailure(null));
@@ -1,60 +1,9 @@
import 'package:core/presentation/utils/responsive_utils.dart';
import 'package:core/utils/app_logger.dart';
import 'package:dartz/dartz.dart';
import 'package:email_recovery/email_recovery/capability_deleted_messages_vault.dart';
import 'package:flutter/material.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/mail/mailbox/mailbox.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree.dart';
import 'package:tmail_ui_user/main/error/capability_validator.dart';
class MailboxUtils {
static Tuple2<Map<MailboxId, List<MailboxId>>, List<MailboxId>> generateMapDescendantIdsAndMailboxIdList(
List<PresentationMailbox> selectedMailboxList,
MailboxTree defaultMailboxTree,
MailboxTree folderMailboxTree,
) {
Map<MailboxId, List<MailboxId>> mapDescendantIds = {};
List<MailboxId> allMailboxIds = [];
for (var mailbox in selectedMailboxList) {
final currentMailboxId = mailbox.id;
if (allMailboxIds.contains(currentMailboxId)) {
continue;
} else {
final matchedNode = defaultMailboxTree.findNode((node) => node.item.id == currentMailboxId)
?? folderMailboxTree.findNode((node) => node.item.id == currentMailboxId);
if (matchedNode != null) {
final descendantIds = matchedNode.descendantsAsList().mailboxIds;
final descendantIdsReversed = descendantIds.reversed.toList();
mapDescendantIds[currentMailboxId] = descendantIdsReversed;
allMailboxIds.addAll(descendantIdsReversed);
}
}
}
log('MailboxUtils::generateMapDescendantIdsAndMailboxIdList(): mapDescendantIds: $mapDescendantIds');
return Tuple2(mapDescendantIds, allMailboxIds);
}
static EdgeInsets getPaddingListViewMailboxSearched(BuildContext context, ResponsiveUtils responsiveUtils) {
if (responsiveUtils.isWebDesktop(context)) {
return const EdgeInsets.only(left: 16, right: 16, bottom: 16);
} else {
if (responsiveUtils.isScreenWithShortestSide(context)) {
return const EdgeInsets.all(16);
} else {
return const EdgeInsets.symmetric(horizontal: 32, vertical: 16);
}
}
}
static bool isDeletedMessageVaultSupported(Session? session, AccountId? accountId) {
if (session == null || accountId == null) {
return false;
@@ -63,7 +63,6 @@ import 'package:tmail_ui_user/features/mailbox/presentation/extensions/presentat
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_actions.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_action_reactor.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_utils.dart';
import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/mailbox_creator_arguments.dart';
import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_mailbox_arguments.dart';
@@ -584,19 +583,10 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa
void _deleteMailboxAction(PresentationMailbox presentationMailbox) {
if (session != null && accountId != null) {
final tupleMap = MailboxUtils.generateMapDescendantIdsAndMailboxIdList(
[presentationMailbox],
defaultMailboxTree.value,
personalMailboxTree.value
);
final mapDescendantIds = tupleMap.value1;
final listMailboxId = tupleMap.value2;
consumeState(_deleteMultipleMailboxInteractor.execute(
session!,
accountId!,
mapDescendantIds,
listMailboxId
[presentationMailbox.id],
));
} else {
_deleteMailboxFailure(DeleteMultipleMailboxFailure(null));