diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 673398e3..d1685592 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -23,6 +23,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient MailCopyIdHashSet = []; + // Cache ThreadIds to quickly find items that should be threaded together + private readonly Dictionary> _threadIdToItemsMap = new(); + public event EventHandler MailItemRemoved; public event EventHandler ItemSelectionChanged; @@ -78,6 +81,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient(); + } + _threadIdToItemsMap[threadId].Add(item); + } + else + { + if (_threadIdToItemsMap.ContainsKey(threadId)) + { + _threadIdToItemsMap[threadId].Remove(item); + if (_threadIdToItemsMap[threadId].Count == 0) + { + _threadIdToItemsMap.Remove(threadId); + } + } + } + } + } + + private IEnumerable GetThreadIdsFromItem(IMailListItem item) + { + if (item is MailItemViewModel mailItem && !string.IsNullOrEmpty(mailItem.MailCopy.ThreadId)) + { + yield return mailItem.MailCopy.ThreadId; + } + else if (item is ThreadMailItemViewModel threadItem) + { + var uniqueThreadIds = threadItem.ThreadEmails + .Where(e => !string.IsNullOrEmpty(e.MailCopy.ThreadId)) + .Select(e => e.MailCopy.ThreadId) + .Distinct(); + + foreach (var threadId in uniqueThreadIds) + { + yield return threadId; + } + } + } + + private IMailListItem FindThreadableItem(string threadId) + { + if (string.IsNullOrEmpty(threadId) || !_threadIdToItemsMap.ContainsKey(threadId)) + { + return null; + } + + return _threadIdToItemsMap[threadId].FirstOrDefault(); + } + private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem) { UpdateUniqueIdHashes(mailItem, true); + UpdateThreadIdCache(mailItem, true); await ExecuteUIThread(() => { _mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer); @@ -116,6 +181,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient group, IMailListItem mailItem) { UpdateUniqueIdHashes(mailItem, false); + UpdateThreadIdCache(mailItem, false); if (mailItem is MailItemViewModel singleMailItem) { @@ -156,12 +222,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { var newMailItem = new MailItemViewModel(addedItem); threadViewModel.AddEmail(newMailItem); }); + // Update ThreadId cache after modifying the thread + UpdateThreadIdCache(threadViewModel, true); + var newGroupKey = GetGroupingKey(threadViewModel); if (!existingGroupKey.Equals(newGroupKey)) @@ -212,41 +284,50 @@ public class WinoMailCollection : ObservableRecipient, IRecipient - !string.IsNullOrEmpty(e.MailCopy.ThreadId) && - string.Equals(addedItem.ThreadId, e.MailCopy.ThreadId, StringComparison.OrdinalIgnoreCase)); - } - - if (shouldThread) - { - await HandleThreadingAsync(group, item, addedItem); - return; - } - else if (item is MailItemViewModel itemViewModel && itemViewModel.MailCopy.UniqueId == addedItem.UniqueId) - { - await UpdateExistingItemAsync(itemViewModel, addedItem); + await HandleThreadingAsync(targetGroup, threadableItem, addedItem); return; } } } + // No threading needed, add as new item await AddNewItemAsync(addedItem); } + private ObservableGroup FindGroupContainingItem(IMailListItem item) + { + foreach (var group in _mailItemSource) + { + if (group.Contains(item)) + { + return group; + } + } + return null; + } + private async Task AddNewItemAsync(MailCopy addedItem) { var newMailItem = new MailItemViewModel(addedItem); @@ -265,25 +346,103 @@ public class WinoMailCollection : ObservableRecipient, IRecipient /// Adds multiple emails to the collection. /// - public async Task AddRangeAsync(IEnumerable items, bool clearIdCache) + public async Task AddRangeAsync(IEnumerable items, bool clearIdCache) { if (clearIdCache) { MailCopyIdHashSet.Clear(); + _threadIdToItemsMap.Clear(); } - var groupedByName = items - .GroupBy(GetGroupingKey) - .Select(a => new ObservableGroup(a.Key, a)); + var itemsList = items.ToList(); + var itemsToAdd = new List(); + var processedItems = new HashSet(); + + // Process items and handle threading + foreach (var item in itemsList) + { + if (processedItems.Contains(item)) + continue; + + // Check if this is an update to an existing item + if (MailCopyIdHashSet.Contains(item.MailCopy.UniqueId)) + { + var existingItemContainer = GetMailItemContainer(item.MailCopy.UniqueId); + if (existingItemContainer?.ItemViewModel != null) + { + await UpdateExistingItemAsync(existingItemContainer.ItemViewModel, item.MailCopy); + processedItems.Add(item); + continue; + } + } + + // Check if this item should be threaded + if (!string.IsNullOrEmpty(item.MailCopy.ThreadId)) + { + // Look for existing item with same ThreadId + var existingThreadableItem = FindThreadableItem(item.MailCopy.ThreadId); + + if (existingThreadableItem != null) + { + // Thread with existing item + var targetGroup = FindGroupContainingItem(existingThreadableItem); + if (targetGroup != null) + { + await HandleThreadingAsync(targetGroup, existingThreadableItem, item.MailCopy); + processedItems.Add(item); + continue; + } + } + + // Look for other items in the current batch with same ThreadId + var threadableItems = itemsList + .Where(i => !processedItems.Contains(i) && + !string.IsNullOrEmpty(i.MailCopy.ThreadId) && + i.MailCopy.ThreadId == item.MailCopy.ThreadId) + .ToList(); + + if (threadableItems.Count > 1) + { + // Create a new thread with all matching items + var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); + + await ExecuteUIThread(() => + { + foreach (var threadItem in threadableItems) + { + threadViewModel.AddEmail(threadItem); + } + }); + + itemsToAdd.Add(threadViewModel); + + // Mark all threaded items as processed + foreach (var threadItem in threadableItems) + { + processedItems.Add(threadItem); + } + continue; + } + } + + // No threading needed, add as single item + itemsToAdd.Add(item); + processedItems.Add(item); + } + + // Group items by their grouping key and add them + var groupedItems = itemsToAdd + .GroupBy(GetGroupingKey) + .Select(g => new ObservableGroup(g.Key, g)); await ExecuteUIThread(() => { - foreach (var group in groupedByName) + foreach (var group in groupedItems) { - // Store all mail copy ids for faster access. foreach (var item in group) { UpdateUniqueIdHashes(item, true); + UpdateThreadIdCache(item, true); } var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key); @@ -497,8 +656,17 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { threadMailItemViewModel.RemoveEmail(removalItem); }); + // Update ThreadId cache after modifying the thread + if (threadMailItemViewModel.EmailCount > 0) + { + UpdateThreadIdCache(threadMailItemViewModel, true); + } + if (threadMailItemViewModel.EmailCount == 1) { // Convert to single item. @@ -559,8 +727,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n); } + partial void OnIsSelectedChanged(bool oldValue, bool newValue) + { + } + public IEnumerable GetContainingIds() => [MailCopy.UniqueId]; public IEnumerable GetSelectedMailItems() diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 3e48d985..03f3e7f2 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; -using Wino.Core.Domain.Interfaces; namespace Wino.Mail.ViewModels.Data; @@ -13,7 +13,6 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, { private readonly string _threadId; - private readonly List _threadEmails = []; private bool _disposed; [ObservableProperty] @@ -26,35 +25,37 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, /// /// Gets the number of emails in this thread /// - public int EmailCount => _threadEmails.Count; + public int EmailCount => ThreadEmails.Count; /// /// Gets the latest email's subject for display /// - public string Subject => _threadEmails + public string Subject => ThreadEmails .OrderByDescending(e => e.MailCopy?.CreationDate) .FirstOrDefault()?.MailCopy?.Subject; /// /// Gets the latest email's sender name for display /// - public string FromName => _threadEmails + public string FromName => ThreadEmails .OrderByDescending(e => e.MailCopy?.CreationDate) .FirstOrDefault()?.MailCopy?.SenderContact.Name; /// /// Gets the latest email's creation date for sorting /// - public DateTime CreationDate => _threadEmails + public DateTime CreationDate => ThreadEmails .OrderByDescending(e => e.MailCopy?.CreationDate) .FirstOrDefault()?.MailCopy?.CreationDate ?? DateTime.MinValue; /// - /// Gets all emails in this thread (read-only) + /// Gets all emails in this thread (observable) /// - public IReadOnlyList ThreadEmails => _threadEmails.AsReadOnly(); + /// + [ObservableProperty] + public partial ObservableCollection ThreadEmails { get; set; } = []; - public MailItemViewModel LatestMailViewModel => _threadEmails.OrderByDescending(e => e.MailCopy?.CreationDate).FirstOrDefault()!; + public MailItemViewModel LatestMailViewModel => ThreadEmails.OrderByDescending(e => e.MailCopy?.CreationDate).FirstOrDefault()!; public ThreadMailItemViewModel(string threadId) { @@ -68,7 +69,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, if (disposing) { - _threadEmails.Clear(); + ThreadEmails.Clear(); } _disposed = true; @@ -86,6 +87,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, OnPropertyChanged(nameof(FromName)); OnPropertyChanged(nameof(CreationDate)); OnPropertyChanged(nameof(LatestMailViewModel)); + OnPropertyChanged(nameof(ThreadEmails)); } @@ -97,7 +99,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, if (email.MailCopy.ThreadId != _threadId) throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'"); - _threadEmails.Add(email); + ThreadEmails.Add(email); NotifyPropertyChanges(); } @@ -106,7 +108,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, /// public void RemoveEmail(MailItemViewModel email) { - if (_threadEmails.Remove(email)) + if (ThreadEmails.Remove(email)) { NotifyPropertyChanges(); } @@ -115,7 +117,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, /// /// Checks if this thread contains an email with the specified unique ID /// - public bool HasUniqueId(Guid uniqueId) => _threadEmails.Any(email => email.MailCopy.UniqueId == uniqueId); + public bool HasUniqueId(Guid uniqueId) => ThreadEmails.Any(email => email.MailCopy.UniqueId == uniqueId); public IEnumerable GetContainingIds() => ThreadEmails.Select(a => a.MailCopy.UniqueId); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 9bf25549..d9d6476c 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -753,7 +753,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, try { - _ = MailCollection.ClearAsync(); + await MailCollection.ClearAsync(); if (ActiveFolder == null) return; diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs index 39a16dfb..e8b74947 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs @@ -1,4 +1,5 @@ using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Wino.Mail.ViewModels.Data; namespace Wino.Mail.WinUI.Controls.ListView; @@ -7,34 +8,70 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView { public bool IsAllSelected => Items.Count == SelectedItems.Count; - protected override DependencyObject GetContainerForItemOverride() => new WinoListViewItem(); + public WinoListView() + { + ChoosingItemContainer += WinoListView_ChoosingItemContainer; + } + + private void WinoListView_ChoosingItemContainer(ListViewBase sender, ChoosingItemContainerEventArgs args) + { + if (args.Item is ThreadMailItemViewModel) + { + args.ItemContainer = new WinoThreadMailItemViewModelListViewItem(); + } + else if (args.Item is MailItemViewModel) + { + args.ItemContainer = new WinoMailItemViewModelListViewItem(); + } + + // Handle the preparation in PrepareContainerForItemOverride + args.IsContainerPrepared = false; + } + + protected override void PrepareContainerForItemOverride(DependencyObject element, object item) + { + if (item is MailItemViewModel mailItemViewModel && element is WinoMailItemViewModelListViewItem container) + { + // Ensure the container's selection state matches the model's state + // This is crucial for virtualization scenarios where containers are recycled + + container.IsSelected = mailItemViewModel.IsSelected; + } + else if (item is ThreadMailItemViewModel threadMailItemViewModel && element is WinoThreadMailItemViewModelListViewItem threadContainer) + { + threadContainer.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded; + } + + base.PrepareContainerForItemOverride(element, item); + } public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel) { - WinoListViewItem? itemContainer = null; + WinoMailItemViewModelListViewItem? itemContainer = null; + WinoThreadMailItemViewModelListViewItem? threadContainer = null; foreach (var item in Items) { if (item is MailItemViewModel mailItem && mailItem.Id == mailItemViewModel.Id) { - itemContainer = ContainerFromItem(mailItemViewModel) as WinoListViewItem; + itemContainer = ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem; break; } else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailItemViewModel.MailCopy.UniqueId)) { - itemContainer = ContainerFromItem(threadMailItemViewModel) as WinoListViewItem; + threadContainer = ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem; // Try to get the inner WinoListView. - if (itemContainer != null) + if (threadContainer != null) { - itemContainer.IsExpanded = true; + threadContainer.IsThreadExpanded = true; - var innerListViewControl = itemContainer.GetWinoListViewControl(); + var innerListViewControl = threadContainer.GetWinoListViewControl(); if (innerListViewControl != null) { - itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoListViewItem; + itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem; } } @@ -42,8 +79,37 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView } } - itemContainer?.IsSelected = true; + if (itemContainer != null) + { + itemContainer.IsSelected = true; + return true; + } + else if (threadContainer != null) + { + return true; + } - return itemContainer != null; + return false; + } + + public void ChangeSelectionMode(ListViewSelectionMode mode) + { + // Not only this control, but also all inner WinoListView controls should change the selection mode. + // TODO: New threads added after this call won't have the correct selection mode. + + SelectionMode = mode; + + foreach (var item in Items) + { + if (item is ThreadMailItemViewModel) + { + var itemContainer = ContainerFromItem(item) as WinoThreadMailItemViewModelListViewItem; + if (itemContainer != null) + { + var innerListViewControl = itemContainer.GetWinoListViewControl(); + innerListViewControl?.ChangeSelectionMode(mode); + } + } + } } } diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml b/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml index 6aaa2501..14219189 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml @@ -2,14 +2,16 @@ + -