Optimize mail list selection and draft pruning

This commit is contained in:
Burak Kaan Köse
2026-04-11 10:54:14 +02:00
parent fdb340549d
commit 40318ef99c
5 changed files with 305 additions and 187 deletions
@@ -81,6 +81,26 @@ public class WinoMailCollectionTests
container.ThreadViewModel.Should().BeNull(); 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] [Fact]
public async Task RemoveAsync_ShouldRemoveSingleItem() 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() private static WinoMailCollection CreateCollection() => new()
{ {
CoreDispatcher = new ImmediateDispatcher() CoreDispatcher = new ImmediateDispatcher()
@@ -44,6 +44,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private readonly ObservableGroupedCollection<object, IMailListItem> _mailItemSource = new ObservableGroupedCollection<object, IMailListItem>(); private readonly ObservableGroupedCollection<object, IMailListItem> _mailItemSource = new ObservableGroupedCollection<object, IMailListItem>();
private readonly SemaphoreSlim _mutationGate = new(1, 1); private readonly SemaphoreSlim _mutationGate = new(1, 1);
private int _selectionNotificationSuppressionCount;
private bool _selectionNotificationPending;
public ReadOnlyObservableGroupedCollection<object, IMailListItem> MailItems { get; } public ReadOnlyObservableGroupedCollection<object, IMailListItem> MailItems { get; }
@@ -305,6 +307,36 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return null; return null;
} }
public ThreadMailItemViewModel GetThreadByMailUniqueId(Guid uniqueId)
{
if (_uniqueIdToThreadMap.TryGetValue(uniqueId, out var threadViewModel))
{
return threadViewModel;
}
_ = Find(uniqueId);
return _uniqueIdToThreadMap.TryGetValue(uniqueId, out threadViewModel) ? threadViewModel : null;
}
public List<ThreadMailItemViewModel> GetThreadItems()
{
var threads = new List<ThreadMailItemViewModel>();
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) private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem)
{ {
UpdateUniqueIdHashes(mailItem, true); UpdateUniqueIdHashes(mailItem, true);
@@ -1049,14 +1081,35 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
public bool IsAllItemsSelected => AllItems.Any() && AllItems.All(a => a.IsSelected); public bool IsAllItemsSelected => AllItems.Any() && AllItems.All(a => a.IsSelected);
public bool HasSingleItemSelected => SelectedItemsCount == 1; public bool HasSingleItemSelected => SelectedItemsCount == 1;
public async Task ExecuteWithoutRaiseSelectionChangedAsync(Action<IMailListItem> action, bool includeThreads) public async Task ExecuteSelectionBatchAsync(Action action, bool notifySelectionChanged = true)
{ {
try try
{ {
// Do not listen to individual selection changes while we are doing bulk selection. _selectionNotificationSuppressionCount++;
Messenger.Unregister<SelectedItemsChangedMessage>(this); await ExecuteUIThread(action);
}
catch (Exception)
{
}
finally
{
_selectionNotificationSuppressionCount = Math.Max(0, _selectionNotificationSuppressionCount - 1);
await ExecuteUIThread(() => if (_selectionNotificationSuppressionCount == 0)
{
var shouldNotify = notifySelectionChanged || _selectionNotificationPending;
_selectionNotificationPending = false;
if (shouldNotify)
{
await NotifySelectionChangesAsync();
}
}
}
}
public Task ExecuteWithoutRaiseSelectionChangedAsync(Action<IMailListItem> action, bool includeThreads)
=> ExecuteSelectionBatchAsync(() =>
{ {
if (includeThreads) if (includeThreads)
{ {
@@ -1073,19 +1126,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
} }
} }
}); });
}
catch (Exception)
{
}
finally
{
Messenger.Unregister<SelectedItemsChangedMessage>(this);
Messenger.Register<SelectedItemsChangedMessage>(this);
Messenger.Send(new SelectedItemsChangedMessage());
await NotifySelectionChangesAsync();
}
}
public Task ToggleSelectAllAsync() public Task ToggleSelectAllAsync()
{ {
@@ -1144,7 +1184,16 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
} }
} }
public void Receive(SelectedItemsChangedMessage message) => _ = NotifySelectionChangesAsync(); public void Receive(SelectedItemsChangedMessage message)
{
if (_selectionNotificationSuppressionCount > 0)
{
_selectionNotificationPending = true;
return;
}
_ = NotifySelectionChangesAsync();
}
private async Task NotifySelectionChangesAsync() private async Task NotifySelectionChangesAsync()
{ {
@@ -1120,6 +1120,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
if (ActiveFolder == null) if (ActiveFolder == null)
return; return;
MailCollection.PruneSingleNonDraftItems = IsActiveDraftFolder();
await ExecuteUIThread(() => { IsInitializingFolder = true; }); await ExecuteUIThread(() => { IsInitializingFolder = true; });
// Folder is changed during initialization. // Folder is changed during initialization.
@@ -12,6 +12,9 @@ namespace Wino.Mail.WinUI.Controls.ListView;
[GeneratedBindableCustomProperty] [GeneratedBindableCustomProperty]
public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
{ {
private WinoExpander? _expander;
private WinoListView? _threadListView;
[GeneratedDependencyProperty] [GeneratedDependencyProperty]
public partial bool IsThreadExpanded { get; set; } public partial bool IsThreadExpanded { get; set; }
@@ -28,14 +31,27 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
public WinoListView? GetWinoListViewControl() public WinoListView? GetWinoListViewControl()
{ {
var expander = GetExpander(); if (_threadListView?.XamlRoot != null)
{
if (expander?.Content is WinoListView control) return control; return _threadListView;
return null;
} }
public WinoExpander? GetExpander() => WinoVisualTreeHelper.FindDescendants<WinoExpander>(this).FirstOrDefault(); var expander = GetExpander();
_threadListView = expander?.Content as WinoListView;
return _threadListView;
}
public WinoExpander? GetExpander()
{
if (_expander?.XamlRoot != null)
{
return _expander;
}
_expander = WinoVisualTreeHelper.FindDescendants<WinoExpander>(this).FirstOrDefault();
return _expander;
}
partial void OnItemPropertyChanged(DependencyPropertyChangedEventArgs e) partial void OnItemPropertyChanged(DependencyPropertyChangedEventArgs e)
{ {
@@ -51,5 +67,8 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
{ {
IsCustomSelected = false; IsCustomSelected = false;
} }
_expander = null;
_threadListView = null;
} }
} }
+84 -81
View File
@@ -57,6 +57,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
private const int SELECTION_SETTLE_DELAY_MS = 120; private const int SELECTION_SETTLE_DELAY_MS = 120;
private const int RENDERING_FRAME_RELEASE_DELAY_MS = 2000; private const int RENDERING_FRAME_RELEASE_DELAY_MS = 2000;
private int _idleNavigationRequestVersion = 0; private int _idleNavigationRequestVersion = 0;
private int _mailActivationRequestVersion = 0;
private IPopoutClient? _activePopoutClient; private IPopoutClient? _activePopoutClient;
private readonly Dictionary<FrameworkElement, HostedContentPopoutWindow> _hostedPopoutWindows = []; private readonly Dictionary<FrameworkElement, HostedContentPopoutWindow> _hostedPopoutWindows = [];
private PendingHostedPopoutNavigation? _pendingHostedPopoutNavigation; private PendingHostedPopoutNavigation? _pendingHostedPopoutNavigation;
@@ -108,6 +109,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
base.OnNavigatedFrom(e); base.OnNavigatedFrom(e);
InvalidatePendingIdleNavigation(); InvalidatePendingIdleNavigation();
InvalidatePendingMailActivation();
DetachPopoutClient(); DetachPopoutClient();
this.Bindings.StopTracking(); this.Bindings.StopTracking();
@@ -269,23 +271,30 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
// Context menu on a thread should target the whole thread and keep it expanded. // Context menu on a thread should target the whole thread and keep it expanded.
await ViewModel.MailCollection.UnselectAllAsync(); await ViewModel.MailCollection.ExecuteSelectionBatchAsync(() =>
await ViewModel.MailCollection.ExecuteWithoutRaiseSelectionChangedAsync(item =>
{ {
if (item is ThreadMailItemViewModel thread && !ReferenceEquals(thread, threadItem)) foreach (var group in ViewModel.MailCollection.MailItems)
{ {
thread.IsThreadExpanded = false; foreach (var item in group)
} {
}, true); if (item is ThreadMailItemViewModel thread)
{
thread.IsSelected = ReferenceEquals(thread, threadItem);
thread.IsThreadExpanded = ReferenceEquals(thread, threadItem);
threadItem.IsSelected = true; foreach (var threadMail in thread.ThreadEmails)
threadItem.IsThreadExpanded = true;
foreach (var threadMail in threadItem.ThreadEmails)
{ {
threadMail.IsSelected = true; threadMail.IsSelected = ReferenceEquals(thread, threadItem);
} }
} }
else if (item is MailItemViewModel mailItem)
{
mailItem.IsSelected = false;
}
}
}
});
}
private async Task<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions, private async Task<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions,
UIElement showAtElement, UIElement showAtElement,
@@ -311,9 +320,21 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
void IRecipient<ActiveMailItemChangedEvent>.Receive(ActiveMailItemChangedEvent message) void IRecipient<ActiveMailItemChangedEvent>.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. // No active mail item. Go to empty page.
if (message.SelectedMailItemViewModel == null) if (selectedMailItemViewModel == null)
{ {
_ = NavigateIdleWhenSelectionSettlesAsync(); _ = NavigateIdleWhenSelectionSettlesAsync();
} }
@@ -322,7 +343,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
InvalidatePendingIdleNavigation(); InvalidatePendingIdleNavigation();
// Navigate to composing page. // Navigate to composing page.
if (message.SelectedMailItemViewModel.IsDraft) if (selectedMailItemViewModel.IsDraft)
{ {
NavigationTransitionType composerPageTransition = NavigationTransitionType.None; NavigationTransitionType composerPageTransition = NavigationTransitionType.None;
@@ -347,20 +368,18 @@ public sealed partial class MailListPage : MailListPageAbstract,
else else
composerPageTransition = NavigationTransitionType.DrillIn; composerPageTransition = NavigationTransitionType.DrillIn;
ViewModel.NavigationService.Navigate(WinoPage.ComposePage, message.SelectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame, composerPageTransition); ViewModel.NavigationService.Navigate(WinoPage.ComposePage, selectedMailItemViewModel, NavigationReferenceFrame.RenderingFrame, composerPageTransition);
} }
else else
{ {
// Find the MIME and go to rendering page. // Find the MIME and go to rendering page.
if (message.SelectedMailItemViewModel == null) return;
if (IsComposingPageActive()) if (IsComposingPageActive())
{ {
PrepareComposePageWebViewTransition(); 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() private async Task NavigateIdleWhenSelectionSettlesAsync()
{ {
int requestVersion = ++_idleNavigationRequestVersion; int requestVersion = ++_idleNavigationRequestVersion;
@@ -765,42 +792,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
// Lazily built caches for this invocation. // Lazily built caches for this invocation.
List<ThreadMailItemViewModel>? threadItems = null; List<ThreadMailItemViewModel>? threadItems = null;
Dictionary<string, ThreadMailItemViewModel>? threadById = null;
List<ThreadMailItemViewModel> GetThreadItems() List<ThreadMailItemViewModel> GetThreadItems()
{ {
if (threadItems != null) return threadItems; return threadItems ??= ViewModel.MailCollection.GetThreadItems();
threadItems = [];
foreach (var group in ViewModel.MailCollection.MailItems)
{
foreach (var item in group)
{
if (item is ThreadMailItemViewModel thread)
{
threadItems.Add(thread);
}
}
} }
return threadItems; ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail) => ViewModel.MailCollection.GetThreadByMailUniqueId(mail.MailCopy.UniqueId);
}
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;
}
void CollapseAllThreadsExcept(ThreadMailItemViewModel? except) 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) static void SyncThreadSelectionFromChildren(ThreadMailItemViewModel? thread)
{ {
if (thread == null) return; if (thread == null) return;
@@ -836,68 +857,58 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
} }
await ViewModel.MailCollection.ExecuteSelectionBatchAsync(() =>
{
if (isCtrlPressed) if (isCtrlPressed)
{ {
switch (clickedItem) switch (clickedItem)
{ {
case ThreadMailItemViewModel thread: case ThreadMailItemViewModel thread:
{ {
// Determine if thread + all children currently selected
bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected); bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected);
if (allSelected) if (allSelected)
{ {
// Unselect thread & all children
thread.IsSelected = false; thread.IsSelected = false;
foreach (var child in thread.ThreadEmails) foreach (var child in thread.ThreadEmails)
child.IsSelected = false; child.IsSelected = false;
} }
else else
{ {
// Select thread & all children (do NOT disturb other selections in CTRL mode)
thread.IsSelected = true; thread.IsSelected = true;
foreach (var child in thread.ThreadEmails) foreach (var child in thread.ThreadEmails)
child.IsSelected = true; child.IsSelected = true;
// Keep it expanded so user can see items
thread.IsThreadExpanded = true; thread.IsThreadExpanded = true;
} }
break; break;
} }
case MailItemViewModel mail: case MailItemViewModel mail:
{ {
// Toggle just this item; no collapse/unselect of others in multi-select mode.
mail.IsSelected = !mail.IsSelected; mail.IsSelected = !mail.IsSelected;
SyncThreadSelectionFromChildren(FindParentThread(mail)); SyncThreadSelectionFromChildren(FindParentThread(mail));
break; break;
} }
} }
return; // Multi-select path ends here.
return;
} }
// SINGLE-SELECTION (exclusive) MODE WITH TOGGLE SUPPORT
if (clickedItem is ThreadMailItemViewModel clickedThread) if (clickedItem is ThreadMailItemViewModel clickedThread)
{ {
bool wasThreadSelected = clickedThread.IsSelected; bool wasThreadSelected = clickedThread.IsSelected;
bool wasThreadExpanded = clickedThread.IsThreadExpanded; 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); var alreadySelectedChild = clickedThread.ThreadEmails.FirstOrDefault(e => e.IsSelected);
// Reset everything first (exclusive selection scenario) ResetSelectionState();
await ViewModel.MailCollection.UnselectAllAsync();
CollapseAllThreadsExcept(clickedThread); CollapseAllThreadsExcept(clickedThread);
if (wasThreadSelected && wasThreadExpanded) if (wasThreadSelected && wasThreadExpanded)
{ {
// Toggle off -> leave nothing selected (all unselected, thread collapsed)
clickedThread.IsThreadExpanded = false; clickedThread.IsThreadExpanded = false;
return; return;
} }
// Select thread header
clickedThread.IsSelected = true; clickedThread.IsSelected = true;
// If a child was already selected (e.g., from notification), keep that selection
// Otherwise, select the first child
if (alreadySelectedChild != null) if (alreadySelectedChild != null)
{ {
alreadySelectedChild.IsSelected = true; alreadySelectedChild.IsSelected = true;
@@ -911,37 +922,29 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
} }
clickedThread.IsThreadExpanded = true; // Show contents of active thread clickedThread.IsThreadExpanded = true;
} }
else if (clickedItem is MailItemViewModel clickedMail) else if (clickedItem is MailItemViewModel clickedMail)
{ {
bool wasSelected = clickedMail.IsSelected; bool wasSelected = clickedMail.IsSelected;
// 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); ThreadMailItemViewModel? parentThread = FindParentThread(clickedMail);
bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded; bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded;
if (isInSelectedExpandedThread) if (isInSelectedExpandedThread)
{ {
// Switch selection within the thread: unselect previously selected children, select the clicked one. var selectedParentThread = parentThread!;
if (parentThread?.ThreadEmails != null)
foreach (var child in selectedParentThread.ThreadEmails)
{ {
foreach (var child in parentThread.ThreadEmails) child.IsSelected = child == clickedMail && !wasSelected;
{
child.IsSelected = child == clickedMail && !wasSelected; // If clicking an already selected child -> toggle off (none selected in thread except header)
}
} }
SyncThreadSelectionFromChildren(parentThread); SyncThreadSelectionFromChildren(selectedParentThread);
return; // Done. return;
} }
// Normal single-item (non-thread or entering a thread via child) behavior. ResetSelectionState();
await ViewModel.MailCollection.UnselectAllAsync();
// If parent thread is already expanded, keep it as-is to avoid collapse/expand animation.
if (parentThread != null && parentThread.IsThreadExpanded) if (parentThread != null && parentThread.IsThreadExpanded)
{ {
CollapseAllThreadsExcept(parentThread); CollapseAllThreadsExcept(parentThread);
@@ -953,18 +956,18 @@ public sealed partial class MailListPage : MailListPageAbstract,
if (parentThread != null && selectExpandThread) if (parentThread != null && selectExpandThread)
{ {
// We're clicking an item inside a thread; select & expand the thread header as well.
parentThread.IsSelected = true; parentThread.IsSelected = true;
parentThread.IsThreadExpanded = true; parentThread.IsThreadExpanded = true;
} }
if (!wasSelected) if (!wasSelected)
{ {
clickedMail.IsSelected = true; // Toggle on clickedMail.IsSelected = true;
} }
SyncThreadSelectionFromChildren(parentThread); SyncThreadSelectionFromChildren(parentThread);
} }
});
} }
private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e) private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e)