From 54148716bb5ddff893e9e62f6fbb1a50641a6fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Mon, 20 Apr 2026 02:18:23 +0200 Subject: [PATCH] Fixing UI thread issues with bulk operations and request queue refactoring. --- Wino.Core.Domain/Interfaces/IMailService.cs | 1 + .../Models/MailItem/MailCopyStateUpdate.cs | 3 + .../Models/Requests/RequestBase.cs | 4 +- .../Services/MailRequestStateTests.cs | 22 +- .../Services/MailThreadingTests.cs | 116 ++++++++ .../BaseSynchronizerUiChangeTests.cs | 84 ++++++ .../UnifiedImapSynchronizerTests.cs | 59 ++++ .../WinoSynchronizerMailRequestTests.cs | 42 +++ .../Requests/Folder/EmptyFolderRequest.cs | 25 +- .../Folder/MarkFolderAsReadRequest.cs | 37 +-- Wino.Core/Requests/Mail/ArchiveRequest.cs | 20 ++ Wino.Core/Requests/Mail/ChangeFlagRequest.cs | 48 +++- .../Requests/Mail/ChangeJunkStateRequest.cs | 20 ++ Wino.Core/Requests/Mail/DeleteRequest.cs | 20 ++ Wino.Core/Requests/Mail/MarkReadRequest.cs | 48 +++- Wino.Core/Requests/Mail/MoveRequest.cs | 20 ++ Wino.Core/Services/SynchronizationManager.cs | 33 ++- Wino.Core/Services/WinoRequestDelegator.cs | 20 +- Wino.Core/Services/WinoRequestProcessor.cs | 10 +- Wino.Core/Synchronizers/BaseSynchronizer.cs | 72 +++++ .../ImapSync/UnifiedImapSynchronizer.cs | 184 +++++++++--- Wino.Core/Synchronizers/ImapSynchronizer.cs | 112 +++++--- .../Synchronizers/OutlookSynchronizer.cs | 5 +- Wino.Core/Synchronizers/WinoSynchronizer.cs | 14 +- .../Collections/WinoMailCollection.cs | 147 +++++++++- .../Data/MailItemViewModel.cs | 24 ++ Wino.Mail.ViewModels/MailBaseViewModel.cs | 54 ++++ Wino.Mail.ViewModels/MailListPageViewModel.cs | 268 ++++++++++++++++-- .../MailRenderingPageViewModel.cs | 17 ++ .../Services/NotificationBuilder.cs | 46 ++- Wino.Messages/UI/BulkMailAddedMessage.cs | 7 + Wino.Messages/UI/BulkMailReadStatusChanged.cs | 6 + Wino.Messages/UI/BulkMailRemovedMessage.cs | 7 + .../UI/BulkMailStateUpdatedMessage.cs | 8 + Wino.Messages/UI/BulkMailUpdatedMessage.cs | 6 +- Wino.Messages/UI/MailStateChange.cs | 11 + Wino.Messages/UI/MailStateUpdatedMessage.cs | 7 + Wino.Services/MailService.cs | 223 +++++++++++++-- 38 files changed, 1644 insertions(+), 206 deletions(-) create mode 100644 Wino.Core.Domain/Models/MailItem/MailCopyStateUpdate.cs create mode 100644 Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs create mode 100644 Wino.Messages/UI/BulkMailAddedMessage.cs create mode 100644 Wino.Messages/UI/BulkMailReadStatusChanged.cs create mode 100644 Wino.Messages/UI/BulkMailRemovedMessage.cs create mode 100644 Wino.Messages/UI/BulkMailStateUpdatedMessage.cs create mode 100644 Wino.Messages/UI/MailStateChange.cs create mode 100644 Wino.Messages/UI/MailStateUpdatedMessage.cs diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index f5f4b30d..5afdb5b6 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -36,6 +36,7 @@ public interface IMailService Task ChangeReadStatusAsync(string mailCopyId, bool isRead); Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged); + Task ApplyMailStateUpdatesAsync(IEnumerable updates); Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); diff --git a/Wino.Core.Domain/Models/MailItem/MailCopyStateUpdate.cs b/Wino.Core.Domain/Models/MailItem/MailCopyStateUpdate.cs new file mode 100644 index 00000000..5581782c --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailCopyStateUpdate.cs @@ -0,0 +1,3 @@ +namespace Wino.Core.Domain.Models.MailItem; + +public sealed record MailCopyStateUpdate(string MailCopyId, bool? IsRead = null, bool? IsFlagged = null); diff --git a/Wino.Core.Domain/Models/Requests/RequestBase.cs b/Wino.Core.Domain/Models/Requests/RequestBase.cs index 2f307e17..586654b3 100644 --- a/Wino.Core.Domain/Models/Requests/RequestBase.cs +++ b/Wino.Core.Domain/Models/Requests/RequestBase.cs @@ -44,6 +44,6 @@ public class BatchCollection : List, IUIChangeReques public BatchCollection(IEnumerable collection) : base(collection) { } - public void ApplyUIChanges() => ForEach(x => x.ApplyUIChanges()); - public void RevertUIChanges() => ForEach(x => x.RevertUIChanges()); + public virtual void ApplyUIChanges() => ForEach(x => x.ApplyUIChanges()); + public virtual void RevertUIChanges() => ForEach(x => x.RevertUIChanges()); } diff --git a/Wino.Core.Tests/Services/MailRequestStateTests.cs b/Wino.Core.Tests/Services/MailRequestStateTests.cs index ae41b930..82d9a35c 100644 --- a/Wino.Core.Tests/Services/MailRequestStateTests.cs +++ b/Wino.Core.Tests/Services/MailRequestStateTests.cs @@ -27,10 +27,10 @@ public sealed class MailRequestStateTests request.RevertUIChanges(); mailCopy.IsRead.Should().BeFalse(); - recipient.Updated.Should().HaveCount(2); - recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); - recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted); - recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse(); + recipient.StateUpdates.Should().HaveCount(2); + recipient.StateUpdates[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); + recipient.StateUpdates[1].Source.Should().Be(EntityUpdateSource.ClientReverted); + recipient.StateUpdates[1].UpdatedState.IsRead.Should().BeFalse(); } finally { @@ -55,10 +55,10 @@ public sealed class MailRequestStateTests request.RevertUIChanges(); mailCopy.IsFlagged.Should().BeFalse(); - recipient.Updated.Should().HaveCount(2); - recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); - recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted); - recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse(); + recipient.StateUpdates.Should().HaveCount(2); + recipient.StateUpdates[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); + recipient.StateUpdates[1].Source.Should().Be(EntityUpdateSource.ClientReverted); + recipient.StateUpdates[1].UpdatedState.IsFlagged.Should().BeFalse(); } finally { @@ -76,10 +76,10 @@ public sealed class MailRequestStateTests IsFlagged = isFlagged }; - internal sealed class MailRequestRecipient : IRecipient + internal sealed class MailRequestRecipient : IRecipient { - public List Updated { get; } = []; + public List StateUpdates { get; } = []; - public void Receive(MailUpdatedMessage message) => Updated.Add(message); + public void Receive(MailStateUpdatedMessage message) => StateUpdates.Add(message); } } diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs index 3e1f827b..07b20dfe 100644 --- a/Wino.Core.Tests/Services/MailThreadingTests.cs +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -1,3 +1,4 @@ +using CommunityToolkit.Mvvm.Messaging; using FluentAssertions; using MimeKit; using Moq; @@ -8,6 +9,7 @@ using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Tests.Helpers; +using Wino.Messaging.UI; using Wino.Services; using Xunit; @@ -229,6 +231,102 @@ public class MailThreadingTests : IAsyncLifetime mimeMessage.ReplyTo.Mailboxes.Should().ContainSingle(m => m.Address == "support@test.local"); } + [Fact] + public async Task ApplyMailStateUpdatesAsync_ForBatchReadStateChange_SendsBulkMailUpdatedMessage() + { + var mail1 = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = _draftFolder.Id, + IsRead = true, + Subject = "First" + }; + var mail2 = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = _draftFolder.Id, + IsRead = true, + Subject = "Second" + }; + + await _databaseService.Connection.InsertAllAsync(new[] { mail1, mail2 }, typeof(MailCopy)); + + var recipient = new MailUpdateRecipient(); + WeakReferenceMessenger.Default.Register(recipient); + WeakReferenceMessenger.Default.Register(recipient); + + try + { + await _mailService.ApplyMailStateUpdatesAsync( + [ + new MailCopyStateUpdate(mail1.Id, IsRead: false), + new MailCopyStateUpdate(mail2.Id, IsRead: false) + ]); + + recipient.SingleUpdates.Should().BeEmpty(); + recipient.BulkUpdates.Should().ContainSingle(); + recipient.BulkUpdates[0].Source.Should().Be(EntityUpdateSource.Server); + recipient.BulkUpdates[0].ChangedProperties.Should().Be(MailCopyChangeFlags.IsRead); + recipient.BulkUpdates[0].UpdatedMails.Should().HaveCount(2); + recipient.BulkUpdates[0].UpdatedMails.Should().OnlyContain(x => !x.IsRead); + + (await _databaseService.Connection.FindAsync(mail1.UniqueId))!.IsRead.Should().BeFalse(); + (await _databaseService.Connection.FindAsync(mail2.UniqueId))!.IsRead.Should().BeFalse(); + } + finally + { + WeakReferenceMessenger.Default.Unregister(recipient); + WeakReferenceMessenger.Default.Unregister(recipient); + } + } + + [Fact] + public async Task ApplyMailStateUpdatesAsync_ForBatchMarkRead_SendsBulkMailReadStatusChanged() + { + var mail1 = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = _draftFolder.Id, + IsRead = false, + Subject = "First unread" + }; + var mail2 = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = _draftFolder.Id, + IsRead = false, + Subject = "Second unread" + }; + + await _databaseService.Connection.InsertAllAsync(new[] { mail1, mail2 }, typeof(MailCopy)); + + var recipient = new MailReadStatusRecipient(); + WeakReferenceMessenger.Default.Register(recipient); + WeakReferenceMessenger.Default.Register(recipient); + + try + { + await _mailService.ApplyMailStateUpdatesAsync( + [ + new MailCopyStateUpdate(mail1.Id, IsRead: true), + new MailCopyStateUpdate(mail2.Id, IsRead: true) + ]); + + recipient.SingleUpdates.Should().BeEmpty(); + recipient.BulkUpdates.Should().ContainSingle(); + recipient.BulkUpdates[0].UniqueIds.Should().BeEquivalentTo([mail1.UniqueId, mail2.UniqueId]); + } + finally + { + WeakReferenceMessenger.Default.Unregister(recipient); + WeakReferenceMessenger.Default.Unregister(recipient); + } + } + private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null) { var message = new MimeMessage(); @@ -243,6 +341,24 @@ public class MailThreadingTests : IAsyncLifetime return message; } + internal sealed class MailUpdateRecipient : IRecipient, IRecipient + { + public List SingleUpdates { get; } = []; + public List BulkUpdates { get; } = []; + + public void Receive(MailUpdatedMessage message) => SingleUpdates.Add(message); + public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message); + } + + internal sealed class MailReadStatusRecipient : IRecipient, IRecipient + { + public List SingleUpdates { get; } = []; + public List BulkUpdates { get; } = []; + + public void Receive(MailReadStatusChanged message) => SingleUpdates.Add(message); + public void Receive(BulkMailReadStatusChanged message) => BulkUpdates.Add(message); + } + private static MailService BuildMailService(InMemoryDatabaseService db) { var signatureService = new Mock(); diff --git a/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs b/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs new file mode 100644 index 00000000..51dd96f9 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using FluentAssertions; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Requests.Bundles; +using Wino.Core.Requests.Mail; +using Wino.Core.Synchronizers; +using Wino.Messaging.UI; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public sealed class BaseSynchronizerUiChangeTests +{ + [Fact] + public void ApplyOptimisticUiChanges_UsesBundleUiChangeRequest_ForBatchBundle() + { + var folderId = Guid.NewGuid(); + var account = new MailAccount { Id = Guid.NewGuid(), Name = "Test account" }; + var synchronizer = new TestSynchronizer(account); + var recipient = new UiChangeRecipient(); + + var request1 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true); + var request2 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true); + var batchRequest = new BatchMarkReadRequest([request1, request2]); + var bundle = new HttpRequestBundle(new object(), batchRequest, request1); + + WeakReferenceMessenger.Default.Register(recipient); + WeakReferenceMessenger.Default.Register(recipient); + + try + { + synchronizer.ApplyUiChanges([bundle]); + + recipient.SingleUpdates.Should().BeEmpty(); + recipient.BulkUpdates.Should().ContainSingle(); + recipient.BulkUpdates[0].UpdatedStates.Should().HaveCount(2); + request1.Item.IsRead.Should().BeFalse(); + request2.Item.IsRead.Should().BeFalse(); + } + finally + { + WeakReferenceMessenger.Default.Unregister(recipient); + WeakReferenceMessenger.Default.Unregister(recipient); + } + } + + private static MailCopy CreateMailCopy(Guid folderId, bool isRead) => + new() + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = folderId, + IsRead = isRead + }; + + private sealed class TestSynchronizer : BaseSynchronizer + { + public TestSynchronizer(MailAccount account) + : base(account, WeakReferenceMessenger.Default) + { + } + + public void ApplyUiChanges(List> bundles) => ApplyOptimisticUiChanges(bundles); + + public override Task ExecuteNativeRequestsAsync(List> batchedRequests, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + internal sealed class UiChangeRecipient : IRecipient, IRecipient + { + public List SingleUpdates { get; } = []; + public List BulkUpdates { get; } = []; + + public void Receive(MailStateUpdatedMessage message) => SingleUpdates.Add(message); + public void Receive(BulkMailStateUpdatedMessage message) => BulkUpdates.Add(message); + } +} diff --git a/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs b/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs index 87c00b9d..6472a594 100644 --- a/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs +++ b/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs @@ -2,12 +2,14 @@ using FluentAssertions; using MailKit; using MailKit.Net.Imap; using Moq; +using System.Linq; using System.Reflection; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Synchronizers.ImapSync; +using Wino.Services.Extensions; using Xunit; using IMailService = Wino.Core.Domain.Interfaces.IMailService; @@ -223,4 +225,61 @@ public class UnifiedImapSynchronizerTests capturedPackage.Should().NotBeNull(); capturedPackage!.MimeMessage.Should().BeNull(); } + + [Fact] + public async Task ProcessSummariesAsync_ShouldBatchStateUpdates_ForExistingMailCopies() + { + var localFolder = new MailItemFolder + { + Id = Guid.NewGuid(), + MailAccountId = Guid.NewGuid(), + FolderName = "Inbox", + RemoteFolderId = "INBOX" + }; + + var summaryMock = new Mock(); + summaryMock.SetupGet(x => x.UniqueId).Returns(new UniqueId(42)); + summaryMock.SetupGet(x => x.Flags).Returns(MessageFlags.Seen | MessageFlags.Flagged); + + var existingMailCopy = new MailCopy + { + Id = MailkitClientExtensions.CreateUid(localFolder.Id, 42), + IsRead = false, + IsFlagged = false + }; + + var mailServiceMock = new Mock(); + mailServiceMock + .Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny>())) + .ReturnsAsync([existingMailCopy]); + mailServiceMock + .Setup(x => x.ApplyMailStateUpdatesAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + var sut = new UnifiedImapSynchronizer( + Mock.Of(), + mailServiceMock.Object, + Mock.Of()); + + var imapSynchronizerMock = new Mock(); + + var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic); + processMethod.Should().NotBeNull(); + + var task = (Task>)processMethod!.Invoke( + sut, + [imapSynchronizerMock.Object, localFolder, new List { summaryMock.Object }, CancellationToken.None])!; + + var result = await task; + + result.Should().BeEmpty(); + mailServiceMock.Verify( + x => x.ApplyMailStateUpdatesAsync(It.Is>(updates => + updates.Count() == 1 + && updates.First().MailCopyId == existingMailCopy.Id + && updates.First().IsRead == true + && updates.First().IsFlagged == true)), + Times.Once); + mailServiceMock.Verify(x => x.CreateMailAsync(It.IsAny(), It.IsAny()), Times.Never); + } } diff --git a/Wino.Core.Tests/Synchronizers/WinoSynchronizerMailRequestTests.cs b/Wino.Core.Tests/Synchronizers/WinoSynchronizerMailRequestTests.cs index 6227b056..9d605d72 100644 --- a/Wino.Core.Tests/Synchronizers/WinoSynchronizerMailRequestTests.cs +++ b/Wino.Core.Tests/Synchronizers/WinoSynchronizerMailRequestTests.cs @@ -10,6 +10,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Mail; using Wino.Core.Requests.Folder; using Wino.Core.Synchronizers; using Xunit; @@ -49,6 +50,38 @@ public sealed class WinoSynchronizerMailRequestTests synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1); } + [Fact] + public async Task ExecuteRequests_should_dispatch_grouped_mark_read_requests_with_composite_grouping_key() + { + var synchronizer = new TestMailSynchronizer(); + var folderId = Guid.NewGuid(); + + synchronizer.QueueRequest(new MarkReadRequest(new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = folderId + }, IsRead: true)); + + synchronizer.QueueRequest(new MarkReadRequest(new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = folderId + }, IsRead: true)); + + var result = await synchronizer.SynchronizeMailsAsync(new MailSynchronizationOptions + { + AccountId = synchronizer.Account.Id, + Type = MailSynchronizationType.ExecuteRequests + }); + + result.CompletedState.Should().Be(SynchronizationCompletedState.Success); + synchronizer.MarkReadInvocationCount.Should().Be(1); + synchronizer.LastMarkReadBatchCount.Should().Be(2); + synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1); + } + private sealed class TestMailSynchronizer : WinoSynchronizer { @@ -60,6 +93,8 @@ public sealed class WinoSynchronizerMailRequestTests public override uint BatchModificationSize => 1; public override uint InitialMessageDownloadCountPerFolder => 0; public int CreateRootFolderInvocationCount { get; private set; } + public int MarkReadInvocationCount { get; private set; } + public int LastMarkReadBatchCount { get; private set; } public int ExecuteNativeRequestsInvocationCount { get; private set; } public override List> CreateRootFolder(CreateRootFolderRequest request) @@ -68,6 +103,13 @@ public sealed class WinoSynchronizerMailRequestTests return [new TestRequestBundle(new object(), request)]; } + public override List> MarkRead(BatchMarkReadRequest request) + { + MarkReadInvocationCount++; + LastMarkReadBatchCount = request.Count; + return [new TestRequestBundle(new object(), request[0])]; + } + public override Task ExecuteNativeRequestsAsync(List> batchedRequests, CancellationToken cancellationToken = default) { ExecuteNativeRequestsInvocationCount++; diff --git a/Wino.Core/Requests/Folder/EmptyFolderRequest.cs b/Wino.Core/Requests/Folder/EmptyFolderRequest.cs index bf2b34f9..3082470b 100644 --- a/Wino.Core/Requests/Folder/EmptyFolderRequest.cs +++ b/Wino.Core/Requests/Folder/EmptyFolderRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -14,18 +15,26 @@ public record EmptyFolderRequest(MailItemFolder Folder, List MailsToDe public bool ExcludeMustHaveFolders => false; public override void ApplyUIChanges() { - foreach (var item in MailsToDelete) - { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item, EntityUpdateSource.ClientUpdated)); - } + var removedMails = MailsToDelete? + .Where(item => item != null) + .ToList(); + + if (removedMails == null || removedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - foreach (var item in MailsToDelete) - { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(item, EntityUpdateSource.ClientReverted)); - } + var addedMails = MailsToDelete? + .Where(item => item != null) + .ToList(); + + if (addedMails == null || addedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailAddedMessage(addedMails, EntityUpdateSource.ClientReverted)); } public List SynchronizationFolderIds => [Folder.Id]; diff --git a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs index 172a1aa0..1e844d08 100644 --- a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs +++ b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -17,31 +18,31 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail public override void ApplyUIChanges() { - if (MailsToMarkRead == null || MailsToMarkRead.Count == 0) return; + var updatedMails = MailsToMarkRead? + .Where(item => item != null && !item.IsRead) + .Select(item => new MailStateChange(item.UniqueId, IsRead: true)) + .ToList(); - foreach (var item in MailsToMarkRead) - { - // Skip if already read - if (item.IsRead) continue; + if (updatedMails == null || updatedMails.Count == 0) + return; - item.IsRead = true; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); - } + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - if (MailsToMarkRead == null || MailsToMarkRead.Count == 0) return; + var updatedMails = MailsToMarkRead? + .Where(item => item != null && !item.IsRead) + .Select(item => new MailStateChange(item.UniqueId, IsRead: false)) + .ToList(); - foreach (var item in MailsToMarkRead) - { - // Skip if already unread (wasn't changed by ApplyUIChanges) - if (!item.IsRead) continue; + if (updatedMails == null || updatedMails.Count == 0) + return; - item.IsRead = false; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); - } + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/ArchiveRequest.cs b/Wino.Core/Requests/Mail/ArchiveRequest.cs index 1fd87e22..9fd8d550 100644 --- a/Wino.Core/Requests/Mail/ArchiveRequest.cs +++ b/Wino.Core/Requests/Mail/ArchiveRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -38,6 +39,7 @@ public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder Fro } public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Archive; + public override object GroupingKey() => (Operation, IsArchiving, FromFolder.Id, ToFolder?.Id ?? Guid.Empty); public override void ApplyUIChanges() { @@ -55,4 +57,22 @@ public class BatchArchiveRequest : BatchCollection public BatchArchiveRequest(IEnumerable collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var removedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (removedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var addedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (addedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailAddedMessage(addedMails, EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs index 7ac9ac80..befa489c 100644 --- a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs +++ b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -25,25 +26,26 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase /// If the mail is already in the desired flagged state, no change is needed. /// public bool IsNoOp { get; } = Item.IsFlagged == IsFlagged; + public bool OriginalIsFlagged => _originalIsFlagged; + + public override object GroupingKey() => (Operation, Item.FolderId, IsFlagged); public override void ApplyUIChanges() { - // Skip UI update if the mail is already in the desired state if (IsNoOp) return; - Item.IsFlagged = IsFlagged; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsFlagged)); + WeakReferenceMessenger.Default.Send(new MailStateUpdatedMessage( + new MailStateChange(Item.UniqueId, IsFlagged: IsFlagged), + EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - // Skip UI revert if this was a no-op request if (IsNoOp) return; - Item.IsFlagged = _originalIsFlagged; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged)); + WeakReferenceMessenger.Default.Send(new MailStateUpdatedMessage( + new MailStateChange(Item.UniqueId, IsFlagged: _originalIsFlagged), + EntityUpdateSource.ClientReverted)); } } @@ -52,4 +54,34 @@ public class BatchChangeFlagRequest : BatchCollection public BatchChangeFlagRequest(IEnumerable collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var updatedMails = this + .Where(x => !x.IsNoOp) + .Select(x => new MailStateChange(x.Item.UniqueId, IsFlagged: x.IsFlagged)) + .ToList(); + + if (updatedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var updatedMails = this + .Where(x => !x.IsNoOp) + .Select(x => new MailStateChange(x.Item.UniqueId, IsFlagged: x.OriginalIsFlagged)) + .ToList(); + + if (updatedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Requests/Mail/ChangeJunkStateRequest.cs b/Wino.Core/Requests/Mail/ChangeJunkStateRequest.cs index c301ed04..b7f4d414 100644 --- a/Wino.Core/Requests/Mail/ChangeJunkStateRequest.cs +++ b/Wino.Core/Requests/Mail/ChangeJunkStateRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -30,6 +31,7 @@ public record ChangeJunkStateRequest(bool IsJunk, MailCopy Item, MailItemFolder public bool ExcludeMustHaveFolders => false; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeJunkState; + public override object GroupingKey() => (Operation, IsJunk, FromFolder.Id, TargetFolder?.Id ?? Guid.Empty); public override void ApplyUIChanges() { @@ -47,4 +49,22 @@ public class BatchChangeJunkStateRequest : BatchCollection collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var removedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (removedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var addedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (addedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailAddedMessage(addedMails, EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Requests/Mail/DeleteRequest.cs b/Wino.Core/Requests/Mail/DeleteRequest.cs index 1aa67244..8c4f9bc0 100644 --- a/Wino.Core/Requests/Mail/DeleteRequest.cs +++ b/Wino.Core/Requests/Mail/DeleteRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -19,6 +20,7 @@ public record DeleteRequest(MailCopy MailItem) : MailRequestBase(MailItem), public List SynchronizationFolderIds => [Item.FolderId]; public bool ExcludeMustHaveFolders => false; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Delete; + public override object GroupingKey() => (Operation, Item.FolderId); public override void ApplyUIChanges() { @@ -36,4 +38,22 @@ public class BatchDeleteRequest : BatchCollection public BatchDeleteRequest(IEnumerable collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var removedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (removedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var addedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (addedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailAddedMessage(addedMails, EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Requests/Mail/MarkReadRequest.cs b/Wino.Core/Requests/Mail/MarkReadRequest.cs index a5ccc165..a6a4d66b 100644 --- a/Wino.Core/Requests/Mail/MarkReadRequest.cs +++ b/Wino.Core/Requests/Mail/MarkReadRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -24,25 +25,26 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item /// If the mail is already in the desired read state, no change is needed. /// public bool IsNoOp { get; } = Item.IsRead == IsRead; + public bool OriginalIsRead => _originalIsRead; + + public override object GroupingKey() => (Operation, Item.FolderId, IsRead); public override void ApplyUIChanges() { - // Skip UI update if the mail is already in the desired state if (IsNoOp) return; - Item.IsRead = IsRead; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailStateUpdatedMessage( + new MailStateChange(Item.UniqueId, IsRead: IsRead), + EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - // Skip UI revert if this was a no-op request if (IsNoOp) return; - Item.IsRead = _originalIsRead; - - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailStateUpdatedMessage( + new MailStateChange(Item.UniqueId, IsRead: _originalIsRead), + EntityUpdateSource.ClientReverted)); } } @@ -51,4 +53,34 @@ public class BatchMarkReadRequest : BatchCollection public BatchMarkReadRequest(IEnumerable collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var updatedMails = this + .Where(x => !x.IsNoOp) + .Select(x => new MailStateChange(x.Item.UniqueId, IsRead: x.IsRead)) + .ToList(); + + if (updatedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var updatedMails = this + .Where(x => !x.IsNoOp) + .Select(x => new MailStateChange(x.Item.UniqueId, IsRead: x.OriginalIsRead)) + .ToList(); + + if (updatedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailStateUpdatedMessage( + updatedMails, + EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Requests/Mail/MoveRequest.cs b/Wino.Core/Requests/Mail/MoveRequest.cs index 824bc6c9..66b119e2 100644 --- a/Wino.Core/Requests/Mail/MoveRequest.cs +++ b/Wino.Core/Requests/Mail/MoveRequest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -15,6 +16,7 @@ public record MoveRequest(MailCopy Item, MailItemFolder FromFolder, MailItemFold public List SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id }; public bool ExcludeMustHaveFolders => false; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Move; + public override object GroupingKey() => (Operation, FromFolder.Id, ToFolder?.Id ?? Guid.Empty); public override void ApplyUIChanges() { @@ -32,4 +34,22 @@ public class BatchMoveRequest : BatchCollection, IUIChangeRequest public BatchMoveRequest(IEnumerable collection) : base(collection) { } + + public override void ApplyUIChanges() + { + var removedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (removedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.ClientUpdated)); + } + + public override void RevertUIChanges() + { + var addedMails = this.Select(x => x.Item).Where(x => x != null).ToList(); + if (addedMails.Count == 0) + return; + + WeakReferenceMessenger.Default.Send(new BulkMailAddedMessage(addedMails, EntityUpdateSource.ClientReverted)); + } } diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index dde49cbc..f34d2f29 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -259,25 +259,48 @@ public class SynchronizationManager : ISynchronizationManager, IRecipientAccount ID to queue the request for /// Whether to automatically trigger synchronization after queuing the request public async Task QueueRequestAsync(IRequestBase request, Guid accountId, bool triggerSynchronization) + => await QueueRequestsAsync([request], accountId, triggerSynchronization).ConfigureAwait(false); + + public async Task QueueRequestsAsync(IEnumerable requests, Guid accountId, bool triggerSynchronization) { EnsureInitialized(); + var requestList = requests?.Where(request => request != null).ToList() ?? []; + if (requestList.Count == 0) + return; + var synchronizer = await GetOrCreateSynchronizerAsync(accountId); if (synchronizer == null) { - _logger.Error("Could not find or create synchronizer for account {AccountId} to queue request", accountId); + _logger.Error("Could not find or create synchronizer for account {AccountId} to queue {RequestCount} request(s)", accountId, requestList.Count); return; } - _logger.Debug("Queuing request {RequestType} for account {AccountId}", - request.GetType().Name, accountId); + if (requestList.Count == 1) + { + _logger.Debug("Queuing request {RequestType} for account {AccountId}", + requestList[0].GetType().Name, accountId); + } + else + { + var requestSummary = string.Join(", ", requestList + .GroupBy(request => request.GetType().Name) + .OrderBy(group => group.Key) + .Select(group => $"{group.Key} x{group.Count()}")); - synchronizer.QueueRequest(request); + _logger.Debug("Queuing {RequestCount} requests for account {AccountId}: {RequestSummary}", + requestList.Count, accountId, requestSummary); + } + + foreach (var request in requestList) + { + synchronizer.QueueRequest(request); + } if (triggerSynchronization) { // Determine if this is a calendar or mail operation - bool isCalendarOperation = request is ICalendarActionRequest; + bool isCalendarOperation = requestList.All(request => request is ICalendarActionRequest); if (isCalendarOperation) { diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index bbeb692a..b7f6274a 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -93,13 +93,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator // Queue requests for each account and start synchronization. foreach (var accountGroup in accountIds) { - foreach (var accountRequest in accountGroup) - { - await QueueRequestAsync(accountRequest, accountGroup.Key); - } + var groupedRequests = accountGroup.Cast().ToList(); + await QueueRequestsAsync(groupedRequests, accountGroup.Key).ConfigureAwait(false); var account = accountGroup.First().Item.AssignedAccount; - var actionItems = SynchronizationActionHelper.CreateActionItems(accountGroup, accountGroup.Key, account.Name); + var actionItems = SynchronizationActionHelper.CreateActionItems(groupedRequests, accountGroup.Key, account.Name); if (actionItems.Count > 0) WeakReferenceMessenger.Default.Send(new SynchronizationActionsAdded(accountGroup.Key, account.Name, actionItems)); @@ -214,10 +212,7 @@ public class WinoRequestDelegator : IWinoRequestDelegator if (requestList.Count == 0) return; - foreach (var request in requestList) - { - await QueueRequestAsync(request, accountId).ConfigureAwait(false); - } + await QueueRequestsAsync(requestList, accountId).ConfigureAwait(false); await SendSyncActionsAddedAsync(requestList, accountId).ConfigureAwait(false); await QueueSynchronizationAsync(accountId).ConfigureAwait(false); @@ -274,7 +269,12 @@ public class WinoRequestDelegator : IWinoRequestDelegator private async Task QueueRequestAsync(IRequestBase request, Guid accountId) { // Don't trigger synchronization for individual requests - we'll trigger it once for all requests - await SynchronizationManager.Instance.QueueRequestAsync(request, accountId, triggerSynchronization: false); + await SynchronizationManager.Instance.QueueRequestAsync(request, accountId, triggerSynchronization: false).ConfigureAwait(false); + } + + private async Task QueueRequestsAsync(IEnumerable requests, Guid accountId) + { + await SynchronizationManager.Instance.QueueRequestsAsync(requests, accountId, triggerSynchronization: false).ConfigureAwait(false); } private Task QueueSynchronizationAsync(Guid accountId) diff --git a/Wino.Core/Services/WinoRequestProcessor.cs b/Wino.Core/Services/WinoRequestProcessor.cs index 22848bda..e6b84a92 100644 --- a/Wino.Core/Services/WinoRequestProcessor.cs +++ b/Wino.Core/Services/WinoRequestProcessor.cs @@ -54,6 +54,10 @@ public class WinoRequestProcessor : IWinoRequestProcessor { var action = preperationRequest.Action; var moveTargetStructure = preperationRequest.MoveTargetFolder; + var mailItems = preperationRequest.MailItems?.Where(item => item != null).ToList() ?? []; + + if (mailItems.Count == 0) + return []; // Ask confirmation for permanent delete operation. // Drafts are always hard deleted without any protection. @@ -78,12 +82,12 @@ public class WinoRequestProcessor : IWinoRequestProcessor // Handle the case when user is trying to move multiple mails that belong to different accounts. // We can't handle this with only 1 picker dialog. - bool isInvalidMoveTarget = preperationRequest.MailItems.Select(a => a.AssignedAccount.Id).Distinct().Count() > 1; + bool isInvalidMoveTarget = mailItems.Select(a => a.AssignedAccount.Id).Distinct().Count() > 1; if (isInvalidMoveTarget) throw new InvalidMoveTargetException(InvalidMoveTargetReason.MultipleAccounts); - var accountId = preperationRequest.MailItems.FirstOrDefault().AssignedAccount.Id; + var accountId = mailItems[0].AssignedAccount.Id; moveTargetStructure = await _dialogService.PickFolderAsync(accountId, PickFolderReason.Move, _folderService); @@ -94,7 +98,7 @@ public class WinoRequestProcessor : IWinoRequestProcessor var requests = new List(); // TODO: Fix: Collection was modified; enumeration operation may not execute - foreach (var item in preperationRequest.MailItems.ToList()) + foreach (var item in mailItems) { var singleRequest = await GetSingleRequestAsync(item, action, moveTargetStructure, preperationRequest.ToggleExecution); diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 9b06656f..05d81349 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -12,6 +12,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Mail; using Wino.Core.Requests.Bundles; using Wino.Messaging.UI; @@ -262,4 +263,75 @@ public abstract partial class BaseSynchronizer : ObservableObject, return ret; } + + protected void ApplyOptimisticUiChanges(IEnumerable> bundles, Func shouldApply = null) + { + var bundleList = bundles? + .Where(b => b?.Request != null && (shouldApply?.Invoke(b.Request) ?? true)) + .ToList() ?? []; + + if (bundleList.Count == 0) + return; + + var requestList = new List(bundleList.Count); + + foreach (var bundle in bundleList) + { + if (bundle.UIChangeRequest != null && !ReferenceEquals(bundle.UIChangeRequest, bundle.Request)) + { + bundle.UIChangeRequest.ApplyUIChanges(); + continue; + } + + requestList.Add(bundle.Request); + } + + if (requestList.Count == 0) + return; + + var appliedBatchRequestKeys = new HashSet(); + + foreach (var group in requestList.GroupBy(r => r.GroupingKey())) + { + var groupRequests = group.ToList(); + if (groupRequests.Count <= 1) + continue; + + if (!TryApplyBatchUiChanges(groupRequests)) + continue; + + appliedBatchRequestKeys.Add(group.Key); + } + + foreach (var request in requestList) + { + if (!appliedBatchRequestKeys.Contains(request.GroupingKey())) + { + request.ApplyUIChanges(); + } + } + } + + private static bool TryApplyBatchUiChanges(IReadOnlyList requests) + { + if (requests == null || requests.Count <= 1) + return false; + + return requests[0] switch + { + MarkReadRequest => ApplyBatch(new BatchMarkReadRequest(requests.Cast())), + ChangeFlagRequest => ApplyBatch(new BatchChangeFlagRequest(requests.Cast())), + DeleteRequest => ApplyBatch(new BatchDeleteRequest(requests.Cast())), + MoveRequest => ApplyBatch(new BatchMoveRequest(requests.Cast())), + ArchiveRequest => ApplyBatch(new BatchArchiveRequest(requests.Cast())), + ChangeJunkStateRequest => ApplyBatch(new BatchChangeJunkStateRequest(requests.Cast())), + _ => false + }; + + static bool ApplyBatch(IUIChangeRequest request) + { + request.ApplyUIChanges(); + return true; + } + } } diff --git a/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs index a25e1692..37d81459 100644 --- a/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs @@ -29,6 +29,8 @@ namespace Wino.Core.Synchronizers.ImapSync; public class UnifiedImapSynchronizer { private static readonly TimeSpan UidReconcileInterval = TimeSpan.FromHours(12); + private const int NewMessageFetchBatchSize = 50; + private const int ExistingMessageFlagFetchBatchSize = 250; private readonly ILogger _logger = Log.ForContext(); private readonly IFolderService _folderService; @@ -47,6 +49,9 @@ public class UnifiedImapSynchronizer MessageSummaryItems.References | MessageSummaryItems.ModSeq | MessageSummaryItems.BodyStructure; + private readonly MessageSummaryItems _existingMailSynchronizationFlags = + MessageSummaryItems.Flags | + MessageSummaryItems.UniqueId; public UnifiedImapSynchronizer( IFolderService folderService, @@ -182,15 +187,35 @@ public class UnifiedImapSynchronizer var downloadedMessageIds = new List(); - foreach (var batch in uids.Distinct().OrderBy(a => a.Id).Batch(50)) + foreach (var batch in uids.Distinct().OrderBy(a => a.Id).Batch(ExistingMessageFlagFetchBatchSize)) { cancellationToken.ThrowIfCancellationRequested(); - var summaryBatch = await remoteFolder - .FetchAsync(new UniqueIdSet(batch.ToList(), SortOrder.Ascending), _mailSynchronizationFlags, cancellationToken) - .ConfigureAwait(false); + var batchUids = batch.ToList(); + var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, batchUids).ConfigureAwait(false); + var existingByUid = CreateExistingMailLookup(existingMails); + var existingUids = batchUids.Where(uid => existingByUid.ContainsKey(uid.Id)).ToList(); + var newUids = batchUids.Where(uid => !existingByUid.ContainsKey(uid.Id)).ToList(); - downloadedMessageIds.AddRange(await ProcessSummariesAsync(synchronizer, localFolder, summaryBatch, cancellationToken).ConfigureAwait(false)); + if (existingUids.Count > 0) + { + var existingSummaryBatch = await remoteFolder + .FetchAsync(new UniqueIdSet(existingUids, SortOrder.Ascending), _existingMailSynchronizationFlags, cancellationToken) + .ConfigureAwait(false); + + await ApplySummaryFlagUpdatesAsync(existingByUid, existingSummaryBatch).ConfigureAwait(false); + } + + foreach (var newBatch in newUids.Batch(NewMessageFetchBatchSize)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var newSummaryBatch = await remoteFolder + .FetchAsync(new UniqueIdSet(newBatch.ToList(), SortOrder.Ascending), _mailSynchronizationFlags, cancellationToken) + .ConfigureAwait(false); + + downloadedMessageIds.AddRange(await ProcessSummariesCoreAsync(synchronizer, localFolder, newSummaryBatch, existingByUid, cancellationToken).ConfigureAwait(false)); + } } UpdateHighestKnownUid(localFolder, remoteFolder, uids.Select(a => a.Id)); @@ -268,7 +293,29 @@ public class UnifiedImapSynchronizer .ConfigureAwait(false); } - downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false); + var existingMails = await _mailService.GetExistingMailsAsync(folder.Id, changedUids).ConfigureAwait(false); + var existingByUid = CreateExistingMailLookup(existingMails); + var newOrUnknownUids = changedUids.Where(uid => !existingByUid.ContainsKey(uid.Id)).ToList(); + var existingUidsWithoutFlagEvents = changedUids + .Where(uid => existingByUid.ContainsKey(uid.Id) && !changedFlags.ContainsKey(uid.Id)) + .ToList(); + + if (existingUidsWithoutFlagEvents.Count > 0) + { + var missingEventSummaries = await remoteFolder + .FetchAsync(new UniqueIdSet(existingUidsWithoutFlagEvents, SortOrder.Ascending), _existingMailSynchronizationFlags, cancellationToken) + .ConfigureAwait(false); + + foreach (var summary in missingEventSummaries) + { + if (summary.UniqueId != UniqueId.Invalid && summary.Flags != null) + { + changedFlags[summary.UniqueId.Id] = summary.Flags.Value; + } + } + } + + downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, newOrUnknownUids, synchronizer, cancellationToken).ConfigureAwait(false); folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq); @@ -456,11 +503,19 @@ public class UnifiedImapSynchronizer folder.UidValidity = remoteFolder.UidValidity; } - private async Task> ProcessSummariesAsync( + private Task> ProcessSummariesAsync( IImapSynchronizer synchronizer, MailItemFolder localFolder, IList summaries, CancellationToken cancellationToken) + => ProcessSummariesCoreAsync(synchronizer, localFolder, summaries, existingByUid: null, cancellationToken); + + private async Task> ProcessSummariesCoreAsync( + IImapSynchronizer synchronizer, + MailItemFolder localFolder, + IList summaries, + IReadOnlyDictionary existingByUid, + CancellationToken cancellationToken) { var downloadedMessageIds = new List(); @@ -475,10 +530,8 @@ public class UnifiedImapSynchronizer if (uniqueIds.Count == 0) return downloadedMessageIds; - var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, uniqueIds).ConfigureAwait(false); - var existingByUid = existingMails - .Select(m => (Uid: MailkitClientExtensions.ResolveUidStruct(m.Id), Mail: m)) - .ToDictionary(a => a.Uid.Id, a => a.Mail); + existingByUid ??= CreateExistingMailLookup(await _mailService.GetExistingMailsAsync(localFolder.Id, uniqueIds).ConfigureAwait(false)); + var pendingStateUpdates = new List(); foreach (var summary in summaries) { @@ -491,7 +544,11 @@ public class UnifiedImapSynchronizer { if (summary.Flags != null) { - await UpdateMailFlagsAsync(existingMail, summary.Flags.Value).ConfigureAwait(false); + var pendingStateUpdate = CreateMailStateUpdate(existingMail, summary.Flags.Value); + if (pendingStateUpdate != null) + { + pendingStateUpdates.Add(pendingStateUpdate); + } } continue; @@ -516,23 +573,79 @@ public class UnifiedImapSynchronizer } } + if (pendingStateUpdates.Count > 0) + { + await _mailService.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false); + } + return downloadedMessageIds; } - private async Task UpdateMailFlagsAsync(MailCopy mailCopy, MessageFlags flags) + private async Task ApplySummaryFlagUpdatesAsync( + IReadOnlyDictionary existingByUid, + IList summaries) + { + if (existingByUid == null || existingByUid.Count == 0 || summaries == null || summaries.Count == 0) + return; + + var pendingStateUpdates = new List(); + + foreach (var summary in summaries) + { + if (summary.UniqueId == UniqueId.Invalid || summary.Flags == null) + continue; + + if (!existingByUid.TryGetValue(summary.UniqueId.Id, out var existingMail)) + continue; + + var pendingStateUpdate = CreateMailStateUpdate(existingMail, summary.Flags.Value); + if (pendingStateUpdate != null) + { + pendingStateUpdates.Add(pendingStateUpdate); + } + } + + if (pendingStateUpdates.Count > 0) + { + await _mailService.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false); + } + } + + private static IReadOnlyDictionary CreateExistingMailLookup(IEnumerable existingMails) + { + var lookup = new Dictionary(); + + foreach (var mail in existingMails ?? []) + { + if (mail == null || string.IsNullOrEmpty(mail.Id)) + continue; + + try + { + lookup[MailkitClientExtensions.ResolveUidStruct(mail.Id).Id] = mail; + } + catch (ArgumentOutOfRangeException) + { + } + } + + return lookup; + } + + private static MailCopyStateUpdate CreateMailStateUpdate(MailCopy mailCopy, MessageFlags flags) { var isFlagged = MailkitClientExtensions.GetIsFlagged(flags); var isRead = MailkitClientExtensions.GetIsRead(flags); - if (isFlagged != mailCopy.IsFlagged) - { - await _mailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false); - } + bool shouldUpdateFlagged = isFlagged != mailCopy.IsFlagged; + bool shouldUpdateRead = isRead != mailCopy.IsRead; - if (isRead != mailCopy.IsRead) - { - await _mailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false); - } + return !shouldUpdateFlagged && !shouldUpdateRead + ? null + : new MailCopyStateUpdate( + mailCopy.Id, + shouldUpdateRead ? isRead : null, + shouldUpdateFlagged ? isFlagged : null); } private async Task ApplyDeletedUidsAsync(MailItemFolder folder, IList uniqueIds) @@ -552,15 +665,14 @@ public class UnifiedImapSynchronizer if (changedFlags == null || changedFlags.Count == 0) return; - foreach (var changed in changedFlags) - { - var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, changed.Key); - var isFlagged = MailkitClientExtensions.GetIsFlagged(changed.Value); - var isRead = MailkitClientExtensions.GetIsRead(changed.Value); + var stateUpdates = changedFlags + .Select(changed => new MailCopyStateUpdate( + MailkitClientExtensions.CreateUid(folder.Id, changed.Key), + MailkitClientExtensions.GetIsRead(changed.Value), + MailkitClientExtensions.GetIsFlagged(changed.Value))) + .ToList(); - await _mailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false); - await _mailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false); - } + await _mailService.ApplyMailStateUpdatesAsync(stateUpdates).ConfigureAwait(false); } private async Task ReconcileUidBasedFlagChangesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken) @@ -613,13 +725,14 @@ public class UnifiedImapSynchronizer var existingMarkReadCandidates = await FilterExistingRemoteUidsAsync(remoteFolder, markReadCandidates, cancellationToken).ConfigureAwait(false); var existingUnflagCandidates = await FilterExistingRemoteUidsAsync(remoteFolder, unflagCandidates, cancellationToken).ConfigureAwait(false); + var pendingStateUpdates = new List(); foreach (var uid in existingMarkReadCandidates) { if (!localByUid.TryGetValue(uid, out var localMail) || localMail.IsRead) continue; - await _mailService.ChangeReadStatusAsync(localMail.Id, true).ConfigureAwait(false); + pendingStateUpdates.Add(new MailCopyStateUpdate(localMail.Id, IsRead: true)); } foreach (var uid in remoteUnreadUids) @@ -627,7 +740,7 @@ public class UnifiedImapSynchronizer if (!localByUid.TryGetValue(uid, out var localMail) || !localMail.IsRead) continue; - await _mailService.ChangeReadStatusAsync(localMail.Id, false).ConfigureAwait(false); + pendingStateUpdates.Add(new MailCopyStateUpdate(localMail.Id, IsRead: false)); } foreach (var uid in existingUnflagCandidates) @@ -635,7 +748,7 @@ public class UnifiedImapSynchronizer if (!localByUid.TryGetValue(uid, out var localMail) || !localMail.IsFlagged) continue; - await _mailService.ChangeFlagStatusAsync(localMail.Id, false).ConfigureAwait(false); + pendingStateUpdates.Add(new MailCopyStateUpdate(localMail.Id, IsFlagged: false)); } foreach (var uid in remoteFlaggedUids) @@ -643,7 +756,12 @@ public class UnifiedImapSynchronizer if (!localByUid.TryGetValue(uid, out var localMail) || localMail.IsFlagged) continue; - await _mailService.ChangeFlagStatusAsync(localMail.Id, true).ConfigureAwait(false); + pendingStateUpdates.Add(new MailCopyStateUpdate(localMail.Id, IsFlagged: true)); + } + + if (pendingStateUpdates.Count > 0) + { + await _mailService.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false); } } diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 8c62edbe..94c6ad9a 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -112,56 +112,104 @@ public class ImapSynchronizer : WinoSynchronizer> Move(BatchMoveRequest requests) { - return CreateTaskBundle(async (client, item) => - { - var sourceFolder = await client.GetFolderAsync(item.FromFolder.RemoteFolderId); - var destinationFolder = await client.GetFolderAsync(item.ToFolder.RemoteFolderId); + if (requests == null || requests.Count == 0) + return []; + + return CreateSingleTaskBundle(async (client, _) => + { + var sourceFolder = await client.GetFolderAsync(requests[0].FromFolder.RemoteFolderId).ConfigureAwait(false); + var destinationFolder = await client.GetFolderAsync(requests[0].ToFolder.RemoteFolderId).ConfigureAwait(false); + var uniqueIds = requests.Select(item => GetUniqueId(item.Item.Id)).ToList(); - // Only opening source folder is enough. await sourceFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false); - await sourceFolder.MoveToAsync(GetUniqueId(item.Item.Id), destinationFolder).ConfigureAwait(false); - await sourceFolder.CloseAsync().ConfigureAwait(false); - }, requests); + try + { + await sourceFolder.MoveToAsync(uniqueIds, destinationFolder).ConfigureAwait(false); + } + finally + { + await sourceFolder.CloseAsync().ConfigureAwait(false); + } + }, requests[0], requests); } public override List> ChangeFlag(BatchChangeFlagRequest requests) { - return CreateTaskBundle(async (client, item) => + if (requests == null || requests.Count == 0) + return []; + + return CreateSingleTaskBundle(async (client, _) => { - var folder = item.Item.AssignedFolder; - var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId); + var folder = requests[0].Item.AssignedFolder; + var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false); + var uniqueIds = requests.Select(item => GetUniqueId(item.Item.Id)).ToList(); + var request = new StoreFlagsRequest(requests[0].IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged) + { + Silent = true + }; await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false); - await remoteFolder.StoreAsync(GetUniqueId(item.Item.Id), new StoreFlagsRequest(item.IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged) { Silent = true }).ConfigureAwait(false); - await remoteFolder.CloseAsync().ConfigureAwait(false); - }, requests); + try + { + await remoteFolder.StoreAsync(uniqueIds, request).ConfigureAwait(false); + } + finally + { + await remoteFolder.CloseAsync().ConfigureAwait(false); + } + }, requests[0], requests); } public override List> Delete(BatchDeleteRequest requests) { - return CreateTaskBundle(async (client, request) => + if (requests == null || requests.Count == 0) + return []; + + return CreateSingleTaskBundle(async (client, _) => { - var folder = request.Item.AssignedFolder; + var folder = requests[0].Item.AssignedFolder; var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false); + var uniqueIds = requests.Select(request => GetUniqueId(request.Item.Id)).ToList(); + var storeRequest = new StoreFlagsRequest(StoreAction.Add, MessageFlags.Deleted) { Silent = true }; await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false); - await remoteFolder.AddFlagsAsync(GetUniqueId(request.Item.Id), MessageFlags.Deleted, true); - await remoteFolder.ExpungeAsync().ConfigureAwait(false); - await remoteFolder.CloseAsync().ConfigureAwait(false); - }, requests); + try + { + await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false); + await remoteFolder.ExpungeAsync(uniqueIds).ConfigureAwait(false); + } + finally + { + await remoteFolder.CloseAsync().ConfigureAwait(false); + } + }, requests[0], requests); } public override List> MarkRead(BatchMarkReadRequest requests) { - return CreateTaskBundle(async (client, request) => + if (requests == null || requests.Count == 0) + return []; + + return CreateSingleTaskBundle(async (client, _) => { - var folder = request.Item.AssignedFolder; - var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId); + var folder = requests[0].Item.AssignedFolder; + var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false); + var uniqueIds = requests.Select(request => GetUniqueId(request.Item.Id)).ToList(); + var storeRequest = new StoreFlagsRequest(requests[0].IsRead ? StoreAction.Add : StoreAction.Remove, MessageFlags.Seen) + { + Silent = true + }; await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false); - await remoteFolder.StoreAsync(GetUniqueId(request.Item.Id), new StoreFlagsRequest(request.IsRead ? StoreAction.Add : StoreAction.Remove, MessageFlags.Seen) { Silent = true }).ConfigureAwait(false); - await remoteFolder.CloseAsync().ConfigureAwait(false); - }, requests); + try + { + await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false); + } + finally + { + await remoteFolder.CloseAsync().ConfigureAwait(false); + } + }, requests[0], requests); } public override List> CreateDraft(CreateDraftRequest request) @@ -718,13 +766,7 @@ public class ImapSynchronizer : WinoSynchronizer()))); @@ -204,9 +204,9 @@ public abstract class WinoSynchronizer RunSerializedAsync(() => + { + if (updatedState == null) + return Task.CompletedTask; + + var itemContainer = GetMailItemContainer(updatedState.UniqueId); + + if (itemContainer?.ItemViewModel == null) + { + return Task.CompletedTask; + } + + return UpdateExistingMailStateAsync(itemContainer, updatedState, mailUpdateSource); + }); + + public Task UpdateMailStatesAsync(IEnumerable updatedStates, EntityUpdateSource mailUpdateSource) + => RunSerializedAsync(() => UpdateMailStatesInternalAsync(updatedStates, mailUpdateSource)); + + public Task UpdateMailCopiesAsync(IEnumerable updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) + => RunSerializedAsync(() => UpdateMailCopiesInternalAsync(updatedMailCopies, mailUpdateSource, changedProperties)); + + private async Task UpdateExistingMailStateAsync(MailItemContainer itemContainer, MailStateChange updatedState, EntityUpdateSource mailUpdateSource) + { + if (itemContainer?.ItemViewModel == null || updatedState == null) + return; + + var existingItem = itemContainer.ItemViewModel; + + await ExecuteUIThread(() => + { + var appliedChanges = existingItem.ApplyStateChanges(updatedState.IsRead, updatedState.IsFlagged); + existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; + + if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + { + itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + } + }); + } + + private async Task UpdateMailStatesInternalAsync(IEnumerable updatedStates, EntityUpdateSource mailUpdateSource) + { + var updates = updatedStates? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => + { + var updatedState = group.Last(); + return new + { + UpdatedState = updatedState, + ItemContainer = GetMailItemContainer(updatedState.UniqueId) + }; + }) + .Where(x => x.ItemContainer?.ItemViewModel != null) + .ToList() ?? []; + + if (updates.Count == 0) + return; + + await ExecuteUIThread(() => + { + foreach (var update in updates) + { + var existingItem = update.ItemContainer.ItemViewModel; + var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged); + existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; + + if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + { + update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + } + } + }); + } + + private async Task UpdateMailCopiesInternalAsync(IEnumerable updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties) + { + var updates = updatedMailCopies? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => + { + var updatedMail = group.First(); + return new + { + UpdatedMail = updatedMail, + ItemContainer = GetMailItemContainer(updatedMail.UniqueId) + }; + }) + .Where(x => x.ItemContainer?.ItemViewModel != null) + .ToList() ?? []; + + if (updates.Count == 0) + return; + + await ExecuteUIThread(() => + { + foreach (var update in updates) + { + var updatedMail = update.UpdatedMail; + var itemContainer = update.ItemContainer; + var existingItem = itemContainer.ItemViewModel; + var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties); + existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; + + if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + { + itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + } + } + }); + } + public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0); public MailItemViewModel GetNextItem(MailCopy mailCopy) @@ -963,7 +1079,32 @@ public class WinoMailCollection : ObservableRecipient, IRecipient RunSerializedAsync(() => RemoveInternalAsync(removeItem)); + public Task RemoveRangeAsync(IEnumerable removeItems) + => RunSerializedAsync(() => RemoveRangeInternalAsync(removeItems)); + private async Task RemoveInternalAsync(MailCopy removeItem) + => await RemoveInternalAsync(removeItem, notifySelectionChanges: true); + + private async Task RemoveRangeInternalAsync(IEnumerable removeItems) + { + var distinctItems = removeItems? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => group.First()) + .ToList() ?? []; + + if (distinctItems.Count == 0) + return; + + foreach (var removeItem in distinctItems) + { + await RemoveInternalAsync(removeItem, notifySelectionChanges: false); + } + + await NotifySelectionChangesAsync(); + } + + private async Task RemoveInternalAsync(MailCopy removeItem, bool notifySelectionChanges) { var itemContainer = GetMailItemContainer(removeItem.UniqueId); @@ -1030,7 +1171,10 @@ public class WinoMailCollection : ObservableRecipient, IRecipient AllItemsIncludingThreads @@ -1207,4 +1351,5 @@ public class WinoMailCollection : ObservableRecipient, IRecipient(T currentValue, T newValue, Action setter, MailCopyChangeFlags flag) { if (EqualityComparer.Default.Equals(currentValue, newValue)) diff --git a/Wino.Mail.ViewModels/MailBaseViewModel.cs b/Wino.Mail.ViewModels/MailBaseViewModel.cs index 449ee42a..911c5e2c 100644 --- a/Wino.Mail.ViewModels/MailBaseViewModel.cs +++ b/Wino.Mail.ViewModels/MailBaseViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.Messaging; +using System.Collections.Generic; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -10,8 +11,13 @@ namespace Wino.Mail.ViewModels; public class MailBaseViewModel : CoreBaseViewModel, IRecipient, + IRecipient, IRecipient, + IRecipient, + IRecipient, + IRecipient, IRecipient, + IRecipient, IRecipient, IRecipient, IRecipient, @@ -21,8 +27,41 @@ public class MailBaseViewModel : CoreBaseViewModel, IRecipient { protected virtual void OnMailAdded(MailCopy addedMail, EntityUpdateSource source) { } + protected virtual void OnBulkMailAdded(IReadOnlyList addedMails, EntityUpdateSource source) + { + foreach (var addedMail in addedMails ?? []) + { + OnMailAdded(addedMail, source); + } + } + protected virtual void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { } + protected virtual void OnBulkMailRemoved(IReadOnlyList removedMails, EntityUpdateSource source) + { + foreach (var removedMail in removedMails ?? []) + { + OnMailRemoved(removedMail, source); + } + } + + protected virtual void OnMailStateUpdated(MailStateChange updatedState, EntityUpdateSource source) { } + protected virtual void OnBulkMailStateUpdated(IReadOnlyList updatedStates, EntityUpdateSource source) + { + foreach (var updatedState in updatedStates ?? []) + { + OnMailStateUpdated(updatedState, source); + } + } + protected virtual void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { } + protected virtual void OnBulkMailUpdated(IReadOnlyList updatedMails, EntityUpdateSource source, MailCopyChangeFlags changedProperties) + { + foreach (var updatedMail in updatedMails ?? []) + { + OnMailUpdated(updatedMail, source, changedProperties); + } + } + protected virtual void OnMailDownloaded(MailCopy downloadedMail) { } protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { } protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { } @@ -32,8 +71,13 @@ public class MailBaseViewModel : CoreBaseViewModel, protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { } void IRecipient.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail, message.Source); + void IRecipient.Receive(BulkMailAddedMessage message) => OnBulkMailAdded(message.AddedMails, message.Source); void IRecipient.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail, message.Source); + void IRecipient.Receive(BulkMailRemovedMessage message) => OnBulkMailRemoved(message.RemovedMails, message.Source); + void IRecipient.Receive(MailStateUpdatedMessage message) => OnMailStateUpdated(message.UpdatedState, message.Source); + void IRecipient.Receive(BulkMailStateUpdatedMessage message) => OnBulkMailStateUpdated(message.UpdatedStates, message.Source); void IRecipient.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties); + void IRecipient.Receive(BulkMailUpdatedMessage message) => OnBulkMailUpdated(message.UpdatedMails, message.Source, message.ChangedProperties); void IRecipient.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail); void IRecipient.Receive(DraftMapped message) => OnDraftMapped(message.LocalDraftCopyId, message.RemoteDraftCopyId); @@ -51,8 +95,13 @@ public class MailBaseViewModel : CoreBaseViewModel, UnregisterRecipients(); Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); @@ -67,8 +116,13 @@ public class MailBaseViewModel : CoreBaseViewModel, base.UnregisterRecipients(); Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index d89c2206..55a94968 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -23,6 +23,7 @@ using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Menus; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Reader; +using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Requests.Mail; using Wino.Core.Services; using Wino.Mail.ViewModels.Collections; @@ -77,6 +78,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private readonly INotificationBuilder _notificationBuilder; private readonly IFolderService _folderService; private readonly IContextMenuItemService _contextMenuItemService; + private readonly ILogger _logger = Log.ForContext(); private readonly IMailCategoryService _mailCategoryService; private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IKeyPressService _keyPressService; @@ -492,29 +494,29 @@ public partial class MailListPageViewModel : MailBaseViewModel, { if (!CanSynchronize) return; - _notificationBuilder.CreateNotificationsAsync(MailCollection.SelectedItems.Select(a => a.MailCopy)); - return; + //_notificationBuilder.CreateNotificationsAsync(MailCollection.SelectedItems.Select(a => a.MailCopy)); + //return; // Only synchronize listed folders. // When doing linked inbox sync, we need to save the sync id to report progress back only once. // Otherwise, we will report progress for each folder and that's what we don't want. - //trackingSynchronizationId = Guid.NewGuid(); - //completedTrackingSynchronizationCount = 0; + trackingSynchronizationId = Guid.NewGuid(); + completedTrackingSynchronizationCount = 0; - //foreach (var folder in ActiveFolder.HandlingFolders) - //{ - // var options = new MailSynchronizationOptions() - // { - // AccountId = folder.MailAccountId, - // Type = MailSynchronizationType.CustomFolders, - // SynchronizationFolderIds = [folder.Id], - // GroupedSynchronizationTrackingId = trackingSynchronizationId - // }; + foreach (var folder in ActiveFolder.HandlingFolders) + { + var options = new MailSynchronizationOptions() + { + AccountId = folder.MailAccountId, + Type = MailSynchronizationType.CustomFolders, + SynchronizationFolderIds = [folder.Id], + GroupedSynchronizationTrackingId = trackingSynchronizationId + }; - // Messenger.Send(new NewMailSynchronizationRequested(options)); - //} + Messenger.Send(new NewMailSynchronizationRequested(options)); + } } [RelayCommand] @@ -749,7 +751,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, return; var requests = new List(); - foreach (var mailItem in targetList.Select(a => a.MailCopy).DistinctBy(a => a.UniqueId)) + foreach (var mailItem in targetList.Select(a => a.MailCopy).GroupBy(a => a.UniqueId).Select(group => group.First())) { var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailItem.UniqueId).ConfigureAwait(false); requests.Add(new MailCategoryAssignmentRequest(mailItem, category.Id, category.Name, categoryNames, !isAssignedToAll)); @@ -958,7 +960,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, protected override async void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { base.OnMailUpdated(updatedMail, source, changedProperties); - try { await listManipulationSemepahore.WaitAsync(); @@ -983,6 +984,115 @@ public partial class MailListPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { SetupTopBarActions(); }); } + protected override async void OnMailStateUpdated(MailStateChange updatedState, EntityUpdateSource source) + { + base.OnMailStateUpdated(updatedState, source); + + if (updatedState == null) + return; + + try + { + await listManipulationSemepahore.WaitAsync(); + + if (!MailCollection.ContainsMailUniqueId(updatedState.UniqueId)) + return; + + await MailCollection.UpdateMailStateAsync(updatedState, source); + } + finally + { + listManipulationSemepahore.Release(); + } + + await ExecuteUIThread(() => { SetupTopBarActions(); }); + } + + protected override async void OnBulkMailStateUpdated(IReadOnlyList updatedStates, EntityUpdateSource source) + { + var targetStates = updatedStates? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => group.Last()) + .ToList() ?? []; + + if (targetStates.Count == 0) + return; + + try + { + await listManipulationSemepahore.WaitAsync(); + + var listedStates = targetStates + .Where(state => MailCollection.ContainsMailUniqueId(state.UniqueId)) + .ToList(); + + if (listedStates.Count == 0) + return; + + await MailCollection.UpdateMailStatesAsync(listedStates, source); + } + finally + { + listManipulationSemepahore.Release(); + } + + await ExecuteUIThread(() => { SetupTopBarActions(); }); + } + + protected override async void OnBulkMailUpdated(IReadOnlyList updatedMails, EntityUpdateSource source, MailCopyChangeFlags changedProperties) + { + var targetMails = updatedMails? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => group.First()) + .ToList() ?? []; + + if (targetMails.Count == 0) + return; + + try + { + await listManipulationSemepahore.WaitAsync(); + + var listedMails = targetMails + .Where(mail => MailCollection.ContainsMailUniqueId(mail.UniqueId)) + .ToList(); + + if (listedMails.Count == 0) + return; + + var mailsToRemove = listedMails + .Where(ShouldRemoveUpdatedMailFromCurrentList) + .ToList(); + + var mailIdsToRemove = mailsToRemove.Select(x => x.UniqueId).ToHashSet(); + var mailsToUpdate = listedMails + .Where(mail => !mailIdsToRemove.Contains(mail.UniqueId)) + .ToList(); + + if (mailsToRemove.Count > 0) + { + await MailCollection.RemoveRangeAsync(mailsToRemove); + } + + if (mailsToUpdate.Count > 0) + { + await MailCollection.UpdateMailCopiesAsync(mailsToUpdate, source, changedProperties); + } + + await ExecuteUIThread(() => + { + NotifyItemFoundState(); + SetupTopBarActions(); + }); + } + finally + { + listManipulationSemepahore.Release(); + } + } + protected override async void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { base.OnMailRemoved(removedMail, source); @@ -1003,18 +1113,18 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (removedItemExistsInCurrentList && !isDeletedByGmailUnreadFolderAction) { - bool isDeletedMailSelected = MailCollection.SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId); - - // Automatically select the next item in the list if the setting is enabled. MailItemViewModel nextItem = null; + bool isDeletedMailSelected = false; - if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem) + await ExecuteUIThread(() => { - await ExecuteUIThread(() => + isDeletedMailSelected = MailCollection.SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId); + + if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem) { nextItem = MailCollection.GetNextItem(removedMail); - }); - } + } + }); // RemoveAsync already handles UI threading internally await MailCollection.RemoveAsync(removedMail); @@ -1044,6 +1154,115 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + protected override async void OnBulkMailRemoved(IReadOnlyList removedMails, EntityUpdateSource source) + { + var targetMails = removedMails? + .Where(x => x != null && x.AssignedAccount != null) + .GroupBy(x => x.UniqueId) + .Select(group => group.First()) + .ToList() ?? []; + + if (targetMails.Count == 0) + return; + + try + { + await listManipulationSemepahore.WaitAsync(); + + var existingMails = targetMails + .Where(mail => MailCollection.ContainsMailUniqueId(mail.UniqueId)) + .ToList(); + + if (existingMails.Count == 0) + return; + + var removedMailIds = existingMails.Select(mail => mail.UniqueId).ToHashSet(); + var shouldClearSelection = false; + + await ExecuteUIThread(() => + { + shouldClearSelection = MailCollection.SelectedItems.Any(item => removedMailIds.Contains(item.MailCopy.UniqueId)); + }); + + await MailCollection.RemoveRangeAsync(existingMails); + + if (shouldClearSelection) + { + await MailCollection.UnselectAllAsync(); + } + + await ExecuteUIThread(() => + { + NotifyItemFoundState(); + SetupTopBarActions(); + }); + } + finally + { + listManipulationSemepahore.Release(); + } + } + + protected override async void OnBulkMailAdded(IReadOnlyList addedMails, EntityUpdateSource source) + { + var targetMails = addedMails? + .Where(x => x != null) + .GroupBy(x => x.UniqueId) + .Select(group => group.First()) + .ToList() ?? []; + + if (targetMails.Count == 0) + return; + + try + { + await listManipulationSemepahore.WaitAsync(); + + var mailsToAdd = new List(); + + foreach (var addedMail in targetMails) + { + if (MailCollection.ContainsMailUniqueId(addedMail.UniqueId)) + continue; + + if (!ShouldIncludeAddedMailInCurrentList(addedMail)) + continue; + + if (ShouldPreventItemAdd(addedMail)) + continue; + + if (SelectedFolderPivot?.IsFocused is bool isFocused && addedMail.IsFocused != isFocused) + continue; + + if (IsInSearchMode) + { + if (IsOnlineSearchEnabled || AreSearchResultsOnline) + continue; + + if (!IsMailMatchingLocalSearch(addedMail)) + continue; + } + + mailsToAdd.Add(addedMail); + } + + if (mailsToAdd.Count == 0) + return; + + await MailCollection.AddRangeAsync(mailsToAdd.Select(mail => new MailItemViewModel(mail)), false); + + await ExecuteUIThread(() => + { + NotifyItemFoundState(); + SetupTopBarActions(); + }); + } + finally + { + listManipulationSemepahore.Release(); + } + } + protected override async void OnFolderDeleted(MailItemFolder folder) { base.OnFolderDeleted(folder); @@ -1648,4 +1867,5 @@ public partial class MailListPageViewModel : MailBaseViewModel, var package = new MailOperationPreperationRequest(message.Operation, mailCopies); await ExecuteMailOperationAsync(package); } + } diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index f895667e..39e0dc21 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -667,6 +667,23 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { InitializeCommandBarItems(); }); } + protected override async void OnMailStateUpdated(MailStateChange updatedState, EntityUpdateSource source) + { + base.OnMailStateUpdated(updatedState, source); + + if (initializedMailItemViewModel == null || updatedState == null) + return; + + if (initializedMailItemViewModel.MailCopy.UniqueId != updatedState.UniqueId) + return; + + await ExecuteUIThread(() => + { + initializedMailItemViewModel.ApplyStateChanges(updatedState.IsRead, updatedState.IsFlagged); + InitializeCommandBarItems(); + }); + } + protected override async void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { base.OnMailRemoved(removedMail, source); diff --git a/Wino.Mail.WinUI/Services/NotificationBuilder.cs b/Wino.Mail.WinUI/Services/NotificationBuilder.cs index 63813f09..5361d5e1 100644 --- a/Wino.Mail.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Mail.WinUI/Services/NotificationBuilder.cs @@ -58,7 +58,12 @@ public class NotificationBuilder : INotificationBuilder WeakReferenceMessenger.Default.Register(this, (r, msg) => { - RemoveNotification(msg.UniqueId); + QueueRemoveNotifications([msg.UniqueId]); + }); + + WeakReferenceMessenger.Default.Register(this, (r, msg) => + { + QueueRemoveNotifications(msg.UniqueIds); }); } @@ -156,16 +161,37 @@ public class NotificationBuilder : INotificationBuilder public void RemoveNotification(Guid mailUniqueId) { - try + QueueRemoveNotifications([mailUniqueId]); + } + + private void QueueRemoveNotifications(IEnumerable mailUniqueIds) + { + var uniqueIds = mailUniqueIds? + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (uniqueIds == null || uniqueIds.Count == 0) + return; + + _ = RemoveNotificationsAsync(uniqueIds); + } + + private static async Task RemoveNotificationsAsync(IReadOnlyList mailUniqueIds) + { + foreach (var mailUniqueId in mailUniqueIds) { - AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().GetAwaiter().GetResult(); - } - catch (ArgumentException) - { - } - catch (Exception ex) - { - Log.Error(ex, $"Failed to remove notification for mail {mailUniqueId}"); + try + { + await AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().ConfigureAwait(false); + } + catch (ArgumentException) + { + } + catch (Exception ex) + { + Log.Error(ex, "Failed to remove notification for mail {MailUniqueId}", mailUniqueId); + } } } diff --git a/Wino.Messages/UI/BulkMailAddedMessage.cs b/Wino.Messages/UI/BulkMailAddedMessage.cs new file mode 100644 index 00000000..7c7df29e --- /dev/null +++ b/Wino.Messages/UI/BulkMailAddedMessage.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public record BulkMailAddedMessage(IReadOnlyList AddedMails, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Messages/UI/BulkMailReadStatusChanged.cs b/Wino.Messages/UI/BulkMailReadStatusChanged.cs new file mode 100644 index 00000000..3ff2e008 --- /dev/null +++ b/Wino.Messages/UI/BulkMailReadStatusChanged.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; + +namespace Wino.Messaging.UI; + +public record BulkMailReadStatusChanged(IReadOnlyList UniqueIds); diff --git a/Wino.Messages/UI/BulkMailRemovedMessage.cs b/Wino.Messages/UI/BulkMailRemovedMessage.cs new file mode 100644 index 00000000..7002e48b --- /dev/null +++ b/Wino.Messages/UI/BulkMailRemovedMessage.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public record BulkMailRemovedMessage(IReadOnlyList RemovedMails, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Messages/UI/BulkMailStateUpdatedMessage.cs b/Wino.Messages/UI/BulkMailStateUpdatedMessage.cs new file mode 100644 index 00000000..a92d73ed --- /dev/null +++ b/Wino.Messages/UI/BulkMailStateUpdatedMessage.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public record BulkMailStateUpdatedMessage( + IReadOnlyList UpdatedStates, + EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Messages/UI/BulkMailUpdatedMessage.cs b/Wino.Messages/UI/BulkMailUpdatedMessage.cs index f4335f54..513e8a4a 100644 --- a/Wino.Messages/UI/BulkMailUpdatedMessage.cs +++ b/Wino.Messages/UI/BulkMailUpdatedMessage.cs @@ -1,6 +1,10 @@ using System.Collections.Generic; using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; namespace Wino.Messaging.UI; -public record BulkMailUpdatedMessage(IReadOnlyList UpdatedMails) : UIMessageBase; +public record BulkMailUpdatedMessage( + IReadOnlyList UpdatedMails, + EntityUpdateSource Source = EntityUpdateSource.Server, + MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase; diff --git a/Wino.Messages/UI/MailStateChange.cs b/Wino.Messages/UI/MailStateChange.cs new file mode 100644 index 00000000..b62ec3e2 --- /dev/null +++ b/Wino.Messages/UI/MailStateChange.cs @@ -0,0 +1,11 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public sealed record MailStateChange(Guid UniqueId, bool? IsRead = null, bool? IsFlagged = null) +{ + public MailCopyChangeFlags ChangedProperties => + (IsRead.HasValue ? MailCopyChangeFlags.IsRead : MailCopyChangeFlags.None) | + (IsFlagged.HasValue ? MailCopyChangeFlags.IsFlagged : MailCopyChangeFlags.None); +} diff --git a/Wino.Messages/UI/MailStateUpdatedMessage.cs b/Wino.Messages/UI/MailStateUpdatedMessage.cs new file mode 100644 index 00000000..765bc796 --- /dev/null +++ b/Wino.Messages/UI/MailStateUpdatedMessage.cs @@ -0,0 +1,7 @@ +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public record MailStateUpdatedMessage( + MailStateChange UpdatedState, + EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index d3a33897..a543ff66 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -517,6 +517,53 @@ public class MailService : BaseDatabaseService, IMailService } } + private async Task PopulateAssignedPropertiesAsync(List mails) + { + if (mails == null || mails.Count == 0) + return; + + var folderIds = mails + .Select(m => m.FolderId) + .Distinct() + .ToList(); + + if (folderIds.Count == 0) + return; + + var folders = await Task.WhenAll(folderIds.Select(id => _folderService.GetFolderAsync(id))).ConfigureAwait(false); + var folderCache = folders + .Where(f => f != null) + .ToDictionary(f => f.Id); + + if (folderCache.Count == 0) + return; + + var accountIds = folderCache.Values + .Select(f => f.MailAccountId) + .Distinct() + .ToHashSet(); + + var allAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var accountCache = allAccounts + .Where(a => accountIds.Contains(a.Id)) + .ToDictionary(a => a.Id); + + var addresses = mails + .Where(m => !string.IsNullOrEmpty(m.FromAddress)) + .Select(m => m.FromAddress) + .Distinct() + .ToList(); + + var contactCache = addresses.Count == 0 + ? new Dictionary() + : (await _contactService.GetContactsByAddressesAsync(addresses).ConfigureAwait(false)) + .Where(c => c != null) + .ToDictionary(c => c.Address); + + AssignPropertiesFromCaches(mails, folderCache, accountCache, contactCache); + await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false); + } + private async Task> GetMailsByThreadIdsAsync(List threadIds, HashSet excludeMailIds) { if (threadIds?.Count == 0) @@ -565,11 +612,34 @@ public class MailService : BaseDatabaseService, IMailService private async Task> GetMailItemsAsync(string mailCopyId) { - var mailCopies = await Connection.Table().Where(a => a.Id == mailCopyId).ToListAsync(); + var mailCopies = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false); + await PopulateAssignedPropertiesAsync(mailCopies).ConfigureAwait(false); - foreach (var mailCopy in mailCopies) + return mailCopies; + } + + private async Task> GetMailCopiesByIdAsync(IEnumerable mailCopyIds) + { + var distinctMailCopyIds = mailCopyIds? + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (distinctMailCopyIds == null || distinctMailCopyIds.Count == 0) + return []; + + var mailCopies = new List(); + + const int batchSize = 200; + + for (int i = 0; i < distinctMailCopyIds.Count; i += batchSize) { - await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false); + var batchIds = distinctMailCopyIds.Skip(i).Take(batchSize).ToList(); + var placeholders = string.Join(",", batchIds.Select(_ => "?")); + var sql = $"SELECT * FROM MailCopy WHERE Id IN ({placeholders})"; + + var batch = await Connection.QueryAsync(sql, batchIds.Cast().ToArray()).ConfigureAwait(false); + mailCopies.AddRange(batch); } return mailCopies; @@ -754,9 +824,51 @@ public class MailService : BaseDatabaseService, IMailService #endregion - private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func action) + private async Task PersistMailCopyUpdatesAsync(IReadOnlyList<(MailCopy MailCopy, MailCopyChangeFlags ChangedProperties)> pendingUpdates) { - var mailCopies = await GetMailItemsAsync(mailCopyId); + if (pendingUpdates == null || pendingUpdates.Count == 0) + return; + + await Connection.RunInTransactionAsync(connection => + { + foreach (var (mailCopy, _) in pendingUpdates) + { + connection.Update(mailCopy, typeof(MailCopy)); + } + }).ConfigureAwait(false); + + var readMailUniqueIds = pendingUpdates + .Where(x => (x.ChangedProperties & MailCopyChangeFlags.IsRead) != 0 && + x.MailCopy?.IsRead == true && + x.MailCopy.UniqueId != Guid.Empty) + .Select(x => x.MailCopy.UniqueId) + .Distinct() + .ToList(); + + if (readMailUniqueIds.Count > 0) + { + WeakReferenceMessenger.Default.Send(new BulkMailReadStatusChanged(readMailUniqueIds)); + } + + foreach (var updateGroup in pendingUpdates + .Where(x => x.MailCopy != null) + .GroupBy(x => x.ChangedProperties)) + { + var updatedMails = updateGroup + .Select(x => x.MailCopy) + .Where(x => x != null) + .ToList(); + + if (updatedMails.Count == 0) + continue; + + ReportUIChange(new BulkMailUpdatedMessage(updatedMails, EntityUpdateSource.Server, updateGroup.Key)); + } + } + + private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func action) + { + var mailCopies = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false); if (mailCopies == null || !mailCopies.Any()) { @@ -767,43 +879,110 @@ public class MailService : BaseDatabaseService, IMailService _logger.Debug("Updating {MailCopyCount} mail copies with Id {MailCopyId}", mailCopies.Count, mailCopyId); + var pendingUpdates = new List<(MailCopy MailCopy, MailCopyChangeFlags ChangedProperties)>(); + foreach (var mailCopy in mailCopies) { - bool shouldUpdateItem = action(mailCopy); + var changedProperties = action(mailCopy); - if (shouldUpdateItem) + if (changedProperties != MailCopyChangeFlags.None) { - await UpdateMailAsync(mailCopy).ConfigureAwait(false); + pendingUpdates.Add((mailCopy, changedProperties)); } else + { _logger.Debug("Skipped updating mail because it is already in the desired state."); + } } + + await PersistMailCopyUpdatesAsync(pendingUpdates).ConfigureAwait(false); } public Task ChangeReadStatusAsync(string mailCopyId, bool isRead) => UpdateAllMailCopiesAsync(mailCopyId, (item) => { - if (item.IsRead == isRead) return false; + if (item.IsRead == isRead) return MailCopyChangeFlags.None; item.IsRead = isRead; - if (isRead && item.UniqueId != Guid.Empty) - { - WeakReferenceMessenger.Default.Send(new MailReadStatusChanged(item.UniqueId)); - } - return true; + return MailCopyChangeFlags.IsRead; }); public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged) => UpdateAllMailCopiesAsync(mailCopyId, (item) => { - if (item.IsFlagged == isFlagged) return false; + if (item.IsFlagged == isFlagged) return MailCopyChangeFlags.None; item.IsFlagged = isFlagged; - return true; + return MailCopyChangeFlags.IsFlagged; }); + public async Task ApplyMailStateUpdatesAsync(IEnumerable updates) + { + var updateLookup = new Dictionary(StringComparer.Ordinal); + + foreach (var update in updates ?? []) + { + if (update == null || string.IsNullOrWhiteSpace(update.MailCopyId)) + continue; + + if (updateLookup.TryGetValue(update.MailCopyId, out var existingUpdate)) + { + updateLookup[update.MailCopyId] = new MailCopyStateUpdate( + update.MailCopyId, + update.IsRead ?? existingUpdate.IsRead, + update.IsFlagged ?? existingUpdate.IsFlagged); + } + else + { + updateLookup[update.MailCopyId] = update; + } + } + + if (updateLookup.Count == 0) + return; + + var mailCopies = await GetMailCopiesByIdAsync(updateLookup.Keys).ConfigureAwait(false); + + if (mailCopies.Count == 0) + { + _logger.Warning("Applying mail state updates failed because there are no matching copies for {MailCopyCount} ids.", updateLookup.Count); + return; + } + + await PopulateAssignedPropertiesAsync(mailCopies).ConfigureAwait(false); + + var pendingUpdates = new List<(MailCopy MailCopy, MailCopyChangeFlags ChangedProperties)>(); + + foreach (var mailCopy in mailCopies) + { + if (!updateLookup.TryGetValue(mailCopy.Id, out var update)) + continue; + + var changedProperties = MailCopyChangeFlags.None; + + if (update.IsRead.HasValue && mailCopy.IsRead != update.IsRead.Value) + { + mailCopy.IsRead = update.IsRead.Value; + changedProperties |= MailCopyChangeFlags.IsRead; + } + + if (update.IsFlagged.HasValue && mailCopy.IsFlagged != update.IsFlagged.Value) + { + mailCopy.IsFlagged = update.IsFlagged.Value; + changedProperties |= MailCopyChangeFlags.IsFlagged; + } + + if (changedProperties != MailCopyChangeFlags.None) + { + pendingUpdates.Add((mailCopy, changedProperties)); + } + } + + await PersistMailCopyUpdatesAsync(pendingUpdates).ConfigureAwait(false); + } + public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) { // Note: Folder might not be available at the moment due to user not syncing folders before the delta processing. @@ -1396,18 +1575,26 @@ public class MailService : BaseDatabaseService, IMailService (shouldUpdateDraftId && item.DraftId != newDraftId)) { var oldDraftId = item.DraftId; + var changedProperties = MailCopyChangeFlags.None; if (shouldUpdateDraftId) + { item.DraftId = newDraftId; + changedProperties |= MailCopyChangeFlags.DraftId; + } + if (shouldUpdateThreadId) + { item.ThreadId = newThreadId; + changedProperties |= MailCopyChangeFlags.ThreadId; + } ReportUIChange(new DraftMapped(oldDraftId, item.DraftId)); - return true; + return changedProperties; } - return false; + return MailCopyChangeFlags.None; }); }