Add local mail pinning support
This commit is contained in:
@@ -92,6 +92,11 @@ public class MailCopy
|
||||
/// </summary>
|
||||
public bool IsFlagged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this mail should stay pinned to the top locally.
|
||||
/// </summary>
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// To support Outlook.
|
||||
/// Gmail doesn't use it.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface IMailListItemSorting
|
||||
{
|
||||
DateTime SortingDate { get; }
|
||||
string SortingName { get; }
|
||||
bool IsPinned { get; }
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public interface IMailService
|
||||
/// </summary>
|
||||
Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds);
|
||||
Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
|
||||
Task<List<MailCopy>> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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<Guid> uniqueMailIds, bool isPinned);
|
||||
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
|
||||
|
||||
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||
|
||||
@@ -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<object>
|
||||
{
|
||||
@@ -9,14 +10,48 @@ public class ListItemComparer : IComparer<object>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<MailCopy>(existingMail.UniqueId);
|
||||
storedMail.Should().NotBeNull();
|
||||
storedMail!.IsPinned.Should().BeTrue();
|
||||
storedMail.Subject.Should().Be("Updated subject");
|
||||
}
|
||||
|
||||
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<MailUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Register<BulkMailUpdatedMessage>(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<MailUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Unregister<BulkMailUpdatedMessage>(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<MailAddedMessage>(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<MailAddedMessage>(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<MailAddedMessage>
|
||||
{
|
||||
public List<MailAddedMessage> Added { get; } = [];
|
||||
|
||||
public void Receive(MailAddedMessage message) => Added.Add(message);
|
||||
}
|
||||
|
||||
internal sealed class MailReadStatusRecipient : IRecipient<MailReadStatusChanged>, IRecipient<BulkMailReadStatusChanged>
|
||||
{
|
||||
public List<MailReadStatusChanged> SingleUpdates { get; } = [];
|
||||
|
||||
@@ -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<MailItemViewModel>().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<MailItemViewModel>().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,
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<SelectedItemsC
|
||||
|
||||
private object GetGroupingKey(IMailListItem mailItem)
|
||||
{
|
||||
if (mailItem.IsPinned)
|
||||
return MailListGroupKey.Pinned;
|
||||
|
||||
if (SortingType == SortingOptionType.ReceiveDate)
|
||||
return mailItem.CreationDate.ToLocalTime().Date;
|
||||
else
|
||||
return mailItem.FromName;
|
||||
return new MailListGroupKey(false, mailItem.CreationDate.ToLocalTime().Date);
|
||||
|
||||
return new MailListGroupKey(false, mailItem.FromName);
|
||||
}
|
||||
|
||||
private bool ShouldReinsertForChanges(MailCopyChangeFlags changedProperties)
|
||||
{
|
||||
if ((changedProperties & (MailCopyChangeFlags.ThreadId | MailCopyChangeFlags.IsPinned)) != 0)
|
||||
return true;
|
||||
|
||||
if (SortingType == SortingOptionType.ReceiveDate)
|
||||
return (changedProperties & MailCopyChangeFlags.CreationDate) != 0;
|
||||
|
||||
return (changedProperties & (MailCopyChangeFlags.FromName | MailCopyChangeFlags.FromAddress)) != 0;
|
||||
}
|
||||
|
||||
private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd)
|
||||
@@ -608,7 +623,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
||||
}
|
||||
});
|
||||
|
||||
if ((appliedChanges & MailCopyChangeFlags.ThreadId) != 0)
|
||||
if (ShouldReinsertForChanges(appliedChanges))
|
||||
{
|
||||
await ReinsertUpdatedItemAsync(updatedItem, wasSelected, existingItem.IsBusy);
|
||||
return;
|
||||
@@ -993,6 +1008,16 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
||||
if (updates.Count == 0)
|
||||
return;
|
||||
|
||||
if (changedProperties == MailCopyChangeFlags.None || ShouldReinsertForChanges(changedProperties))
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
await UpdateExistingItemAsync(update.ItemContainer, update.UpdatedMail, mailUpdateSource, changedProperties);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
foreach (var update in updates)
|
||||
|
||||
@@ -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>(T currentValue, T newValue, Action<T> 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));
|
||||
|
||||
@@ -91,6 +91,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
||||
/// </summary>
|
||||
public bool IsFlagged => ThreadEmails.Any(e => e.IsFlagged);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any email in this thread is pinned.
|
||||
/// </summary>
|
||||
public bool IsPinned => ThreadEmails.Any(e => e.IsPinned);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the latest email is focused
|
||||
/// </summary>
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<MailItemViewModel> 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<Guid, bool>(MailCollection.MailCopyIdHashSet);
|
||||
var localPinnedItems = new List<MailCopy>();
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<MailOperationMenuItem> availableActions,
|
||||
IReadOnlyList<MailCategory> availableCategories,
|
||||
IReadOnlyCollection<Guid> assignedCategoryIds,
|
||||
bool areAllPinned,
|
||||
UIElement showAtElement,
|
||||
double x,
|
||||
double y)
|
||||
@@ -320,7 +330,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
var source = new TaskCompletionSource<MailContextAction?>();
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,11 +232,13 @@ 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
|
||||
if (!pinnedOnly)
|
||||
{
|
||||
var limit = options.Take > 0 ? options.Take : ItemLoadCount;
|
||||
sql.Append($" LIMIT {limit}");
|
||||
|
||||
@@ -239,11 +246,12 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
{
|
||||
sql.Append($" OFFSET {options.Skip}");
|
||||
}
|
||||
}
|
||||
|
||||
return (sql.ToString(), parameters.ToArray());
|
||||
}
|
||||
|
||||
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options)
|
||||
private static List<MailCopy> 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<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||
public Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||
=> FetchMailsInternalAsync(options, pinnedOnly: false, cancellationToken);
|
||||
|
||||
public Task<List<MailCopy>> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||
=> FetchMailsInternalAsync(options, pinnedOnly: true, cancellationToken);
|
||||
|
||||
private async Task<List<MailCopy>> FetchMailsInternalAsync(MailListInitializationOptions options, bool pinnedOnly, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<MailCopy> 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<MailCopy>(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>(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<Guid> 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<MailCopy>($"SELECT * FROM MailCopy WHERE UniqueId IN ({placeholders})", distinctUniqueIds.Cast<object>().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<MailCopyStateUpdate> updates)
|
||||
{
|
||||
var updateLookup = new Dictionary<string, MailCopyStateUpdate>(StringComparer.Ordinal);
|
||||
|
||||
Reference in New Issue
Block a user