From 09820dda715be9cea2c899110f146d5a39a6d1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 21 Apr 2026 23:17:08 +0200 Subject: [PATCH] Add local mail pinning support --- Wino.Core.Domain/Entities/Mail/MailCopy.cs | 5 + Wino.Core.Domain/Enums/MailCopyChangeFlags.cs | 24 ++-- .../Interfaces/IMailListItemSorting.cs | 1 + Wino.Core.Domain/Interfaces/IMailService.cs | 2 + .../Models/Comparers/ListItemComparer.cs | 47 ++++++- .../Models/MailItem/MailListGroupKey.cs | 6 + Wino.Core.Tests/Services/MailFetchingTests.cs | 41 ++++++ .../Services/MailThreadingTests.cs | 95 ++++++++++++++ .../Collections/WinoMailCollectionTests.cs | 43 ++++++- .../Data/MailItemViewModelUpdateTests.cs | 25 ++++ .../Collections/WinoMailCollection.cs | 33 ++++- .../Data/MailItemViewModel.cs | 26 +++- .../Data/ThreadMailItemViewModel.cs | 9 ++ Wino.Mail.ViewModels/MailListPageViewModel.cs | 29 ++++- .../MailRenderingPageViewModel.cs | 15 ++- Wino.Mail.WinUI/Helpers/XamlHelpers.cs | 9 ++ .../Views/Mail/MailListPage.xaml.cs | 45 ++++++- Wino.Services/DatabaseService.cs | 9 ++ Wino.Services/MailService.cs | 120 +++++++++++++++--- 19 files changed, 531 insertions(+), 53 deletions(-) create mode 100644 Wino.Core.Domain/Models/MailItem/MailListGroupKey.cs diff --git a/Wino.Core.Domain/Entities/Mail/MailCopy.cs b/Wino.Core.Domain/Entities/Mail/MailCopy.cs index 845b843d..e8082268 100644 --- a/Wino.Core.Domain/Entities/Mail/MailCopy.cs +++ b/Wino.Core.Domain/Entities/Mail/MailCopy.cs @@ -92,6 +92,11 @@ public class MailCopy /// public bool IsFlagged { get; set; } + /// + /// Whether this mail should stay pinned to the top locally. + /// + public bool IsPinned { get; set; } + /// /// To support Outlook. /// Gmail doesn't use it. diff --git a/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs index a6dea93f..0ceaddfb 100644 --- a/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs +++ b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs @@ -20,17 +20,18 @@ public enum MailCopyChangeFlags 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, - ReadReceiptState = 1 << 24, + IsPinned = 1 << 14, + IsFocused = 1 << 15, + HasAttachments = 1 << 16, + ItemType = 1 << 17, + DraftId = 1 << 18, + IsDraft = 1 << 19, + FileId = 1 << 20, + AssignedFolder = 1 << 21, + AssignedAccount = 1 << 22, + SenderContact = 1 << 23, + UniqueId = 1 << 24, + ReadReceiptState = 1 << 25, All = Id | FolderId | ThreadId | @@ -45,6 +46,7 @@ public enum MailCopyChangeFlags Importance | IsRead | IsFlagged | + IsPinned | IsFocused | HasAttachments | ItemType | diff --git a/Wino.Core.Domain/Interfaces/IMailListItemSorting.cs b/Wino.Core.Domain/Interfaces/IMailListItemSorting.cs index 5d4c24f5..d9884992 100644 --- a/Wino.Core.Domain/Interfaces/IMailListItemSorting.cs +++ b/Wino.Core.Domain/Interfaces/IMailListItemSorting.cs @@ -6,4 +6,5 @@ public interface IMailListItemSorting { DateTime SortingDate { get; } string SortingName { get; } + bool IsPinned { get; } } diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index 5afdb5b6..5b4d4420 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -26,6 +26,7 @@ public interface IMailService /// Task> GetMailItemsAsync(IEnumerable mailCopyIds); Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default); + Task> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default); /// /// Deletes all mail copies for all folders. @@ -36,6 +37,7 @@ public interface IMailService Task ChangeReadStatusAsync(string mailCopyId, bool isRead); Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged); + Task ChangePinnedStatusAsync(IEnumerable uniqueMailIds, bool isPinned); Task ApplyMailStateUpdatesAsync(IEnumerable updates); Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); diff --git a/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs b/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs index 50aecc8a..bae0ec27 100644 --- a/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs +++ b/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; public class ListItemComparer : IComparer { @@ -9,14 +10,48 @@ public class ListItemComparer : IComparer public int Compare(object x, object y) { + if (x is MailListGroupKey xGroupKey && y is MailListGroupKey yGroupKey) + { + if (xGroupKey.IsPinned != yGroupKey.IsPinned) + return yGroupKey.IsPinned.CompareTo(xGroupKey.IsPinned); + + if (xGroupKey.IsPinned && yGroupKey.IsPinned) + return 0; + + return CompareSortValues(xGroupKey.Value, yGroupKey.Value); + } + if (x is IMailListItemSorting xSorting && y is IMailListItemSorting ySorting) - return SortByName ? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(ySorting.SortingDate, xSorting.SortingDate); - else if (x is MailCopy xMail && y is MailCopy yMail) - return SortByName ? string.Compare(xMail.FromName, yMail.FromName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(yMail.CreationDate, xMail.CreationDate); - else if (x is DateTime dateX && y is DateTime dateY) + { + if (xSorting.IsPinned != ySorting.IsPinned) + return ySorting.IsPinned.CompareTo(xSorting.IsPinned); + + return SortByName + ? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase) + : DateTime.Compare(ySorting.SortingDate, xSorting.SortingDate); + } + + if (x is MailCopy xMail && y is MailCopy yMail) + { + if (xMail.IsPinned != yMail.IsPinned) + return yMail.IsPinned.CompareTo(xMail.IsPinned); + + return SortByName + ? string.Compare(xMail.FromName, yMail.FromName, StringComparison.OrdinalIgnoreCase) + : DateTime.Compare(yMail.CreationDate, xMail.CreationDate); + } + + return CompareSortValues(x, y); + } + + private static int CompareSortValues(object x, object y) + { + if (x is DateTime dateX && y is DateTime dateY) return DateTime.Compare(dateY, dateX); - else if (x is string stringX && y is string stringY) + + if (x is string stringX && y is string stringY) return stringY.CompareTo(stringX); + return 0; } } diff --git a/Wino.Core.Domain/Models/MailItem/MailListGroupKey.cs b/Wino.Core.Domain/Models/MailItem/MailListGroupKey.cs new file mode 100644 index 00000000..b3657889 --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailListGroupKey.cs @@ -0,0 +1,6 @@ +namespace Wino.Core.Domain.Models.MailItem; + +public sealed record MailListGroupKey(bool IsPinned, object Value) +{ + public static MailListGroupKey Pinned { get; } = new(true, null); +} diff --git a/Wino.Core.Tests/Services/MailFetchingTests.cs b/Wino.Core.Tests/Services/MailFetchingTests.cs index c13869f3..f36a86ba 100644 --- a/Wino.Core.Tests/Services/MailFetchingTests.cs +++ b/Wino.Core.Tests/Services/MailFetchingTests.cs @@ -259,6 +259,26 @@ public class MailFetchingTests : IAsyncLifetime result.Single().FolderId.Should().Be(_inboxFolder.Id, "a copy from the actively searched folder should win over newer non-searched copies"); } + [Fact] + public async Task FetchPinnedMailsAsync_ReturnsPinnedMailsOutsideRegularPage() + { + var oldPinned = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddDays(-5)); + oldPinned.IsPinned = true; + + var recentMails = Enumerable.Range(0, 120) + .Select(i => BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-i))) + .ToList(); + + await _databaseService.Connection.InsertAsync(oldPinned, typeof(MailCopy)); + await _databaseService.Connection.InsertAllAsync(recentMails, typeof(MailCopy)); + + var options = BuildOptions([_inboxFolder], createThreads: false, take: 20); + + var result = await _mailService.FetchPinnedMailsAsync(options); + + result.Should().ContainSingle(mail => mail.UniqueId == oldPinned.UniqueId); + } + [Fact] public async Task CreateAssignmentAsync_ExistingAssignment_IsIgnored() { @@ -297,6 +317,27 @@ public class MailFetchingTests : IAsyncLifetime insertedCopies.Select(mail => mail.FolderId).Should().BeEquivalentTo([_inboxFolder.Id, archiveFolder.Id]); } + [Fact] + public async Task UpdateMailAsync_PreservesLocalPinnedState() + { + var existingMail = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddHours(-1)); + existingMail.IsPinned = true; + + await _databaseService.Connection.InsertAsync(existingMail, typeof(MailCopy)); + + var refreshedMail = BuildMail(_inboxFolder.Id, DateTime.UtcNow, id: existingMail.Id); + refreshedMail.UniqueId = existingMail.UniqueId; + refreshedMail.FileId = existingMail.FileId; + refreshedMail.Subject = "Updated subject"; + + await _mailService.UpdateMailAsync(refreshedMail); + + var storedMail = await _databaseService.Connection.FindAsync(existingMail.UniqueId); + storedMail.Should().NotBeNull(); + storedMail!.IsPinned.Should().BeTrue(); + storedMail.Subject.Should().Be("Updated subject"); + } + // ── Performance: 1 000 mails / ~70 threads ───────────────────────────────── /// diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs index 07b20dfe..8d094f44 100644 --- a/Wino.Core.Tests/Services/MailThreadingTests.cs +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -327,6 +327,94 @@ public class MailThreadingTests : IAsyncLifetime } } + [Fact] + public async Task ChangePinnedStatusAsync_SendsHydratedBulkMailUpdatedMessage() + { + var mail = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + FolderId = _draftFolder.Id, + IsPinned = false, + Subject = "Pinned draft" + }; + + await _databaseService.Connection.InsertAsync(mail, typeof(MailCopy)); + + var recipient = new MailUpdateRecipient(); + WeakReferenceMessenger.Default.Register(recipient); + WeakReferenceMessenger.Default.Register(recipient); + + try + { + await _mailService.ChangePinnedStatusAsync([mail.UniqueId], true); + + recipient.SingleUpdates.Should().BeEmpty(); + recipient.BulkUpdates.Should().ContainSingle(); + recipient.BulkUpdates[0].ChangedProperties.Should().Be(MailCopyChangeFlags.IsPinned); + recipient.BulkUpdates[0].UpdatedMails.Should().ContainSingle(); + + var updatedMail = recipient.BulkUpdates[0].UpdatedMails[0]; + updatedMail.IsPinned.Should().BeTrue(); + updatedMail.AssignedFolder.Should().NotBeNull(); + updatedMail.AssignedFolder!.Id.Should().Be(_draftFolder.Id); + updatedMail.AssignedAccount.Should().NotBeNull(); + updatedMail.AssignedAccount!.Id.Should().Be(_account.Id); + } + finally + { + WeakReferenceMessenger.Default.Unregister(recipient); + WeakReferenceMessenger.Default.Unregister(recipient); + } + } + + [Fact] + public async Task CreateAssignmentAsync_SendsHydratedMailAddedMessage() + { + var archiveFolder = new MailItemFolder + { + Id = Guid.NewGuid(), + MailAccountId = _account.Id, + FolderName = "Archive", + RemoteFolderId = "archive", + SpecialFolderType = SpecialFolderType.Archive, + IsSystemFolder = true, + IsSynchronizationEnabled = true + }; + + var mail = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = "assignment-mail", + FolderId = _draftFolder.Id, + Subject = "Assigned copy" + }; + + await _databaseService.Connection.InsertAsync(archiveFolder, typeof(MailItemFolder)); + await _databaseService.Connection.InsertAsync(mail, typeof(MailCopy)); + + var recipient = new MailAddRecipient(); + WeakReferenceMessenger.Default.Register(recipient); + + try + { + await _mailService.CreateAssignmentAsync(_account.Id, mail.Id, archiveFolder.RemoteFolderId); + + recipient.Added.Should().ContainSingle(); + + var addedMail = recipient.Added[0].AddedMail; + addedMail.UniqueId.Should().NotBe(mail.UniqueId); + addedMail.AssignedFolder.Should().NotBeNull(); + addedMail.AssignedFolder!.Id.Should().Be(archiveFolder.Id); + addedMail.AssignedAccount.Should().NotBeNull(); + addedMail.AssignedAccount!.Id.Should().Be(_account.Id); + } + finally + { + WeakReferenceMessenger.Default.Unregister(recipient); + } + } + private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null) { var message = new MimeMessage(); @@ -350,6 +438,13 @@ public class MailThreadingTests : IAsyncLifetime public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message); } + internal sealed class MailAddRecipient : IRecipient + { + public List Added { get; } = []; + + public void Receive(MailAddedMessage message) => Added.Add(message); + } + internal sealed class MailReadStatusRecipient : IRecipient, IRecipient { public List SingleUpdates { get; } = []; diff --git a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs index 3a1d3dca..aa2237c9 100644 --- a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs +++ b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; using Wino.Mail.ViewModels.Collections; using Wino.Mail.ViewModels.Data; using Xunit; @@ -167,7 +168,7 @@ public class WinoMailCollectionTests groupItems.Add(item); } - groups.Add(((DateTime)group.Key, groupItems)); + groups.Add((((MailListGroupKey)group.Key).Value is DateTime keyDate ? keyDate : default, groupItems)); } groups.Should().NotBeEmpty(); @@ -188,6 +189,45 @@ public class WinoMailCollectionTests } } + [Fact] + public async Task AddRangeAsync_ShouldPlacePinnedItemsBeforeUnpinnedItems() + { + var sut = CreateCollection(); + var olderPinned = CreateMailCopy(threadId: "pinned", creationDate: DateTime.UtcNow.AddDays(-3)); + olderPinned.IsPinned = true; + + var newerUnpinned = CreateMailCopy(threadId: "regular", creationDate: DateTime.UtcNow); + + await sut.AddRangeAsync( + [ + new MailItemViewModel(newerUnpinned), + new MailItemViewModel(olderPinned) + ], + clearIdCache: true); + + var firstItem = FlattenItems(sut).First().Should().BeOfType().Subject; + firstItem.MailCopy.UniqueId.Should().Be(olderPinned.UniqueId); + } + + [Fact] + public async Task UpdateMailCopy_ShouldMovePinnedItemToTop() + { + var sut = CreateCollection(); + var older = CreateMailCopy(threadId: "older", creationDate: DateTime.UtcNow.AddDays(-2)); + var newer = CreateMailCopy(threadId: "newer", creationDate: DateTime.UtcNow); + + await sut.AddAsync(older); + await sut.AddAsync(newer); + + var updatedOlder = CloneMailCopy(older); + updatedOlder.IsPinned = true; + + await sut.UpdateMailCopy(updatedOlder, EntityUpdateSource.Server, MailCopyChangeFlags.IsPinned); + + var firstItem = FlattenItems(sut).First().Should().BeOfType().Subject; + firstItem.MailCopy.UniqueId.Should().Be(older.UniqueId); + } + [Fact] public async Task UpdateMailCopy_ShouldMergeExistingSingles_WhenThreadIdChangesToMatch() { @@ -371,6 +411,7 @@ public class WinoMailCollectionTests Importance = source.Importance, IsRead = source.IsRead, IsFlagged = source.IsFlagged, + IsPinned = source.IsPinned, IsFocused = source.IsFocused, HasAttachments = source.HasAttachments, ItemType = source.ItemType, diff --git a/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs index 94a4bedf..b525a041 100644 --- a/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs +++ b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs @@ -59,6 +59,29 @@ public class MailItemViewModelUpdateTests nameof(MailItemViewModel.SortingName)); } + [Fact] + public void UpdateFrom_ShouldNotifyPinnedState_WhenPinnedChanges() + { + var original = CreateMailCopy("thread-1", DateTime.UtcNow); + var updated = CloneMailCopy(original); + updated.IsPinned = true; + + 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().Contain(nameof(MailItemViewModel.IsPinned)); + } + [Fact] public async Task UpdateMailCopy_ShouldNotifyThreadOnlyForReadState_WhenReadStateChanges() { @@ -125,6 +148,7 @@ public class MailItemViewModelUpdateTests Importance = MailImportance.Normal, IsRead = false, IsFlagged = false, + IsPinned = false, IsFocused = false, HasAttachments = false, ItemType = MailItemType.Mail, @@ -151,6 +175,7 @@ public class MailItemViewModelUpdateTests Importance = source.Importance, IsRead = source.IsRead, IsFlagged = source.IsFlagged, + IsPinned = source.IsPinned, IsFocused = source.IsFocused, HasAttachments = source.HasAttachments, ItemType = source.ItemType, diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 1250652b..e6ca7a7e 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -12,6 +12,7 @@ using Serilog; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Mails; using Wino.Messaging.UI; @@ -139,10 +140,24 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { foreach (var update in updates) diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 8f696dfb..e11200ea 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -17,6 +17,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, [ObservableProperty] [NotifyPropertyChangedFor(nameof(CreationDate))] [NotifyPropertyChangedFor(nameof(IsFlagged))] + [NotifyPropertyChangedFor(nameof(IsPinned))] [NotifyPropertyChangedFor(nameof(FromName))] [NotifyPropertyChangedFor(nameof(IsFocused))] [NotifyPropertyChangedFor(nameof(IsRead))] @@ -82,6 +83,12 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, set => SetProperty(MailCopy.IsFlagged, value, MailCopy, (u, n) => u.IsFlagged = n); } + public bool IsPinned + { + get => MailCopy.IsPinned; + set => SetProperty(MailCopy.IsPinned, value, MailCopy, (u, n) => u.IsPinned = n); + } + public string FromName { get => string.IsNullOrEmpty(MailCopy.FromName) ? MailCopy.FromAddress : MailCopy.FromName; @@ -233,6 +240,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, { nameof(CreationDate) or nameof(SortingDate) => MailCopyChangeFlags.CreationDate, nameof(IsFlagged) => MailCopyChangeFlags.IsFlagged, + nameof(IsPinned) => MailCopyChangeFlags.IsPinned, nameof(FromName) or nameof(SortingName) => MailCopyChangeFlags.FromName, nameof(IsFocused) => MailCopyChangeFlags.IsFocused, nameof(IsRead) => MailCopyChangeFlags.IsRead, @@ -293,12 +301,13 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, 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.IsPinned, source.IsPinned, value => MailCopy.IsPinned = value, MailCopyChangeFlags.IsPinned); 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 |= SetIfChangedIfNotNull(MailCopy.SenderContact, source.SenderContact, value => MailCopy.SenderContact = value, MailCopyChangeFlags.SenderContact); + changedFlags |= SetIfChangedIfNotNull(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount); + changedFlags |= SetIfChangedIfNotNull(MailCopy.AssignedFolder, source.AssignedFolder, value => MailCopy.AssignedFolder = value, MailCopyChangeFlags.AssignedFolder); changedFlags |= SetIfChanged(MailCopy.UniqueId, source.UniqueId, value => MailCopy.UniqueId = value, MailCopyChangeFlags.UniqueId); changedFlags |= SetIfChanged(MailCopy.IsReadReceiptRequested, source.IsReadReceiptRequested, value => MailCopy.IsReadReceiptRequested = value, MailCopyChangeFlags.ReadReceiptState); changedFlags |= SetIfChanged(MailCopy.ReadReceiptStatus, source.ReadReceiptStatus, value => MailCopy.ReadReceiptStatus = value, MailCopyChangeFlags.ReadReceiptState); @@ -353,6 +362,14 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, return flag; } + private static MailCopyChangeFlags SetIfChangedIfNotNull(T currentValue, T newValue, Action setter, MailCopyChangeFlags flag) where T : class + { + if (newValue == null) + return MailCopyChangeFlags.None; + + return SetIfChanged(currentValue, newValue, setter, flag); + } + private void RaisePropertyChanges(MailCopyChangeFlags changedFlags) { if (changedFlags == MailCopyChangeFlags.None) @@ -377,6 +394,9 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0) Queue(nameof(IsFlagged)); + if ((changedFlags & MailCopyChangeFlags.IsPinned) != 0) + Queue(nameof(IsPinned)); + if ((changedFlags & MailCopyChangeFlags.FromName) != 0) { Queue(nameof(FromName)); diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 4af29f66..82797f58 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -91,6 +91,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// public bool IsFlagged => ThreadEmails.Any(e => e.IsFlagged); + /// + /// Gets whether any email in this thread is pinned. + /// + public bool IsPinned => ThreadEmails.Any(e => e.IsPinned); + /// /// Gets whether the latest email is focused /// @@ -182,6 +187,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(HasAttachments))] [NotifyPropertyChangedFor(nameof(IsCalendarEvent))] [NotifyPropertyChangedFor(nameof(IsFlagged))] + [NotifyPropertyChangedFor(nameof(IsPinned))] [NotifyPropertyChangedFor(nameof(IsFocused))] [NotifyPropertyChangedFor(nameof(IsRead))] [NotifyPropertyChangedFor(nameof(HasReadReceiptTracking))] @@ -473,6 +479,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0 || changedFlags == MailCopyChangeFlags.All) Queue(nameof(IsFlagged)); + if ((changedFlags & MailCopyChangeFlags.IsPinned) != 0 || changedFlags == MailCopyChangeFlags.All) + Queue(nameof(IsPinned)); + if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All) Queue(nameof(IsRead)); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 724901b6..a671f181 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; @@ -760,6 +761,17 @@ public partial class MailListPageViewModel : MailBaseViewModel, await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false); } + public Task ChangePinnedStatusAsync(IEnumerable targetItems, bool isPinned) + { + var uniqueIds = targetItems? + .Where(a => a?.MailCopy != null) + .Select(a => a.MailCopy.UniqueId) + .Distinct() + .ToList() ?? []; + + return _mailService.ChangePinnedStatusAsync(uniqueIds, isPinned); + } + private bool ShouldPreventItemAdd(MailCopy mailItem) { bool condition = mailItem.IsRead @@ -1553,13 +1565,28 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + var initialExistingIds = new ConcurrentDictionary(MailCollection.MailCopyIdHashSet); + var localPinnedItems = new List(); + + if (!isDoingOnlineSearch) + { + var pinnedOptions = CreateInitializationOptions(SearchQuery, MailCollection.MailCopyIdHashSet); + localPinnedItems = await _mailService.FetchPinnedMailsAsync(pinnedOptions, cancellationToken).ConfigureAwait(false); + + foreach (var pinnedItem in localPinnedItems) + { + initialExistingIds.TryAdd(pinnedItem.UniqueId, true); + } + } + var initializationOptions = CreateInitializationOptions( isDoingOnlineSearch ? string.Empty : SearchQuery, - MailCollection.MailCopyIdHashSet, + initialExistingIds, onlineSearchItems, isDoingOnlineSearch); items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); + items = localPinnedItems.Count > 0 ? [.. localPinnedItems, .. items] : items; if (!listManipulationCancellationTokenSource.IsCancellationRequested) { diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index f562bdbe..e236347b 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -604,6 +604,15 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, if (initializedMailItemViewModel == null) return; + var assignedFolder = initializedMailItemViewModel.MailCopy.AssignedFolder; + + if (assignedFolder == null) + { + Log.Warning("Skipping folder-specific mail commands because AssignedFolder is missing for {MailUniqueId}", + initializedMailItemViewModel.MailCopy.UniqueId); + return; + } + MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Seperator)); // You can't do these to draft items. @@ -625,7 +634,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, } // Archive - Unarchive - if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive) + if (assignedFolder.SpecialFolderType == SpecialFolderType.Archive) MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive)); else MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive)); @@ -647,10 +656,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, else MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false)); - if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk) + if (assignedFolder.SpecialFolderType == SpecialFolderType.Junk) MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsNotJunk, true, true)); else if (!initializedMailItemViewModel.IsDraft && - initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType != SpecialFolderType.Sent) + assignedFolder.SpecialFolderType != SpecialFolderType.Sent) MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MoveToJunk, true, true)); } diff --git a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs index bd15c89c..3f12bafc 100644 --- a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs +++ b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs @@ -16,6 +16,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.MailItem; using Wino.Mail.WinUI.Controls; namespace Wino.Helpers; @@ -184,6 +185,14 @@ public static class XamlHelpers } public static string GetMailGroupDateString(object groupObject) { + if (groupObject is MailListGroupKey pinnedGroupKey) + { + if (pinnedGroupKey.IsPinned) + return Translator.FolderCustomization_SectionPinned; + + groupObject = pinnedGroupKey.Value!; + } + if (groupObject is string stringObject) return stringObject; diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs index 3a332669..b9dfa5eb 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs @@ -245,27 +245,36 @@ public sealed partial class MailListPage : MailListPageAbstract, // Default to all selected items. targetItems = ViewModel.MailCollection.SelectedItems; + var areAllPinned = targetItems.Any() && targetItems.All(item => item.MailCopy.IsPinned); var availableActions = ViewModel.GetAvailableMailActions(targetItems); var (availableCategories, assignedCategoryIds) = await ViewModel.GetAvailableCategoriesAsync(targetItems); - if (availableActions == null || !availableActions.Any()) return; - var clickedAction = await GetMailContextActionFromFlyoutAsync( availableActions, availableCategories, assignedCategoryIds, + areAllPinned, control, p.X, p.Y); if (clickedAction == null) return; + if (clickedAction.PinState.HasValue) + { + await ViewModel.ChangePinnedStatusAsync(targetItems, clickedAction.PinState.Value); + return; + } + if (clickedAction.Category != null) { await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll); return; } + if (clickedAction.Operation == null) + return; + var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy)); await ViewModel.ExecuteMailOperationAsync(prepRequest); @@ -313,6 +322,7 @@ public sealed partial class MailListPage : MailListPageAbstract, IEnumerable availableActions, IReadOnlyList availableCategories, IReadOnlyCollection assignedCategoryIds, + bool areAllPinned, UIElement showAtElement, double x, double y) @@ -320,7 +330,7 @@ public sealed partial class MailListPage : MailListPageAbstract, var source = new TaskCompletionSource(); var flyout = new WinoMenuFlyout(); - foreach (var action in availableActions) + foreach (var action in availableActions ?? []) { if (action.Operation == MailOperation.Seperator) { @@ -337,6 +347,27 @@ public sealed partial class MailListPage : MailListPageAbstract, flyout.Items.Add(menuFlyoutItem); } + if (flyout.Items.Count > 0 && flyout.Items.LastOrDefault() is not MenuFlyoutSeparator) + { + flyout.Items.Add(new MenuFlyoutSeparator()); + } + + var pinItem = new MenuFlyoutItem + { + Text = areAllPinned ? Translator.FolderOperation_Unpin : Translator.FolderOperation_Pin, + Icon = new WinoFontIcon { Icon = areAllPinned ? WinoIconGlyph.UnPin : WinoIconGlyph.Pin } + }; + + MenuFlyoutLanguageHelper.Apply(pinItem); + + pinItem.Click += (_, _) => + { + source.TrySetResult(new MailContextAction(!areAllPinned)); + flyout.Hide(); + }; + + flyout.Items.Add(pinItem); + if (availableCategories?.Count > 0) { if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator) @@ -381,9 +412,13 @@ public sealed partial class MailListPage : MailListPageAbstract, return await source.Task; } - private sealed record MailContextAction(MailOperationMenuItem Operation, MailCategory Category = null, bool IsCategoryAssignedToAll = false) + private sealed record MailContextAction(MailOperationMenuItem? Operation = null, MailCategory? Category = null, bool IsCategoryAssignedToAll = false, bool? PinState = null) { - public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this(null, category, isCategoryAssignedToAll) + public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this((MailOperationMenuItem?)null, category, isCategoryAssignedToAll) + { + } + + public MailContextAction(bool pinState) : this((MailOperationMenuItem?)null, (MailCategory?)null, false, pinState) { } } diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index fa93107e..a7a82ef0 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -81,6 +81,15 @@ public class DatabaseService : IDatabaseService { await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false); + var mailCopyColumns = await Connection.GetTableInfoAsync(nameof(MailCopy)).ConfigureAwait(false); + + if (!mailCopyColumns.Any(c => c.Name == nameof(MailCopy.IsPinned))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(MailCopy)} ADD COLUMN {nameof(MailCopy.IsPinned)} INTEGER NOT NULL DEFAULT 0") + .ConfigureAwait(false); + } + var accountColumns = await Connection.GetTableInfoAsync(nameof(MailAccount)).ConfigureAwait(false); if (!accountColumns.Any(c => c.Name == nameof(MailAccount.CreatedAt))) diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index faf8d556..b6b6f153 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -158,7 +158,7 @@ public class MailService : BaseDatabaseService, IMailService return await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false); } - private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options) + private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options, bool pinnedOnly = false) { var sql = new StringBuilder(); sql.Append(options.IsCategoryView @@ -194,6 +194,11 @@ public class MailService : BaseDatabaseService, IMailService break; } + if (pinnedOnly) + { + whereClauses.Add("MailCopy.IsPinned = 1"); + } + // Focused filter if (options.IsFocusedOnly != null) { @@ -227,23 +232,26 @@ public class MailService : BaseDatabaseService, IMailService // Sorting if (options.SortingOptionType == SortingOptionType.ReceiveDate) - sql.Append(" ORDER BY CreationDate DESC"); + sql.Append(" ORDER BY IsPinned DESC, CreationDate DESC"); else if (options.SortingOptionType == SortingOptionType.Sender) - sql.Append(" ORDER BY FromName ASC"); + sql.Append(" ORDER BY IsPinned DESC, FromName ASC, CreationDate DESC"); // Pagination - var limit = options.Take > 0 ? options.Take : ItemLoadCount; - sql.Append($" LIMIT {limit}"); - - if (options.Skip > 0) + if (!pinnedOnly) { - sql.Append($" OFFSET {options.Skip}"); + var limit = options.Take > 0 ? options.Take : ItemLoadCount; + sql.Append($" LIMIT {limit}"); + + if (options.Skip > 0) + { + sql.Append($" OFFSET {options.Skip}"); + } } return (sql.ToString(), parameters.ToArray()); } - private static List ApplyOptionsToPreFetchedMails(MailListInitializationOptions options) + private static List ApplyOptionsToPreFetchedMails(MailListInitializationOptions options, bool pinnedOnly = false) { var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet(); var accountIdsByFolderId = options.Folders @@ -287,6 +295,11 @@ public class MailService : BaseDatabaseService, IMailService query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId)); } + if (pinnedOnly) + { + query = query.Where(m => m.IsPinned); + } + query = options.DeduplicateByServerId ? query .GroupBy(m => (ResolveMailAccountId(m, accountIdsByFolderId), ResolveServerMailId(m))) @@ -302,16 +315,21 @@ public class MailService : BaseDatabaseService, IMailService query = options.SortingOptionType switch { - SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate), - _ => query.OrderByDescending(m => m.CreationDate) + SortingOptionType.Sender => query + .OrderByDescending(m => m.IsPinned) + .ThenBy(m => m.FromName) + .ThenByDescending(m => m.CreationDate), + _ => query + .OrderByDescending(m => m.IsPinned) + .ThenByDescending(m => m.CreationDate) }; - if (options.Skip > 0) + if (!pinnedOnly && options.Skip > 0) { query = query.Skip(options.Skip); } - if (options.Take > 0) + if (!pinnedOnly && options.Take > 0) { query = query.Take(options.Take); } @@ -333,17 +351,23 @@ public class MailService : BaseDatabaseService, IMailService private static string ResolveServerMailId(MailCopy mail) => string.IsNullOrWhiteSpace(mail?.Id) ? mail?.UniqueId.ToString("N") ?? string.Empty : mail.Id; - public async Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default) + public Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default) + => FetchMailsInternalAsync(options, pinnedOnly: false, cancellationToken); + + public Task> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default) + => FetchMailsInternalAsync(options, pinnedOnly: true, cancellationToken); + + private async Task> FetchMailsInternalAsync(MailListInitializationOptions options, bool pinnedOnly, CancellationToken cancellationToken = default) { List mails; if (options.PreFetchMailCopies != null && !options.IsCategoryView) { - mails = ApplyOptionsToPreFetchedMails(options); + mails = ApplyOptionsToPreFetchedMails(options, pinnedOnly); } else { - var (query, parameters) = BuildMailFetchQuery(options); + var (query, parameters) = BuildMailFetchQuery(options, pinnedOnly); mails = await Connection.QueryAsync(query, parameters); } @@ -735,7 +759,8 @@ public class MailService : BaseDatabaseService, IMailService await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); - ReportUIChange(new MailAddedMessage(mailCopy, EntityUpdateSource.Server)); + var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false); + ReportUIChange(new MailAddedMessage(hydratedMailCopy, EntityUpdateSource.Server)); } public async Task UpdateMailAsync(MailCopy mailCopy) @@ -749,9 +774,20 @@ public class MailService : BaseDatabaseService, IMailService _logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId); + var existingMailCopy = mailCopy.UniqueId != Guid.Empty + ? await Connection.FindAsync(mailCopy.UniqueId).ConfigureAwait(false) + : null; + + if (existingMailCopy != null) + { + // Pinning is managed locally for now, so server refreshes should not clear it. + mailCopy.IsPinned = existingMailCopy.IsPinned; + } + await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); - ReportUIChange(new MailUpdatedMessage(mailCopy, EntityUpdateSource.Server)); + var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false); + ReportUIChange(new MailUpdatedMessage(hydratedMailCopy, EntityUpdateSource.Server)); } private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile) @@ -807,12 +843,23 @@ public class MailService : BaseDatabaseService, IMailService WeakReferenceMessenger.Default.Send(new BulkMailReadStatusChanged(readMailUniqueIds)); } + var hydratedUpdatesByUniqueId = (await HydrateMailCopiesAsync( + pendingUpdates + .Where(x => x.MailCopy != null) + .Select(x => x.MailCopy) + .GroupBy(x => x.UniqueId) + .Select(group => group.First()) + .ToList()) + .ConfigureAwait(false)) + .Where(x => x != null) + .ToDictionary(x => x.UniqueId); + foreach (var updateGroup in pendingUpdates .Where(x => x.MailCopy != null) .GroupBy(x => x.ChangedProperties)) { var updatedMails = updateGroup - .Select(x => x.MailCopy) + .Select(x => hydratedUpdatesByUniqueId.GetValueOrDefault(x.MailCopy.UniqueId, x.MailCopy)) .Where(x => x != null) .ToList(); @@ -875,6 +922,41 @@ public class MailService : BaseDatabaseService, IMailService return MailCopyChangeFlags.IsFlagged; }); + public async Task ChangePinnedStatusAsync(IEnumerable uniqueMailIds, bool isPinned) + { + var distinctUniqueIds = uniqueMailIds? + .Where(id => id != Guid.Empty) + .Distinct() + .ToList() ?? []; + + if (distinctUniqueIds.Count == 0) + return; + + var placeholders = string.Join(",", distinctUniqueIds.Select(_ => "?")); + var mailCopies = await Connection + .QueryAsync($"SELECT * FROM MailCopy WHERE UniqueId IN ({placeholders})", distinctUniqueIds.Cast().ToArray()) + .ConfigureAwait(false); + + if (mailCopies.Count == 0) + { + _logger.Warning("Changing pin status failed because there are no matching copies for {MailCopyCount} unique ids.", distinctUniqueIds.Count); + return; + } + + var pendingUpdates = new List<(MailCopy MailCopy, MailCopyChangeFlags ChangedProperties)>(); + + foreach (var mailCopy in mailCopies) + { + if (mailCopy.IsPinned == isPinned) + continue; + + mailCopy.IsPinned = isPinned; + pendingUpdates.Add((mailCopy, MailCopyChangeFlags.IsPinned)); + } + + await PersistMailCopyUpdatesAsync(pendingUpdates).ConfigureAwait(false); + } + public async Task ApplyMailStateUpdatesAsync(IEnumerable updates) { var updateLookup = new Dictionary(StringComparer.Ordinal);