From 37199d84cb53b8a03270867949ef70efcdb8a310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Wed, 11 Feb 2026 11:34:50 +0100 Subject: [PATCH] Fixed the caching issue that causes mails to be not removed. Improved drag/drop. --- .../Translations/en_US/resources.json | 1 + .../Collections/WinoMailCollection.cs | 70 +++++---- Wino.Mail.ViewModels/MailListPageViewModel.cs | 116 +++++++++------ .../Controls/ListView/WinoListView.cs | 108 ++++++++++++-- Wino.Mail.WinUI/Controls/WinoExpander.cs | 17 +-- Wino.Mail.WinUI/MailAppShell.xaml.cs | 45 +++--- Wino.Mail.WinUI/Views/Mail/MailListPage.xaml | 25 +++- .../Views/Mail/MailListPage.xaml.cs | 134 ++++++++++++------ 8 files changed, 347 insertions(+), 169 deletions(-) diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 5d553eba..49b27e33 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -484,6 +484,7 @@ "MailOperation_Unarchive": "Unarchive", "MailOperation_ViewMessageSource": "View message source", "MailOperation_Zoom": "Zoom", + "MailsDragging": "Dragging {0} item(s)", "MailsSelected": "{0} item(s) selected", "MarkFlagUnflag": "Mark as flagged/unflagged", "MarkReadUnread": "Mark as read/unread", diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 31d829ad..da9b4b35 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -233,6 +233,11 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + /// Checks whether a mail with the given UniqueId currently exists in this collection. + /// + public bool ContainsMailUniqueId(Guid uniqueId) => MailCopyIdHashSet.ContainsKey(uniqueId); + /// /// Finds a MailItemViewModel by its UniqueId, searching through all items including those inside threads. /// @@ -444,7 +449,14 @@ public class WinoMailCollection : ObservableRecipient, IRecipient 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 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 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); } diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 5c3f9c41..929a3c54 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -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); /// /// 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(); } } diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs index 2ed82ab2..fa08b548 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; using CommunityToolkit.WinUI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain; using Wino.Core.Domain.Models.MailItem; using Wino.Mail.ViewModels.Data; @@ -21,12 +23,16 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView [GeneratedDependencyProperty] public partial ICommand? LoadMoreCommand { get; set; } + public event EventHandler? MailDragStateChanged; + protected override void OnApplyTemplate() { base.OnApplyTemplate(); - DragItemsStarting += ItemDragStarting; DragItemsStarting -= ItemDragStarting; + DragItemsStarting += ItemDragStarting; + DragItemsCompleted -= ItemDragCompleted; + DragItemsCompleted += ItemDragCompleted; internalScrollviewer = GetTemplateChild(PART_ScrollViewer) as ScrollViewer; @@ -222,6 +228,7 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView public void Cleanup() { DragItemsStarting -= ItemDragStarting; + DragItemsCompleted -= ItemDragCompleted; if (internalScrollviewer != null) { @@ -236,20 +243,99 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView // Meaning that if users drag 1 mail from Account A/Inbox and 1 mail from Account B/Inbox, // and drop to Account A/Inbox, the mail from Account B/Inbox will NOT be moved. + var itemsToDrag = ResolveDraggedMailItems(args); + + if (itemsToDrag.Count == 0) + { + return; + } + + var dragPackage = new MailDragPackage(itemsToDrag.Cast()); + args.Data.Properties.Add(nameof(MailDragPackage), dragPackage); + + var draggingText = string.Format(Translator.MailsDragging, itemsToDrag.Count); + args.Data.SetText(draggingText); + args.Data.Properties.Title = draggingText; + // args.DragUI.SetContentFromDataPackage(); + + MailDragStateChanged?.Invoke(this, new MailDragStateChangedEventArgs(true, itemsToDrag.Count)); + } + + private void ItemDragCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) + { + MailDragStateChanged?.Invoke(this, new MailDragStateChangedEventArgs(false, 0)); + } + + private List ResolveDraggedMailItems(DragItemsStartingEventArgs args) + { + var draggedItems = ExpandDragItems(args.Items.Cast()); + var selectedItems = GetSelectedMailItemsFromCurrentList(); + + if (selectedItems.Count > 1) + { + var selectedIds = selectedItems.Select(a => a.UniqueId).ToHashSet(); + bool dragStartedFromSelection = draggedItems.Any(a => selectedIds.Contains(a.UniqueId)); + + if (dragStartedFromSelection) + { + return selectedItems; + } + } + + return draggedItems.Count > 0 ? draggedItems : selectedItems; + } + + private List GetSelectedMailItemsFromCurrentList() + { if (IsThreadListView) { - var allItems = args.Items.Cast(); - - // Set native drag arg properties. - var dragPackage = new MailDragPackage(allItems.Cast()); - - args.Data.Properties.Add(nameof(MailDragPackage), dragPackage); + return Items + .Cast() + .OfType() + .Where(a => a.IsSelected) + .GroupBy(a => a.UniqueId) + .Select(a => a.First()) + .ToList(); } - else + + return Items + .Cast() + .OfType() + .SelectMany(a => a.GetSelectedMailItems()) + .GroupBy(a => a.UniqueId) + .Select(a => a.First()) + .ToList(); + } + + private static List ExpandDragItems(IEnumerable dragItems) + { + var result = new List(); + + foreach (var dragItem in dragItems) { - var dragPackage = new MailDragPackage(args.Items.Cast()); - - args.Data.Properties.Add(nameof(MailDragPackage), dragPackage); + if (dragItem is MailItemViewModel mailItem) + { + result.Add(mailItem); + } + else if (dragItem is ThreadMailItemViewModel threadItem) + { + result.AddRange(threadItem.ThreadEmails); + } + else if (dragItem is IMailListItem mailListItem) + { + result.AddRange(mailListItem.GetSelectedMailItems()); + } } + + return result + .GroupBy(a => a.UniqueId) + .Select(a => a.First()) + .ToList(); } } + +public sealed class MailDragStateChangedEventArgs(bool isDragging, int draggedItemCount) : EventArgs +{ + public bool IsDragging { get; } = isDragging; + public int DraggedItemCount { get; } = draggedItemCount; +} diff --git a/Wino.Mail.WinUI/Controls/WinoExpander.cs b/Wino.Mail.WinUI/Controls/WinoExpander.cs index 34a4180c..ff3adb5e 100644 --- a/Wino.Mail.WinUI/Controls/WinoExpander.cs +++ b/Wino.Mail.WinUI/Controls/WinoExpander.cs @@ -62,7 +62,7 @@ public partial class WinoExpander : Control clipComposition.Clip = clipComposition.Compositor.CreateInsetClip(); ContentAreaWrapper.SizeChanged += ContentSizeChanged; - HeaderGrid.Tapped += HeaderTapped; + } private void ContentSizeChanged(object sender, SizeChangedEventArgs e) @@ -71,21 +71,6 @@ public partial class WinoExpander : Control TemplateSettings.NegativeContentHeight = -1 * (double)e.NewSize.Height; } - private void HeaderTapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) - { - // Tapped is delegated from executing hover action like flag or delete. - // No need to toggle the expander. - - if (Header is MailItemDisplayInformationControl itemDisplayInformationControl && - itemDisplayInformationControl.IsRunningHoverAction) - { - itemDisplayInformationControl.IsRunningHoverAction = false; - return; - } - - // IsExpanded = !IsExpanded; - } - private static void OnIsExpandedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { if (obj is WinoExpander control) diff --git a/Wino.Mail.WinUI/MailAppShell.xaml.cs b/Wino.Mail.WinUI/MailAppShell.xaml.cs index 81d0eb2d..049c360a 100644 --- a/Wino.Mail.WinUI/MailAppShell.xaml.cs +++ b/Wino.Mail.WinUI/MailAppShell.xaml.cs @@ -64,28 +64,12 @@ public sealed partial class MailAppShell : MailAppShellAbstract, { if (droppedContainer.DataContext is IBaseFolderMenuItem draggingFolder) { - var mailCopies = new List(); - var dragPackage = e.DataView.Properties[nameof(MailDragPackage)] as MailDragPackage; if (dragPackage == null) return; e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move; - - // Extract mail copies from IMailItem. - // ThreadViewModels will be divided into pieces. - - foreach (var item in dragPackage.DraggingMails) - { - if (item is MailItemViewModel singleMailItemViewModel) - { - mailCopies.Add(singleMailItemViewModel.MailCopy); - } - else if (item is ThreadMailItemViewModel threadViewModel) - { - mailCopies.AddRange(threadViewModel.ThreadEmails.Select(a => a.MailCopy)); - } - } + var mailCopies = ExtractMailCopies(dragPackage).ToList(); await ViewModel.PerformMoveOperationAsync(mailCopies, draggingFolder); } @@ -125,11 +109,36 @@ public sealed partial class MailAppShell : MailAppShellAbstract, // Check whether the moving item's account has at least one same as the target folder's account. var draggedAccountIds = folderMenuItem.HandlingFolders.Select(a => a.MailAccountId); - if (!dragPackage.DraggingMails.Cast().Any(a => draggedAccountIds.Contains(a.AssignedAccount.Id))) return false; + var draggedMails = ExtractMailCopies(dragPackage); + + if (!draggedMails.Any()) return false; + if (!draggedMails.Any(a => draggedAccountIds.Contains(a.AssignedAccount.Id))) return false; return true; } + private static IEnumerable ExtractMailCopies(MailDragPackage dragPackage) + { + foreach (var item in dragPackage.DraggingMails) + { + if (item is MailCopy mailCopy) + { + yield return mailCopy; + } + else if (item is MailItemViewModel singleMailItemViewModel) + { + yield return singleMailItemViewModel.MailCopy; + } + else if (item is ThreadMailItemViewModel threadViewModel) + { + foreach (var threadMail in threadViewModel.ThreadEmails) + { + yield return threadMail.MailCopy; + } + } + } + } + private void ItemDragEnterOnFolder(object sender, DragEventArgs e) { // Validate package content. diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index 4594e38d..7c68af30 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -69,10 +69,14 @@ + MailItemInformation="{x:Bind}" + Tapped="ThreadHeaderTapped" /> + + + +