Fix email list does not automatically refill after bulk delete (#4323)

This commit is contained in:
Dat Vu
2026-02-12 16:02:12 +07:00
committed by GitHub
parent 51b9ffdd5c
commit 1c1d50a9d2
4 changed files with 206 additions and 21 deletions
@@ -594,7 +594,9 @@ class MailboxController extends BaseMailboxController
await _handleRefreshChangeMailboxSuccess(refreshState);
} else {
_clearNewFolderId();
onDataFailureViewState(refreshState);
if (refreshState != null) {
onDataFailureViewState(refreshState);
}
}
} catch (e, stackTrace) {
logWarning('MailboxController::_processMailboxStateQueue:Error processing state: $e');
@@ -100,6 +100,7 @@ class ThreadController extends BaseController with EmailActionController {
bool canLoadMore = false;
bool canSearchMore = false;
MailboxId? _currentMemoryMailboxId;
int _peakEmailCount = 0;
final ScrollController listEmailController = ScrollController();
final latestEmailSelectedOrUnselected = Rxn<PresentationEmail>();
@visibleForTesting
@@ -408,6 +409,20 @@ class ThreadController extends BaseController with EmailActionController {
_checkIfCurrentMailboxCanLoadMore();
}
});
ever(mailboxDashBoardController.emailsInCurrentMailbox, (emails) {
final countEmails = emails.length;
log(
'ThreadController::Ever(mailboxDashBoardController.emailsInCurrentMailbox): '
'Count emails is $countEmails, '
'_peakEmailCount is $_peakEmailCount',
);
if (emails.isEmpty) {
_peakEmailCount = 0;
} else if (countEmails > _peakEmailCount) {
_peakEmailCount = countEmails;
}
});
}
void _handleMarkEmailsAsReadByMailboxId(MailboxId mailboxId) {
@@ -571,7 +586,7 @@ class ThreadController extends BaseController with EmailActionController {
if (mailboxDashBoardController.isSelectionEnabled()) {
mailboxDashBoardController.listEmailSelected.value = listEmailSelected;
}
canLoadMore = newListEmail.length >= ThreadConstants.maxCountEmails;
canLoadMore = emailListSynced.length >= ThreadConstants.maxCountEmails;
if (PlatformInfo.isWeb) {
_validateBrowserHeight();
@@ -616,7 +631,7 @@ class ThreadController extends BaseController with EmailActionController {
}
canLoadMore = false;
loadingMoreStatus.value == LoadingMoreStatus.idle;
loadingMoreStatus.value = LoadingMoreStatus.idle;
cancelSelectEmail();
if (searchController.isSearchEmailRunning) {
@@ -627,11 +642,9 @@ class ThreadController extends BaseController with EmailActionController {
}
UnsignedInt get limitEmailFetched {
final emailsInCurrentMailbox = mailboxDashBoardController.emailsInCurrentMailbox;
final limit = emailsInCurrentMailbox.isNotEmpty
? UnsignedInt(emailsInCurrentMailbox.length)
: ThreadConstants.defaultLimit;
return limit;
return _peakEmailCount == 0
? ThreadConstants.defaultLimit
: UnsignedInt(_peakEmailCount);
}
void _refreshEmailChanges({required jmap.State newState}) {
@@ -699,6 +712,8 @@ class ThreadController extends BaseController with EmailActionController {
needRefreshSearchState: true,
).last;
dispatchState(searchViewState);
final searchState = searchViewState
.foldSuccessWithResult<SearchEmailSuccess>();
@@ -709,7 +724,9 @@ class ThreadController extends BaseController with EmailActionController {
Left(RefreshAllEmailFailure()));
canSearchMore = false;
mailboxDashBoardController.emailsInCurrentMailbox.clear();
onDataFailureViewState(searchState);
if (searchState != null) {
onDataFailureViewState(searchState);
}
}
}
@@ -750,9 +767,11 @@ class ThreadController extends BaseController with EmailActionController {
final refreshState = refreshViewState
.foldSuccessWithResult<RefreshChangesAllEmailSuccess>();
dispatchState(refreshViewState);
if (refreshState is RefreshChangesAllEmailSuccess) {
_refreshChangesAllEmailSuccess(refreshState);
} else {
} else if (refreshState != null) {
onDataFailureViewState(refreshState);
}
}
@@ -781,12 +800,14 @@ class ThreadController extends BaseController with EmailActionController {
)
.last;
dispatchState(viewState);
final emailSuccessState = viewState
.foldSuccessWithResult<GetAllEmailSuccess>();
if (emailSuccessState is GetAllEmailSuccess) {
_getAllEmailSuccess(emailSuccessState);
} else {
} else if (emailSuccessState != null) {
onDataFailureViewState(emailSuccessState);
}
}
@@ -406,6 +406,7 @@ void main() {
mockGetEmailByIdInteractor,
mockCleanAndGetEmailsInMailboxInteractor,
);
threadController.onInit();
mailboxDashboardController.sessionCurrent = SessionFixtures.aliceSession;
mailboxDashboardController.filterMessageOption.value = FilterMessageOption.all;
@@ -266,6 +266,24 @@ void main() {
});
group('_refreshEmailChanges::test', () {
late ThreadController refreshChangesController;
setUp(() {
refreshChangesController = ThreadController(
mockGetEmailsInMailboxInteractor,
mockRefreshChangesEmailsInMailboxInteractor,
mockLoadMoreEmailsInMailboxInteractor,
mockSearchEmailInteractor,
mockSearchMoreEmailInteractor,
mockGetEmailByIdInteractor,
mockCleanAndGetEmailsInMailboxInteractor,
);
});
tearDown(() {
refreshChangesController.onClose();
});
test(
'WHEN thread controller in searching\n'
'AND `MarkAsStarEmailSuccess` is coming\n'
@@ -310,23 +328,24 @@ void main() {
properties: anyNamed('properties'),
needRefreshSearchState: anyNamed('needRefreshSearchState'),
)).thenAnswer((_) => Stream.value(Right(SearchEmailSuccess(emailList))));
when(mockRefreshChangesEmailsInMailboxInteractor.execute(
any,
any,
any,
any,
any,
sort: anyNamed('sort'),
limit: anyNamed('limit'),
propertiesCreated: anyNamed('propertiesCreated'),
propertiesUpdated: anyNamed('propertiesUpdated'),
emailFilter: anyNamed('emailFilter'),
emailFilter: anyNamed('emailFilter'),
)).thenAnswer((_) => Stream.value(Right(RefreshChangesAllEmailSuccess(
emailList: emailList,
emailList: emailList,
currentEmailState: State('old-state'))))
);
// Act
threadController.onInit();
refreshChangesController.onInit();
mockMailboxDashBoardController.emailsInCurrentMailbox.refresh();
mockMailboxDashBoardController.emailUIAction.value =
RefreshChangeEmailAction(newState: State('new-state'));
@@ -357,7 +376,7 @@ void main() {
)).called(1);
expect(mockMailboxDashBoardController.emailsInCurrentMailbox.isNotEmpty, isTrue);
expect(mockMailboxDashBoardController.emailsInCurrentMailbox.length, emailList.length);
expect(threadController.isListEmailScrollViewJumping, isFalse);
expect(refreshChangesController.isListEmailScrollViewJumping, isFalse);
PlatformInfo.isTestingForWeb = false;
});
@@ -406,7 +425,7 @@ void main() {
)).thenAnswer((_) => Stream.value(Right(SearchEmailSuccess(emailList))));
// Act
threadController.onInit();
refreshChangesController.onInit();
mockMailboxDashBoardController.dashBoardAction.value = StartSearchEmailAction();
await untilCalled(mockSearchEmailInteractor.execute(
@@ -439,6 +458,24 @@ void main() {
});
group('_registerObxStreamListener test:', () {
late ThreadController obxListenerController;
setUp(() {
obxListenerController = ThreadController(
mockGetEmailsInMailboxInteractor,
mockRefreshChangesEmailsInMailboxInteractor,
mockLoadMoreEmailsInMailboxInteractor,
mockSearchEmailInteractor,
mockSearchMoreEmailInteractor,
mockGetEmailByIdInteractor,
mockCleanAndGetEmailsInMailboxInteractor,
);
});
tearDown(() {
obxListenerController.onClose();
});
test(
'should call _getEmailsInMailboxInteractor.execute with getLatestChanges is false '
'when mailboxDashBoardController.selectedMailbox updated',
@@ -459,9 +496,9 @@ void main() {
when(mockMailboxDashBoardController.currentSelectMode).thenReturn(Rx(SelectMode.INACTIVE));
when(mockMailboxDashBoardController.filterMessageOption).thenReturn(Rx(FilterMessageOption.all));
when(mockSearchController.searchState).thenReturn(SearchState(SearchStatus.INACTIVE).obs);
// act
threadController.onInit();
obxListenerController.onInit();
mockMailboxDashBoardController.selectedMailbox.value = mailboxAfter;
await untilCalled(mockGetEmailsInMailboxInteractor.execute(
any,
@@ -487,5 +524,129 @@ void main() {
));
});
});
group('limitEmailFetched::test', () {
late RxList<PresentationEmail> emailsRxList;
late ThreadController limitEmailFetchedController;
setUp(() {
emailsRxList = RxList<PresentationEmail>();
limitEmailFetchedController = ThreadController(
mockGetEmailsInMailboxInteractor,
mockRefreshChangesEmailsInMailboxInteractor,
mockLoadMoreEmailsInMailboxInteractor,
mockSearchEmailInteractor,
mockSearchMoreEmailInteractor,
mockGetEmailByIdInteractor,
mockCleanAndGetEmailsInMailboxInteractor,
);
when(mockMailboxDashBoardController.selectedMailbox).thenReturn(Rxn(null));
when(mockMailboxDashBoardController.searchController).thenReturn(mockSearchController);
when(mockMailboxDashBoardController.dashBoardAction).thenReturn(Rxn());
when(mockMailboxDashBoardController.emailUIAction).thenReturn(Rxn());
when(mockMailboxDashBoardController.viewState).thenReturn(Rx(Right(UIState.idle)));
when(mockMailboxDashBoardController.emailsInCurrentMailbox).thenReturn(emailsRxList);
when(mockMailboxDashBoardController.listEmailSelected).thenReturn(RxList());
when(mockMailboxDashBoardController.currentSelectMode).thenReturn(Rx(SelectMode.INACTIVE));
when(mockMailboxDashBoardController.filterMessageOption).thenReturn(Rx(FilterMessageOption.all));
when(mockSearchController.searchState).thenReturn(SearchState(SearchStatus.INACTIVE).obs);
limitEmailFetchedController.onInit();
});
tearDown(() {
limitEmailFetchedController.onClose();
});
List<PresentationEmail> generateEmails(int count, {String prefix = 'email'}) {
return List.generate(
count,
(i) => PresentationEmail(id: EmailId(Id('$prefix$i'))),
);
}
test(
'SHOULD return defaultLimit\n'
'WHEN no emails loaded',
() {
// Assert
expect(limitEmailFetchedController.limitEmailFetched, ThreadConstants.defaultLimit);
});
test(
'SHOULD return email count\n'
'WHEN emails are loaded into the list',
() {
// Act
emailsRxList.addAll(generateEmails(40));
// Assert
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(40));
});
test(
'SHOULD retain peak count\n'
'WHEN emails are bulk deleted from the list',
() {
// Arrange
emailsRxList.addAll(generateEmails(40));
// Act - simulate bulk delete (removeWhere)
emailsRxList.removeRange(0, 20);
// Assert - peak should still be 40, not 20
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(40));
});
test(
'SHOULD reset to defaultLimit\n'
'WHEN resetToOriginalValue is called (mailbox switch)',
() {
// Arrange
emailsRxList.addAll(generateEmails(40));
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(40));
// Act
limitEmailFetchedController.resetToOriginalValue();
// Assert
expect(limitEmailFetchedController.limitEmailFetched, ThreadConstants.defaultLimit);
});
test(
'SHOULD track peak across load-more then delete\n'
'WHEN emails are loaded incrementally and then partially deleted',
() {
// Arrange - simulate initial load + 2 load-mores
emailsRxList.addAll(generateEmails(20, prefix: 'init'));
emailsRxList.addAll(generateEmails(20, prefix: 'more1'));
emailsRxList.addAll(generateEmails(20, prefix: 'more2'));
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(60));
// Act - delete 30 emails
emailsRxList.removeRange(0, 30);
// Assert - peak was 60
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(60));
});
test(
'SHOULD reset between mailbox switches\n'
'WHEN switching to a mailbox with fewer emails',
() {
// Arrange - first mailbox has 40 emails
emailsRxList.addAll(generateEmails(40));
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(40));
// Act - switch mailbox (reset) then load fewer emails
limitEmailFetchedController.resetToOriginalValue();
emailsRxList.addAll(generateEmails(15, prefix: 'new'));
// Assert - peak should be 15, not stale 40
expect(limitEmailFetchedController.limitEmailFetched, UnsignedInt(15));
});
});
});
}