2025-10-25 10:54:38 +02:00
|
|
|
using System;
|
2025-10-31 19:53:31 +01:00
|
|
|
using System.Collections.Concurrent;
|
2025-10-25 10:54:38 +02:00
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
2026-04-06 11:21:51 +02:00
|
|
|
using System.Threading;
|
2025-10-25 10:54:38 +02:00
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using CommunityToolkit.Mvvm.Collections;
|
2025-10-26 14:53:22 +01:00
|
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
|
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
|
|
|
using MoreLinq.Extensions;
|
2025-10-25 10:54:38 +02:00
|
|
|
using Serilog;
|
|
|
|
|
using Wino.Core.Domain.Entities.Mail;
|
|
|
|
|
using Wino.Core.Domain.Enums;
|
|
|
|
|
using Wino.Core.Domain.Interfaces;
|
|
|
|
|
using Wino.Mail.ViewModels.Data;
|
2025-10-26 14:53:22 +01:00
|
|
|
using Wino.Messaging.Client.Mails;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
namespace Wino.Mail.ViewModels.Collections;
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage>
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
// We cache each mail copy id for faster access on updates.
|
|
|
|
|
// If the item provider here for update or removal doesn't exist here
|
|
|
|
|
// we can ignore the operation.
|
|
|
|
|
|
2025-10-31 19:53:31 +01:00
|
|
|
public ConcurrentDictionary<Guid, bool> MailCopyIdHashSet = [];
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
// Cache ThreadIds to quickly find items that should be threaded together
|
2025-10-31 19:53:31 +01:00
|
|
|
private readonly ConcurrentDictionary<string, List<IMailListItem>> _threadIdToItemsMap = new();
|
2025-10-26 23:35:09 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Cache item to group mapping for faster lookups
|
2025-10-31 19:53:31 +01:00
|
|
|
private readonly ConcurrentDictionary<IMailListItem, ObservableGroup<object, IMailListItem>> _itemToGroupMap = new();
|
2025-10-31 00:51:27 +01:00
|
|
|
|
|
|
|
|
// Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups
|
2025-10-31 19:53:31 +01:00
|
|
|
private readonly ConcurrentDictionary<Guid, MailItemViewModel> _uniqueIdToMailItemMap = new();
|
2025-10-31 00:51:27 +01:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Cache uniqueId to ThreadMailItemViewModel for O(1) thread membership checks
|
|
|
|
|
private readonly ConcurrentDictionary<Guid, ThreadMailItemViewModel> _uniqueIdToThreadMap = new();
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
public event EventHandler<MailItemViewModel> MailItemRemoved;
|
2025-10-26 14:53:22 +01:00
|
|
|
public event EventHandler ItemSelectionChanged;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
private ListItemComparer listComparer = new();
|
|
|
|
|
|
|
|
|
|
private readonly ObservableGroupedCollection<object, IMailListItem> _mailItemSource = new ObservableGroupedCollection<object, IMailListItem>();
|
2026-04-06 11:21:51 +02:00
|
|
|
private readonly SemaphoreSlim _mutationGate = new(1, 1);
|
2026-04-11 10:54:14 +02:00
|
|
|
private int _selectionNotificationSuppressionCount;
|
|
|
|
|
private bool _selectionNotificationPending;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
public ReadOnlyObservableGroupedCollection<object, IMailListItem> MailItems { get; }
|
|
|
|
|
|
2025-11-01 12:35:47 +01:00
|
|
|
private SortingOptionType _sortingType;
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Property that defines how the item sorting should be done in the collection.
|
|
|
|
|
/// </summary>
|
2025-11-01 12:35:47 +01:00
|
|
|
public SortingOptionType SortingType
|
|
|
|
|
{
|
|
|
|
|
get => _sortingType;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
_sortingType = value;
|
|
|
|
|
// Update the comparer's sort mode when sorting type changes
|
|
|
|
|
listComparer.SortByName = value == SortingOptionType.Sender;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets or sets the grouping type for emails.
|
|
|
|
|
/// Note: WinoMailCollection groups automatically on the UI, so this just affects the grouping key logic.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public EmailGroupingType GroupingType
|
|
|
|
|
{
|
|
|
|
|
get => SortingType == SortingOptionType.ReceiveDate ? EmailGroupingType.ByDate : EmailGroupingType.ByFromName;
|
|
|
|
|
set => SortingType = value == EmailGroupingType.ByDate ? SortingOptionType.ReceiveDate : SortingOptionType.Sender;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Automatically deletes single mail items after the delete operation or thread->single transition.
|
|
|
|
|
/// This is useful when reply draft is discarded in the thread. Only enabled for Draft folder for now.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool PruneSingleNonDraftItems { get; set; }
|
|
|
|
|
|
|
|
|
|
public int Count => _mailItemSource.Count;
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
public bool IsAllSelected
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
return AllItemsCount == SelectedItemsCount;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
public IDispatcher CoreDispatcher { get; set; }
|
|
|
|
|
|
|
|
|
|
public WinoMailCollection()
|
|
|
|
|
{
|
|
|
|
|
MailItems = new ReadOnlyObservableGroupedCollection<object, IMailListItem>(_mailItemSource);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
2025-11-01 12:35:47 +01:00
|
|
|
// Initialize sorting type to default (date-based)
|
|
|
|
|
SortingType = SortingOptionType.ReceiveDate;
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
Messenger.Register<SelectedItemsChangedMessage>(this);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-27 01:43:36 +01:00
|
|
|
public void Cleanup()
|
|
|
|
|
{
|
|
|
|
|
Messenger.Unregister<SelectedItemsChangedMessage>(this);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
public async Task ClearAsync()
|
|
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
await RunSerializedAsync(async () =>
|
2025-10-26 14:53:22 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
await ExecuteUIThread(() =>
|
2026-02-10 01:03:03 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
foreach (var group in _mailItemSource)
|
2026-02-10 01:03:03 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
foreach (var item in group)
|
2026-02-10 01:03:03 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
if (item is ThreadMailItemViewModel threadItem)
|
|
|
|
|
{
|
|
|
|
|
threadItem.UnregisterThreadEmailPropertyChangedHandlers();
|
|
|
|
|
}
|
2026-02-10 01:03:03 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
_mailItemSource.Clear();
|
|
|
|
|
MailCopyIdHashSet.Clear();
|
|
|
|
|
_threadIdToItemsMap.Clear();
|
|
|
|
|
_itemToGroupMap.Clear();
|
|
|
|
|
_uniqueIdToMailItemMap.Clear();
|
|
|
|
|
_uniqueIdToThreadMap.Clear();
|
|
|
|
|
});
|
2025-10-26 14:53:22 +01:00
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
private object GetGroupingKey(IMailListItem mailItem)
|
|
|
|
|
{
|
|
|
|
|
if (SortingType == SortingOptionType.ReceiveDate)
|
|
|
|
|
return mailItem.CreationDate.ToLocalTime().Date;
|
|
|
|
|
else
|
|
|
|
|
return mailItem.FromName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
if (isAdd)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
if (itemContainer is MailItemViewModel mailItemVM)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
MailCopyIdHashSet.TryAdd(mailItemVM.MailCopy.UniqueId, true);
|
|
|
|
|
_uniqueIdToMailItemMap[mailItemVM.MailCopy.UniqueId] = mailItemVM;
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
2026-02-08 01:41:09 +01:00
|
|
|
else if (itemContainer is ThreadMailItemViewModel threadVM)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
foreach (var email in threadVM.ThreadEmails)
|
2025-10-31 19:53:31 +01:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
MailCopyIdHashSet.TryAdd(email.MailCopy.UniqueId, true);
|
|
|
|
|
_uniqueIdToMailItemMap[email.MailCopy.UniqueId] = email;
|
|
|
|
|
_uniqueIdToThreadMap[email.MailCopy.UniqueId] = threadVM;
|
2025-10-31 19:53:31 +01:00
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 01:41:09 +01:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
foreach (var id in itemContainer.GetContainingIds())
|
|
|
|
|
{
|
|
|
|
|
MailCopyIdHashSet.TryRemove(id, out _);
|
|
|
|
|
_uniqueIdToMailItemMap.TryRemove(id, out _);
|
|
|
|
|
_uniqueIdToThreadMap.TryRemove(id, out _);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
private void UpdateThreadIdCache(IMailListItem item, bool isAdd)
|
|
|
|
|
{
|
|
|
|
|
var threadIds = GetThreadIdsFromItem(item);
|
|
|
|
|
|
|
|
|
|
foreach (var threadId in threadIds)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(threadId)) continue;
|
|
|
|
|
|
|
|
|
|
if (isAdd)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
var list = _threadIdToItemsMap.GetOrAdd(threadId, _ => new List<IMailListItem>());
|
|
|
|
|
list.Add(item);
|
2025-10-26 23:35:09 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
if (_threadIdToItemsMap.TryGetValue(threadId, out var list))
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
list.Remove(item);
|
|
|
|
|
if (list.Count == 0)
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2025-10-31 19:53:31 +01:00
|
|
|
_threadIdToItemsMap.TryRemove(threadId, out _);
|
2025-10-26 23:35:09 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private IEnumerable<string> 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:03:52 +01:00
|
|
|
private IMailListItem FindThreadableItem(string threadId, Guid? excludedUniqueId = null, IMailListItem excludedItem = null)
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
if (string.IsNullOrEmpty(threadId) || !_threadIdToItemsMap.TryGetValue(threadId, out var items))
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:03:52 +01:00
|
|
|
foreach (var item in items)
|
|
|
|
|
{
|
|
|
|
|
if (ReferenceEquals(item, excludedItem))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (excludedUniqueId.HasValue)
|
|
|
|
|
{
|
|
|
|
|
if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == excludedUniqueId.Value)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item is ThreadMailItemViewModel threadItem && threadItem.HasUniqueId(excludedUniqueId.Value))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2025-10-26 23:35:09 +01:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 11:26:51 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Checks if a ThreadId exists in the collection.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="threadId">The ThreadId to check for.</param>
|
|
|
|
|
/// <returns>True if the ThreadId exists in the collection, false otherwise.</returns>
|
|
|
|
|
public bool ContainsThreadId(string threadId)
|
|
|
|
|
{
|
|
|
|
|
return !string.IsNullOrEmpty(threadId) && _threadIdToItemsMap.ContainsKey(threadId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 11:34:50 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Checks whether a mail with the given UniqueId currently exists in this collection.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool ContainsMailUniqueId(Guid uniqueId) => MailCopyIdHashSet.ContainsKey(uniqueId);
|
|
|
|
|
|
2025-11-12 15:44:43 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Finds a MailItemViewModel by its UniqueId, searching through all items including those inside threads.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="uniqueId">The UniqueId of the mail item to find.</param>
|
|
|
|
|
/// <returns>The MailItemViewModel if found, otherwise null.</returns>
|
|
|
|
|
public MailItemViewModel Find(Guid uniqueId)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
// Fast path: check the cache for O(1) lookup
|
2025-11-12 15:44:43 +01:00
|
|
|
if (_uniqueIdToMailItemMap.TryGetValue(uniqueId, out var cachedMailItem))
|
|
|
|
|
{
|
|
|
|
|
return cachedMailItem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Fallback: scan all groups and populate caches
|
2025-11-12 15:44:43 +01:00
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var item in group)
|
|
|
|
|
{
|
|
|
|
|
if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == uniqueId)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
_uniqueIdToMailItemMap[uniqueId] = mailItem;
|
2025-11-12 15:44:43 +01:00
|
|
|
return mailItem;
|
|
|
|
|
}
|
|
|
|
|
else if (item is ThreadMailItemViewModel threadItem)
|
|
|
|
|
{
|
|
|
|
|
var foundInThread = threadItem.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueId);
|
|
|
|
|
if (foundInThread != null)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
_uniqueIdToMailItemMap[uniqueId] = foundInThread;
|
|
|
|
|
_uniqueIdToThreadMap[uniqueId] = threadItem;
|
2025-11-12 15:44:43 +01:00
|
|
|
return foundInThread;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 10:54:14 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
UpdateUniqueIdHashes(mailItem, true);
|
2025-10-26 23:35:09 +01:00
|
|
|
UpdateThreadIdCache(mailItem, true);
|
2025-10-26 14:53:22 +01:00
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
|
|
|
|
_mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer);
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Update item-to-group cache
|
|
|
|
|
var group = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
|
|
|
|
if (group != null)
|
|
|
|
|
{
|
|
|
|
|
_itemToGroupMap[mailItem] = group;
|
|
|
|
|
}
|
2025-10-26 14:53:22 +01:00
|
|
|
});
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-10 01:03:03 +01:00
|
|
|
private async Task RemoveItemInternalAsync(ObservableGroup<object, IMailListItem> group, IMailListItem mailItem, bool detachThreadHandlers = true)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
UpdateUniqueIdHashes(mailItem, false);
|
2025-10-26 23:35:09 +01:00
|
|
|
UpdateThreadIdCache(mailItem, false);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
if (mailItem is MailItemViewModel singleMailItem)
|
|
|
|
|
{
|
|
|
|
|
MailItemRemoved?.Invoke(this, singleMailItem);
|
|
|
|
|
}
|
|
|
|
|
else if (mailItem is ThreadMailItemViewModel threadViewModel)
|
|
|
|
|
{
|
|
|
|
|
foreach (var threadMailItem in threadViewModel.ThreadEmails)
|
|
|
|
|
{
|
|
|
|
|
MailItemRemoved?.Invoke(this, threadMailItem);
|
|
|
|
|
}
|
2026-02-10 01:03:03 +01:00
|
|
|
|
|
|
|
|
if (detachThreadHandlers)
|
|
|
|
|
{
|
|
|
|
|
threadViewModel.UnregisterThreadEmailPropertyChangedHandlers();
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
await ExecuteUIThread(() =>
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-26 14:53:22 +01:00
|
|
|
group.Remove(mailItem);
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Remove from item-to-group cache
|
2025-10-31 19:53:31 +01:00
|
|
|
_itemToGroupMap.TryRemove(mailItem, out _);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
|
|
|
|
if (group.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
_mailItemSource.RemoveGroup(group.Key);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task HandleThreadingAsync(ObservableGroup<object, IMailListItem> group, IMailListItem item, MailCopy addedItem)
|
|
|
|
|
{
|
|
|
|
|
if (item is ThreadMailItemViewModel threadViewModel)
|
|
|
|
|
{
|
|
|
|
|
await HandleExistingThreadAsync(group, threadViewModel, addedItem);
|
|
|
|
|
}
|
|
|
|
|
else if (item is MailItemViewModel mailViewModel)
|
|
|
|
|
{
|
|
|
|
|
await HandleNewThreadAsync(group, mailViewModel, addedItem);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task HandleExistingThreadAsync(ObservableGroup<object, IMailListItem> group, ThreadMailItemViewModel threadViewModel, MailCopy addedItem)
|
|
|
|
|
{
|
|
|
|
|
var existingGroupKey = GetGroupingKey(threadViewModel);
|
|
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
// Update ThreadId cache before modifying the thread
|
|
|
|
|
UpdateThreadIdCache(threadViewModel, false);
|
|
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
var newMailItem = new MailItemViewModel(addedItem);
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
2025-10-25 10:54:38 +02:00
|
|
|
threadViewModel.AddEmail(newMailItem);
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
// Update ThreadId cache after modifying the thread
|
|
|
|
|
UpdateThreadIdCache(threadViewModel, true);
|
|
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// 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;
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
var newGroupKey = GetGroupingKey(threadViewModel);
|
|
|
|
|
|
|
|
|
|
if (!existingGroupKey.Equals(newGroupKey))
|
|
|
|
|
{
|
|
|
|
|
await MoveThreadToNewGroupAsync(group, threadViewModel, newGroupKey);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2025-11-01 12:11:05 +01:00
|
|
|
await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; });
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task HandleNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem)
|
|
|
|
|
{
|
|
|
|
|
if (item.MailCopy.UniqueId == addedItem.UniqueId)
|
|
|
|
|
{
|
2026-03-14 21:03:52 +01:00
|
|
|
var existingItemContainer = GetMailItemContainer(addedItem.UniqueId);
|
|
|
|
|
await UpdateExistingItemAsync(existingItemContainer, addedItem);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
await CreateNewThreadAsync(group, item, addedItem);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task MoveThreadToNewGroupAsync(ObservableGroup<object, IMailListItem> currentGroup, ThreadMailItemViewModel threadViewModel, object newGroupKey)
|
|
|
|
|
{
|
2026-02-10 01:03:03 +01:00
|
|
|
await RemoveItemInternalAsync(currentGroup, threadViewModel, detachThreadHandlers: false);
|
2025-10-26 14:53:22 +01:00
|
|
|
await InsertItemInternalAsync(newGroupKey, threadViewModel);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task CreateNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem)
|
|
|
|
|
{
|
|
|
|
|
var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
|
|
|
|
threadViewModel.AddEmail(item);
|
|
|
|
|
threadViewModel.AddEmail(new MailItemViewModel(addedItem));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var newGroupKey = GetGroupingKey(threadViewModel);
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
await RemoveItemInternalAsync(group, item);
|
|
|
|
|
await InsertItemInternalAsync(newGroupKey, threadViewModel);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
public Task AddAsync(MailCopy addedItem)
|
|
|
|
|
=> RunSerializedAsync(() => AddInternalAsync(addedItem));
|
|
|
|
|
|
|
|
|
|
private async Task AddInternalAsync(MailCopy addedItem)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-26 23:35:09 +01:00
|
|
|
// First check if this is an update to an existing item
|
2025-10-31 19:53:31 +01:00
|
|
|
if (MailCopyIdHashSet.ContainsKey(addedItem.UniqueId))
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-26 23:35:09 +01:00
|
|
|
// Find and update the existing item
|
|
|
|
|
var existingItemContainer = GetMailItemContainer(addedItem.UniqueId);
|
|
|
|
|
if (existingItemContainer?.ItemViewModel != null)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-03-14 21:03:52 +01:00
|
|
|
await UpdateExistingItemAsync(existingItemContainer, addedItem);
|
2025-10-26 23:35:09 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
// Check if this item should be threaded with an existing item
|
|
|
|
|
if (!string.IsNullOrEmpty(addedItem.ThreadId))
|
|
|
|
|
{
|
|
|
|
|
var threadableItem = FindThreadableItem(addedItem.ThreadId);
|
|
|
|
|
if (threadableItem != null)
|
|
|
|
|
{
|
|
|
|
|
// Find the group containing this item
|
|
|
|
|
var targetGroup = FindGroupContainingItem(threadableItem);
|
|
|
|
|
if (targetGroup != null)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-26 23:35:09 +01:00
|
|
|
await HandleThreadingAsync(targetGroup, threadableItem, addedItem);
|
2025-10-25 10:54:38 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
// No threading needed, add as new item
|
2025-10-25 10:54:38 +02:00
|
|
|
await AddNewItemAsync(addedItem);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 23:35:09 +01:00
|
|
|
private ObservableGroup<object, IMailListItem> FindGroupContainingItem(IMailListItem item)
|
|
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
// Try cache first
|
|
|
|
|
if (_itemToGroupMap.TryGetValue(item, out var cachedGroup))
|
|
|
|
|
{
|
2026-02-11 11:34:50 +01:00
|
|
|
// Cache can become stale during concurrent list refreshes/moves.
|
|
|
|
|
// Validate before returning so we don't mutate a detached group.
|
|
|
|
|
if (_mailItemSource.Contains(cachedGroup) && cachedGroup.Contains(item))
|
|
|
|
|
{
|
|
|
|
|
return cachedGroup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_itemToGroupMap.TryRemove(item, out _);
|
2025-10-31 00:51:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to search if not in cache
|
2025-10-26 23:35:09 +01:00
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
if (group.Contains(item))
|
|
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
_itemToGroupMap[item] = group;
|
2025-10-26 23:35:09 +01:00
|
|
|
return group;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
private async Task AddNewItemAsync(MailCopy addedItem)
|
|
|
|
|
{
|
|
|
|
|
var newMailItem = new MailItemViewModel(addedItem);
|
|
|
|
|
var groupKey = GetGroupingKey(newMailItem);
|
2025-10-26 14:53:22 +01:00
|
|
|
await InsertItemInternalAsync(groupKey, newMailItem);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:03:52 +01:00
|
|
|
private async Task ReinsertUpdatedItemAsync(MailCopy updatedItem, bool isSelected, bool isBusy)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
await RemoveInternalAsync(updatedItem);
|
|
|
|
|
await AddInternalAsync(updatedItem);
|
2026-03-14 21:03:52 +01:00
|
|
|
|
|
|
|
|
var updatedContainer = GetMailItemContainer(updatedItem.UniqueId);
|
|
|
|
|
if (updatedContainer?.ItemViewModel == null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
|
|
|
|
updatedContainer.ItemViewModel.IsSelected = isSelected;
|
|
|
|
|
updatedContainer.ItemViewModel.IsBusy = isBusy;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task UpdateExistingItemAsync(MailItemContainer itemContainer,
|
|
|
|
|
MailCopy updatedItem,
|
2026-04-07 16:48:46 +02:00
|
|
|
EntityUpdateSource mailUpdateSource = EntityUpdateSource.Server,
|
2026-03-14 21:03:52 +01:00
|
|
|
MailCopyChangeFlags changeHint = MailCopyChangeFlags.None)
|
|
|
|
|
{
|
|
|
|
|
if (itemContainer?.ItemViewModel == null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var existingItem = itemContainer.ItemViewModel;
|
|
|
|
|
var threadOwner = itemContainer.ThreadViewModel as IMailListItem ?? existingItem;
|
|
|
|
|
var wasSelected = existingItem.IsSelected;
|
|
|
|
|
MailCopyChangeFlags appliedChanges = MailCopyChangeFlags.None;
|
2025-11-01 21:46:23 +01:00
|
|
|
|
|
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
2026-03-14 21:03:52 +01:00
|
|
|
UpdateUniqueIdHashes(existingItem, false);
|
|
|
|
|
UpdateThreadIdCache(threadOwner, false);
|
|
|
|
|
|
|
|
|
|
itemContainer.ThreadViewModel?.SuspendChildPropertyNotifications();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
appliedChanges = existingItem.UpdateFrom(updatedItem, changeHint);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
itemContainer.ThreadViewModel?.ResumeChildPropertyNotifications();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 16:48:46 +02:00
|
|
|
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
|
2026-03-14 21:03:52 +01:00
|
|
|
|
|
|
|
|
UpdateUniqueIdHashes(existingItem, true);
|
|
|
|
|
UpdateThreadIdCache(threadOwner, true);
|
|
|
|
|
|
|
|
|
|
if (itemContainer.ThreadViewModel != null)
|
|
|
|
|
{
|
|
|
|
|
_uniqueIdToThreadMap[existingItem.MailCopy.UniqueId] = itemContainer.ThreadViewModel;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_uniqueIdToThreadMap.TryRemove(existingItem.MailCopy.UniqueId, out _);
|
|
|
|
|
}
|
2025-11-01 12:11:05 +01:00
|
|
|
});
|
2025-11-01 21:46:23 +01:00
|
|
|
|
2026-03-14 21:03:52 +01:00
|
|
|
if ((appliedChanges & MailCopyChangeFlags.ThreadId) != 0)
|
|
|
|
|
{
|
|
|
|
|
await ReinsertUpdatedItemAsync(updatedItem, wasSelected, existingItem.IsBusy);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
|
|
|
|
|
{
|
|
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
|
|
|
|
itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Adds multiple emails to the collection.
|
|
|
|
|
/// </summary>
|
2026-04-06 11:21:51 +02:00
|
|
|
public Task AddRangeAsync(IEnumerable<MailItemViewModel> items, bool clearIdCache)
|
|
|
|
|
=> RunSerializedAsync(() => AddRangeInternalAsync(items, clearIdCache));
|
|
|
|
|
|
|
|
|
|
private async Task AddRangeInternalAsync(IEnumerable<MailItemViewModel> items, bool clearIdCache)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
if (clearIdCache)
|
|
|
|
|
{
|
|
|
|
|
MailCopyIdHashSet.Clear();
|
2025-10-26 23:35:09 +01:00
|
|
|
_threadIdToItemsMap.Clear();
|
2026-02-11 11:34:50 +01:00
|
|
|
_itemToGroupMap.Clear();
|
|
|
|
|
_uniqueIdToMailItemMap.Clear();
|
2026-02-08 01:41:09 +01:00
|
|
|
_uniqueIdToThreadMap.Clear();
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
var itemsList = items as List<MailItemViewModel> ?? items.ToList();
|
|
|
|
|
if (itemsList.Count == 0) return;
|
|
|
|
|
|
|
|
|
|
var itemsToAdd = new List<IMailListItem>(itemsList.Count);
|
|
|
|
|
var processedItems = new HashSet<MailItemViewModel>(itemsList.Count);
|
|
|
|
|
var itemsToUpdate = new List<(MailItemViewModel existing, MailCopy updated)>();
|
|
|
|
|
var threadingOperations = new List<(ObservableGroup<object, IMailListItem> group, IMailListItem item, MailCopy addedItem)>();
|
|
|
|
|
|
|
|
|
|
// Build a lookup for existing groups to avoid repeated searches
|
|
|
|
|
var groupLookup = new Dictionary<IMailListItem, ObservableGroup<object, IMailListItem>>(_mailItemSource.Count * 10);
|
|
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var item in group)
|
|
|
|
|
{
|
|
|
|
|
groupLookup[item] = group;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build thread lookup from the batch items
|
|
|
|
|
var batchThreadLookup = new Dictionary<string, List<MailItemViewModel>>();
|
|
|
|
|
foreach (var item in itemsList)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(item.MailCopy.ThreadId))
|
|
|
|
|
{
|
|
|
|
|
if (!batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var list))
|
|
|
|
|
{
|
|
|
|
|
list = new List<MailItemViewModel>();
|
|
|
|
|
batchThreadLookup[item.MailCopy.ThreadId] = list;
|
|
|
|
|
}
|
|
|
|
|
list.Add(item);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-26 23:35:09 +01:00
|
|
|
|
|
|
|
|
// 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
|
2025-10-31 19:53:31 +01:00
|
|
|
if (MailCopyIdHashSet.ContainsKey(item.MailCopy.UniqueId))
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
|
|
|
|
var existingItemContainer = GetMailItemContainer(item.MailCopy.UniqueId);
|
|
|
|
|
if (existingItemContainer?.ItemViewModel != null)
|
|
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
itemsToUpdate.Add((existingItemContainer.ItemViewModel, item.MailCopy));
|
2025-10-26 23:35:09 +01:00
|
|
|
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
|
2025-10-31 00:51:27 +01:00
|
|
|
if (groupLookup.TryGetValue(existingThreadableItem, out var targetGroup))
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
threadingOperations.Add((targetGroup, existingThreadableItem, item.MailCopy));
|
2025-10-26 23:35:09 +01:00
|
|
|
processedItems.Add(item);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Look for other items in the current batch with same ThreadId
|
2025-10-31 00:51:27 +01:00
|
|
|
if (batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var threadableItems) && threadableItems.Count > 1)
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
// Create a new thread with all matching items - defer UI operations
|
2025-10-26 23:35:09 +01:00
|
|
|
var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId);
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Add emails without UI thread for now
|
|
|
|
|
foreach (var threadItem in threadableItems)
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
threadViewModel.AddEmail(threadItem);
|
|
|
|
|
}
|
2025-10-26 23:35:09 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Execute all threading operations in a single UI thread call
|
|
|
|
|
if (threadingOperations.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
foreach (var (group, existingItem, addedItem) in threadingOperations)
|
|
|
|
|
{
|
|
|
|
|
await HandleThreadingAsync(group, existingItem, addedItem);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Execute all updates in a single UI thread call
|
|
|
|
|
if (itemsToUpdate.Count > 0)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
await ExecuteUIThread(() =>
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
foreach (var (existing, updated) in itemsToUpdate)
|
2025-10-26 14:53:22 +01:00
|
|
|
{
|
2025-10-31 00:51:27 +01:00
|
|
|
UpdateUniqueIdHashes(existing, false);
|
2026-01-27 20:37:18 +01:00
|
|
|
existing.UpdateFrom(updated);
|
2025-11-01 12:11:05 +01:00
|
|
|
UpdateUniqueIdHashes(existing, true);
|
2025-10-26 14:53:22 +01:00
|
|
|
}
|
2025-10-31 00:51:27 +01:00
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
// Group items by their grouping key and add them in a single UI thread call
|
|
|
|
|
if (itemsToAdd.Count > 0)
|
|
|
|
|
{
|
2025-10-31 01:41:51 +01:00
|
|
|
var groupedItems = await Task.Run(() => itemsToAdd
|
2025-10-31 00:51:27 +01:00
|
|
|
.GroupBy(GetGroupingKey)
|
2026-04-06 11:21:51 +02:00
|
|
|
.OrderBy(group => group.Key, listComparer)
|
|
|
|
|
.Select(group => new
|
|
|
|
|
{
|
|
|
|
|
Key = group.Key,
|
|
|
|
|
Items = group.OrderBy(item => (object)item, listComparer).ToList()
|
|
|
|
|
})
|
|
|
|
|
.ToList()).ConfigureAwait(false);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
foreach (var groupedItem in groupedItems)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
var groupKey = groupedItem.Key;
|
|
|
|
|
var groupItems = groupedItem.Items;
|
2025-10-31 00:51:27 +01:00
|
|
|
|
|
|
|
|
// Update caches first
|
|
|
|
|
foreach (var item in groupItems)
|
|
|
|
|
{
|
|
|
|
|
UpdateUniqueIdHashes(item, true);
|
|
|
|
|
UpdateThreadIdCache(item, true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
foreach (var item in groupItems)
|
2025-10-31 00:51:27 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
|
|
|
|
if (targetGroup != null)
|
2025-10-31 00:51:27 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
_itemToGroupMap[item] = targetGroup;
|
2025-10-31 00:51:27 +01:00
|
|
|
}
|
2025-10-26 14:53:22 +01:00
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
2025-10-31 00:51:27 +01:00
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
// Fast path: use caches for O(1) lookup
|
2025-10-31 00:51:27 +01:00
|
|
|
if (_uniqueIdToMailItemMap.TryGetValue(uniqueMailId, out var cachedMailItem))
|
|
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
if (_uniqueIdToThreadMap.TryGetValue(uniqueMailId, out var threadVM))
|
2025-10-31 00:51:27 +01:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
return new MailItemContainer(cachedMailItem, threadVM);
|
2025-10-31 00:51:27 +01:00
|
|
|
}
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
return new MailItemContainer(cachedMailItem);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Fallback: scan all groups and populate caches
|
|
|
|
|
for (int i = 0; i < _mailItemSource.Count; i++)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
var group = _mailItemSource[i];
|
|
|
|
|
|
|
|
|
|
for (int k = 0; k < group.Count; k++)
|
|
|
|
|
{
|
|
|
|
|
var item = group[k];
|
|
|
|
|
|
|
|
|
|
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.MailCopy.UniqueId == uniqueMailId)
|
2025-10-31 00:51:27 +01:00
|
|
|
{
|
|
|
|
|
_uniqueIdToMailItemMap[uniqueMailId] = singleMailItemViewModel;
|
2025-10-25 10:54:38 +02:00
|
|
|
return new MailItemContainer(singleMailItemViewModel);
|
2025-10-31 00:51:27 +01:00
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
|
|
|
|
|
{
|
|
|
|
|
var singleItemViewModel = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueMailId);
|
2025-10-31 19:53:31 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
if (singleItemViewModel != null)
|
|
|
|
|
{
|
|
|
|
|
_uniqueIdToMailItemMap[uniqueMailId] = singleItemViewModel;
|
2026-02-08 01:41:09 +01:00
|
|
|
_uniqueIdToThreadMap[uniqueMailId] = threadMailItemViewModel;
|
2025-10-31 00:51:27 +01:00
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
|
|
|
|
return new MailItemContainer(singleItemViewModel, threadMailItemViewModel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Updates thumbnails for all mail items with the specified address.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task UpdateThumbnailsForAddressAsync(string address)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2025-10-26 14:53:22 +01:00
|
|
|
if (CoreDispatcher == null) return Task.CompletedTask;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
return RunSerializedAsync(() => CoreDispatcher.ExecuteOnUIThread(() =>
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var item in group)
|
|
|
|
|
{
|
2025-10-26 14:53:22 +01:00
|
|
|
if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.FromAddress?.Equals(address, StringComparison.OrdinalIgnoreCase) == true)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
mailItemViewModel.ThumbnailUpdatedEvent = !mailItemViewModel.ThumbnailUpdatedEvent;
|
|
|
|
|
}
|
|
|
|
|
else if (item is ThreadMailItemViewModel threadViewModel)
|
|
|
|
|
{
|
|
|
|
|
foreach (var threadMailItem in threadViewModel.ThreadEmails)
|
|
|
|
|
{
|
2025-10-26 14:53:22 +01:00
|
|
|
if (threadMailItem.MailCopy.FromAddress?.Equals(address, StringComparison.OrdinalIgnoreCase) == true)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
|
|
|
|
threadMailItem.ThumbnailUpdatedEvent = !threadMailItem.ThumbnailUpdatedEvent;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 11:21:51 +02:00
|
|
|
}));
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-01-27 20:37:18 +01:00
|
|
|
/// Finds the item container that updated mail copy belongs to and updates it.
|
2025-10-25 10:54:38 +02:00
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="updatedMailCopy">Updated mail copy.</param>
|
|
|
|
|
/// <returns></returns>
|
2026-04-07 16:48:46 +02:00
|
|
|
public Task UpdateMailCopy(MailCopy updatedMailCopy, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None)
|
2026-04-06 11:21:51 +02:00
|
|
|
=> RunSerializedAsync(() =>
|
2026-03-14 21:03:52 +01:00
|
|
|
{
|
2026-04-06 11:21:51 +02:00
|
|
|
var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
if (itemContainer?.ItemViewModel == null)
|
|
|
|
|
{
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return UpdateExistingItemAsync(itemContainer, updatedMailCopy, mailUpdateSource, changedProperties);
|
|
|
|
|
});
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0);
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
public MailItemViewModel GetNextItem(MailCopy mailCopy)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var groupCount = _mailItemSource.Count;
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < groupCount; i++)
|
|
|
|
|
{
|
|
|
|
|
var group = _mailItemSource[i];
|
|
|
|
|
|
|
|
|
|
for (int k = 0; k < group.Count; k++)
|
|
|
|
|
{
|
|
|
|
|
var item = group[k];
|
|
|
|
|
|
|
|
|
|
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.MailCopy.UniqueId == mailCopy.UniqueId)
|
|
|
|
|
{
|
|
|
|
|
if (k + 1 < group.Count)
|
|
|
|
|
{
|
|
|
|
|
return group[k + 1] as MailItemViewModel;
|
|
|
|
|
}
|
|
|
|
|
else if (i + 1 < groupCount)
|
|
|
|
|
{
|
|
|
|
|
return _mailItemSource[i + 1][0] as MailItemViewModel;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailCopy.UniqueId))
|
|
|
|
|
{
|
|
|
|
|
var singleItemViewModel = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == mailCopy.UniqueId);
|
|
|
|
|
|
|
|
|
|
if (singleItemViewModel == null) return null;
|
|
|
|
|
|
|
|
|
|
var singleItemIndex = threadMailItemViewModel.ThreadEmails.ToList().IndexOf(singleItemViewModel);
|
|
|
|
|
|
|
|
|
|
if (singleItemIndex + 1 < threadMailItemViewModel.ThreadEmails.Count)
|
|
|
|
|
{
|
|
|
|
|
return threadMailItemViewModel.ThreadEmails[singleItemIndex + 1];
|
|
|
|
|
}
|
|
|
|
|
else if (i + 1 < groupCount)
|
|
|
|
|
{
|
|
|
|
|
return _mailItemSource[i + 1][0] as MailItemViewModel;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Log.Warning(ex, "Failed to find the next item to select.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
public Task RemoveAsync(MailCopy removeItem)
|
|
|
|
|
=> RunSerializedAsync(() => RemoveInternalAsync(removeItem));
|
|
|
|
|
|
|
|
|
|
private async Task RemoveInternalAsync(MailCopy removeItem)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-02-11 11:34:50 +01:00
|
|
|
var itemContainer = GetMailItemContainer(removeItem.UniqueId);
|
|
|
|
|
|
2025-10-25 10:54:38 +02:00
|
|
|
// This item doesn't exist in the list.
|
2026-02-11 11:34:50 +01:00
|
|
|
if (itemContainer?.ItemViewModel == null) return;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-11 11:34:50 +01:00
|
|
|
if (itemContainer.ThreadViewModel != null)
|
2025-10-25 10:54:38 +02:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
// Item is inside a thread - use cached lookups instead of scanning all groups.
|
2026-02-11 11:34:50 +01:00
|
|
|
var threadMailItemViewModel = itemContainer.ThreadViewModel;
|
2026-02-08 01:41:09 +01:00
|
|
|
var group = FindGroupContainingItem(threadMailItemViewModel);
|
|
|
|
|
if (group == null) return;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-11 11:34:50 +01:00
|
|
|
var removalItem = itemContainer.ItemViewModel;
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Update ThreadId cache before modifying the thread
|
|
|
|
|
UpdateThreadIdCache(threadMailItemViewModel, false);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
await ExecuteUIThread(() => { threadMailItemViewModel.RemoveEmail(removalItem); });
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Always clean up the removed item's hashes (fixes leak when thread converts to single)
|
|
|
|
|
UpdateUniqueIdHashes(removalItem, false);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// Update ThreadId cache after modifying the thread
|
|
|
|
|
if (threadMailItemViewModel.EmailCount > 0)
|
|
|
|
|
{
|
|
|
|
|
UpdateThreadIdCache(threadMailItemViewModel, true);
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
if (threadMailItemViewModel.EmailCount == 1)
|
|
|
|
|
{
|
|
|
|
|
// Convert to single item.
|
|
|
|
|
var singleViewModel = threadMailItemViewModel.ThreadEmails.First();
|
|
|
|
|
var groupKey = GetGroupingKey(singleViewModel);
|
2025-10-26 23:35:09 +01:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
await RemoveItemInternalAsync(group, threadMailItemViewModel);
|
|
|
|
|
await InsertItemInternalAsync(groupKey, singleViewModel);
|
2025-10-25 10:54:38 +02:00
|
|
|
|
2026-02-08 01:41:09 +01:00
|
|
|
// 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 newGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
|
|
|
|
if (newGroup != null)
|
2025-10-26 23:35:09 +01:00
|
|
|
{
|
2026-02-08 01:41:09 +01:00
|
|
|
await RemoveItemInternalAsync(newGroup, singleViewModel);
|
2025-10-26 23:35:09 +01:00
|
|
|
}
|
2026-02-08 01:41:09 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (threadMailItemViewModel.EmailCount == 0)
|
|
|
|
|
{
|
|
|
|
|
await RemoveItemInternalAsync(group, threadMailItemViewModel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-02-11 11:34:50 +01:00
|
|
|
// Standalone item.
|
|
|
|
|
IMailListItem mailItem = itemContainer.ItemViewModel;
|
|
|
|
|
var group = FindGroupContainingItem(mailItem);
|
2025-10-26 23:35:09 +01:00
|
|
|
|
2026-02-11 11:34:50 +01:00
|
|
|
if (group != null)
|
2026-02-08 01:41:09 +01:00
|
|
|
{
|
|
|
|
|
await RemoveItemInternalAsync(group, mailItem);
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-26 14:53:22 +01:00
|
|
|
|
|
|
|
|
await NotifySelectionChangesAsync();
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-27 22:52:26 +01:00
|
|
|
private IEnumerable<IMailListItem> AllItemsIncludingThreads
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var item in group)
|
|
|
|
|
{
|
|
|
|
|
if (item is ThreadMailItemViewModel threadMailItemViewModel)
|
|
|
|
|
{
|
|
|
|
|
foreach (var child in threadMailItemViewModel.ThreadEmails)
|
|
|
|
|
{
|
|
|
|
|
yield return child;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
yield return item;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
private IEnumerable<MailItemViewModel> AllItems
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var item in group)
|
|
|
|
|
{
|
|
|
|
|
if (item is ThreadMailItemViewModel threadMail)
|
|
|
|
|
{
|
|
|
|
|
foreach (var singleItem in threadMail.ThreadEmails)
|
|
|
|
|
{
|
|
|
|
|
yield return singleItem;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-26 23:35:09 +01:00
|
|
|
else if (item is MailItemViewModel mailItemViewModel)
|
|
|
|
|
yield return mailItemViewModel;
|
2025-10-26 14:53:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public IEnumerable<MailItemViewModel> SelectedItems => AllItems.Where(a => a.IsSelected);
|
|
|
|
|
public int SelectedItemsCount => AllItems.Count(a => a.IsSelected);
|
|
|
|
|
public int AllItemsCount => AllItems.Count();
|
|
|
|
|
public bool IsAllItemsSelected => AllItems.Any() && AllItems.All(a => a.IsSelected);
|
|
|
|
|
public bool HasSingleItemSelected => SelectedItemsCount == 1;
|
|
|
|
|
|
2026-04-11 10:54:14 +02:00
|
|
|
public async Task ExecuteSelectionBatchAsync(Action action, bool notifySelectionChanged = true)
|
2025-10-26 14:53:22 +01:00
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-04-11 10:54:14 +02:00
|
|
|
_selectionNotificationSuppressionCount++;
|
|
|
|
|
await ExecuteUIThread(action);
|
2025-10-26 14:53:22 +01:00
|
|
|
}
|
|
|
|
|
catch (Exception)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
2026-04-11 10:54:14 +02:00
|
|
|
_selectionNotificationSuppressionCount = Math.Max(0, _selectionNotificationSuppressionCount - 1);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
2026-04-11 10:54:14 +02:00
|
|
|
if (_selectionNotificationSuppressionCount == 0)
|
|
|
|
|
{
|
|
|
|
|
var shouldNotify = notifySelectionChanged || _selectionNotificationPending;
|
|
|
|
|
_selectionNotificationPending = false;
|
|
|
|
|
|
|
|
|
|
if (shouldNotify)
|
|
|
|
|
{
|
|
|
|
|
await NotifySelectionChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-26 14:53:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 10:54:14 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-26 14:53:22 +01:00
|
|
|
public Task ToggleSelectAllAsync()
|
|
|
|
|
{
|
|
|
|
|
if (IsAllItemsSelected)
|
|
|
|
|
{
|
|
|
|
|
return UnselectAllAsync();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
return SelectAllAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the index of an item in the flat Items collection.
|
|
|
|
|
/// Note: WinoMailCollection doesn't have a flat Items collection like GroupedEmailCollection.
|
|
|
|
|
/// This returns -1 as it's not applicable to the grouped structure.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public int IndexOf(object item)
|
|
|
|
|
{
|
|
|
|
|
// WinoMailCollection uses grouped structure, so we need to search through groups
|
|
|
|
|
int currentIndex = 0;
|
|
|
|
|
|
|
|
|
|
foreach (var group in _mailItemSource)
|
|
|
|
|
{
|
|
|
|
|
foreach (var groupItem in group)
|
|
|
|
|
{
|
|
|
|
|
if (ReferenceEquals(groupItem, item))
|
|
|
|
|
{
|
|
|
|
|
return currentIndex;
|
|
|
|
|
}
|
|
|
|
|
currentIndex++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 22:52:26 +01:00
|
|
|
public Task SelectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = true, true);
|
2026-02-08 01:41:09 +01:00
|
|
|
public Task UnselectAllAsync(IMailListItem exceptItem = null) => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a != exceptItem) a.IsSelected = false; }, true);
|
2025-10-27 22:52:26 +01:00
|
|
|
public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
2025-10-31 00:51:27 +01:00
|
|
|
private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action);
|
2025-10-26 14:53:22 +01:00
|
|
|
|
2026-04-06 11:21:51 +02:00
|
|
|
private async Task RunSerializedAsync(Func<Task> action)
|
|
|
|
|
{
|
|
|
|
|
await _mutationGate.WaitAsync().ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await action().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_mutationGate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 10:54:14 +02:00
|
|
|
public void Receive(SelectedItemsChangedMessage message)
|
|
|
|
|
{
|
|
|
|
|
if (_selectionNotificationSuppressionCount > 0)
|
|
|
|
|
{
|
|
|
|
|
_selectionNotificationPending = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = NotifySelectionChangesAsync();
|
|
|
|
|
}
|
2025-10-26 14:53:22 +01:00
|
|
|
|
|
|
|
|
private async Task NotifySelectionChangesAsync()
|
|
|
|
|
{
|
|
|
|
|
await ExecuteUIThread(() =>
|
|
|
|
|
{
|
|
|
|
|
OnPropertyChanged(nameof(IsAllItemsSelected));
|
|
|
|
|
OnPropertyChanged(nameof(SelectedItemsCount));
|
|
|
|
|
OnPropertyChanged(nameof(HasSingleItemSelected));
|
|
|
|
|
|
|
|
|
|
ItemSelectionChanged?.Invoke(this, null);
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 10:54:38 +02:00
|
|
|
}
|