Optimize mail list selection and draft pruning
This commit is contained in:
@@ -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,44 +1081,52 @@ 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);
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
|
||||||
{
|
|
||||||
if (includeThreads)
|
|
||||||
{
|
|
||||||
foreach (var item in AllItemsIncludingThreads)
|
|
||||||
{
|
|
||||||
action(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (var item in AllItems)
|
|
||||||
{
|
|
||||||
action(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Messenger.Unregister<SelectedItemsChangedMessage>(this);
|
_selectionNotificationSuppressionCount = Math.Max(0, _selectionNotificationSuppressionCount - 1);
|
||||||
Messenger.Register<SelectedItemsChangedMessage>(this);
|
|
||||||
Messenger.Send(new SelectedItemsChangedMessage());
|
|
||||||
|
|
||||||
await NotifySelectionChangesAsync();
|
if (_selectionNotificationSuppressionCount == 0)
|
||||||
|
{
|
||||||
|
var shouldNotify = notifySelectionChanged || _selectionNotificationPending;
|
||||||
|
_selectionNotificationPending = false;
|
||||||
|
|
||||||
|
if (shouldNotify)
|
||||||
|
{
|
||||||
|
await NotifySelectionChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task ExecuteWithoutRaiseSelectionChangedAsync(Action<IMailListItem> action, bool includeThreads)
|
||||||
|
=> ExecuteSelectionBatchAsync(() =>
|
||||||
|
{
|
||||||
|
if (includeThreads)
|
||||||
|
{
|
||||||
|
foreach (var item in AllItemsIncludingThreads)
|
||||||
|
{
|
||||||
|
action(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var item in AllItems)
|
||||||
|
{
|
||||||
|
action(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
public Task ToggleSelectAllAsync()
|
public Task ToggleSelectAllAsync()
|
||||||
{
|
{
|
||||||
if (IsAllItemsSelected)
|
if (IsAllItemsSelected)
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
if (_threadListView?.XamlRoot != null)
|
||||||
|
{
|
||||||
|
return _threadListView;
|
||||||
|
}
|
||||||
|
|
||||||
var expander = GetExpander();
|
var expander = GetExpander();
|
||||||
|
_threadListView = expander?.Content as WinoListView;
|
||||||
|
|
||||||
if (expander?.Content is WinoListView control) return control;
|
return _threadListView;
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
partial void OnItemPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -51,5 +67,8 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
|
|||||||
{
|
{
|
||||||
IsCustomSelected = false;
|
IsCustomSelected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_expander = null;
|
||||||
|
_threadListView = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 +271,29 @@ 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)
|
||||||
|
{
|
||||||
|
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,
|
private async Task<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions,
|
||||||
@@ -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)
|
ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail) => ViewModel.MailCollection.GetThreadByMailUniqueId(mail.MailCopy.UniqueId);
|
||||||
{
|
|
||||||
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,135 +857,117 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCtrlPressed)
|
await ViewModel.MailCollection.ExecuteSelectionBatchAsync(() =>
|
||||||
{
|
{
|
||||||
switch (clickedItem)
|
if (isCtrlPressed)
|
||||||
{
|
{
|
||||||
case ThreadMailItemViewModel thread:
|
switch (clickedItem)
|
||||||
{
|
{
|
||||||
// Determine if thread + all children currently selected
|
case ThreadMailItemViewModel thread:
|
||||||
bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected);
|
|
||||||
if (allSelected)
|
|
||||||
{
|
{
|
||||||
// Unselect thread & all children
|
bool allSelected = thread.IsSelected && thread.ThreadEmails.All(e => e.IsSelected);
|
||||||
thread.IsSelected = false;
|
if (allSelected)
|
||||||
foreach (var child in thread.ThreadEmails)
|
{
|
||||||
child.IsSelected = false;
|
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)
|
mail.IsSelected = !mail.IsSelected;
|
||||||
thread.IsSelected = true;
|
SyncThreadSelectionFromChildren(FindParentThread(mail));
|
||||||
foreach (var child in thread.ThreadEmails)
|
break;
|
||||||
child.IsSelected = true;
|
|
||||||
// Keep it expanded so user can see items
|
|
||||||
thread.IsThreadExpanded = true;
|
|
||||||
}
|
}
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select thread header
|
if (clickedItem is ThreadMailItemViewModel clickedThread)
|
||||||
clickedThread.IsSelected = true;
|
{
|
||||||
|
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
|
ResetSelectionState();
|
||||||
// Otherwise, select the first child
|
CollapseAllThreadsExcept(clickedThread);
|
||||||
if (alreadySelectedChild != null)
|
|
||||||
{
|
if (wasThreadSelected && wasThreadExpanded)
|
||||||
alreadySelectedChild.IsSelected = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var firstChild = clickedThread.ThreadEmails.FirstOrDefault();
|
|
||||||
if (firstChild != null)
|
|
||||||
{
|
{
|
||||||
firstChild.IsSelected = true;
|
clickedThread.IsThreadExpanded = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
clickedThread.IsThreadExpanded = true; // Show contents of active thread
|
clickedThread.IsSelected = true;
|
||||||
}
|
|
||||||
else if (clickedItem is MailItemViewModel clickedMail)
|
|
||||||
{
|
|
||||||
bool wasSelected = clickedMail.IsSelected;
|
|
||||||
|
|
||||||
// Determine if this mail belongs to an already selected & expanded thread.
|
if (alreadySelectedChild != null)
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
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);
|
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)
|
private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e)
|
||||||
|
|||||||
Reference in New Issue
Block a user