Fixing UI thread issues with bulk operations and request queue refactoring.

This commit is contained in:
Burak Kaan Köse
2026-04-20 02:18:23 +02:00
parent 3bd0b69429
commit 54148716bb
38 changed files with 1644 additions and 206 deletions
@@ -36,6 +36,7 @@ public interface IMailService
Task ChangeReadStatusAsync(string mailCopyId, bool isRead);
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
@@ -0,0 +1,3 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailCopyStateUpdate(string MailCopyId, bool? IsRead = null, bool? IsFlagged = null);
@@ -44,6 +44,6 @@ public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeReques
public BatchCollection(IEnumerable<TRequestType> 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());
}
@@ -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<MailUpdatedMessage>
internal sealed class MailRequestRecipient : IRecipient<MailStateUpdatedMessage>
{
public List<MailUpdatedMessage> Updated { get; } = [];
public List<MailStateUpdatedMessage> StateUpdates { get; } = [];
public void Receive(MailUpdatedMessage message) => Updated.Add(message);
public void Receive(MailStateUpdatedMessage message) => StateUpdates.Add(message);
}
}
@@ -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<MailUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Register<BulkMailUpdatedMessage>(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<MailCopy>(mail1.UniqueId))!.IsRead.Should().BeFalse();
(await _databaseService.Connection.FindAsync<MailCopy>(mail2.UniqueId))!.IsRead.Should().BeFalse();
}
finally
{
WeakReferenceMessenger.Default.Unregister<MailUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Unregister<BulkMailUpdatedMessage>(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<MailReadStatusChanged>(recipient);
WeakReferenceMessenger.Default.Register<BulkMailReadStatusChanged>(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<MailReadStatusChanged>(recipient);
WeakReferenceMessenger.Default.Unregister<BulkMailReadStatusChanged>(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<MailUpdatedMessage>, IRecipient<BulkMailUpdatedMessage>
{
public List<MailUpdatedMessage> SingleUpdates { get; } = [];
public List<BulkMailUpdatedMessage> BulkUpdates { get; } = [];
public void Receive(MailUpdatedMessage message) => SingleUpdates.Add(message);
public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message);
}
internal sealed class MailReadStatusRecipient : IRecipient<MailReadStatusChanged>, IRecipient<BulkMailReadStatusChanged>
{
public List<MailReadStatusChanged> SingleUpdates { get; } = [];
public List<BulkMailReadStatusChanged> 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<ISignatureService>();
@@ -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<object>(new object(), batchRequest, request1);
WeakReferenceMessenger.Default.Register<MailStateUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Register<BulkMailStateUpdatedMessage>(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<MailStateUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Unregister<BulkMailStateUpdatedMessage>(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<object>
{
public TestSynchronizer(MailAccount account)
: base(account, WeakReferenceMessenger.Default)
{
}
public void ApplyUiChanges(List<IRequestBundle<object>> bundles) => ApplyOptimisticUiChanges(bundles);
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
internal sealed class UiChangeRecipient : IRecipient<MailStateUpdatedMessage>, IRecipient<BulkMailStateUpdatedMessage>
{
public List<MailStateUpdatedMessage> SingleUpdates { get; } = [];
public List<BulkMailStateUpdatedMessage> BulkUpdates { get; } = [];
public void Receive(MailStateUpdatedMessage message) => SingleUpdates.Add(message);
public void Receive(BulkMailStateUpdatedMessage message) => BulkUpdates.Add(message);
}
}
@@ -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<IMessageSummary>();
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<IMailService>();
mailServiceMock
.Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny<IEnumerable<UniqueId>>()))
.ReturnsAsync([existingMailCopy]);
mailServiceMock
.Setup(x => x.ApplyMailStateUpdatesAsync(It.IsAny<IEnumerable<MailCopyStateUpdate>>()))
.Returns(Task.CompletedTask);
var sut = new UnifiedImapSynchronizer(
Mock.Of<IFolderService>(),
mailServiceMock.Object,
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
var imapSynchronizerMock = new Mock<IImapSynchronizer>();
var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic);
processMethod.Should().NotBeNull();
var task = (Task<List<string>>)processMethod!.Invoke(
sut,
[imapSynchronizerMock.Object, localFolder, new List<IMessageSummary> { summaryMock.Object }, CancellationToken.None])!;
var result = await task;
result.Should().BeEmpty();
mailServiceMock.Verify(
x => x.ApplyMailStateUpdatesAsync(It.Is<IEnumerable<MailCopyStateUpdate>>(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<Guid>(), It.IsAny<NewMailItemPackage>()), Times.Never);
}
}
@@ -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<object, object, object>
{
@@ -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<IRequestBundle<object>> CreateRootFolder(CreateRootFolderRequest request)
@@ -68,6 +103,13 @@ public sealed class WinoSynchronizerMailRequestTests
return [new TestRequestBundle(new object(), request)];
}
public override List<IRequestBundle<object>> MarkRead(BatchMarkReadRequest request)
{
MarkReadInvocationCount++;
LastMarkReadBatchCount = request.Count;
return [new TestRequestBundle(new object(), request[0])];
}
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
{
ExecuteNativeRequestsInvocationCount++;
@@ -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<MailCopy> 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<Guid> SynchronizationFolderIds => [Folder.Id];
@@ -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<MailCopy> 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));
}
}
+20
View File
@@ -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<ArchiveRequest>
public BatchArchiveRequest(IEnumerable<ArchiveRequest> 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));
}
}
+40 -8
View File
@@ -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.
/// </summary>
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<ChangeFlagRequest>
public BatchChangeFlagRequest(IEnumerable<ChangeFlagRequest> 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));
}
}
@@ -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<ChangeJunkStateReques
public BatchChangeJunkStateRequest(IEnumerable<ChangeJunkStateRequest> 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));
}
}
+20
View File
@@ -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<Guid> 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<DeleteRequest>
public BatchDeleteRequest(IEnumerable<DeleteRequest> 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));
}
}
+40 -8
View File
@@ -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.
/// </summary>
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<MarkReadRequest>
public BatchMarkReadRequest(IEnumerable<MarkReadRequest> 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));
}
}
+20
View File
@@ -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<Guid> 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<MoveRequest>, IUIChangeRequest
public BatchMoveRequest(IEnumerable<MoveRequest> 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));
}
}
+26 -3
View File
@@ -259,25 +259,48 @@ public class SynchronizationManager : ISynchronizationManager, IRecipient<Accoun
/// <param name="accountId">Account ID to queue the request for</param>
/// <param name="triggerSynchronization">Whether to automatically trigger synchronization after queuing the request</param>
public async Task QueueRequestAsync(IRequestBase request, Guid accountId, bool triggerSynchronization)
=> await QueueRequestsAsync([request], accountId, triggerSynchronization).ConfigureAwait(false);
public async Task QueueRequestsAsync(IEnumerable<IRequestBase> 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;
}
if (requestList.Count == 1)
{
_logger.Debug("Queuing request {RequestType} for account {AccountId}",
request.GetType().Name, 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()}"));
_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)
{
+10 -10
View File
@@ -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<IRequestBase>().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<IRequestBase> requests, Guid accountId)
{
await SynchronizationManager.Instance.QueueRequestsAsync(requests, accountId, triggerSynchronization: false).ConfigureAwait(false);
}
private Task QueueSynchronizationAsync(Guid accountId)
+7 -3
View File
@@ -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<IMailActionRequest>();
// 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);
@@ -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<TBaseRequest> : ObservableObject,
return ret;
}
protected void ApplyOptimisticUiChanges(IEnumerable<IRequestBundle<TBaseRequest>> bundles, Func<IRequestBase, bool> 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<IRequestBase>(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<object>();
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<IRequestBase> requests)
{
if (requests == null || requests.Count <= 1)
return false;
return requests[0] switch
{
MarkReadRequest => ApplyBatch(new BatchMarkReadRequest(requests.Cast<MarkReadRequest>())),
ChangeFlagRequest => ApplyBatch(new BatchChangeFlagRequest(requests.Cast<ChangeFlagRequest>())),
DeleteRequest => ApplyBatch(new BatchDeleteRequest(requests.Cast<DeleteRequest>())),
MoveRequest => ApplyBatch(new BatchMoveRequest(requests.Cast<MoveRequest>())),
ArchiveRequest => ApplyBatch(new BatchArchiveRequest(requests.Cast<ArchiveRequest>())),
ChangeJunkStateRequest => ApplyBatch(new BatchChangeJunkStateRequest(requests.Cast<ChangeJunkStateRequest>())),
_ => false
};
static bool ApplyBatch(IUIChangeRequest request)
{
request.ApplyUIChanges();
return true;
}
}
}
@@ -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<UnifiedImapSynchronizer>();
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<string>();
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)
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();
if (existingUids.Count > 0)
{
var existingSummaryBatch = await remoteFolder
.FetchAsync(new UniqueIdSet(existingUids, SortOrder.Ascending), _existingMailSynchronizationFlags, cancellationToken)
.ConfigureAwait(false);
downloadedMessageIds.AddRange(await ProcessSummariesAsync(synchronizer, localFolder, summaryBatch, 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<List<string>> ProcessSummariesAsync(
private Task<List<string>> ProcessSummariesAsync(
IImapSynchronizer synchronizer,
MailItemFolder localFolder,
IList<IMessageSummary> summaries,
CancellationToken cancellationToken)
=> ProcessSummariesCoreAsync(synchronizer, localFolder, summaries, existingByUid: null, cancellationToken);
private async Task<List<string>> ProcessSummariesCoreAsync(
IImapSynchronizer synchronizer,
MailItemFolder localFolder,
IList<IMessageSummary> summaries,
IReadOnlyDictionary<uint, MailCopy> existingByUid,
CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
@@ -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<MailCopyStateUpdate>();
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<uint, MailCopy> existingByUid,
IList<IMessageSummary> summaries)
{
if (existingByUid == null || existingByUid.Count == 0 || summaries == null || summaries.Count == 0)
return;
var pendingStateUpdates = new List<MailCopyStateUpdate>();
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<uint, MailCopy> CreateExistingMailLookup(IEnumerable<MailCopy> existingMails)
{
var lookup = new Dictionary<uint, MailCopy>();
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<UniqueId> 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<MailCopyStateUpdate>();
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);
}
}
+73 -31
View File
@@ -112,56 +112,104 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
public override List<IRequestBundle<ImapRequest>> 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);
try
{
await sourceFolder.MoveToAsync(uniqueIds, destinationFolder).ConfigureAwait(false);
}
finally
{
await sourceFolder.CloseAsync().ConfigureAwait(false);
}, requests);
}
}, requests[0], requests);
}
public override List<IRequestBundle<ImapRequest>> 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);
try
{
await remoteFolder.StoreAsync(uniqueIds, request).ConfigureAwait(false);
}
finally
{
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, requests);
}
}, requests[0], requests);
}
public override List<IRequestBundle<ImapRequest>> 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);
try
{
await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false);
await remoteFolder.ExpungeAsync(uniqueIds).ConfigureAwait(false);
}
finally
{
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, requests);
}
}, requests[0], requests);
}
public override List<IRequestBundle<ImapRequest>> 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);
try
{
await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false);
}
finally
{
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, requests);
}
}, requests[0], requests);
}
public override List<IRequestBundle<ImapRequest>> CreateDraft(CreateDraftRequest request)
@@ -718,13 +766,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
// First apply the UI changes for each bundle.
// This is important to reflect changes to the UI before the network call is done.
foreach (var item in batchedRequests)
{
if (ShouldApplyOptimisticUIChanges(item.Request))
{
item.Request.ApplyUIChanges();
}
}
ApplyOptimisticUiChanges(batchedRequests, ShouldApplyOptimisticUIChanges);
// All task bundles will execute on the same client.
// Tasks themselves don't pull the client from the pool
@@ -754,7 +796,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (ShouldApplyOptimisticUIChanges(item.Request))
{
item.Request.RevertUIChanges();
item.UIChangeRequest?.RevertUIChanges();
}
isCrashed = true;
@@ -795,7 +837,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (ShouldApplyOptimisticUIChanges(item.Request))
{
item.Request.RevertUIChanges();
item.UIChangeRequest?.RevertUIChanges();
}
throw;
}
@@ -2020,10 +2020,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{
// First apply all UI changes immediately before any batching.
// This ensures UI reflects changes right away, regardless of batch processing.
foreach (var bundle in batchedRequests)
{
bundle.UIChangeRequest?.ApplyUIChanges();
}
ApplyOptimisticUiChanges(batchedRequests);
// SendDraft requests may include large attachments, which require upload sessions.
// Upload these attachments before the batched patch/send sequence.
+7 -7
View File
@@ -161,11 +161,11 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
foreach (var group in keys)
{
var key = group.Key;
var firstRequest = group.FirstOrDefault();
if (key is MailSynchronizerOperation mailSynchronizerOperation)
if (firstRequest is IMailActionRequest mailActionRequest)
{
switch (mailSynchronizerOperation)
switch (mailActionRequest.Operation)
{
case MailSynchronizerOperation.MarkRead:
nativeRequests.AddRange(MarkRead(new BatchMarkReadRequest(group.Cast<MarkReadRequest>())));
@@ -204,9 +204,9 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
break;
}
}
else if (key is FolderSynchronizerOperation folderSynchronizerOperation)
else if (firstRequest is IFolderActionRequest folderActionRequest)
{
switch (folderSynchronizerOperation)
switch (folderActionRequest.Operation)
{
case FolderSynchronizerOperation.RenameFolder:
nativeRequests.AddRange(RenameFolder(group.ElementAt(0) as RenameFolderRequest));
@@ -230,9 +230,9 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
break;
}
}
else if (key is CategorySynchronizerOperation categorySynchronizerOperation)
else if (firstRequest is ICategoryActionRequest categoryActionRequest)
{
switch (categorySynchronizerOperation)
switch (categoryActionRequest.Operation)
{
case CategorySynchronizerOperation.CreateCategory:
nativeRequests.AddRange(CreateCategory(group.ElementAt(0) as MailCategoryCreateRequest));
@@ -14,6 +14,7 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels.Collections;
@@ -895,6 +896,121 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return UpdateExistingItemAsync(itemContainer, updatedMailCopy, mailUpdateSource, changedProperties);
});
public Task UpdateMailStateAsync(MailStateChange updatedState, EntityUpdateSource mailUpdateSource)
=> 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<MailStateChange> updatedStates, EntityUpdateSource mailUpdateSource)
=> RunSerializedAsync(() => UpdateMailStatesInternalAsync(updatedStates, mailUpdateSource));
public Task UpdateMailCopiesAsync(IEnumerable<MailCopy> 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<MailStateChange> 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<MailCopy> 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<SelectedItemsC
public Task RemoveAsync(MailCopy removeItem)
=> RunSerializedAsync(() => RemoveInternalAsync(removeItem));
public Task RemoveRangeAsync(IEnumerable<MailCopy> removeItems)
=> RunSerializedAsync(() => RemoveRangeInternalAsync(removeItems));
private async Task RemoveInternalAsync(MailCopy removeItem)
=> await RemoveInternalAsync(removeItem, notifySelectionChanges: true);
private async Task RemoveRangeInternalAsync(IEnumerable<MailCopy> 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,8 +1171,11 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
}
if (notifySelectionChanges)
{
await NotifySelectionChangesAsync();
}
}
private IEnumerable<IMailListItem> AllItemsIncludingThreads
{
@@ -1207,4 +1351,5 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
ItemSelectionChanged?.Invoke(this, null);
});
}
}
@@ -320,6 +320,30 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
return changedFlags;
}
public MailCopyChangeFlags ApplyStateChanges(bool? isRead = null, bool? isFlagged = null)
{
var changedFlags = MailCopyChangeFlags.None;
if (isRead.HasValue && MailCopy.IsRead != isRead.Value)
{
MailCopy.IsRead = isRead.Value;
changedFlags |= MailCopyChangeFlags.IsRead;
}
if (isFlagged.HasValue && MailCopy.IsFlagged != isFlagged.Value)
{
MailCopy.IsFlagged = isFlagged.Value;
changedFlags |= MailCopyChangeFlags.IsFlagged;
}
if (changedFlags != MailCopyChangeFlags.None)
{
RaisePropertyChanges(changedFlags);
}
return changedFlags;
}
private static MailCopyChangeFlags SetIfChanged<T>(T currentValue, T newValue, Action<T> setter, MailCopyChangeFlags flag)
{
if (EqualityComparer<T>.Default.Equals(currentValue, newValue))
+54
View File
@@ -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<MailAddedMessage>,
IRecipient<BulkMailAddedMessage>,
IRecipient<MailRemovedMessage>,
IRecipient<BulkMailRemovedMessage>,
IRecipient<MailStateUpdatedMessage>,
IRecipient<BulkMailStateUpdatedMessage>,
IRecipient<MailUpdatedMessage>,
IRecipient<BulkMailUpdatedMessage>,
IRecipient<MailDownloadedMessage>,
IRecipient<DraftCreated>,
IRecipient<DraftFailed>,
@@ -21,8 +27,41 @@ public class MailBaseViewModel : CoreBaseViewModel,
IRecipient<FolderSynchronizationEnabled>
{
protected virtual void OnMailAdded(MailCopy addedMail, EntityUpdateSource source) { }
protected virtual void OnBulkMailAdded(IReadOnlyList<MailCopy> addedMails, EntityUpdateSource source)
{
foreach (var addedMail in addedMails ?? [])
{
OnMailAdded(addedMail, source);
}
}
protected virtual void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { }
protected virtual void OnBulkMailRemoved(IReadOnlyList<MailCopy> removedMails, EntityUpdateSource source)
{
foreach (var removedMail in removedMails ?? [])
{
OnMailRemoved(removedMail, source);
}
}
protected virtual void OnMailStateUpdated(MailStateChange updatedState, EntityUpdateSource source) { }
protected virtual void OnBulkMailStateUpdated(IReadOnlyList<MailStateChange> 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<MailCopy> 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<MailAddedMessage>.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail, message.Source);
void IRecipient<BulkMailAddedMessage>.Receive(BulkMailAddedMessage message) => OnBulkMailAdded(message.AddedMails, message.Source);
void IRecipient<MailRemovedMessage>.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail, message.Source);
void IRecipient<BulkMailRemovedMessage>.Receive(BulkMailRemovedMessage message) => OnBulkMailRemoved(message.RemovedMails, message.Source);
void IRecipient<MailStateUpdatedMessage>.Receive(MailStateUpdatedMessage message) => OnMailStateUpdated(message.UpdatedState, message.Source);
void IRecipient<BulkMailStateUpdatedMessage>.Receive(BulkMailStateUpdatedMessage message) => OnBulkMailStateUpdated(message.UpdatedStates, message.Source);
void IRecipient<MailUpdatedMessage>.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties);
void IRecipient<BulkMailUpdatedMessage>.Receive(BulkMailUpdatedMessage message) => OnBulkMailUpdated(message.UpdatedMails, message.Source, message.ChangedProperties);
void IRecipient<MailDownloadedMessage>.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail);
void IRecipient<DraftMapped>.Receive(DraftMapped message) => OnDraftMapped(message.LocalDraftCopyId, message.RemoteDraftCopyId);
@@ -51,8 +95,13 @@ public class MailBaseViewModel : CoreBaseViewModel,
UnregisterRecipients();
Messenger.Register<MailAddedMessage>(this);
Messenger.Register<BulkMailAddedMessage>(this);
Messenger.Register<MailRemovedMessage>(this);
Messenger.Register<BulkMailRemovedMessage>(this);
Messenger.Register<MailStateUpdatedMessage>(this);
Messenger.Register<BulkMailStateUpdatedMessage>(this);
Messenger.Register<MailUpdatedMessage>(this);
Messenger.Register<BulkMailUpdatedMessage>(this);
Messenger.Register<MailDownloadedMessage>(this);
Messenger.Register<DraftCreated>(this);
Messenger.Register<DraftFailed>(this);
@@ -67,8 +116,13 @@ public class MailBaseViewModel : CoreBaseViewModel,
base.UnregisterRecipients();
Messenger.Unregister<MailAddedMessage>(this);
Messenger.Unregister<BulkMailAddedMessage>(this);
Messenger.Unregister<MailRemovedMessage>(this);
Messenger.Unregister<BulkMailRemovedMessage>(this);
Messenger.Unregister<MailStateUpdatedMessage>(this);
Messenger.Unregister<BulkMailStateUpdatedMessage>(this);
Messenger.Unregister<MailUpdatedMessage>(this);
Messenger.Unregister<BulkMailUpdatedMessage>(this);
Messenger.Unregister<MailDownloadedMessage>(this);
Messenger.Unregister<DraftCreated>(this);
Messenger.Unregister<DraftFailed>(this);
+243 -23
View File
@@ -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<MailListPageViewModel>();
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<IRequestBase>();
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<MailStateChange> 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<MailCopy> 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;
await ExecuteUIThread(() =>
{
isDeletedMailSelected = MailCollection.SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId);
if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem)
{
await ExecuteUIThread(() =>
{
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<MailCopy> 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<MailCopy> 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<MailCopy>();
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);
}
}
@@ -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);
@@ -58,7 +58,12 @@ public class NotificationBuilder : INotificationBuilder
WeakReferenceMessenger.Default.Register<MailReadStatusChanged>(this, (r, msg) =>
{
RemoveNotification(msg.UniqueId);
QueueRemoveNotifications([msg.UniqueId]);
});
WeakReferenceMessenger.Default.Register<BulkMailReadStatusChanged>(this, (r, msg) =>
{
QueueRemoveNotifications(msg.UniqueIds);
});
}
@@ -155,17 +160,38 @@ public class NotificationBuilder : INotificationBuilder
}
public void RemoveNotification(Guid mailUniqueId)
{
QueueRemoveNotifications([mailUniqueId]);
}
private void QueueRemoveNotifications(IEnumerable<Guid> 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<Guid> mailUniqueIds)
{
foreach (var mailUniqueId in mailUniqueIds)
{
try
{
AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().GetAwaiter().GetResult();
await AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().ConfigureAwait(false);
}
catch (ArgumentException)
{
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to remove notification for mail {mailUniqueId}");
Log.Error(ex, "Failed to remove notification for mail {MailUniqueId}", mailUniqueId);
}
}
}
+7
View File
@@ -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<MailCopy> AddedMails, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<BulkMailAddedMessage>;
@@ -0,0 +1,6 @@
using System;
using System.Collections.Generic;
namespace Wino.Messaging.UI;
public record BulkMailReadStatusChanged(IReadOnlyList<Guid> UniqueIds);
@@ -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<MailCopy> RemovedMails, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<BulkMailRemovedMessage>;
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI;
public record BulkMailStateUpdatedMessage(
IReadOnlyList<MailStateChange> UpdatedStates,
EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<BulkMailStateUpdatedMessage>;
+5 -1
View File
@@ -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<MailCopy> UpdatedMails) : UIMessageBase<BulkMailUpdatedMessage>;
public record BulkMailUpdatedMessage(
IReadOnlyList<MailCopy> UpdatedMails,
EntityUpdateSource Source = EntityUpdateSource.Server,
MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase<BulkMailUpdatedMessage>;
+11
View File
@@ -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);
}
@@ -0,0 +1,7 @@
using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI;
public record MailStateUpdatedMessage(
MailStateChange UpdatedState,
EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<MailStateUpdatedMessage>;
+205 -18
View File
@@ -517,6 +517,53 @@ public class MailService : BaseDatabaseService, IMailService
}
}
private async Task PopulateAssignedPropertiesAsync(List<MailCopy> 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<string, AccountContact>()
: (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<List<MailCopy>> GetMailsByThreadIdsAsync(List<string> threadIds, HashSet<string> excludeMailIds)
{
if (threadIds?.Count == 0)
@@ -565,11 +612,34 @@ public class MailService : BaseDatabaseService, IMailService
private async Task<List<MailCopy>> GetMailItemsAsync(string mailCopyId)
{
var mailCopies = await Connection.Table<MailCopy>().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<List<MailCopy>> GetMailCopiesByIdAsync(IEnumerable<string> mailCopyIds)
{
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
var distinctMailCopyIds = mailCopyIds?
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.Ordinal)
.ToList();
if (distinctMailCopyIds == null || distinctMailCopyIds.Count == 0)
return [];
var mailCopies = new List<MailCopy>();
const int batchSize = 200;
for (int i = 0; i < distinctMailCopyIds.Count; i += batchSize)
{
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<MailCopy>(sql, batchIds.Cast<object>().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<MailCopy, bool> 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<MailCopy, MailCopyChangeFlags> 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<MailCopyStateUpdate> updates)
{
var updateLookup = new Dictionary<string, MailCopyStateUpdate>(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;
});
}