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
@@ -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<WinoExpander>(this).FirstOrDefault();
public WinoExpander? GetExpander()
{
if (_expander?.XamlRoot != null)
{
return _expander;
}
_expander = WinoVisualTreeHelper.FindDescendants<WinoExpander>(this).FirstOrDefault();
return _expander;
}
partial void OnItemPropertyChanged(DependencyPropertyChangedEventArgs e)
{
@@ -51,5 +67,8 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
{
IsCustomSelected = false;
}
_expander = null;
_threadListView = null;
}
}
+160 -157
View File
@@ -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<FrameworkElement, HostedContentPopoutWindow> _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<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions,
@@ -311,9 +320,21 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
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.
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<ThreadMailItemViewModel>? threadItems = null;
Dictionary<string, ThreadMailItemViewModel>? threadById = null;
List<ThreadMailItemViewModel> 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)