diff --git a/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs new file mode 100644 index 00000000..f500aa5d --- /dev/null +++ b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs @@ -0,0 +1,57 @@ +using System; + +namespace Wino.Core.Domain.Enums; + +[Flags] +public enum MailCopyChangeFlags +{ + None = 0, + Id = 1 << 0, + FolderId = 1 << 1, + ThreadId = 1 << 2, + MessageId = 1 << 3, + References = 1 << 4, + InReplyTo = 1 << 5, + FromName = 1 << 6, + FromAddress = 1 << 7, + Subject = 1 << 8, + PreviewText = 1 << 9, + CreationDate = 1 << 10, + Importance = 1 << 11, + IsRead = 1 << 12, + IsFlagged = 1 << 13, + IsFocused = 1 << 14, + HasAttachments = 1 << 15, + ItemType = 1 << 16, + DraftId = 1 << 17, + IsDraft = 1 << 18, + FileId = 1 << 19, + AssignedFolder = 1 << 20, + AssignedAccount = 1 << 21, + SenderContact = 1 << 22, + UniqueId = 1 << 23, + All = Id | + FolderId | + ThreadId | + MessageId | + References | + InReplyTo | + FromName | + FromAddress | + Subject | + PreviewText | + CreationDate | + Importance | + IsRead | + IsFlagged | + IsFocused | + HasAttachments | + ItemType | + DraftId | + IsDraft | + FileId | + AssignedFolder | + AssignedAccount | + SenderContact | + UniqueId +} diff --git a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs index 93620883..f423eadf 100644 --- a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs +++ b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -30,6 +31,11 @@ public interface IBaseSynchronizer /// Mail unique id to check. bool HasPendingOperation(Guid mailUniqueId); + /// + /// Returns mail unique ids that currently have queued or executing operations. + /// + IReadOnlyCollection GetPendingOperationUniqueIds(); + /// /// Returns whether there is an in-progress (queued or currently executing) operation for the given calendar item id. /// diff --git a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs index 63d133b2..d9b3ded1 100644 --- a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs +++ b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs @@ -20,7 +20,7 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail item.IsRead = true; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); } } @@ -33,7 +33,7 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail item.IsRead = false; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); } } diff --git a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs index 50ab0809..54f33a19 100644 --- a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs +++ b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs @@ -31,7 +31,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase Item.IsFlagged = IsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsFlagged)); } public override void RevertUIChanges() @@ -41,7 +41,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase Item.IsFlagged = !IsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged)); } } diff --git a/Wino.Core/Requests/Mail/MarkReadRequest.cs b/Wino.Core/Requests/Mail/MarkReadRequest.cs index a7edc8c2..656df721 100644 --- a/Wino.Core/Requests/Mail/MarkReadRequest.cs +++ b/Wino.Core/Requests/Mail/MarkReadRequest.cs @@ -30,7 +30,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item Item.IsRead = IsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); } public override void RevertUIChanges() @@ -40,7 +40,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item Item.IsRead = !IsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); } } diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 5b1637c5..2cd51dce 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Net.Http; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -130,6 +131,8 @@ public abstract partial class BaseSynchronizer : ObservableObject, public bool HasPendingOperation(Guid mailUniqueId) => _pendingMailOperationIds.ContainsKey(mailUniqueId); + public IReadOnlyCollection GetPendingOperationUniqueIds() => _pendingMailOperationIds.Keys.ToArray(); + public bool HasPendingCalendarOperation(Guid calendarItemId) => _pendingCalendarOperationIds.ContainsKey(calendarItemId); protected void TrackQueuedRequest(IRequestBase request) diff --git a/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs new file mode 100644 index 00000000..a674ba1f --- /dev/null +++ b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs @@ -0,0 +1,173 @@ +using FluentAssertions; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.ViewModels.Collections; +using Wino.Mail.ViewModels.Data; +using Xunit; + +namespace Wino.Mail.ViewModels.Tests.Data; + +public class MailItemViewModelUpdateTests +{ + [Fact] + public void UpdateFrom_ShouldNotifyOnlyReadState_WhenSameInstanceAndHintProvided() + { + var mailCopy = CreateMailCopy("thread-1", DateTime.UtcNow); + var sut = new MailItemViewModel(mailCopy); + var raisedProperties = new List(); + + sut.PropertyChanged += (_, e) => + { + if (!string.IsNullOrEmpty(e.PropertyName)) + { + raisedProperties.Add(e.PropertyName); + } + }; + + mailCopy.IsRead = true; + + sut.UpdateFrom(mailCopy, MailCopyChangeFlags.IsRead); + + raisedProperties.Should().Equal(nameof(MailItemViewModel.IsRead)); + } + + [Fact] + public void UpdateFrom_ShouldNotifyAddressAndDependentSenderFields_WhenFromAddressChanges() + { + var original = CreateMailCopy("thread-1", DateTime.UtcNow); + original.FromName = string.Empty; + var updated = CloneMailCopy(original); + updated.FromAddress = "updated@wino.dev"; + + var sut = new MailItemViewModel(original); + var raisedProperties = new List(); + + sut.PropertyChanged += (_, e) => + { + if (!string.IsNullOrEmpty(e.PropertyName)) + { + raisedProperties.Add(e.PropertyName); + } + }; + + sut.UpdateFrom(updated); + + raisedProperties.Should().Equal( + nameof(MailItemViewModel.FromAddress), + nameof(MailItemViewModel.FromName), + nameof(MailItemViewModel.SortingName)); + } + + [Fact] + public async Task UpdateMailCopy_ShouldNotifyThreadOnlyForReadState_WhenReadStateChanges() + { + var collection = new WinoMailCollection + { + CoreDispatcher = new ImmediateDispatcher() + }; + + var older = CreateMailCopy("thread-1", DateTime.UtcNow.AddMinutes(-5)); + var latest = CreateMailCopy("thread-1", DateTime.UtcNow); + + await collection.AddAsync(older); + await collection.AddAsync(latest); + + ThreadMailItemViewModel? threadItem = null; + foreach (var group in collection.MailItems) + { + foreach (var item in group) + { + if (item is ThreadMailItemViewModel thread) + { + threadItem = thread; + break; + } + } + + if (threadItem != null) + break; + } + + threadItem.Should().NotBeNull(); + + var raisedProperties = new List(); + threadItem!.PropertyChanged += (_, e) => + { + if (!string.IsNullOrEmpty(e.PropertyName)) + { + raisedProperties.Add(e.PropertyName); + } + }; + + latest.IsRead = true; + + await collection.UpdateMailCopy(latest, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead); + + raisedProperties.Should().Equal(nameof(ThreadMailItemViewModel.IsRead)); + } + + private static MailCopy CreateMailCopy(string threadId, DateTime creationDate) + => new() + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString("N"), + FolderId = Guid.NewGuid(), + ThreadId = threadId, + MessageId = $"message-{Guid.NewGuid():N}", + References = string.Empty, + InReplyTo = string.Empty, + FromName = "Sender", + FromAddress = "sender@wino.dev", + Subject = "Subject", + PreviewText = "Preview", + CreationDate = creationDate, + Importance = MailImportance.Normal, + IsRead = false, + IsFlagged = false, + IsFocused = false, + HasAttachments = false, + ItemType = MailItemType.Mail, + DraftId = string.Empty, + IsDraft = false, + FileId = Guid.NewGuid() + }; + + private static MailCopy CloneMailCopy(MailCopy source) + => new() + { + UniqueId = source.UniqueId, + Id = source.Id, + FolderId = source.FolderId, + ThreadId = source.ThreadId, + MessageId = source.MessageId, + References = source.References, + InReplyTo = source.InReplyTo, + FromName = source.FromName, + FromAddress = source.FromAddress, + Subject = source.Subject, + PreviewText = source.PreviewText, + CreationDate = source.CreationDate, + Importance = source.Importance, + IsRead = source.IsRead, + IsFlagged = source.IsFlagged, + IsFocused = source.IsFocused, + HasAttachments = source.HasAttachments, + ItemType = source.ItemType, + DraftId = source.DraftId, + IsDraft = source.IsDraft, + FileId = source.FileId, + SenderContact = source.SenderContact, + AssignedAccount = source.AssignedAccount, + AssignedFolder = source.AssignedFolder + }; + + private sealed class ImmediateDispatcher : IDispatcher + { + public Task ExecuteOnUIThread(Action action) + { + action(); + return Task.CompletedTask; + } + } +} diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index da9b4b35..2b378ecb 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -753,21 +753,29 @@ public class WinoMailCollection : ObservableRecipient, IRecipient /// Updated mail copy. /// - public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource) + public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) { return ExecuteUIThread(() => { var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId); if (itemContainer == null) return; + MailCopyChangeFlags appliedChanges = MailCopyChangeFlags.None; if (itemContainer.ItemViewModel != null) { UpdateUniqueIdHashes(itemContainer.ItemViewModel, false); - // Update the MailCopy using UpdateFrom to properly notify all XAML bindings - // This maintains reference integrity and ensures PropertyChanged is raised for all properties - itemContainer.ItemViewModel.UpdateFrom(updatedMailCopy); + itemContainer.ThreadViewModel?.SuspendChildPropertyNotifications(); + + try + { + appliedChanges = itemContainer.ItemViewModel.UpdateFrom(updatedMailCopy, changedProperties); + } + finally + { + itemContainer.ThreadViewModel?.ResumeChildPropertyNotifications(); + } // Mark the item view model as busy until the network operation is completed. itemContainer.ItemViewModel.IsBusy = mailUpdateSource == MailUpdateSource.ClientUpdated; @@ -781,8 +789,10 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { - CurrentMailDraftItem.UpdateFrom(updatedMail); + CurrentMailDraftItem.UpdateFrom(updatedMail, changedProperties); await UpdatePendingOperationStateAsync(); NotifyComposeActionStateChanged(); }); diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index dce77d34..75af1f53 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -211,68 +211,200 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, } } - /// - /// Updates the MailCopy with new data and notifies all bound properties. - /// This method copies values from the source to the existing MailCopy to maintain reference integrity, - /// then explicitly raises PropertyChanged for all dependent properties. - /// - /// The source MailCopy with updated values. - public void UpdateFrom(MailCopy source) + public static MailCopyChangeFlags GetChangeFlagsForProperty(string propertyName) { - if (source == null) return; + return propertyName switch + { + nameof(CreationDate) or nameof(SortingDate) => MailCopyChangeFlags.CreationDate, + nameof(IsFlagged) => MailCopyChangeFlags.IsFlagged, + nameof(FromName) or nameof(SortingName) => MailCopyChangeFlags.FromName, + nameof(IsFocused) => MailCopyChangeFlags.IsFocused, + nameof(IsRead) => MailCopyChangeFlags.IsRead, + nameof(IsDraft) => MailCopyChangeFlags.IsDraft, + nameof(DraftId) => MailCopyChangeFlags.DraftId, + nameof(Id) => MailCopyChangeFlags.Id, + nameof(Subject) => MailCopyChangeFlags.Subject, + nameof(PreviewText) => MailCopyChangeFlags.PreviewText, + nameof(FromAddress) => MailCopyChangeFlags.FromAddress, + nameof(HasAttachments) => MailCopyChangeFlags.HasAttachments, + nameof(IsCalendarEvent) => MailCopyChangeFlags.ItemType, + nameof(Importance) => MailCopyChangeFlags.Importance, + nameof(ThreadId) => MailCopyChangeFlags.ThreadId, + nameof(MessageId) => MailCopyChangeFlags.MessageId, + nameof(References) => MailCopyChangeFlags.References, + nameof(InReplyTo) => MailCopyChangeFlags.InReplyTo, + nameof(FileId) => MailCopyChangeFlags.FileId, + nameof(FolderId) => MailCopyChangeFlags.FolderId, + nameof(UniqueId) => MailCopyChangeFlags.UniqueId, + nameof(Base64ContactPicture) or nameof(SenderContact) => MailCopyChangeFlags.SenderContact, + _ => MailCopyChangeFlags.None + }; + } - // Update the underlying MailCopy properties directly to maintain reference integrity - // This is important because other parts of the app may hold references to this MailCopy - // Note: UniqueId is the primary key and should match - we don't update it - MailCopy.Id = source.Id; - MailCopy.FolderId = source.FolderId; - MailCopy.ThreadId = source.ThreadId; - MailCopy.MessageId = source.MessageId; - MailCopy.References = source.References; - MailCopy.InReplyTo = source.InReplyTo; - MailCopy.IsDraft = source.IsDraft; - MailCopy.DraftId = source.DraftId; - MailCopy.CreationDate = source.CreationDate; - MailCopy.Subject = source.Subject; - MailCopy.PreviewText = source.PreviewText; - MailCopy.FromName = source.FromName; - MailCopy.FromAddress = source.FromAddress; - MailCopy.HasAttachments = source.HasAttachments; - MailCopy.Importance = source.Importance; - MailCopy.IsRead = source.IsRead; - MailCopy.IsFlagged = source.IsFlagged; - MailCopy.IsFocused = source.IsFocused; - MailCopy.FileId = source.FileId; - MailCopy.ItemType = source.ItemType; - MailCopy.SenderContact = source.SenderContact; - MailCopy.AssignedAccount = source.AssignedAccount; - MailCopy.AssignedFolder = source.AssignedFolder; + /// + /// Updates the existing while raising only the relevant UI notifications. + /// + /// Source data used to update this item. + /// + /// Optional set of known changes. This is required when is the same instance + /// and has already been mutated by Apply/Revert flows. + /// + /// The effective set of changed fields used for notifications. + public MailCopyChangeFlags UpdateFrom(MailCopy source, MailCopyChangeFlags changeHint = MailCopyChangeFlags.None) + { + if (source == null) return MailCopyChangeFlags.None; - // Raise PropertyChanged for all properties that XAML may bind to - OnPropertyChanged(nameof(CreationDate)); - OnPropertyChanged(nameof(IsFlagged)); - OnPropertyChanged(nameof(FromName)); - OnPropertyChanged(nameof(IsFocused)); - OnPropertyChanged(nameof(IsRead)); - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(DraftId)); - OnPropertyChanged(nameof(Id)); - OnPropertyChanged(nameof(Subject)); - OnPropertyChanged(nameof(PreviewText)); - OnPropertyChanged(nameof(FromAddress)); - OnPropertyChanged(nameof(HasAttachments)); - OnPropertyChanged(nameof(IsCalendarEvent)); - OnPropertyChanged(nameof(Importance)); - OnPropertyChanged(nameof(ThreadId)); - OnPropertyChanged(nameof(MessageId)); - OnPropertyChanged(nameof(References)); - OnPropertyChanged(nameof(InReplyTo)); - OnPropertyChanged(nameof(FileId)); - OnPropertyChanged(nameof(FolderId)); - OnPropertyChanged(nameof(UniqueId)); - OnPropertyChanged(nameof(Base64ContactPicture)); - OnPropertyChanged(nameof(SenderContact)); - OnPropertyChanged(nameof(SortingDate)); - OnPropertyChanged(nameof(SortingName)); + var changedFlags = MailCopyChangeFlags.None; + var isSameReference = ReferenceEquals(MailCopy, source); + + if (!isSameReference) + { + changedFlags |= SetIfChanged(MailCopy.Id, source.Id, value => MailCopy.Id = value, MailCopyChangeFlags.Id); + changedFlags |= SetIfChanged(MailCopy.FolderId, source.FolderId, value => MailCopy.FolderId = value, MailCopyChangeFlags.FolderId); + changedFlags |= SetIfChanged(MailCopy.ThreadId, source.ThreadId, value => MailCopy.ThreadId = value, MailCopyChangeFlags.ThreadId); + changedFlags |= SetIfChanged(MailCopy.MessageId, source.MessageId, value => MailCopy.MessageId = value, MailCopyChangeFlags.MessageId); + changedFlags |= SetIfChanged(MailCopy.References, source.References, value => MailCopy.References = value, MailCopyChangeFlags.References); + changedFlags |= SetIfChanged(MailCopy.InReplyTo, source.InReplyTo, value => MailCopy.InReplyTo = value, MailCopyChangeFlags.InReplyTo); + changedFlags |= SetIfChanged(MailCopy.IsDraft, source.IsDraft, value => MailCopy.IsDraft = value, MailCopyChangeFlags.IsDraft); + changedFlags |= SetIfChanged(MailCopy.DraftId, source.DraftId, value => MailCopy.DraftId = value, MailCopyChangeFlags.DraftId); + changedFlags |= SetIfChanged(MailCopy.CreationDate, source.CreationDate, value => MailCopy.CreationDate = value, MailCopyChangeFlags.CreationDate); + changedFlags |= SetIfChanged(MailCopy.Subject, source.Subject, value => MailCopy.Subject = value, MailCopyChangeFlags.Subject); + changedFlags |= SetIfChanged(MailCopy.PreviewText, source.PreviewText, value => MailCopy.PreviewText = value, MailCopyChangeFlags.PreviewText); + changedFlags |= SetIfChanged(MailCopy.FromName, source.FromName, value => MailCopy.FromName = value, MailCopyChangeFlags.FromName); + changedFlags |= SetIfChanged(MailCopy.FromAddress, source.FromAddress, value => MailCopy.FromAddress = value, MailCopyChangeFlags.FromAddress); + changedFlags |= SetIfChanged(MailCopy.HasAttachments, source.HasAttachments, value => MailCopy.HasAttachments = value, MailCopyChangeFlags.HasAttachments); + changedFlags |= SetIfChanged(MailCopy.Importance, source.Importance, value => MailCopy.Importance = value, MailCopyChangeFlags.Importance); + changedFlags |= SetIfChanged(MailCopy.IsRead, source.IsRead, value => MailCopy.IsRead = value, MailCopyChangeFlags.IsRead); + changedFlags |= SetIfChanged(MailCopy.IsFlagged, source.IsFlagged, value => MailCopy.IsFlagged = value, MailCopyChangeFlags.IsFlagged); + changedFlags |= SetIfChanged(MailCopy.IsFocused, source.IsFocused, value => MailCopy.IsFocused = value, MailCopyChangeFlags.IsFocused); + changedFlags |= SetIfChanged(MailCopy.FileId, source.FileId, value => MailCopy.FileId = value, MailCopyChangeFlags.FileId); + changedFlags |= SetIfChanged(MailCopy.ItemType, source.ItemType, value => MailCopy.ItemType = value, MailCopyChangeFlags.ItemType); + changedFlags |= SetIfChanged(MailCopy.SenderContact, source.SenderContact, value => MailCopy.SenderContact = value, MailCopyChangeFlags.SenderContact); + changedFlags |= SetIfChanged(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount); + changedFlags |= SetIfChanged(MailCopy.AssignedFolder, source.AssignedFolder, value => MailCopy.AssignedFolder = value, MailCopyChangeFlags.AssignedFolder); + changedFlags |= SetIfChanged(MailCopy.UniqueId, source.UniqueId, value => MailCopy.UniqueId = value, MailCopyChangeFlags.UniqueId); + } + + changedFlags |= changeHint; + + if (isSameReference && changedFlags == MailCopyChangeFlags.None) + { + // Without a hint there is no reliable way to diff in-place updates on the same instance. + // Fall back to full refresh to preserve correctness. + changedFlags = MailCopyChangeFlags.All; + } + + RaisePropertyChanges(changedFlags); + + return changedFlags; + } + + private static MailCopyChangeFlags SetIfChanged(T currentValue, T newValue, Action setter, MailCopyChangeFlags flag) + { + if (EqualityComparer.Default.Equals(currentValue, newValue)) + return MailCopyChangeFlags.None; + + setter(newValue); + return flag; + } + + private void RaisePropertyChanges(MailCopyChangeFlags changedFlags) + { + if (changedFlags == MailCopyChangeFlags.None) + return; + + var changedProperties = new List(12); + + void Queue(string propertyName) + { + if (!changedProperties.Contains(propertyName)) + { + changedProperties.Add(propertyName); + } + } + + if ((changedFlags & MailCopyChangeFlags.CreationDate) != 0) + { + Queue(nameof(CreationDate)); + Queue(nameof(SortingDate)); + } + + if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0) + Queue(nameof(IsFlagged)); + + if ((changedFlags & MailCopyChangeFlags.FromName) != 0) + { + Queue(nameof(FromName)); + Queue(nameof(SortingName)); + } + + if ((changedFlags & MailCopyChangeFlags.FromAddress) != 0) + { + Queue(nameof(FromAddress)); + Queue(nameof(FromName)); + Queue(nameof(SortingName)); + } + + if ((changedFlags & MailCopyChangeFlags.IsFocused) != 0) + Queue(nameof(IsFocused)); + + if ((changedFlags & MailCopyChangeFlags.IsRead) != 0) + Queue(nameof(IsRead)); + + if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0) + Queue(nameof(IsDraft)); + + if ((changedFlags & MailCopyChangeFlags.DraftId) != 0) + Queue(nameof(DraftId)); + + if ((changedFlags & MailCopyChangeFlags.Id) != 0) + Queue(nameof(Id)); + + if ((changedFlags & MailCopyChangeFlags.Subject) != 0) + Queue(nameof(Subject)); + + if ((changedFlags & MailCopyChangeFlags.PreviewText) != 0) + Queue(nameof(PreviewText)); + + if ((changedFlags & MailCopyChangeFlags.HasAttachments) != 0) + Queue(nameof(HasAttachments)); + + if ((changedFlags & MailCopyChangeFlags.ItemType) != 0) + Queue(nameof(IsCalendarEvent)); + + if ((changedFlags & MailCopyChangeFlags.Importance) != 0) + Queue(nameof(Importance)); + + if ((changedFlags & MailCopyChangeFlags.ThreadId) != 0) + Queue(nameof(ThreadId)); + + if ((changedFlags & MailCopyChangeFlags.MessageId) != 0) + Queue(nameof(MessageId)); + + if ((changedFlags & MailCopyChangeFlags.References) != 0) + Queue(nameof(References)); + + if ((changedFlags & MailCopyChangeFlags.InReplyTo) != 0) + Queue(nameof(InReplyTo)); + + if ((changedFlags & MailCopyChangeFlags.FileId) != 0) + Queue(nameof(FileId)); + + if ((changedFlags & MailCopyChangeFlags.FolderId) != 0) + Queue(nameof(FolderId)); + + if ((changedFlags & MailCopyChangeFlags.UniqueId) != 0) + Queue(nameof(UniqueId)); + + if ((changedFlags & MailCopyChangeFlags.SenderContact) != 0) + { + Queue(nameof(Base64ContactPicture)); + Queue(nameof(SenderContact)); + } + + foreach (var changedProperty in changedProperties) + { + OnPropertyChanged(changedProperty); + } } } diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 62c8163f..c5d40d42 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -19,6 +19,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte private readonly string _threadId; private readonly HashSet _uniqueIdSet = []; private MailItemViewModel _cachedLatestMailViewModel; + private int _suspendChildPropertyNotificationsCount; [ObservableProperty] [NotifyPropertyChangedRecipients] @@ -202,6 +203,23 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte _threadId = threadId; } + internal void SuspendChildPropertyNotifications() => _suspendChildPropertyNotificationsCount++; + + internal void ResumeChildPropertyNotifications() + { + if (_suspendChildPropertyNotificationsCount > 0) + { + _suspendChildPropertyNotificationsCount--; + } + } + + private void RefreshLatestMailCache() + { + _cachedLatestMailViewModel = ThreadEmails + .OrderByDescending(static item => item.MailCopy.CreationDate) + .FirstOrDefault(); + } + /// /// Adds an email to this thread /// @@ -225,9 +243,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte ThreadEmails.Insert(insertIndex, email); email.PropertyChanged += ThreadEmailPropertyChanged; _uniqueIdSet.Add(email.MailCopy.UniqueId); - _cachedLatestMailViewModel = ThreadEmails[0]; + RefreshLatestMailCache(); OnPropertyChanged(nameof(EmailCount)); - NotifyMailItemUpdated(email); + NotifyMailItemUpdated(email, MailCopyChangeFlags.All); } /// @@ -239,9 +257,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte { email.PropertyChanged -= ThreadEmailPropertyChanged; _uniqueIdSet.Remove(email.MailCopy.UniqueId); - _cachedLatestMailViewModel = ThreadEmails.Count > 0 ? ThreadEmails[0] : null; + RefreshLatestMailCache(); OnPropertyChanged(nameof(EmailCount)); - NotifyMailItemUpdated(email); + NotifyMailItemUpdated(email, MailCopyChangeFlags.All); } } @@ -256,51 +274,190 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte private void ThreadEmailPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(MailItemViewModel.IsSelected) || e.PropertyName == nameof(MailItemViewModel.IsDisplayedInThread)) + if (_suspendChildPropertyNotificationsCount > 0) return; - if (e.PropertyName == nameof(MailItemViewModel.IsRead)) + if (sender is not MailItemViewModel updatedMailItem) + return; + + if (e.PropertyName == nameof(MailItemViewModel.IsSelected) || + e.PropertyName == nameof(MailItemViewModel.IsDisplayedInThread) || + e.PropertyName == nameof(MailItemViewModel.IsBusy)) { - OnPropertyChanged(nameof(IsRead)); return; } - NotifyMailItemUpdated(sender as MailItemViewModel); + if (e.PropertyName == nameof(MailItemViewModel.ThumbnailUpdatedEvent)) + { + if (ReferenceEquals(updatedMailItem, latestMailViewModel)) + { + OnPropertyChanged(nameof(ThumbnailUpdatedEvent)); + } + + return; + } + + var changedFlags = string.IsNullOrEmpty(e.PropertyName) + ? MailCopyChangeFlags.All + : MailItemViewModel.GetChangeFlagsForProperty(e.PropertyName); + + if (changedFlags == MailCopyChangeFlags.None) + { + NotifyMailItemUpdated(updatedMailItem, MailCopyChangeFlags.All); + return; + } + + NotifyMailItemUpdated(updatedMailItem, changedFlags); } + /// /// Notifies that a mail item within this thread has been updated. - /// This raises PropertyChanged for all thread-level computed properties that depend on child items. /// /// The mail item that was updated (can be null to refresh all). - public void NotifyMailItemUpdated(MailItemViewModel updatedMailItem) + /// Set of changed child fields. + public void NotifyMailItemUpdated(MailItemViewModel updatedMailItem, MailCopyChangeFlags changedFlags = MailCopyChangeFlags.All) { - // Raise PropertyChanged for all computed properties that depend on ThreadEmails contents - OnPropertyChanged(nameof(Subject)); - OnPropertyChanged(nameof(FromName)); - OnPropertyChanged(nameof(CreationDate)); - OnPropertyChanged(nameof(FromAddress)); - OnPropertyChanged(nameof(PreviewText)); - OnPropertyChanged(nameof(HasAttachments)); - OnPropertyChanged(nameof(IsCalendarEvent)); - OnPropertyChanged(nameof(IsFlagged)); - OnPropertyChanged(nameof(IsFocused)); - OnPropertyChanged(nameof(IsRead)); - OnPropertyChanged(nameof(IsDraft)); - OnPropertyChanged(nameof(DraftId)); - OnPropertyChanged(nameof(Id)); - OnPropertyChanged(nameof(Importance)); - OnPropertyChanged(nameof(ThreadId)); - OnPropertyChanged(nameof(MessageId)); - OnPropertyChanged(nameof(References)); - OnPropertyChanged(nameof(InReplyTo)); - OnPropertyChanged(nameof(FileId)); - OnPropertyChanged(nameof(FolderId)); - OnPropertyChanged(nameof(UniqueId)); - OnPropertyChanged(nameof(Base64ContactPicture)); - OnPropertyChanged(nameof(SenderContact)); - OnPropertyChanged(nameof(ThumbnailUpdatedEvent)); - OnPropertyChanged(nameof(SortingDate)); - OnPropertyChanged(nameof(SortingName)); + if (changedFlags == MailCopyChangeFlags.None) + return; + + var previousLatest = latestMailViewModel; + + if (changedFlags == MailCopyChangeFlags.All || + (changedFlags & MailCopyChangeFlags.CreationDate) != 0 || + previousLatest == null || + !ThreadEmails.Contains(previousLatest)) + { + RefreshLatestMailCache(); + } + + var currentLatest = latestMailViewModel; + var latestChanged = !ReferenceEquals(previousLatest, currentLatest); + + var updatesDisplayedLatest = changedFlags == MailCopyChangeFlags.All || + updatedMailItem == null || + latestChanged || + ReferenceEquals(updatedMailItem, previousLatest) || + ReferenceEquals(updatedMailItem, currentLatest); + + var changedProperties = new List(10); + + void Queue(string propertyName) + { + if (!changedProperties.Contains(propertyName)) + { + changedProperties.Add(propertyName); + } + } + + if (updatesDisplayedLatest) + { + if (changedFlags == MailCopyChangeFlags.All || latestChanged) + { + Queue(nameof(Subject)); + Queue(nameof(FromName)); + Queue(nameof(CreationDate)); + Queue(nameof(FromAddress)); + Queue(nameof(PreviewText)); + Queue(nameof(IsFocused)); + Queue(nameof(DraftId)); + Queue(nameof(Id)); + Queue(nameof(Importance)); + Queue(nameof(ThreadId)); + Queue(nameof(MessageId)); + Queue(nameof(References)); + Queue(nameof(InReplyTo)); + Queue(nameof(FileId)); + Queue(nameof(FolderId)); + Queue(nameof(UniqueId)); + Queue(nameof(Base64ContactPicture)); + Queue(nameof(SenderContact)); + Queue(nameof(ThumbnailUpdatedEvent)); + Queue(nameof(SortingDate)); + Queue(nameof(SortingName)); + } + else + { + if ((changedFlags & MailCopyChangeFlags.Subject) != 0) + Queue(nameof(Subject)); + + if ((changedFlags & MailCopyChangeFlags.FromName) != 0) + { + Queue(nameof(FromName)); + Queue(nameof(SortingName)); + } + + if ((changedFlags & MailCopyChangeFlags.CreationDate) != 0) + { + Queue(nameof(CreationDate)); + Queue(nameof(SortingDate)); + } + + if ((changedFlags & MailCopyChangeFlags.FromAddress) != 0) + Queue(nameof(FromAddress)); + + if ((changedFlags & MailCopyChangeFlags.PreviewText) != 0) + Queue(nameof(PreviewText)); + + if ((changedFlags & MailCopyChangeFlags.IsFocused) != 0) + Queue(nameof(IsFocused)); + + if ((changedFlags & MailCopyChangeFlags.DraftId) != 0) + Queue(nameof(DraftId)); + + if ((changedFlags & MailCopyChangeFlags.Id) != 0) + Queue(nameof(Id)); + + if ((changedFlags & MailCopyChangeFlags.Importance) != 0) + Queue(nameof(Importance)); + + if ((changedFlags & MailCopyChangeFlags.ThreadId) != 0) + Queue(nameof(ThreadId)); + + if ((changedFlags & MailCopyChangeFlags.MessageId) != 0) + Queue(nameof(MessageId)); + + if ((changedFlags & MailCopyChangeFlags.References) != 0) + Queue(nameof(References)); + + if ((changedFlags & MailCopyChangeFlags.InReplyTo) != 0) + Queue(nameof(InReplyTo)); + + if ((changedFlags & MailCopyChangeFlags.FileId) != 0) + Queue(nameof(FileId)); + + if ((changedFlags & MailCopyChangeFlags.FolderId) != 0) + Queue(nameof(FolderId)); + + if ((changedFlags & MailCopyChangeFlags.UniqueId) != 0) + Queue(nameof(UniqueId)); + + if ((changedFlags & MailCopyChangeFlags.SenderContact) != 0) + { + Queue(nameof(Base64ContactPicture)); + Queue(nameof(SenderContact)); + } + } + } + + if ((changedFlags & MailCopyChangeFlags.HasAttachments) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(HasAttachments)); + + if ((changedFlags & MailCopyChangeFlags.ItemType) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(IsCalendarEvent)); + + if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(IsFlagged)); + + if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(IsRead)); + + if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(IsDraft)); + + foreach (var changedProperty in changedProperties) + { + OnPropertyChanged(changedProperty); + } } /// diff --git a/Wino.Mail.ViewModels/MailBaseViewModel.cs b/Wino.Mail.ViewModels/MailBaseViewModel.cs index fb30d5df..07be737f 100644 --- a/Wino.Mail.ViewModels/MailBaseViewModel.cs +++ b/Wino.Mail.ViewModels/MailBaseViewModel.cs @@ -22,7 +22,7 @@ public class MailBaseViewModel : CoreBaseViewModel, { protected virtual void OnMailAdded(MailCopy addedMail) { } protected virtual void OnMailRemoved(MailCopy removedMail) { } - protected virtual void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source) { } + protected virtual void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { } protected virtual void OnMailDownloaded(MailCopy downloadedMail) { } protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { } protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { } @@ -33,7 +33,7 @@ public class MailBaseViewModel : CoreBaseViewModel, void IRecipient.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail); void IRecipient.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail); - void IRecipient.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source); + void IRecipient.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties); void IRecipient.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail); void IRecipient.Receive(DraftMapped message) => OnDraftMapped(message.LocalDraftCopyId, message.RemoteDraftCopyId); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index acc9136c..7ae1f4bd 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -599,6 +599,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, } var viewModels = await PrepareMailViewModelsAsync(items).ConfigureAwait(false); + var pendingOperationUniqueIds = await GetPendingOperationUniqueIdsForActiveFolderAccountsAsync().ConfigureAwait(false); + ApplyPendingOperationBusyStates(viewModels, pendingOperationUniqueIds); await MailCollection.AddRangeAsync(viewModels, false); await ExecuteUIThread(() => { IsInitializingFolder = false; }); @@ -792,9 +794,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source) + protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { - base.OnMailUpdated(updatedMail, source); + base.OnMailUpdated(updatedMail, source, changedProperties); try { @@ -810,7 +812,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, return; } - await MailCollection.UpdateMailCopy(updatedMail, source); + await MailCollection.UpdateMailCopy(updatedMail, source, changedProperties); } finally { @@ -948,6 +950,48 @@ public partial class MailListPageViewModel : MailBaseViewModel, }, cancellationToken).ConfigureAwait(false); } + private async Task> GetPendingOperationUniqueIdsForActiveFolderAccountsAsync(CancellationToken cancellationToken = default) + { + var pendingOperationUniqueIds = new HashSet(); + + var accountIds = ActiveFolder?.HandlingFolders? + .Select(folder => folder.MailAccountId) + .Where(accountId => accountId != Guid.Empty) + .Distinct() + .ToList(); + + if (accountIds == null || accountIds.Count == 0) + return pendingOperationUniqueIds; + + foreach (var accountId in accountIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + var synchronizer = await SynchronizationManager.Instance.GetSynchronizerAsync(accountId).ConfigureAwait(false); + + if (synchronizer == null) + continue; + + foreach (var uniqueId in synchronizer.GetPendingOperationUniqueIds()) + { + pendingOperationUniqueIds.Add(uniqueId); + } + } + + return pendingOperationUniqueIds; + } + + private static void ApplyPendingOperationBusyStates(IEnumerable viewModels, HashSet pendingOperationUniqueIds) + { + if (viewModels == null || pendingOperationUniqueIds == null || pendingOperationUniqueIds.Count == 0) + return; + + foreach (var viewModel in viewModels) + { + viewModel.IsBusy = pendingOperationUniqueIds.Contains(viewModel.MailCopy.UniqueId); + } + } + [RelayCommand] private async Task PerformOnlineSearchAsync() { @@ -1076,6 +1120,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Just create VMs and do bulk insert. var viewModels = await PrepareMailViewModelsAsync(items, cancellationToken).ConfigureAwait(false); + var pendingOperationUniqueIds = await GetPendingOperationUniqueIdsForActiveFolderAccountsAsync(cancellationToken).ConfigureAwait(false); + ApplyPendingOperationBusyStates(viewModels, pendingOperationUniqueIds); await MailCollection.AddRangeAsync(viewModels, clearIdCache: true); diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index 99e3644a..3115a52e 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -651,9 +651,9 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false)); } - protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source) + protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { - base.OnMailUpdated(updatedMail, source); + base.OnMailUpdated(updatedMail, source, changedProperties); if (initializedMailItemViewModel == null) return; diff --git a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs index 0e1abe04..959a6f78 100644 --- a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs +++ b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs @@ -95,6 +95,8 @@ public sealed partial class ImagePreviewControl : PersonPicture if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == nameof(IMailItemDisplayInformation.Base64ContactPicture) || e.PropertyName == nameof(IMailItemDisplayInformation.SenderContact) + || e.PropertyName == nameof(IMailItemDisplayInformation.FromName) + || e.PropertyName == nameof(IMailItemDisplayInformation.FromAddress) || e.PropertyName == nameof(IMailItemDisplayInformation.ThumbnailUpdatedEvent)) { RequestRefresh(); diff --git a/Wino.Mail.WinUI/Services/NotificationBuilder.cs b/Wino.Mail.WinUI/Services/NotificationBuilder.cs index 3d1517e6..372c9625 100644 --- a/Wino.Mail.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Mail.WinUI/Services/NotificationBuilder.cs @@ -243,6 +243,10 @@ public class NotificationBuilder : INotificationBuilder { ToastNotificationManager.History.Remove(mailUniqueId.ToString(), null); } + catch (ArgumentException) + { + // Notification does not exists. Ignore. + } catch (Exception ex) { Log.Error(ex, $"Failed to remove notification for mail {mailUniqueId}"); diff --git a/Wino.Messages/UI/MailUpdatedMessage.cs b/Wino.Messages/UI/MailUpdatedMessage.cs index 546a8d9d..66c4161b 100644 --- a/Wino.Messages/UI/MailUpdatedMessage.cs +++ b/Wino.Messages/UI/MailUpdatedMessage.cs @@ -3,4 +3,4 @@ using Wino.Core.Domain.Enums; namespace Wino.Messaging.UI; -public record MailUpdatedMessage(MailCopy UpdatedMail, MailUpdateSource Source) : UIMessageBase; +public record MailUpdatedMessage(MailCopy UpdatedMail, MailUpdateSource Source, MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase;