From 88ed0ebb1e51e0efc24f679a77c534b8fc8f1179 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:03:06 +0000 Subject: [PATCH 1/2] Remove moved inbox rows after concurrent body hydration --- src/BetterMail.App/MainWindowViewModel.cs | 8 +++++--- tests/BetterMail.Tests/MainWindowViewModelTests.cs | 9 ++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index c3451b6..5cb523e 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -4110,10 +4110,12 @@ private async Task MoveMessagesAsync( var displayed = Messages.FirstOrDefault(candidate => SameMessage(candidate, message)); var actualDestination = Folders.FirstOrDefault(folder => folder.MailboxId == message.MailboxId && (folder.ProviderId == destinationFolderId || folder.WellKnownName == destinationFolderId))?.ProviderId ?? destinationFolderId; - if (displayed?.FolderId == actualDestination || - ConversationThread.SelectedMessage is { Message: var reading } && SameMessage(reading, message) && reading.FolderId == actualDestination) + // A body hydration can already contain the optimistic destination while + // the row still belongs to the source list. Only the actual navigation + // target determines whether the user has opened the destination folder. + if (_selectedFolder?.MailboxId == message.MailboxId && _selectedFolder.ProviderId == actualDestination) continue; - if (displayed is not null && displayed.FolderId == message.FolderId) + if (displayed is not null && (displayed.FolderId == message.FolderId || displayed.FolderId == actualDestination)) { var index = Messages.IndexOf(displayed); var wasCurrent = SameMessage(SelectedMessage, displayed); diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs index 40905a5..c443516 100644 --- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs +++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs @@ -1864,7 +1864,14 @@ await store.ApplySyncPageAsync( await WaitUntilAsync(() => viewModel.IsUnifiedInbox && viewModel.Messages.Count == 2, cancellationToken); provider.MoveRelease = new(TaskCreationOptions.RunContinuationsAsynchronously); - viewModel.DeleteCommand.Execute(null); + var deleting = ((AsyncCommand)viewModel.DeleteCommand).ExecuteAsync(); + // Simulate body hydration observing the optimistic destination during + // the row's feedback interval, before it is removed from the source list. + await WaitUntilAsync(() => viewModel.BusyActions.Any(action => action.Kind == MailActionKind.Move), cancellationToken); + var deletingMessage = viewModel.SelectedMessage!; + typeof(MainWindowViewModel).GetMethod("ApplyMessageUpdate", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(viewModel, [deletingMessage, deletingMessage with { FolderId = "deleteditems" }]); + await deleting; await WaitUntilAsync(() => provider.MoveDestination == "deleteditems", cancellationToken); Assert.False(viewModel.IsMailActionRunning); Assert.Single(viewModel.Messages); From cafec073b1ea9e95d3591990c1d43a2866643ff4 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:19:33 +0000 Subject: [PATCH 2/2] Preserve moved messages in unified destination views --- src/BetterMail.App/MainWindowViewModel.cs | 7 +++- .../MainWindowViewModelTests.cs | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index 5cb523e..649d7c5 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -4113,8 +4113,11 @@ private async Task MoveMessagesAsync( // A body hydration can already contain the optimistic destination while // the row still belongs to the source list. Only the actual navigation // target determines whether the user has opened the destination folder. - if (_selectedFolder?.MailboxId == message.MailboxId && _selectedFolder.ProviderId == actualDestination) - continue; + var destinationIsOpen = _selectedFolder is not null + ? _selectedFolder.MailboxId == message.MailboxId && _selectedFolder.ProviderId == actualDestination + : IsUnifiedSection && Folders.Any(folder => folder.MailboxId == message.MailboxId && + folder.ProviderId == actualDestination && folder.WellKnownName == "inbox"); + if (destinationIsOpen) continue; if (displayed is not null && (displayed.FolderId == message.FolderId || displayed.FolderId == actualDestination)) { var index = Messages.IndexOf(displayed); diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs index c443516..d90bd04 100644 --- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs +++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs @@ -1019,6 +1019,45 @@ public async Task MoveFeedbackIsImmediateAndDoesNotResetANewerSelectionOrQueueSa finally { provider.MoveRelease.TrySetResult(); if (Directory.Exists(directory)) Directory.Delete(directory, true); } } + [Fact] + public async Task MovingToInboxPreservesRowWhenUnifiedInboxOpensDuringFeedback() + { + var token = TestContext.Current.CancellationToken; + var directory = Path.Combine(Path.GetTempPath(), "bettermail-move-unified-" + Guid.NewGuid()); + var provider = new RecordingProvider { MoveRelease = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + try + { + await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))); + await store.InitializeAsync(token); + var account = new MailAccount("microsoft365", "account", "tenant", "alex@work.example", "Alex", ProviderCapabilities.Mail); + var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Alex"); + await store.SaveAccountAsync(account, token); + await store.SaveMailboxAsync(mailbox, token); + var inbox = new MailFolder(mailbox.Id, "actual-inbox-id", "Inbox", 0, 0, "inbox"); + var archive = new MailFolder(mailbox.Id, "archive", "Archive", 0, 0, "archive"); + await store.SaveFoldersAsync(mailbox.Id, [inbox, archive], token); + provider.FolderResults = [inbox, archive]; + var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null, provider); + vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox); + vm.Folders.Add(new(inbox, "Alex")); vm.Folders.Add(new(archive, "Alex")); + var message = Message(mailbox.Id, "archive", "Restore to inbox", "Full body") with { IsRead = true }; + await store.ApplySyncPageAsync("seed", new([message], null, false), token); + await ((AsyncCommand)vm.SelectFolderCommand).ExecuteAsync(vm.Folders[1]); + var moving = vm.MoveSelectionToFolderAsync(vm.Folders[0]); + await WaitUntilAsync(() => vm.BusyActions.Any(action => action.Kind == MailActionKind.Move), token); + await ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync(); + Assert.False(moving.IsCompleted); + Assert.Equal(message.ProviderId, Assert.Single(vm.Messages).ProviderId); + await moving; + Assert.True(vm.IsUnifiedInbox); + Assert.Equal(message.ProviderId, Assert.Single(vm.Messages).ProviderId); + Assert.Equal(inbox.ProviderId, vm.Messages[0].FolderId); + provider.MoveRelease.TrySetResult(); + await WaitUntilAsync(() => !vm.IsSyncing, token); + } + finally { provider.MoveRelease.TrySetResult(); if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } + [Theory] [InlineData("archive")] [InlineData("deleteditems")]