From 40318ef99cb7e28e81d18d5d47a5be05a3ca4868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 11 Apr 2026 10:54:14 +0200 Subject: [PATCH] Optimize mail list selection and draft pruning --- .../Collections/WinoMailCollectionTests.cs | 45 +++ .../Collections/WinoMailCollection.cs | 101 ++++-- Wino.Mail.ViewModels/MailListPageViewModel.cs | 2 + ...WinoThreadMailItemViewModelListViewItem.cs | 27 +- .../Views/Mail/MailListPage.xaml.cs | 317 +++++++++--------- 5 files changed, 305 insertions(+), 187 deletions(-) diff --git a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs index d52274d4..3a1d3dca 100644 --- a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs +++ b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs @@ -81,6 +81,26 @@ public class WinoMailCollectionTests container.ThreadViewModel.Should().BeNull(); } + [Fact] + public async Task RemoveAsync_ShouldPruneRemainingNonDraftSingle_WhenDraftPruningIsEnabled() + { + var sut = CreateCollection(); + sut.PruneSingleNonDraftItems = true; + + var nonDraft = CreateMailCopy(threadId: "shared-thread", creationDate: DateTime.UtcNow.AddMinutes(-1)); + var draft = CreateMailCopy(threadId: "shared-thread", creationDate: DateTime.UtcNow); + draft.IsDraft = true; + draft.AssignedFolder = new MailItemFolder { SpecialFolderType = SpecialFolderType.Draft }; + + await sut.AddAsync(nonDraft); + await sut.AddAsync(draft); + + await sut.RemoveAsync(draft); + + FlattenItems(sut).Should().BeEmpty(); + sut.ContainsMailUniqueId(nonDraft.UniqueId).Should().BeFalse(); + } + [Fact] public async Task RemoveAsync_ShouldRemoveSingleItem() { @@ -252,6 +272,31 @@ public class WinoMailCollectionTests } } + [Fact] + public async Task ExecuteSelectionBatchAsync_ShouldRaiseSelectionChangedOnce() + { + var sut = CreateCollection(); + var first = CreateMailCopy(threadId: "thread-1"); + var second = CreateMailCopy(threadId: "thread-2"); + + await sut.AddAsync(first); + await sut.AddAsync(second); + + var items = FlattenMailItems(sut); + var eventCount = 0; + sut.ItemSelectionChanged += (_, _) => eventCount++; + + await sut.ExecuteSelectionBatchAsync(() => + { + items[0].IsSelected = true; + items[1].IsSelected = true; + items[0].IsSelected = false; + }); + + eventCount.Should().Be(1); + sut.SelectedItems.Should().ContainSingle(); + } + private static WinoMailCollection CreateCollection() => new() { CoreDispatcher = new ImmediateDispatcher() diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 7eb3c790..4fa1391b 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -44,6 +44,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient _mailItemSource = new ObservableGroupedCollection(); private readonly SemaphoreSlim _mutationGate = new(1, 1); + private int _selectionNotificationSuppressionCount; + private bool _selectionNotificationPending; public ReadOnlyObservableGroupedCollection MailItems { get; } @@ -305,6 +307,36 @@ public class WinoMailCollection : ObservableRecipient, IRecipient GetThreadItems() + { + var threads = new List(); + + foreach (var group in _mailItemSource) + { + foreach (var item in group) + { + if (item is ThreadMailItemViewModel threadItem) + { + threads.Add(threadItem); + } + } + } + + return threads; + } + private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem) { UpdateUniqueIdHashes(mailItem, true); @@ -1049,44 +1081,52 @@ public class WinoMailCollection : ObservableRecipient, IRecipient AllItems.Any() && AllItems.All(a => a.IsSelected); public bool HasSingleItemSelected => SelectedItemsCount == 1; - public async Task ExecuteWithoutRaiseSelectionChangedAsync(Action action, bool includeThreads) + public async Task ExecuteSelectionBatchAsync(Action action, bool notifySelectionChanged = true) { try { - // Do not listen to individual selection changes while we are doing bulk selection. - Messenger.Unregister(this); - - await ExecuteUIThread(() => - { - if (includeThreads) - { - foreach (var item in AllItemsIncludingThreads) - { - action(item); - } - } - else - { - foreach (var item in AllItems) - { - action(item); - } - } - }); + _selectionNotificationSuppressionCount++; + await ExecuteUIThread(action); } catch (Exception) { } finally { - Messenger.Unregister(this); - Messenger.Register(this); - Messenger.Send(new SelectedItemsChangedMessage()); + _selectionNotificationSuppressionCount = Math.Max(0, _selectionNotificationSuppressionCount - 1); - await NotifySelectionChangesAsync(); + if (_selectionNotificationSuppressionCount == 0) + { + var shouldNotify = notifySelectionChanged || _selectionNotificationPending; + _selectionNotificationPending = false; + + if (shouldNotify) + { + await NotifySelectionChangesAsync(); + } + } } } + public Task ExecuteWithoutRaiseSelectionChangedAsync(Action action, bool includeThreads) + => ExecuteSelectionBatchAsync(() => + { + if (includeThreads) + { + foreach (var item in AllItemsIncludingThreads) + { + action(item); + } + } + else + { + foreach (var item in AllItems) + { + action(item); + } + } + }); + public Task ToggleSelectAllAsync() { if (IsAllItemsSelected) @@ -1144,7 +1184,16 @@ public class WinoMailCollection : ObservableRecipient, IRecipient _ = NotifySelectionChangesAsync(); + public void Receive(SelectedItemsChangedMessage message) + { + if (_selectionNotificationSuppressionCount > 0) + { + _selectionNotificationPending = true; + return; + } + + _ = NotifySelectionChangesAsync(); + } private async Task NotifySelectionChangesAsync() { diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 0d836d95..1b2996c0 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -1120,6 +1120,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (ActiveFolder == null) return; + MailCollection.PruneSingleNonDraftItems = IsActiveDraftFolder(); + await ExecuteUIThread(() => { IsInitializingFolder = true; }); // Folder is changed during initialization. diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs b/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs index 7114eec7..01d7c51e 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs @@ -12,6 +12,9 @@ namespace Wino.Mail.WinUI.Controls.ListView; [GeneratedBindableCustomProperty] public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem { + private WinoExpander? _expander; + private WinoListView? _threadListView; + [GeneratedDependencyProperty] public partial bool IsThreadExpanded { get; set; } @@ -28,14 +31,27 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem public WinoListView? GetWinoListViewControl() { + if (_threadListView?.XamlRoot != null) + { + return _threadListView; + } + var expander = GetExpander(); + _threadListView = expander?.Content as WinoListView; - if (expander?.Content is WinoListView control) return control; - - return null; + return _threadListView; } - public WinoExpander? GetExpander() => WinoVisualTreeHelper.FindDescendants(this).FirstOrDefault(); + public WinoExpander? GetExpander() + { + if (_expander?.XamlRoot != null) + { + return _expander; + } + + _expander = WinoVisualTreeHelper.FindDescendants(this).FirstOrDefault(); + return _expander; + } partial void OnItemPropertyChanged(DependencyPropertyChangedEventArgs e) { @@ -51,5 +67,8 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem { IsCustomSelected = false; } + + _expander = null; + _threadListView = null; } } diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs index 794e136d..9e716851 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs @@ -57,6 +57,7 @@ public sealed partial class MailListPage : MailListPageAbstract, private const int SELECTION_SETTLE_DELAY_MS = 120; private const int RENDERING_FRAME_RELEASE_DELAY_MS = 2000; private int _idleNavigationRequestVersion = 0; + private int _mailActivationRequestVersion = 0; private IPopoutClient? _activePopoutClient; private readonly Dictionary _hostedPopoutWindows = []; private PendingHostedPopoutNavigation? _pendingHostedPopoutNavigation; @@ -108,6 +109,7 @@ public sealed partial class MailListPage : MailListPageAbstract, base.OnNavigatedFrom(e); InvalidatePendingIdleNavigation(); + InvalidatePendingMailActivation(); DetachPopoutClient(); this.Bindings.StopTracking(); @@ -269,22 +271,29 @@ public sealed partial class MailListPage : MailListPageAbstract, } // Context menu on a thread should target the whole thread and keep it expanded. - await ViewModel.MailCollection.UnselectAllAsync(); - await ViewModel.MailCollection.ExecuteWithoutRaiseSelectionChangedAsync(item => + await ViewModel.MailCollection.ExecuteSelectionBatchAsync(() => { - if (item is ThreadMailItemViewModel thread && !ReferenceEquals(thread, threadItem)) + foreach (var group in ViewModel.MailCollection.MailItems) { - thread.IsThreadExpanded = false; + foreach (var item in group) + { + if (item is ThreadMailItemViewModel thread) + { + thread.IsSelected = ReferenceEquals(thread, threadItem); + thread.IsThreadExpanded = ReferenceEquals(thread, threadItem); + + foreach (var threadMail in thread.ThreadEmails) + { + threadMail.IsSelected = ReferenceEquals(thread, threadItem); + } + } + else if (item is MailItemViewModel mailItem) + { + mailItem.IsSelected = false; + } + } } - }, true); - - threadItem.IsSelected = true; - threadItem.IsThreadExpanded = true; - - foreach (var threadMail in threadItem.ThreadEmails) - { - threadMail.IsSelected = true; - } + }); } private async Task GetMailOperationFromFlyoutAsync(IEnumerable availableActions, @@ -311,9 +320,21 @@ public sealed partial class MailListPage : MailListPageAbstract, } void IRecipient.Receive(ActiveMailItemChangedEvent message) + { + int requestVersion = ++_mailActivationRequestVersion; + + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + if (requestVersion != _mailActivationRequestVersion) return; + + ApplyActiveMailItemChange(message.SelectedMailItemViewModel); + }); + } + + private void ApplyActiveMailItemChange(MailItemViewModel? selectedMailItemViewModel) { // No active mail item. Go to empty page. - if (message.SelectedMailItemViewModel == null) + if (selectedMailItemViewModel == null) { _ = NavigateIdleWhenSelectionSettlesAsync(); } @@ -322,7 +343,7 @@ public sealed partial class MailListPage : MailListPageAbstract, InvalidatePendingIdleNavigation(); // Navigate to composing page. - if (message.SelectedMailItemViewModel.IsDraft) + if (selectedMailItemViewModel.IsDraft) { NavigationTransitionType composerPageTransition = NavigationTransitionType.None; @@ -347,20 +368,18 @@ public sealed partial class MailListPage : MailListPageAbstract, else composerPageTransition = NavigationTransitionType.DrillIn; - ViewModel.NavigationService.Navigate(WinoPage.ComposePage, message.SelectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame, composerPageTransition); + ViewModel.NavigationService.Navigate(WinoPage.ComposePage, selectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame, composerPageTransition); } else { // Find the MIME and go to rendering page. - if (message.SelectedMailItemViewModel == null) return; - if (IsComposingPageActive()) { PrepareComposePageWebViewTransition(); } - ViewModel.NavigationService.Navigate(WinoPage.MailRenderingPage, message.SelectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame); + ViewModel.NavigationService.Navigate(WinoPage.MailRenderingPage, selectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame); } } @@ -427,6 +446,14 @@ public sealed partial class MailListPage : MailListPageAbstract, } } + private void InvalidatePendingMailActivation() + { + unchecked + { + _mailActivationRequestVersion++; + } + } + private async Task NavigateIdleWhenSelectionSettlesAsync() { int requestVersion = ++_idleNavigationRequestVersion; @@ -765,42 +792,13 @@ public sealed partial class MailListPage : MailListPageAbstract, // Lazily built caches for this invocation. List? threadItems = null; - Dictionary? threadById = null; List GetThreadItems() { - if (threadItems != null) return threadItems; - - threadItems = []; - - foreach (var group in ViewModel.MailCollection.MailItems) - { - foreach (var item in group) - { - if (item is ThreadMailItemViewModel thread) - { - threadItems.Add(thread); - } - } - } - - return threadItems; + return threadItems ??= ViewModel.MailCollection.GetThreadItems(); } - ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail) - { - if (string.IsNullOrEmpty(mail.ThreadId)) - { - return null; - } - - threadById ??= GetThreadItems() - .Where(t => !string.IsNullOrEmpty(t.ThreadId)) - .GroupBy(t => t.ThreadId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); - - return threadById.TryGetValue(mail.ThreadId, out var threadItem) ? threadItem : null; - } + ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail) => ViewModel.MailCollection.GetThreadByMailUniqueId(mail.MailCopy.UniqueId); void CollapseAllThreadsExcept(ThreadMailItemViewModel? except) { @@ -813,6 +811,29 @@ public sealed partial class MailListPage : MailListPageAbstract, } } + void ResetSelectionState() + { + foreach (var group in ViewModel.MailCollection.MailItems) + { + foreach (var item in group) + { + if (item is ThreadMailItemViewModel thread) + { + thread.IsSelected = false; + + foreach (var child in thread.ThreadEmails) + { + child.IsSelected = false; + } + } + else if (item is MailItemViewModel mail) + { + mail.IsSelected = false; + } + } + } + } + static void SyncThreadSelectionFromChildren(ThreadMailItemViewModel? thread) { if (thread == null) return; @@ -836,135 +857,117 @@ public sealed partial class MailListPage : MailListPageAbstract, } } - if (isCtrlPressed) + await ViewModel.MailCollection.ExecuteSelectionBatchAsync(() => { - switch (clickedItem) + if (isCtrlPressed) { - case ThreadMailItemViewModel thread: - { - // Determine if thread + all children currently selected - bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected); - if (allSelected) + switch (clickedItem) + { + case ThreadMailItemViewModel thread: { - // Unselect thread & all children - thread.IsSelected = false; - foreach (var child in thread.ThreadEmails) - child.IsSelected = false; + bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected); + if (allSelected) + { + thread.IsSelected = false; + foreach (var child in thread.ThreadEmails) + child.IsSelected = false; + } + else + { + thread.IsSelected = true; + foreach (var child in thread.ThreadEmails) + child.IsSelected = true; + thread.IsThreadExpanded = true; + } + break; } - else + case MailItemViewModel mail: { - // Select thread & all children (do NOT disturb other selections in CTRL mode) - thread.IsSelected = true; - foreach (var child in thread.ThreadEmails) - child.IsSelected = true; - // Keep it expanded so user can see items - thread.IsThreadExpanded = true; + mail.IsSelected = !mail.IsSelected; + SyncThreadSelectionFromChildren(FindParentThread(mail)); + break; } - break; - } - case MailItemViewModel mail: - { - // Toggle just this item; no collapse/unselect of others in multi-select mode. - mail.IsSelected = !mail.IsSelected; - SyncThreadSelectionFromChildren(FindParentThread(mail)); - break; - } - } - return; // Multi-select path ends here. - } + } - // SINGLE-SELECTION (exclusive) MODE WITH TOGGLE SUPPORT - if (clickedItem is ThreadMailItemViewModel clickedThread) - { - bool wasThreadSelected = clickedThread.IsSelected; - bool wasThreadExpanded = clickedThread.IsThreadExpanded; - - // Check if any child in this thread is already selected (e.g., from notification click) - var alreadySelectedChild = clickedThread.ThreadEmails.FirstOrDefault(e => e.IsSelected); - - // Reset everything first (exclusive selection scenario) - await ViewModel.MailCollection.UnselectAllAsync(); - CollapseAllThreadsExcept(clickedThread); - - if (wasThreadSelected && wasThreadExpanded) - { - // Toggle off -> leave nothing selected (all unselected, thread collapsed) - clickedThread.IsThreadExpanded = false; return; } - // Select thread header - clickedThread.IsSelected = true; + if (clickedItem is ThreadMailItemViewModel clickedThread) + { + bool wasThreadSelected = clickedThread.IsSelected; + bool wasThreadExpanded = clickedThread.IsThreadExpanded; + var alreadySelectedChild = clickedThread.ThreadEmails.FirstOrDefault(e => e.IsSelected); - // If a child was already selected (e.g., from notification), keep that selection - // Otherwise, select the first child - if (alreadySelectedChild != null) - { - alreadySelectedChild.IsSelected = true; - } - else - { - var firstChild = clickedThread.ThreadEmails.FirstOrDefault(); - if (firstChild != null) + ResetSelectionState(); + CollapseAllThreadsExcept(clickedThread); + + if (wasThreadSelected && wasThreadExpanded) { - firstChild.IsSelected = true; + clickedThread.IsThreadExpanded = false; + return; } - } - clickedThread.IsThreadExpanded = true; // Show contents of active thread - } - else if (clickedItem is MailItemViewModel clickedMail) - { - bool wasSelected = clickedMail.IsSelected; + clickedThread.IsSelected = true; - // Determine if this mail belongs to an already selected & expanded thread. - // If so, we only want to switch the selection inside that thread without collapsing or unselecting the thread header. - ThreadMailItemViewModel? parentThread = FindParentThread(clickedMail); - - bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded; - - if (isInSelectedExpandedThread) - { - // Switch selection within the thread: unselect previously selected children, select the clicked one. - if (parentThread?.ThreadEmails != null) + if (alreadySelectedChild != null) { - foreach (var child in parentThread.ThreadEmails) + alreadySelectedChild.IsSelected = true; + } + else + { + var firstChild = clickedThread.ThreadEmails.FirstOrDefault(); + if (firstChild != null) { - child.IsSelected = child == clickedMail && !wasSelected; // If clicking an already selected child -> toggle off (none selected in thread except header) + firstChild.IsSelected = true; } } + clickedThread.IsThreadExpanded = true; + } + else if (clickedItem is MailItemViewModel clickedMail) + { + bool wasSelected = clickedMail.IsSelected; + ThreadMailItemViewModel? parentThread = FindParentThread(clickedMail); + bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded; + + if (isInSelectedExpandedThread) + { + var selectedParentThread = parentThread!; + + foreach (var child in selectedParentThread.ThreadEmails) + { + child.IsSelected = child == clickedMail && !wasSelected; + } + + SyncThreadSelectionFromChildren(selectedParentThread); + return; + } + + ResetSelectionState(); + + if (parentThread != null && parentThread.IsThreadExpanded) + { + CollapseAllThreadsExcept(parentThread); + } + else + { + CollapseAllThreadsExcept(null); + } + + if (parentThread != null && selectExpandThread) + { + parentThread.IsSelected = true; + parentThread.IsThreadExpanded = true; + } + + if (!wasSelected) + { + clickedMail.IsSelected = true; + } + SyncThreadSelectionFromChildren(parentThread); - return; // Done. } - - // Normal single-item (non-thread or entering a thread via child) behavior. - await ViewModel.MailCollection.UnselectAllAsync(); - - // If parent thread is already expanded, keep it as-is to avoid collapse/expand animation. - if (parentThread != null && parentThread.IsThreadExpanded) - { - CollapseAllThreadsExcept(parentThread); - } - else - { - CollapseAllThreadsExcept(null); - } - - if (parentThread != null && selectExpandThread) - { - // We're clicking an item inside a thread; select & expand the thread header as well. - parentThread.IsSelected = true; - parentThread.IsThreadExpanded = true; - } - - if (!wasSelected) - { - clickedMail.IsSelected = true; // Toggle on - } - - SyncThreadSelectionFromChildren(parentThread); - } + }); } private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e)