Fix online search dedupe and pane layout scrolling

This commit is contained in:
Burak Kaan Köse
2026-04-12 15:56:27 +02:00
parent 4d04595d0a
commit d922dd2f2e
9 changed files with 318 additions and 35 deletions
@@ -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);
+145 -3
View File
@@ -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.
+8 -2
View File
@@ -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();
+8 -3
View File
@@ -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;
+20 -3
View File
@@ -267,17 +267,31 @@ 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.
if (existingMessageIds?.Contains(messageId) == true)
{
return;
}
if (existingMessageIds == null)
{
var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false); var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false);
if (existing.Contains(messageId)) if (existing.Contains(messageId))
{ {
return; 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);
@@ -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.
+44 -6
View File
@@ -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);
+7 -2
View File
@@ -642,11 +642,15 @@
</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">
<StackPanel Spacing="6">
<TextBlock <TextBlock
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
@@ -658,6 +662,7 @@
Text="{x:Bind domain:Translator.ContactsPane_DescriptionBody, Mode=OneTime}" Text="{x:Bind domain:Translator.ContactsPane_DescriptionBody, Mode=OneTime}"
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
</StackPanel> </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())
+39 -3
View File
@@ -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)