Fix online search dedupe and pane layout scrolling
This commit is contained in:
@@ -15,5 +15,6 @@ public record MailListInitializationOptions(IEnumerable<IMailItemFolder> Folders
|
|||||||
string SearchQuery,
|
string SearchQuery,
|
||||||
ConcurrentDictionary<Guid, bool> ExistingUniqueIds = null,
|
ConcurrentDictionary<Guid, bool> ExistingUniqueIds = null,
|
||||||
List<MailCopy> PreFetchMailCopies = null,
|
List<MailCopy> PreFetchMailCopies = null,
|
||||||
|
bool DeduplicateByServerId = false,
|
||||||
int Skip = 0,
|
int Skip = 0,
|
||||||
int Take = 0);
|
int Take = 0);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
MailAccountId = _testAccount.Id,
|
MailAccountId = _testAccount.Id,
|
||||||
FolderName = "Inbox",
|
FolderName = "Inbox",
|
||||||
|
RemoteFolderId = "inbox",
|
||||||
SpecialFolderType = SpecialFolderType.Inbox,
|
SpecialFolderType = SpecialFolderType.Inbox,
|
||||||
IsSystemFolder = true,
|
IsSystemFolder = true,
|
||||||
IsSynchronizationEnabled = true
|
IsSynchronizationEnabled = true
|
||||||
@@ -190,6 +191,112 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
"self-sent mail must use account metadata for the sender contact");
|
"self-sent mail must use account metadata for the sender contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchMailsAsync_PreFetchedOnlineSearch_DeduplicatesByServerIdWithinAccount()
|
||||||
|
{
|
||||||
|
var archiveFolder = await CreateFolderAsync(_testAccount, "Archive", "archive", SpecialFolderType.Archive);
|
||||||
|
var sharedId = "server-mail-1";
|
||||||
|
var olderCopy = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-5));
|
||||||
|
olderCopy.Id = sharedId;
|
||||||
|
var newerCopy = BuildMail(archiveFolder.Id, DateTime.UtcNow);
|
||||||
|
newerCopy.Id = sharedId;
|
||||||
|
|
||||||
|
var options = BuildOptions([_inboxFolder, archiveFolder], createThreads: false, deduplicateByServerId: true) with
|
||||||
|
{
|
||||||
|
PreFetchMailCopies = [olderCopy, newerCopy]
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _mailService.FetchMailsAsync(options);
|
||||||
|
|
||||||
|
result.Should().HaveCount(1, "online search should show one visible result per server message within an account");
|
||||||
|
result.Single().UniqueId.Should().Be(newerCopy.UniqueId, "the newest copy should win when the searched folders tie");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchMailsAsync_PreFetchedOnlineSearch_KeepsSameServerIdAcrossAccountsSeparate()
|
||||||
|
{
|
||||||
|
var secondAccount = await CreateAccountAsync("Second Account", "second@test.local");
|
||||||
|
var secondInbox = await CreateFolderAsync(secondAccount, "Inbox", "inbox-2", SpecialFolderType.Inbox);
|
||||||
|
const string sharedId = "server-mail-2";
|
||||||
|
|
||||||
|
var firstAccountCopy = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-1));
|
||||||
|
firstAccountCopy.Id = sharedId;
|
||||||
|
|
||||||
|
var secondAccountCopy = BuildMail(secondInbox.Id, DateTime.UtcNow);
|
||||||
|
secondAccountCopy.Id = sharedId;
|
||||||
|
|
||||||
|
var options = BuildOptions([_inboxFolder, secondInbox], createThreads: false, deduplicateByServerId: true) with
|
||||||
|
{
|
||||||
|
PreFetchMailCopies = [firstAccountCopy, secondAccountCopy]
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _mailService.FetchMailsAsync(options);
|
||||||
|
|
||||||
|
result.Should().HaveCount(2, "dedupe should be scoped per account, not just per server id string");
|
||||||
|
result.Select(m => m.AssignedAccount!.Id).Should().BeEquivalentTo([_testAccount.Id, secondAccount.Id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchMailsAsync_PreFetchedOnlineSearch_PrefersActiveFolderCopy()
|
||||||
|
{
|
||||||
|
var archiveFolder = await CreateFolderAsync(_testAccount, "Archive", "archive-active", SpecialFolderType.Archive);
|
||||||
|
const string sharedId = "server-mail-3";
|
||||||
|
|
||||||
|
var activeFolderCopy = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-5));
|
||||||
|
activeFolderCopy.Id = sharedId;
|
||||||
|
|
||||||
|
var newerNonActiveCopy = BuildMail(archiveFolder.Id, DateTime.UtcNow);
|
||||||
|
newerNonActiveCopy.Id = sharedId;
|
||||||
|
|
||||||
|
var options = BuildOptions([_inboxFolder], createThreads: false, deduplicateByServerId: true) with
|
||||||
|
{
|
||||||
|
PreFetchMailCopies = [activeFolderCopy, newerNonActiveCopy]
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _mailService.FetchMailsAsync(options);
|
||||||
|
|
||||||
|
result.Should().HaveCount(1);
|
||||||
|
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 CreateAssignmentAsync_ExistingAssignment_IsIgnored()
|
||||||
|
{
|
||||||
|
var archiveFolder = await CreateFolderAsync(_testAccount, "Archive", "archive-existing", SpecialFolderType.Archive);
|
||||||
|
const string sharedId = "server-mail-4";
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAllAsync(new[]
|
||||||
|
{
|
||||||
|
BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-1), id: sharedId),
|
||||||
|
BuildMail(archiveFolder.Id, DateTime.UtcNow, id: sharedId)
|
||||||
|
});
|
||||||
|
|
||||||
|
await _mailService.CreateAssignmentAsync(_testAccount.Id, sharedId, archiveFolder.RemoteFolderId);
|
||||||
|
|
||||||
|
var count = await _databaseService.Connection.Table<MailCopy>().Where(mail => mail.Id == sharedId).CountAsync();
|
||||||
|
count.Should().Be(2, "re-creating an existing folder assignment must not insert another row");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAssignmentAsync_NewAssignment_CreatesAdditionalRow()
|
||||||
|
{
|
||||||
|
var archiveFolder = await CreateFolderAsync(_testAccount, "Archive", "archive-new", SpecialFolderType.Archive);
|
||||||
|
const string sharedId = "server-mail-5";
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(
|
||||||
|
BuildMail(_inboxFolder.Id, DateTime.UtcNow, id: sharedId),
|
||||||
|
typeof(MailCopy));
|
||||||
|
|
||||||
|
await _mailService.CreateAssignmentAsync(_testAccount.Id, sharedId, archiveFolder.RemoteFolderId);
|
||||||
|
|
||||||
|
var insertedCopies = await _databaseService.Connection.Table<MailCopy>()
|
||||||
|
.Where(mail => mail.Id == sharedId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
insertedCopies.Should().HaveCount(2, "adding a new folder assignment should still clone one additional local row");
|
||||||
|
insertedCopies.Select(mail => mail.FolderId).Should().BeEquivalentTo([_inboxFolder.Id, archiveFolder.Id]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
|
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -315,12 +422,13 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
Guid folderId,
|
Guid folderId,
|
||||||
DateTime creationDate,
|
DateTime creationDate,
|
||||||
string? threadId = null,
|
string? threadId = null,
|
||||||
string fromAddress = "external@example.com")
|
string fromAddress = "external@example.com",
|
||||||
|
string? id = null)
|
||||||
{
|
{
|
||||||
return new MailCopy
|
return new MailCopy
|
||||||
{
|
{
|
||||||
UniqueId = Guid.NewGuid(),
|
UniqueId = Guid.NewGuid(),
|
||||||
Id = Guid.NewGuid().ToString(),
|
Id = id ?? Guid.NewGuid().ToString(),
|
||||||
FileId = Guid.NewGuid(),
|
FileId = Guid.NewGuid(),
|
||||||
FolderId = folderId,
|
FolderId = folderId,
|
||||||
Subject = $"Subject {Guid.NewGuid():N}",
|
Subject = $"Subject {Guid.NewGuid():N}",
|
||||||
@@ -336,7 +444,8 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
private static MailListInitializationOptions BuildOptions(
|
private static MailListInitializationOptions BuildOptions(
|
||||||
IEnumerable<MailItemFolder> folders,
|
IEnumerable<MailItemFolder> folders,
|
||||||
bool createThreads = true,
|
bool createThreads = true,
|
||||||
int take = 0)
|
int take = 0,
|
||||||
|
bool deduplicateByServerId = false)
|
||||||
{
|
{
|
||||||
return new MailListInitializationOptions(
|
return new MailListInitializationOptions(
|
||||||
Folders: folders,
|
Folders: folders,
|
||||||
@@ -345,9 +454,42 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
CreateThreads: createThreads,
|
CreateThreads: createThreads,
|
||||||
IsFocusedOnly: null,
|
IsFocusedOnly: null,
|
||||||
SearchQuery: null,
|
SearchQuery: null,
|
||||||
|
DeduplicateByServerId: deduplicateByServerId,
|
||||||
Take: take);
|
Take: take);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<MailAccount> CreateAccountAsync(string name, string address)
|
||||||
|
{
|
||||||
|
var account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = name,
|
||||||
|
Address = address,
|
||||||
|
SenderName = name,
|
||||||
|
ProviderType = MailProviderType.IMAP4
|
||||||
|
};
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(account, typeof(MailAccount));
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MailItemFolder> CreateFolderAsync(MailAccount account, string name, string remoteFolderId, SpecialFolderType specialFolderType)
|
||||||
|
{
|
||||||
|
var folder = new MailItemFolder
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MailAccountId = account.Id,
|
||||||
|
FolderName = name,
|
||||||
|
RemoteFolderId = remoteFolderId,
|
||||||
|
SpecialFolderType = specialFolderType,
|
||||||
|
IsSystemFolder = true,
|
||||||
|
IsSynchronizationEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(folder, typeof(MailItemFolder));
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a MailService wired to real FolderService, AccountService, and ContactService
|
/// Builds a MailService wired to real FolderService, AccountService, and ContactService
|
||||||
/// all backed by the shared in-memory database, so the full SQL batch path is exercised.
|
/// all backed by the shared in-memory database, so the full SQL batch path is exercised.
|
||||||
|
|||||||
@@ -1390,6 +1390,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
static bool IsArchiveFolder(IMailItemFolder folder)
|
static bool IsArchiveFolder(IMailItemFolder folder)
|
||||||
=> folder?.SpecialFolderType == SpecialFolderType.Archive || folder?.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID;
|
=> folder?.SpecialFolderType == SpecialFolderType.Archive || folder?.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID;
|
||||||
|
|
||||||
|
var distinctFolders = folders?
|
||||||
|
.Where(folder => folder != null)
|
||||||
|
.GroupBy(folder => folder.Id)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var messageIds = new HashSet<string>(StringComparer.Ordinal);
|
var messageIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
async Task CollectMessageIdsAsync(UsersResource.MessagesResource.ListRequest request)
|
async Task CollectMessageIdsAsync(UsersResource.MessagesResource.ListRequest request)
|
||||||
@@ -1421,7 +1427,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
bool hasScopedQuery = queryText.StartsWith("label:", StringComparison.OrdinalIgnoreCase) ||
|
bool hasScopedQuery = queryText.StartsWith("label:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
queryText.StartsWith("in:", StringComparison.OrdinalIgnoreCase);
|
queryText.StartsWith("in:", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (hasScopedQuery || folders?.Count == 0)
|
if (hasScopedQuery || distinctFolders?.Count == 0)
|
||||||
{
|
{
|
||||||
var request = _gmailService.Users.Messages.List("me");
|
var request = _gmailService.Users.Messages.List("me");
|
||||||
request.Q = queryText;
|
request.Q = queryText;
|
||||||
@@ -1431,7 +1437,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var folder in folders)
|
foreach (var folder in distinctFolders)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
|||||||
@@ -1053,10 +1053,15 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
{
|
{
|
||||||
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
List<MailCopy> searchResults = [];
|
var distinctFolders = folders?
|
||||||
List<string> searchResultFolderMailUids = [];
|
.Where(folder => folder != null)
|
||||||
|
.GroupBy(folder => folder.Id)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList() ?? [];
|
||||||
|
|
||||||
foreach (var folder in folders)
|
HashSet<string> searchResultFolderMailUids = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var folder in distinctFolders)
|
||||||
{
|
{
|
||||||
if (folder is not MailItemFolder localFolder)
|
if (folder is not MailItemFolder localFolder)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -267,18 +267,32 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
public Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||||
|
=> DownloadSearchResultMessageAsync(messageId, assignedFolder, existingMessageIds: null, cancellationToken);
|
||||||
|
|
||||||
|
private async Task DownloadSearchResultMessageAsync(string messageId,
|
||||||
|
MailItemFolder assignedFolder,
|
||||||
|
ISet<string> existingMessageIds,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(messageId) || assignedFolder == null) return;
|
if (string.IsNullOrWhiteSpace(messageId) || assignedFolder == null) return;
|
||||||
|
|
||||||
// Online search can return the same message across repeated invocations/races.
|
// Online search can return the same message across repeated invocations/races.
|
||||||
// Guard before network+MIME download and before database insert.
|
// Guard before network+MIME download and before database insert.
|
||||||
var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false);
|
if (existingMessageIds?.Contains(messageId) == true)
|
||||||
if (existing.Contains(messageId))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingMessageIds == null)
|
||||||
|
{
|
||||||
|
var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false);
|
||||||
|
if (existing.Contains(messageId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Log.Information("Downloading search result message {messageId} for {Name} - {FolderName}", messageId, Account.Name, assignedFolder.FolderName);
|
Log.Information("Downloading search result message {messageId} for {Name} - {FolderName}", messageId, Account.Name, assignedFolder.FolderName);
|
||||||
|
|
||||||
// Outlook message handling was a little strange.
|
// Outlook message handling was a little strange.
|
||||||
@@ -314,6 +328,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
// Use safe upsert path to avoid duplicate rows when message already exists.
|
// Use safe upsert path to avoid duplicate rows when message already exists.
|
||||||
await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingMessageIds?.Add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
|
private async Task<IEnumerable<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
|
||||||
@@ -2226,10 +2242,11 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
if (messageIdsWithKnownFolder.Count == 0) return [];
|
if (messageIdsWithKnownFolder.Count == 0) return [];
|
||||||
|
|
||||||
var locallyExistingMails = await _outlookChangeProcessor.AreMailsExistsAsync(messageIdsWithKnownFolder).ConfigureAwait(false);
|
var locallyExistingMails = await _outlookChangeProcessor.AreMailsExistsAsync(messageIdsWithKnownFolder).ConfigureAwait(false);
|
||||||
|
var existingMessageIds = new HashSet<string>(locallyExistingMails, StringComparer.Ordinal);
|
||||||
|
|
||||||
// Find messages that are not downloaded yet.
|
// Find messages that are not downloaded yet.
|
||||||
List<Message> messagesToDownload = [];
|
List<Message> messagesToDownload = [];
|
||||||
foreach (var id in messageIdsWithKnownFolder.Except(locallyExistingMails, StringComparer.Ordinal))
|
foreach (var id in messageIdsWithKnownFolder.Except(existingMessageIds, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
if (messagesById.TryGetValue(id, out var message))
|
if (messagesById.TryGetValue(id, out var message))
|
||||||
{
|
{
|
||||||
@@ -2239,7 +2256,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
foreach (var message in messagesToDownload)
|
foreach (var message in messagesToDownload)
|
||||||
{
|
{
|
||||||
await DownloadSearchResultMessageAsync(message.Id, localFolders[message.ParentFolderId], cancellationToken).ConfigureAwait(false);
|
await DownloadSearchResultMessageAsync(message.Id, localFolders[message.ParentFolderId], existingMessageIds, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get results from database and return.
|
// Get results from database and return.
|
||||||
|
|||||||
@@ -1084,7 +1084,13 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
{
|
{
|
||||||
if (handlingFolders == null) return [];
|
if (handlingFolders == null) return [];
|
||||||
|
|
||||||
var foldersByAccount = handlingFolders
|
var distinctFolders = handlingFolders
|
||||||
|
.Where(folder => folder != null)
|
||||||
|
.GroupBy(folder => folder.Id)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var foldersByAccount = distinctFolders
|
||||||
.GroupBy(a => a.MailAccountId)
|
.GroupBy(a => a.MailAccountId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -1101,13 +1107,44 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
var allResults = await Task.WhenAll(searchTasks).ConfigureAwait(false);
|
var allResults = await Task.WhenAll(searchTasks).ConfigureAwait(false);
|
||||||
|
|
||||||
return allResults
|
var accountIdsByFolderId = distinctFolders.ToDictionary(folder => folder.Id, folder => folder.MailAccountId);
|
||||||
.SelectMany(a => a)
|
var preferredFolderIds = distinctFolders.Select(folder => folder.Id).ToHashSet();
|
||||||
.GroupBy(a => a.UniqueId)
|
|
||||||
.Select(a => a.First())
|
return DeduplicateOnlineSearchResults(allResults.SelectMany(a => a), accountIdsByFolderId, preferredFolderIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<MailCopy> DeduplicateOnlineSearchResults(IEnumerable<MailCopy> results,
|
||||||
|
IReadOnlyDictionary<Guid, Guid> accountIdsByFolderId,
|
||||||
|
ISet<Guid> preferredFolderIds)
|
||||||
|
{
|
||||||
|
if (results == null) return [];
|
||||||
|
|
||||||
|
return results
|
||||||
|
.Where(mail => mail != null)
|
||||||
|
.GroupBy(mail => (ResolveMailAccountId(mail, accountIdsByFolderId), ResolveSearchMailId(mail)))
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(mail => preferredFolderIds.Contains(mail.FolderId))
|
||||||
|
.ThenByDescending(mail => mail.CreationDate)
|
||||||
|
.ThenBy(mail => mail.FolderId)
|
||||||
|
.ThenBy(mail => mail.UniqueId)
|
||||||
|
.First())
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid ResolveMailAccountId(MailCopy mail, IReadOnlyDictionary<Guid, Guid> accountIdsByFolderId)
|
||||||
|
{
|
||||||
|
if (mail?.AssignedAccount != null)
|
||||||
|
return mail.AssignedAccount.Id;
|
||||||
|
|
||||||
|
if (mail != null && accountIdsByFolderId.TryGetValue(mail.FolderId, out var accountId))
|
||||||
|
return accountId;
|
||||||
|
|
||||||
|
return Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSearchMailId(MailCopy mail)
|
||||||
|
=> string.IsNullOrWhiteSpace(mail?.Id) ? mail?.UniqueId.ToString("N") ?? string.Empty : mail.Id;
|
||||||
|
|
||||||
private async Task InitializeFolderAsync()
|
private async Task InitializeFolderAsync()
|
||||||
{
|
{
|
||||||
if (SelectedFilterOption == null || SelectedFolderPivot == null || SelectedSortingOption == null)
|
if (SelectedFilterOption == null || SelectedFolderPivot == null || SelectedSortingOption == null)
|
||||||
@@ -1188,7 +1225,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
SelectedFolderPivot.IsFocused,
|
SelectedFolderPivot.IsFocused,
|
||||||
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
||||||
MailCollection.MailCopyIdHashSet,
|
MailCollection.MailCopyIdHashSet,
|
||||||
onlineSearchItems);
|
onlineSearchItems,
|
||||||
|
DeduplicateByServerId: isDoingOnlineSearch);
|
||||||
|
|
||||||
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -642,22 +642,27 @@
|
|||||||
</ListView>
|
</ListView>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel
|
<ScrollViewer
|
||||||
x:Name="ContactsPaneContent"
|
x:Name="ContactsPaneContent"
|
||||||
Margin="20,20,16,0"
|
Margin="20,20,16,0"
|
||||||
Spacing="6"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
HorizontalScrollMode="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollMode="Auto"
|
||||||
Visibility="Collapsed">
|
Visibility="Collapsed">
|
||||||
<TextBlock
|
<StackPanel Spacing="6">
|
||||||
FontSize="16"
|
<TextBlock
|
||||||
FontWeight="SemiBold"
|
FontSize="16"
|
||||||
Text="{x:Bind domain:Translator.ContactsPane_DescriptionTitle, Mode=OneTime}"
|
FontWeight="SemiBold"
|
||||||
TextWrapping="WrapWholeWords" />
|
Text="{x:Bind domain:Translator.ContactsPane_DescriptionTitle, Mode=OneTime}"
|
||||||
<TextBlock
|
TextWrapping="WrapWholeWords" />
|
||||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
<TextBlock
|
||||||
Style="{StaticResource BodyTextBlockStyle}"
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
Text="{x:Bind domain:Translator.ContactsPane_DescriptionBody, Mode=OneTime}"
|
Style="{StaticResource BodyTextBlockStyle}"
|
||||||
TextWrapping="WrapWholeWords" />
|
Text="{x:Bind domain:Translator.ContactsPane_DescriptionBody, Mode=OneTime}"
|
||||||
</StackPanel>
|
TextWrapping="WrapWholeWords" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</Grid>
|
</Grid>
|
||||||
</muxc:NavigationView.PaneCustomContent>
|
</muxc:NavigationView.PaneCustomContent>
|
||||||
<Grid ColumnSpacing="0">
|
<Grid ColumnSpacing="0">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ using Wino.Mail.ViewModels.Data;
|
|||||||
using Wino.Mail.WinUI.ViewModels;
|
using Wino.Mail.WinUI.ViewModels;
|
||||||
using Wino.Mail.WinUI.Controls;
|
using Wino.Mail.WinUI.Controls;
|
||||||
using Wino.Mail.WinUI.Helpers;
|
using Wino.Mail.WinUI.Helpers;
|
||||||
|
using Wino.Helpers;
|
||||||
using Wino.MenuFlyouts;
|
using Wino.MenuFlyouts;
|
||||||
using Wino.MenuFlyouts.Context;
|
using Wino.MenuFlyouts.Context;
|
||||||
using Wino.Messaging.Client.Accounts;
|
using Wino.Messaging.Client.Accounts;
|
||||||
@@ -50,10 +51,15 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
|
|||||||
{
|
{
|
||||||
private const string StateDefaultShellContent = "DefaultShellContentState";
|
private const string StateDefaultShellContent = "DefaultShellContentState";
|
||||||
private const string StateEventDetailsContent = "EventDetailsContentState";
|
private const string StateEventDetailsContent = "EventDetailsContentState";
|
||||||
|
private const int PaneCustomContentRowIndex = 4;
|
||||||
|
private const int PaneItemsContainerRowIndex = 6;
|
||||||
private WinoApplicationMode? _activeMode;
|
private WinoApplicationMode? _activeMode;
|
||||||
private bool _isSyncingNavigationViewSelection;
|
private bool _isSyncingNavigationViewSelection;
|
||||||
private bool _isSynchronizingVisibleDateRangeCalendar;
|
private bool _isSynchronizingVisibleDateRangeCalendar;
|
||||||
private bool _isPreparedForWindowClose;
|
private bool _isPreparedForWindowClose;
|
||||||
|
private Grid? _paneContentGrid;
|
||||||
|
private RowDefinition? _paneCustomContentRowDefinition;
|
||||||
|
private RowDefinition? _paneItemsContainerRowDefinition;
|
||||||
|
|
||||||
public WinoAppShell()
|
public WinoAppShell()
|
||||||
{
|
{
|
||||||
@@ -681,6 +687,22 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
|
|||||||
|
|
||||||
private void UpdateNavigationPaneLayout(NavigationViewDisplayMode displayMode)
|
private void UpdateNavigationPaneLayout(NavigationViewDisplayMode displayMode)
|
||||||
{
|
{
|
||||||
|
EnsureNavigationPaneLayoutParts();
|
||||||
|
|
||||||
|
bool shouldStretchCustomPane = displayMode == NavigationViewDisplayMode.Expanded
|
||||||
|
&& navigationView.IsPaneOpen
|
||||||
|
&& (ViewModel.IsCalendarMode || ViewModel.IsContactsMode);
|
||||||
|
|
||||||
|
if (_paneCustomContentRowDefinition != null && _paneItemsContainerRowDefinition != null)
|
||||||
|
{
|
||||||
|
_paneCustomContentRowDefinition.Height = shouldStretchCustomPane
|
||||||
|
? new GridLength(1, GridUnitType.Star)
|
||||||
|
: GridLength.Auto;
|
||||||
|
_paneItemsContainerRowDefinition.Height = shouldStretchCustomPane
|
||||||
|
? GridLength.Auto
|
||||||
|
: new GridLength(1, GridUnitType.Star);
|
||||||
|
}
|
||||||
|
|
||||||
if (displayMode == NavigationViewDisplayMode.Expanded && navigationView.IsPaneOpen)
|
if (displayMode == NavigationViewDisplayMode.Expanded && navigationView.IsPaneOpen)
|
||||||
{
|
{
|
||||||
if (ViewModel.IsCalendarMode)
|
if (ViewModel.IsCalendarMode)
|
||||||
@@ -710,6 +732,17 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
|
|||||||
: new Thickness(0);
|
: new Thickness(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureNavigationPaneLayoutParts()
|
||||||
|
{
|
||||||
|
_paneContentGrid ??= WinoVisualTreeHelper.GetChildObject<Grid>(navigationView, "PaneContentGrid");
|
||||||
|
|
||||||
|
if (_paneContentGrid == null || _paneContentGrid.RowDefinitions.Count <= PaneItemsContainerRowIndex)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_paneCustomContentRowDefinition ??= _paneContentGrid.RowDefinitions[PaneCustomContentRowIndex];
|
||||||
|
_paneItemsContainerRowDefinition ??= _paneContentGrid.RowDefinitions[PaneItemsContainerRowIndex];
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnPreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
private async void OnPreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.KeyStatus.RepeatCount > 1 || ShouldIgnoreShortcut())
|
if (e.KeyStatus.RepeatCount > 1 || ShouldIgnoreShortcut())
|
||||||
|
|||||||
@@ -246,11 +246,13 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options)
|
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options)
|
||||||
{
|
{
|
||||||
var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet();
|
var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet();
|
||||||
|
var accountIdsByFolderId = options.Folders
|
||||||
|
.Where(folder => folder != null)
|
||||||
|
.GroupBy(folder => folder.Id)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().MailAccountId);
|
||||||
|
|
||||||
IEnumerable<MailCopy> query = options.PreFetchMailCopies
|
IEnumerable<MailCopy> query = options.PreFetchMailCopies
|
||||||
.Where(m => m != null && allowedFolderIds.Contains(m.FolderId))
|
.Where(m => m != null && allowedFolderIds.Contains(m.FolderId));
|
||||||
.GroupBy(m => m.UniqueId)
|
|
||||||
.Select(g => g.First());
|
|
||||||
|
|
||||||
switch (options.FilterType)
|
switch (options.FilterType)
|
||||||
{
|
{
|
||||||
@@ -285,6 +287,19 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId));
|
query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = options.DeduplicateByServerId
|
||||||
|
? query
|
||||||
|
.GroupBy(m => (ResolveMailAccountId(m, accountIdsByFolderId), ResolveServerMailId(m)))
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(m => allowedFolderIds.Contains(m.FolderId))
|
||||||
|
.ThenByDescending(m => m.CreationDate)
|
||||||
|
.ThenBy(m => m.FolderId)
|
||||||
|
.ThenBy(m => m.UniqueId)
|
||||||
|
.First())
|
||||||
|
: query
|
||||||
|
.GroupBy(m => m.UniqueId)
|
||||||
|
.Select(group => group.First());
|
||||||
|
|
||||||
query = options.SortingOptionType switch
|
query = options.SortingOptionType switch
|
||||||
{
|
{
|
||||||
SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate),
|
SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate),
|
||||||
@@ -304,6 +319,20 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return query.ToList();
|
return query.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid ResolveMailAccountId(MailCopy mail, IReadOnlyDictionary<Guid, Guid> accountIdsByFolderId)
|
||||||
|
{
|
||||||
|
if (mail?.AssignedAccount != null)
|
||||||
|
return mail.AssignedAccount.Id;
|
||||||
|
|
||||||
|
if (mail != null && accountIdsByFolderId.TryGetValue(mail.FolderId, out var accountId))
|
||||||
|
return accountId;
|
||||||
|
|
||||||
|
return Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
List<MailCopy> mails;
|
List<MailCopy> mails;
|
||||||
@@ -777,6 +806,13 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.",
|
||||||
|
mailCopyId, localFolder.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
|
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
|
||||||
|
|
||||||
if (mailCopy == null)
|
if (mailCopy == null)
|
||||||
|
|||||||
Reference in New Issue
Block a user