diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index c3451b6..649d7c5 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -4110,10 +4110,15 @@ 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) - continue; - if (displayed is not null && displayed.FolderId == message.FolderId) + // 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. + 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); var wasCurrent = SameMessage(SelectedMessage, displayed); diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs index 40905a5..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")] @@ -1864,7 +1903,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);