Fixed the caching issue that causes mails to be not removed. Improved drag/drop.

This commit is contained in:
Burak Kaan Köse
2026-02-11 11:34:50 +01:00
parent 52ee5f1d8a
commit 37199d84cb
8 changed files with 347 additions and 169 deletions
@@ -233,6 +233,11 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return !string.IsNullOrEmpty(threadId) && _threadIdToItemsMap.ContainsKey(threadId);
}
/// <summary>
/// Checks whether a mail with the given UniqueId currently exists in this collection.
/// </summary>
public bool ContainsMailUniqueId(Guid uniqueId) => MailCopyIdHashSet.ContainsKey(uniqueId);
/// <summary>
/// Finds a MailItemViewModel by its UniqueId, searching through all items including those inside threads.
/// </summary>
@@ -444,7 +449,14 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Try cache first
if (_itemToGroupMap.TryGetValue(item, out var cachedGroup))
{
return cachedGroup;
// 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 _);
}
// Fallback to search if not in cache
@@ -487,6 +499,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
{
MailCopyIdHashSet.Clear();
_threadIdToItemsMap.Clear();
_itemToGroupMap.Clear();
_uniqueIdToMailItemMap.Clear();
_uniqueIdToThreadMap.Clear();
}
@@ -741,9 +755,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
/// <returns></returns>
public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource)
{
// This item doesn't exist in the list.
if (!MailCopyIdHashSet.ContainsKey(updatedMailCopy.UniqueId)) return Task.CompletedTask;
return ExecuteUIThread(() =>
{
var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId);
@@ -762,6 +773,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
itemContainer.ItemViewModel.IsBusy = mailUpdateSource == MailUpdateSource.ClientUpdated;
UpdateUniqueIdHashes(itemContainer.ItemViewModel, true);
// Keep thread membership cache in sync for items rendered inside thread containers.
if (itemContainer.ThreadViewModel != null)
{
_uniqueIdToThreadMap[itemContainer.ItemViewModel.MailCopy.UniqueId] = itemContainer.ThreadViewModel;
}
}
// Trigger thread property notifications if this item is in a thread
@@ -836,17 +853,19 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
public async Task RemoveAsync(MailCopy removeItem)
{
// This item doesn't exist in the list.
if (!MailCopyIdHashSet.ContainsKey(removeItem.UniqueId)) return;
var itemContainer = GetMailItemContainer(removeItem.UniqueId);
if (_uniqueIdToThreadMap.TryGetValue(removeItem.UniqueId, out var threadMailItemViewModel))
// This item doesn't exist in the list.
if (itemContainer?.ItemViewModel == null) return;
if (itemContainer.ThreadViewModel != null)
{
// Item is inside a thread - use cached lookups instead of scanning all groups.
var threadMailItemViewModel = itemContainer.ThreadViewModel;
var group = FindGroupContainingItem(threadMailItemViewModel);
if (group == null) return;
var removalItem = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == removeItem.UniqueId);
if (removalItem == null) return;
var removalItem = itemContainer.ItemViewModel;
// Update ThreadId cache before modifying the thread
UpdateThreadIdCache(threadMailItemViewModel, false);
@@ -889,36 +908,11 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
else
{
// Standalone item - use cached lookup.
IMailListItem mailItem = null;
ObservableGroup<object, IMailListItem> group = null;
// Standalone item.
IMailListItem mailItem = itemContainer.ItemViewModel;
var group = FindGroupContainingItem(mailItem);
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++)
{
if (g[k] is MailItemViewModel mvm && mvm.MailCopy.UniqueId == removeItem.UniqueId)
{
mailItem = mvm;
group = g;
break;
}
}
if (mailItem != null) break;
}
}
if (mailItem != null && group != null)
if (group != null)
{
await RemoveItemInternalAsync(group, mailItem);
}
+75 -41
View File
@@ -99,6 +99,16 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[ObservableProperty]
private bool isMultiSelectionModeEnabled;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SelectedMessageText))]
[NotifyPropertyChangedFor(nameof(DraggingMessageText))]
public partial bool IsDragInProgress { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SelectedMessageText))]
[NotifyPropertyChangedFor(nameof(DraggingMessageText))]
public partial int DraggingItemsCount { get; set; }
[ObservableProperty]
public partial string SearchQuery { get; set; }
@@ -291,7 +301,13 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
public string SelectedMessageText => MailCollection.SelectedItemsCount > 0 ? string.Format(Translator.MailsSelected, MailCollection.SelectedItemsCount) : Translator.NoMailSelected;
public string SelectedMessageText => IsDragInProgress
? string.Format(Translator.MailsDragging, DraggingItemsCount)
: MailCollection.SelectedItemsCount > 0
? string.Format(Translator.MailsSelected, MailCollection.SelectedItemsCount)
: Translator.NoMailSelected;
public string DraggingMessageText => string.Format(Translator.MailsDragging, DraggingItemsCount);
/// <summary>
/// Indicates current state of the mail list. Doesn't matter it's loading or no.
@@ -361,6 +377,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
SelectedFolderPivot?.SelectedItemCount = MailCollection.SelectedItemsCount;
}
public void SetDragState(bool isDragInProgress, int draggingItemsCount = 0)
{
IsDragInProgress = isDragInProgress;
DraggingItemsCount = isDragInProgress ? Math.Max(1, draggingItemsCount) : 0;
}
private void NotifyItemFoundState()
{
OnPropertyChanged(nameof(IsEmpty));
@@ -718,7 +740,15 @@ public partial class MailListPageViewModel : MailBaseViewModel,
{
base.OnMailUpdated(updatedMail, source);
await MailCollection.UpdateMailCopy(updatedMail, source);
try
{
await listManipulationSemepahore.WaitAsync();
await MailCollection.UpdateMailCopy(updatedMail, source);
}
finally
{
listManipulationSemepahore.Release();
}
// await ExecuteUIThread(() => { SetupTopBarActions(); });
}
@@ -727,56 +757,60 @@ public partial class MailListPageViewModel : MailBaseViewModel,
{
base.OnMailRemoved(removedMail);
if (removedMail.AssignedAccount == null || removedMail.AssignedFolder == null) return;
if (removedMail.AssignedAccount == null) return;
// We should delete the items only if:
// 1. They are deleted from the active folder.
// 2. Deleted from draft or sent folder.
// 3. Removal is not caused by Gmail Unread folder action.
// Delete/sent are special folders that can list their items in other folders.
bool removedFromActiveFolder = ActiveFolder.HandlingFolders.Any(a => a.Id == removedMail.AssignedFolder.Id);
bool removedFromDraftOrSent = removedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft ||
removedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent;
bool isDeletedByGmailUnreadFolderAction = ActiveFolder.SpecialFolderType == SpecialFolderType.Unread &&
gmailUnreadFolderMarkedAsReadUniqueIds.Contains(removedMail.UniqueId);
if ((removedFromActiveFolder || removedFromDraftOrSent) && !isDeletedByGmailUnreadFolderAction)
try
{
bool isDeletedMailSelected = MailCollection.SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId);
await listManipulationSemepahore.WaitAsync();
// Automatically select the next item in the list if the setting is enabled.
MailItemViewModel nextItem = null;
// Remove only if this specific mail copy currently exists in this list.
// Using AssignedFolder-based checks is unreliable for move flows because the
// same MailCopy instance can be updated before this message is handled.
bool removedItemExistsInCurrentList = MailCollection.ContainsMailUniqueId(removedMail.UniqueId);
if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem)
bool isDeletedByGmailUnreadFolderAction = ActiveFolder?.SpecialFolderType == SpecialFolderType.Unread &&
gmailUnreadFolderMarkedAsReadUniqueIds.Contains(removedMail.UniqueId);
if (removedItemExistsInCurrentList && !isDeletedByGmailUnreadFolderAction)
{
await ExecuteUIThread(() =>
bool isDeletedMailSelected = MailCollection.SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId);
// Automatically select the next item in the list if the setting is enabled.
MailItemViewModel nextItem = null;
if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem)
{
nextItem = MailCollection.GetNextItem(removedMail);
});
await ExecuteUIThread(() =>
{
nextItem = MailCollection.GetNextItem(removedMail);
});
}
// RemoveAsync already handles UI threading internally
await MailCollection.RemoveAsync(removedMail);
if (nextItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem.UniqueId, ScrollToItem: true));
else if (isDeletedMailSelected)
{
// There are no next item to select, but we removed the last item which was selected.
// Clearing selected item will dispose rendering page.
// UnselectAllAsync already handles UI threading internally
await MailCollection.UnselectAllAsync();
}
await ExecuteUIThread(() => { NotifyItemFoundState(); });
}
// RemoveAsync already handles UI threading internally
await MailCollection.RemoveAsync(removedMail);
if (nextItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem.UniqueId, ScrollToItem: true));
else if (isDeletedMailSelected)
else if (isDeletedByGmailUnreadFolderAction)
{
// There are no next item to select, but we removed the last item which was selected.
// Clearing selected item will dispose rendering page.
// UnselectAllAsync already handles UI threading internally
await MailCollection.UnselectAllAsync();
// Remove the entry from the set so we can listen to actual deletes next time.
gmailUnreadFolderMarkedAsReadUniqueIds.Remove(removedMail.UniqueId);
}
await ExecuteUIThread(() => { NotifyItemFoundState(); });
}
else if (isDeletedByGmailUnreadFolderAction)
finally
{
// Remove the entry from the set so we can listen to actual deletes next time.
gmailUnreadFolderMarkedAsReadUniqueIds.Remove(removedMail.UniqueId);
listManipulationSemepahore.Release();
}
}