diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 900766c9..2823a992 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -33,6 +33,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient _uniqueIdToMailItemMap = new(); + // Cache uniqueId to ThreadMailItemViewModel for O(1) thread membership checks + private readonly ConcurrentDictionary _uniqueIdToThreadMap = new(); + public event EventHandler MailItemRemoved; public event EventHandler ItemSelectionChanged; @@ -110,6 +113,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient(); - } - _threadIdToItemsMap[threadId].Add(item); + var list = _threadIdToItemsMap.GetOrAdd(threadId, _ => new List()); + list.Add(item); } else { - if (_threadIdToItemsMap.ContainsKey(threadId)) + if (_threadIdToItemsMap.TryGetValue(threadId, out var list)) { - _threadIdToItemsMap[threadId].Remove(item); - if (_threadIdToItemsMap[threadId].Count == 0) + list.Remove(item); + if (list.Count == 0) { _threadIdToItemsMap.TryRemove(threadId, out _); } @@ -199,12 +204,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient @@ -224,19 +229,20 @@ public class WinoMailCollection : ObservableRecipient, IRecipientThe MailItemViewModel if found, otherwise null. public MailItemViewModel Find(Guid uniqueId) { - // First check the cache for fast lookup + // Fast path: check the cache for O(1) lookup if (_uniqueIdToMailItemMap.TryGetValue(uniqueId, out var cachedMailItem)) { return cachedMailItem; } - // If not in cache, search through all groups + // Fallback: scan all groups and populate caches foreach (var group in _mailItemSource) { foreach (var item in group) { if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == uniqueId) { + _uniqueIdToMailItemMap[uniqueId] = mailItem; return mailItem; } else if (item is ThreadMailItemViewModel threadItem) @@ -244,6 +250,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient e.MailCopy.UniqueId == uniqueId); if (foundInThread != null) { + _uniqueIdToMailItemMap[uniqueId] = foundInThread; + _uniqueIdToThreadMap[uniqueId] = threadItem; return foundInThread; } } @@ -320,15 +328,21 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { - var newMailItem = new MailItemViewModel(addedItem); threadViewModel.AddEmail(newMailItem); }); // Update ThreadId cache after modifying the thread UpdateThreadIdCache(threadViewModel, true); + // Update caches for the new mail item (use the actual instance, not a throwaway) + MailCopyIdHashSet.TryAdd(addedItem.UniqueId, true); + _uniqueIdToMailItemMap[addedItem.UniqueId] = newMailItem; + _uniqueIdToThreadMap[addedItem.UniqueId] = threadViewModel; + var newGroupKey = GetGroupingKey(threadViewModel); if (!existingGroupKey.Equals(newGroupKey)) @@ -339,8 +353,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; }); } - - UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true); } private async Task HandleNewThreadAsync(ObservableGroup group, MailItemViewModel item, MailCopy addedItem) @@ -459,6 +471,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient ?? items.ToList(); @@ -630,34 +643,19 @@ public class WinoMailCollection : ObservableRecipient, IRecipient e.MailCopy.UniqueId == removeItem.UniqueId); + if (removalItem == null) return; - for (int k = 0; k < group.Count; k++) + // Update ThreadId cache before modifying the thread + UpdateThreadIdCache(threadMailItemViewModel, false); + + await ExecuteUIThread(() => { threadMailItemViewModel.RemoveEmail(removalItem); }); + + // Always clean up the removed item's hashes (fixes leak when thread converts to single) + UpdateUniqueIdHashes(removalItem, false); + + // Update ThreadId cache after modifying the thread + if (threadMailItemViewModel.EmailCount > 0) { - var item = group[k]; + UpdateThreadIdCache(threadMailItemViewModel, true); + } - if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(removeItem.UniqueId)) + if (threadMailItemViewModel.EmailCount == 1) + { + // Convert to single item. + var singleViewModel = threadMailItemViewModel.ThreadEmails.First(); + var groupKey = GetGroupingKey(singleViewModel); + + await RemoveItemInternalAsync(group, threadMailItemViewModel); + await InsertItemInternalAsync(groupKey, singleViewModel); + + // If thread->single conversion is being done, we should ignore it for non-draft items. + // eg. Deleting a reply message from draft folder. Single non-draft item should not be re-added. + if (PruneSingleNonDraftItems && !singleViewModel.IsDraft) { - var removalItem = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == removeItem.UniqueId); - - if (removalItem == null) return; - - // Threads' Id is equal to the last item they hold. - // We can't do Id check here because that'd remove the whole thread. - - /* Remove item from the thread. - * If thread had 1 item inside: - * -> Remove the thread and insert item as single item. - * If thread had 0 item inside: - * -> Remove the thread. - */ - - var oldGroupKey = GetGroupingKey(threadMailItemViewModel); - - // Update ThreadId cache before modifying the thread - UpdateThreadIdCache(threadMailItemViewModel, false); - - await ExecuteUIThread(() => { threadMailItemViewModel.RemoveEmail(removalItem); }); - - // Update ThreadId cache after modifying the thread - if (threadMailItemViewModel.EmailCount > 0) + var newGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); + if (newGroup != null) { - UpdateThreadIdCache(threadMailItemViewModel, true); + await RemoveItemInternalAsync(newGroup, singleViewModel); } + } + } + else if (threadMailItemViewModel.EmailCount == 0) + { + await RemoveItemInternalAsync(group, threadMailItemViewModel); + } + } + else + { + // Standalone item - use cached lookup. + IMailListItem mailItem = null; + ObservableGroup group = null; - if (threadMailItemViewModel.EmailCount == 1) + if (_uniqueIdToMailItemMap.TryGetValue(removeItem.UniqueId, out var cachedItem)) + { + mailItem = cachedItem; + group = FindGroupContainingItem(mailItem); + } + + // Fallback to scan if not in cache + if (mailItem == null || group == null) + { + for (int i = 0; i < _mailItemSource.Count; i++) + { + var g = _mailItemSource[i]; + for (int k = 0; k < g.Count; k++) { - // Convert to single item. - - var singleViewModel = threadMailItemViewModel.ThreadEmails.First(); - var groupKey = GetGroupingKey(singleViewModel); - - await RemoveItemInternalAsync(group, threadMailItemViewModel); - await InsertItemInternalAsync(groupKey, singleViewModel); - - // If thread->single conversion is being done, we should ignore it for non-draft items. - // eg. Deleting a reply message from draft folder. Single non-draft item should not be re-added. - - if (PruneSingleNonDraftItems && !singleViewModel.IsDraft) + if (g[k] is MailItemViewModel mvm && mvm.MailCopy.UniqueId == removeItem.UniqueId) { - // This item should not be here anymore. - // It's basically a reply mail in Draft folder. - var newGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); - - if (newGroup != null) - { - await RemoveItemInternalAsync(newGroup, singleViewModel); - } + mailItem = mvm; + group = g; + break; } } - else if (threadMailItemViewModel.EmailCount == 0) - { - await RemoveItemInternalAsync(group, threadMailItemViewModel); - } - else - { - // Item inside the thread is removed - update hash - UpdateUniqueIdHashes(removalItem, false); - } - - shouldExit = true; - break; + if (mailItem != null) break; } - else if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.UniqueId == removeItem.UniqueId) - { - await RemoveItemInternalAsync(group, item); + } - shouldExit = true; - - break; - } + if (mailItem != null && group != null) + { + await RemoveItemInternalAsync(group, mailItem); } } @@ -1045,7 +1036,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = true, true); - public Task UnselectAllAsync(IMailListItem exceptItem = null) => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false && a != exceptItem, true); + public Task UnselectAllAsync(IMailListItem exceptItem = null) => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a != exceptItem) a.IsSelected = false; }, true); public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true); private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action);