diff --git a/Wino.Mail.ViewModels/Collections/GroupedEmailCollection.cs b/Wino.Mail.ViewModels/Collections/GroupedEmailCollection.cs index 67dd5ab7..58865e9a 100644 --- a/Wino.Mail.ViewModels/Collections/GroupedEmailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/GroupedEmailCollection.cs @@ -1180,7 +1180,7 @@ public partial class GroupedEmailCollection : ObservableObject, IRecipient email.MailCopy?.CreationDate ?? DateTime.MinValue, - ThreadMailItemViewModel expander => expander.LatestEmailDate, + ThreadMailItemViewModel expander => expander.LatestMailViewModel?.CreationDate ?? DateTime.MinValue, _ => DateTime.MinValue }; } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 6d4d3edd..673398e3 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -3,15 +3,19 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using MoreLinq.Extensions; using Serilog; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Mails; namespace Wino.Mail.ViewModels.Collections; -public class WinoMailCollection +public class WinoMailCollection : ObservableRecipient, IRecipient { // We cache each mail copy id for faster access on updates. // If the item provider here for update or removal doesn't exist here @@ -20,6 +24,7 @@ public class WinoMailCollection public HashSet MailCopyIdHashSet = []; public event EventHandler MailItemRemoved; + public event EventHandler ItemSelectionChanged; private ListItemComparer listComparer = new(); @@ -32,6 +37,16 @@ public class WinoMailCollection /// public SortingOptionType SortingType { get; set; } + /// + /// Gets or sets the grouping type for emails. + /// Note: WinoMailCollection groups automatically on the UI, so this just affects the grouping key logic. + /// + public EmailGroupingType GroupingType + { + get => SortingType == SortingOptionType.ReceiveDate ? EmailGroupingType.ByDate : EmailGroupingType.ByFromName; + set => SortingType = value == EmailGroupingType.ByDate ? SortingOptionType.ReceiveDate : SortingOptionType.Sender; + } + /// /// 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. @@ -40,14 +55,31 @@ public class WinoMailCollection public int Count => _mailItemSource.Count; + public bool IsAllSelected + { + get + { + return AllItemsCount == SelectedItemsCount; + } + } + public IDispatcher CoreDispatcher { get; set; } public WinoMailCollection() { MailItems = new ReadOnlyObservableGroupedCollection(_mailItemSource); + + Messenger.Register(this); } - public void Clear() => _mailItemSource.Clear(); + public async Task ClearAsync() + { + await ExecuteUIThread(() => + { + _mailItemSource.Clear(); + MailCopyIdHashSet.Clear(); + }); + } private object GetGroupingKey(IMailListItem mailItem) { @@ -72,13 +104,16 @@ public class WinoMailCollection } } - private void InsertItemInternal(object groupKey, IMailListItem mailItem) + private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem) { UpdateUniqueIdHashes(mailItem, true); - _mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer); + await ExecuteUIThread(() => + { + _mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer); + }); } - private void RemoveItemInternal(ObservableGroup group, IMailListItem mailItem) + private async Task RemoveItemInternalAsync(ObservableGroup group, IMailListItem mailItem) { UpdateUniqueIdHashes(mailItem, false); @@ -94,12 +129,15 @@ public class WinoMailCollection } } - group.Remove(mailItem); - - if (group.Count == 0) + await ExecuteUIThread(() => { - _mailItemSource.RemoveGroup(group.Key); - } + group.Remove(mailItem); + + if (group.Count == 0) + { + _mailItemSource.RemoveGroup(group.Key); + } + }); } private async Task HandleThreadingAsync(ObservableGroup group, IMailListItem item, MailCopy addedItem) @@ -118,8 +156,8 @@ public class WinoMailCollection { var existingGroupKey = GetGroupingKey(threadViewModel); - await ExecuteUIThread(() => - { + await ExecuteUIThread(() => + { var newMailItem = new MailItemViewModel(addedItem); threadViewModel.AddEmail(newMailItem); }); @@ -152,17 +190,14 @@ public class WinoMailCollection private async Task MoveThreadToNewGroupAsync(ObservableGroup currentGroup, ThreadMailItemViewModel threadViewModel, object newGroupKey) { - await ExecuteUIThread(() => - { - RemoveItemInternal(currentGroup, threadViewModel); - InsertItemInternal(newGroupKey, threadViewModel); - }); + await RemoveItemInternalAsync(currentGroup, threadViewModel); + await InsertItemInternalAsync(newGroupKey, threadViewModel); } private async Task CreateNewThreadAsync(ObservableGroup group, MailItemViewModel item, MailCopy addedItem) { var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); - + await ExecuteUIThread(() => { threadViewModel.AddEmail(item); @@ -171,11 +206,8 @@ public class WinoMailCollection var newGroupKey = GetGroupingKey(threadViewModel); - await ExecuteUIThread(() => - { - RemoveItemInternal(group, item); - InsertItemInternal(newGroupKey, threadViewModel); - }); + await RemoveItemInternalAsync(group, item); + await InsertItemInternalAsync(newGroupKey, threadViewModel); } public async Task AddAsync(MailCopy addedItem) @@ -185,8 +217,8 @@ public class WinoMailCollection foreach (var item in group) { // Compare ThreadIds - if they match and both have ThreadIds, thread them together - bool shouldThread = !string.IsNullOrEmpty(addedItem.ThreadId) && - item is MailItemViewModel mailItem && + bool shouldThread = !string.IsNullOrEmpty(addedItem.ThreadId) && + item is MailItemViewModel mailItem && !string.IsNullOrEmpty(mailItem.MailCopy.ThreadId) && string.Equals(addedItem.ThreadId, mailItem.MailCopy.ThreadId, StringComparison.OrdinalIgnoreCase); @@ -194,7 +226,7 @@ public class WinoMailCollection { // Check if any email in the thread has matching ThreadId shouldThread = !string.IsNullOrEmpty(addedItem.ThreadId) && - threadViewModel.ThreadEmails.Any(e => + threadViewModel.ThreadEmails.Any(e => !string.IsNullOrEmpty(e.MailCopy.ThreadId) && string.Equals(addedItem.ThreadId, e.MailCopy.ThreadId, StringComparison.OrdinalIgnoreCase)); } @@ -219,7 +251,7 @@ public class WinoMailCollection { var newMailItem = new MailItemViewModel(addedItem); var groupKey = GetGroupingKey(newMailItem); - await ExecuteUIThread(() => { InsertItemInternal(groupKey, newMailItem); }); + await InsertItemInternalAsync(groupKey, newMailItem); } private async Task UpdateExistingItemAsync(MailItemViewModel existingItem, MailCopy updatedItem) @@ -230,7 +262,10 @@ public class WinoMailCollection await ExecuteUIThread(() => { existingItem.MailCopy = updatedItem; }); } - public void AddRange(IEnumerable items, bool clearIdCache) + /// + /// Adds multiple emails to the collection. + /// + public async Task AddRangeAsync(IEnumerable items, bool clearIdCache) { if (clearIdCache) { @@ -238,31 +273,34 @@ public class WinoMailCollection } var groupedByName = items - .GroupBy(a => GetGroupingKey(a)) + .GroupBy(GetGroupingKey) .Select(a => new ObservableGroup(a.Key, a)); - foreach (var group in groupedByName) + await ExecuteUIThread(() => { - // Store all mail copy ids for faster access. - foreach (var item in group) - { - UpdateUniqueIdHashes(item, true); - } - - var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key); - - if (existingGroup == null) - { - _mailItemSource.AddGroup(group.Key, group); - } - else + foreach (var group in groupedByName) { + // Store all mail copy ids for faster access. foreach (var item in group) { - existingGroup.Add(item); + UpdateUniqueIdHashes(item, true); + } + + var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key); + + if (existingGroup == null) + { + _mailItemSource.AddGroup(group.Key, group); + } + else + { + foreach (var item in group) + { + existingGroup.Add(item); + } } } - } + }); } public MailItemContainer GetMailItemContainer(Guid uniqueMailId) @@ -291,17 +329,20 @@ public class WinoMailCollection return null; } - public void UpdateThumbnails(string address) + /// + /// Updates thumbnails for all mail items with the specified address. + /// + public Task UpdateThumbnailsForAddressAsync(string address) { - if (CoreDispatcher == null) return; + if (CoreDispatcher == null) return Task.CompletedTask; - CoreDispatcher.ExecuteOnUIThread(() => + return CoreDispatcher.ExecuteOnUIThread(() => { foreach (var group in _mailItemSource) { foreach (var item in group) { - if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.FromAddress.Equals(address, StringComparison.OrdinalIgnoreCase)) + if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.FromAddress?.Equals(address, StringComparison.OrdinalIgnoreCase) == true) { mailItemViewModel.ThumbnailUpdatedEvent = !mailItemViewModel.ThumbnailUpdatedEvent; } @@ -309,7 +350,7 @@ public class WinoMailCollection { foreach (var threadMailItem in threadViewModel.ThreadEmails) { - if (threadMailItem.MailCopy.FromAddress.Equals(address, StringComparison.OrdinalIgnoreCase)) + if (threadMailItem.MailCopy.FromAddress?.Equals(address, StringComparison.OrdinalIgnoreCase) == true) { threadMailItem.ThumbnailUpdatedEvent = !threadMailItem.ThumbnailUpdatedEvent; } @@ -325,15 +366,12 @@ public class WinoMailCollection /// /// Updated mail copy. /// - public async Task UpdateMailCopy(MailCopy updatedMailCopy) + public Task UpdateMailCopy(MailCopy updatedMailCopy) { // This item doesn't exist in the list. - if (!MailCopyIdHashSet.Contains(updatedMailCopy.UniqueId)) - { - return; - } + if (!MailCopyIdHashSet.Contains(updatedMailCopy.UniqueId)) return Task.CompletedTask; - await ExecuteUIThread(() => + return ExecuteUIThread(() => { var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId); @@ -356,6 +394,8 @@ public class WinoMailCollection }); } + public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0); + public MailItemViewModel GetNextItem(MailCopy mailCopy) { try @@ -466,11 +506,8 @@ public class WinoMailCollection var singleViewModel = threadMailItemViewModel.ThreadEmails.First(); var groupKey = GetGroupingKey(singleViewModel); - await ExecuteUIThread(() => - { - RemoveItemInternal(group, threadMailItemViewModel); - InsertItemInternal(groupKey, 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. @@ -483,13 +520,13 @@ public class WinoMailCollection if (newGroup != null) { - await ExecuteUIThread(() => { RemoveItemInternal(newGroup, singleViewModel); }); + await RemoveItemInternalAsync(newGroup, singleViewModel); } } } else if (threadMailItemViewModel.EmailCount == 0) { - await ExecuteUIThread(() => { RemoveItemInternal(group, threadMailItemViewModel); }); + await RemoveItemInternalAsync(group, threadMailItemViewModel); } else { @@ -502,7 +539,7 @@ public class WinoMailCollection } else if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.UniqueId == removeItem.UniqueId) { - await ExecuteUIThread(() => { RemoveItemInternal(group, item); }); + await RemoveItemInternalAsync(group, item); shouldExit = true; @@ -510,7 +547,120 @@ public class WinoMailCollection } } } + + await NotifySelectionChangesAsync(); } + private IEnumerable AllItems + { + get + { + foreach (var group in _mailItemSource) + { + foreach (var item in group) + { + if (item is not MailItemViewModel mailItemViewModel) throw new Exception("Item is not MailItemViewModel in AllItems"); + + if (item is ThreadMailItemViewModel threadMail) + { + foreach (var singleItem in threadMail.ThreadEmails) + { + yield return singleItem; + } + } + + yield return mailItemViewModel; + } + } + } + } + + public IEnumerable 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; + + public async Task ExecuteWithoutRaiseSelectionChangedAsync(Action action) + { + try + { + // Do not listen to individual selection changes while we are doing bulk selection. + Messenger.Unregister(this); + + await ExecuteUIThread(() => + { + foreach (var item in AllItems) + { + action(item); + } + }); + } + catch (Exception) + { + } + finally + { + Messenger.Register(this); + Messenger.Send(new SelectedItemsChangedMessage()); + + await NotifySelectionChangesAsync(); + } + } + + public Task ToggleSelectAllAsync() + { + if (IsAllItemsSelected) + { + return UnselectAllAsync(); + } + else + { + return SelectAllAsync(); + } + } + + /// + /// 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. + /// + 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; + } + + public Task SelectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = true); + public Task UnselectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false); + private async Task ExecuteUIThread(Action action) => await CoreDispatcher?.ExecuteOnUIThread(action); + + public void Receive(SelectedItemsChangedMessage message) => _ = NotifySelectionChangesAsync(); + + private async Task NotifySelectionChangesAsync() + { + await ExecuteUIThread(() => + { + OnPropertyChanged(nameof(IsAllItemsSelected)); + OnPropertyChanged(nameof(SelectedItemsCount)); + OnPropertyChanged(nameof(HasSingleItemSelected)); + + ItemSelectionChanged?.Invoke(this, null); + }); + } } diff --git a/Wino.Mail.ViewModels/Data/IMailListItem.cs b/Wino.Mail.ViewModels/Data/IMailListItem.cs index ffa84c91..33ec9dbe 100644 --- a/Wino.Mail.ViewModels/Data/IMailListItem.cs +++ b/Wino.Mail.ViewModels/Data/IMailListItem.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using Wino.Core.Domain.Interfaces; namespace Wino.Mail.ViewModels.Data; @@ -7,7 +9,7 @@ namespace Wino.Mail.ViewModels.Data; /// Common interface for mail items that can be displayed in a mail list. /// Implemented by both MailItemViewModel and ThreadMailItemViewModel. /// -public interface IMailListItem : IMailHashContainer +public interface IMailListItem : IMailHashContainer, INotifyPropertyChanged { /// /// Gets the latest creation date for sorting purposes. @@ -22,4 +24,18 @@ public interface IMailListItem : IMailHashContainer /// For ThreadMailItemViewModel: the latest email's from name /// string FromName { get; } + + /// + /// Gets whether this item is selected. + /// For MailItemViewModel: returns IsSelected + /// For ThreadMailItemViewModel: returns IsSelected + /// + bool IsSelected { get; set; } + + /// + /// Gets all selected mail items within this list item. + /// For MailItemViewModel: returns itself if IsSelected is true, otherwise empty + /// For ThreadMailItemViewModel: returns all selected emails within the thread + /// + IEnumerable GetSelectedMailItems(); } diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index d6d37721..d39a989f 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -91,4 +91,12 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IM } public IEnumerable GetContainingIds() => [MailCopy.UniqueId]; + + public IEnumerable GetSelectedMailItems() + { + if (IsSelected) + { + yield return this; + } + } } diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 60151c70..3e48d985 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -118,4 +118,18 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IDisposable, public bool HasUniqueId(Guid uniqueId) => _threadEmails.Any(email => email.MailCopy.UniqueId == uniqueId); public IEnumerable GetContainingIds() => ThreadEmails.Select(a => a.MailCopy.UniqueId); + + public IEnumerable GetSelectedMailItems() + { + if (IsSelected) + { + // If the thread itself is selected, return all emails in the thread + return ThreadEmails; + } + else + { + // Otherwise, return only individually selected emails within the thread + return ThreadEmails.Where(e => e.IsSelected); + } + } } diff --git a/Wino.Mail.ViewModels/Helpers/ThrottledEventHandler.cs b/Wino.Mail.ViewModels/Helpers/ThrottledEventHandler.cs deleted file mode 100644 index 2ebac838..00000000 --- a/Wino.Mail.ViewModels/Helpers/ThrottledEventHandler.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Wino.Mail.ViewModels.Helpers; - -/// -/// A throttled event handler that delays execution of a callback until a specified time has passed -/// without the event being triggered again. This is useful for scenarios where events fire rapidly -/// but you only want to handle the "final" event after a quiet period. -/// -public class ThrottledEventHandler : IDisposable -{ - private readonly int _delayMilliseconds; - private readonly Func _asyncCallback; - private readonly Action _syncCallback; - private Timer _timer; - private volatile bool _disposed; - - /// - /// Creates a new throttled event handler with a synchronous callback. - /// - /// The delay in milliseconds to wait before executing the callback - /// The action to execute after the delay period - public ThrottledEventHandler(int delayMilliseconds, Action callback) - { - _delayMilliseconds = delayMilliseconds; - _syncCallback = callback ?? throw new ArgumentNullException(nameof(callback)); - } - - /// - /// Creates a new throttled event handler with an asynchronous callback. - /// - /// The delay in milliseconds to wait before executing the callback - /// The async function to execute after the delay period - public ThrottledEventHandler(int delayMilliseconds, Func callback) - { - _delayMilliseconds = delayMilliseconds; - _asyncCallback = callback ?? throw new ArgumentNullException(nameof(callback)); - } - - /// - /// Triggers the throttled execution. If called again before the delay period expires, - /// the timer is reset and the callback execution is delayed further. - /// - public void Trigger() - { - if (_disposed) return; - - // Dispose existing timer if it exists - _timer?.Dispose(); - - // Create new timer that will execute the callback after the delay - _timer = new Timer(ExecuteCallback, null, _delayMilliseconds, Timeout.Infinite); - } - - private async void ExecuteCallback(object state) - { - if (_disposed) return; - - try - { - if (_asyncCallback != null) - { - await _asyncCallback(); - } - else - { - _syncCallback?.Invoke(); - } - } - catch (Exception ex) - { - // Log error if logging is available, but don't crash - System.Diagnostics.Debug.WriteLine($"ThrottledEventHandler callback error: {ex}"); - } - finally - { - // Dispose the timer since it's one-shot - _timer?.Dispose(); - _timer = null; - } - } - - public void Dispose() - { - if (_disposed) return; - - _disposed = true; - _timer?.Dispose(); - _timer = null; - } -} \ No newline at end of file diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 673134e6..9bf25549 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -25,7 +25,6 @@ using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Services; using Wino.Mail.ViewModels.Collections; using Wino.Mail.ViewModels.Data; -using Wino.Mail.ViewModels.Helpers; using Wino.Mail.ViewModels.Messages; using Wino.Messaging.Client.Mails; using Wino.Messaging.Server; @@ -55,11 +54,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private readonly HashSet gmailUnreadFolderMarkedAsReadUniqueIds = []; - private ThrottledEventHandler _selectionChangedThrottler; - - public event EventHandler ThrottledSelectionChanged; - public GroupedEmailCollection MailCollection { get; set; } = new GroupedEmailCollection(); - //public ObservableCollection SelectedItems { get; set; } = []; + public WinoMailCollection MailCollection { get; set; } = new WinoMailCollection(); public ObservableCollection PivotFolders { get; set; } = []; public ObservableCollection ActionItems { get; set; } = []; @@ -121,7 +116,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private string barMessage; [ObservableProperty] - private double mailListLength = 420; + public partial double MailListLength { get; set; } = 420; [ObservableProperty] private double maxMailListLength = 1200; @@ -158,11 +153,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, INewThemeService themeService, IWinoLogger winoLogger) { - PreferencesService = preferencesService; - ThemeService = themeService; _winoLogger = winoLogger; - StatePersistenceService = statePersistenceService; - NavigationService = navigationService; _accountService = accountService; _mailDialogService = mailDialogService; _mailService = mailService; @@ -171,63 +162,77 @@ public partial class MailListPageViewModel : MailBaseViewModel, _winoRequestDelegator = winoRequestDelegator; _keyPressService = keyPressService; + PreferencesService = preferencesService; + ThemeService = themeService; + StatePersistenceService = statePersistenceService; + NavigationService = navigationService; + SelectedFilterOption = FilterOptions[0]; SelectedSortingOption = SortingOptions[0]; - mailListLength = statePersistenceService.MailListPaneLength; + MailListLength = statePersistenceService.MailListPaneLength; - _selectionChangedThrottler = new ThrottledEventHandler(100, () => - { - _ = ExecuteUIThread(() => - { - if (MailCollection.SelectedVisibleCount == 1) - { - ActiveMailItemChanged(MailCollection.SelectedVisibleItems.ElementAt(0)); - } - else - { - // At this point, either we don't have any item selected - // or we have multiple item selected. In either case - // there should be no active item. + //_selectionChangedThrottler = new ThrottledEventHandler(100, () => + //{ + // _ = ExecuteUIThread(() => + // { + // if (MailCollection.SelectedVisibleCount == 1) + // { + // ActiveMailItemChanged(MailCollection.SelectedVisibleItems.ElementAt(0)); + // } + // else + // { + // // At this point, either we don't have any item selected + // // or we have multiple item selected. In either case + // // there should be no active item. - ActiveMailItemChanged(null); - } + // ActiveMailItemChanged(null); + // } - NotifyItemSelected(); - SetupTopBarActions(); - }); + // NotifyItemSelected(); + // SetupTopBarActions(); + // }); - ThrottledSelectionChanged?.Invoke(this, EventArgs.Empty); - }); + // ThrottledSelectionChanged?.Invoke(this, EventArgs.Empty); + //}); } public override void OnNavigatedTo(NavigationMode mode, object parameters) { base.OnNavigatedTo(mode, parameters); - MailCollection.SelectionChanged += SelectedItemsChanged; + MailCollection.ItemSelectionChanged += MailItemSelectionChanged; } public override void OnNavigatedFrom(NavigationMode mode, object parameters) { base.OnNavigatedFrom(mode, parameters); - MailCollection.SelectionChanged -= SelectedItemsChanged; - MailCollection.Dispose(); - - _selectionChangedThrottler?.Dispose(); - _selectionChangedThrottler = null; + MailCollection.ItemSelectionChanged -= MailItemSelectionChanged; } - private void SelectedItemsChanged(object sender, EventArgs e) + private void MailItemSelectionChanged(object sender, EventArgs e) { - _selectionChangedThrottler?.Trigger(); + if (MailCollection.HasSingleItemSelected) + { + var selectedItem = MailCollection.SelectedItems.ElementAtOrDefault(0); + ActiveMailItemChanged(selectedItem); + } + else if (MailCollection.SelectedItemsCount > 1) + { + ActiveMailItemChanged(null); + } + + NotifyItemFoundState(); + NotifyItemSelected(); + SetupTopBarActions(); } private void SetupTopBarActions() { ActionItems.Clear(); - var actions = GetAvailableMailActions(MailCollection.SelectedVisibleItems); + + var actions = GetAvailableMailActions(MailCollection.SelectedItems); actions.ForEach(a => ActionItems.Add(a)); } @@ -270,12 +275,12 @@ public partial class MailListPageViewModel : MailBaseViewModel, public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false; public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive; - public string SelectedMessageText => MailCollection.SelectedVisibleCount > 0 ? string.Format(Translator.MailsSelected, MailCollection.SelectedVisibleCount) : Translator.NoMailSelected; + public string SelectedMessageText => MailCollection.SelectedItemsCount > 0 ? string.Format(Translator.MailsSelected, MailCollection.SelectedItemsCount) : Translator.NoMailSelected; /// /// Indicates current state of the mail list. Doesn't matter it's loading or no. /// - public bool IsEmpty => MailCollection.Count == 0; + public bool IsEmpty => MailCollection.AllItemsCount == 0; /// /// Progress ring only should be visible when the folder is initializing and there are no items. We don't need to show it when there are items. @@ -349,10 +354,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, public void NotifyItemSelected() { OnPropertyChanged(nameof(SelectedMessageText)); - //OnPropertyChanged(nameof(HasSingleItemSelection)); - //OnPropertyChanged(nameof(HasSelectedItems)); - //OnPropertyChanged(nameof(SelectedItemCount)); - //OnPropertyChanged(nameof(HasMultipleItemSelections)); SelectedFolderPivot?.SelectedItemCount = MailCollection.SelectedItemsCount; } @@ -435,9 +436,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, [RelayCommand] private async Task ExecuteTopBarAction(MailOperationMenuItem menuItem) { - if (menuItem == null || MailCollection.SelectedVisibleCount == 0) return; + if (menuItem == null || MailCollection.SelectedItemsCount == 0) return; - await HandleMailOperation(menuItem.Operation, MailCollection.SelectedVisibleItems); + await HandleMailOperation(menuItem.Operation, MailCollection.SelectedItems); } /// @@ -447,9 +448,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, [RelayCommand] private async Task ExecuteMailOperation(MailOperation mailOperation) { - if (!MailCollection.SelectedVisibleItems.Any()) return; + if (MailCollection.SelectedItemsCount == 0) return; - await HandleMailOperation(mailOperation, MailCollection.SelectedVisibleItems); + await HandleMailOperation(mailOperation, MailCollection.SelectedItems); } private async Task HandleMailOperation(MailOperation mailOperation, IEnumerable mailItems) @@ -564,7 +565,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, var viewModels = PrepareMailViewModels(items); - await ExecuteUIThread(() => { MailCollection.AddEmails(viewModels); }); + await MailCollection.AddRangeAsync(viewModels, false); await ExecuteUIThread(() => { IsInitializingFolder = false; }); } @@ -585,6 +586,14 @@ public partial class MailListPageViewModel : MailBaseViewModel, return condition; } + [RelayCommand] + public void RemoveFirst() + { + var fi = MailCollection.GetFirst(); + + Messenger.Send(new MailRemovedMessage(fi.MailCopy)); + } + protected override async void OnMailAdded(MailCopy addedMail) { base.OnMailAdded(addedMail); @@ -610,9 +619,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, await listManipulationSemepahore.WaitAsync(); - await ExecuteUIThread(() => + await ExecuteUIThread(async () => { - MailCollection.AddEmail(new MailItemViewModel(addedMail)); + await MailCollection.AddAsync(addedMail); NotifyItemFoundState(); }); } @@ -629,8 +638,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, Debug.WriteLine($"Updating {updatedMail.Id}-> {updatedMail.UniqueId}"); - // TODO - // await MailCollection.UpdateMailCopy(updatedMail); + await MailCollection.UpdateMailCopy(updatedMail); await ExecuteUIThread(() => { SetupTopBarActions(); }); } @@ -656,7 +664,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, if ((removedFromActiveFolder || removedFromDraftOrSent) && !isDeletedByGmailUnreadFolderAction) { - bool isDeletedMailSelected = MailCollection.SelectedVisibleItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId); + 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; @@ -672,7 +680,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Remove the deleted item from the list. await ExecuteUIThread(() => { - MailCollection.RemoveEmailByMailCopy(removedMail); + _ = MailCollection.RemoveAsync(removedMail); }); if (nextItem != null) @@ -684,7 +692,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { - MailCollection.ClearSelections(); + _ = MailCollection.UnselectAllAsync(); }); } @@ -707,10 +715,10 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Otherwise the draft mail item will be duplicated on the next add execution. await listManipulationSemepahore.WaitAsync(); - await ExecuteUIThread(() => + await ExecuteUIThread(async () => { // Create the item. Draft folder navigation is already done at this point. - MailCollection.AddEmail(new MailItemViewModel(draftMail)); + await MailCollection.AddAsync(draftMail); // New draft is created by user. Select the item. Messenger.Send(new MailItemNavigationRequested(draftMail.UniqueId, ScrollToItem: true)); @@ -745,7 +753,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, try { - MailCollection.Clear(); + _ = MailCollection.ClearAsync(); if (ActiveFolder == null) return; @@ -847,10 +855,10 @@ public partial class MailListPageViewModel : MailBaseViewModel, var viewModels = PrepareMailViewModels(items); + await MailCollection.AddRangeAsync(viewModels, clearIdCache: true); + await ExecuteUIThread(() => { - MailCollection.AddEmails(viewModels); - if (isDoingSearch && !isDoingOnlineSearch) { IsOnlineSearchButtonVisible = true; @@ -1059,21 +1067,25 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (handlingFolder == null) return; - _ = ExecuteUIThread(() => + _ = ExecuteUIThread(async () => { - MailCollection.Clear(); + await MailCollection.ClearAsync(); _mailDialogService.InfoBarMessage(Translator.AccountCacheReset_Title, Translator.AccountCacheReset_Message, InfoBarMessageType.Warning); }); } } + protected override void OnDispatcherAssigned() + { + base.OnDispatcherAssigned(); + + MailCollection.CoreDispatcher = Dispatcher; + } + public void Receive(ThumbnailAdded message) { - Dispatcher.ExecuteOnUIThread(() => - { - MailCollection.UpdateThumbnailsForAddress(message.Email); - }); + _ = MailCollection.UpdateThumbnailsForAddressAsync(message.Email); } protected override void RegisterRecipients() diff --git a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj index aa7c3f2f..a5635cb4 100644 --- a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj +++ b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj @@ -19,4 +19,7 @@ + + + \ No newline at end of file diff --git a/Wino.Mail.WinUI/App.xaml b/Wino.Mail.WinUI/App.xaml index bcdfb7e6..945ffdfe 100644 --- a/Wino.Mail.WinUI/App.xaml +++ b/Wino.Mail.WinUI/App.xaml @@ -12,9 +12,11 @@ + + diff --git a/Wino.Mail.WinUI/Controls/Advanced/WinoItemsView.cs b/Wino.Mail.WinUI/Controls/Advanced/WinoItemsView.cs index 7e1c4af9..b7a64657 100644 --- a/Wino.Mail.WinUI/Controls/Advanced/WinoItemsView.cs +++ b/Wino.Mail.WinUI/Controls/Advanced/WinoItemsView.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Windows.Input; using CommunityToolkit.WinUI; using Microsoft.UI.Xaml.Controls; @@ -6,6 +7,7 @@ using Wino.Mail.ViewModels.Data; namespace Wino.Mail.WinUI.Controls.Advanced; +[Obsolete("ItemsView sucks. Hard to deal with virtualization issues. Use ListView. This control is here to wise up anyone who tries to use it.")] public partial class WinoItemsView : ItemsView { private const string PART_ScrollView = nameof(PART_ScrollView); diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs new file mode 100644 index 00000000..39a16dfb --- /dev/null +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs @@ -0,0 +1,49 @@ +using Microsoft.UI.Xaml; +using Wino.Mail.ViewModels.Data; + +namespace Wino.Mail.WinUI.Controls.ListView; + +public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView +{ + public bool IsAllSelected => Items.Count == SelectedItems.Count; + + protected override DependencyObject GetContainerForItemOverride() => new WinoListViewItem(); + + public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel) + { + WinoListViewItem? itemContainer = null; + + foreach (var item in Items) + { + if (item is MailItemViewModel mailItem && mailItem.Id == mailItemViewModel.Id) + { + itemContainer = ContainerFromItem(mailItemViewModel) as WinoListViewItem; + + break; + } + else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailItemViewModel.MailCopy.UniqueId)) + { + itemContainer = ContainerFromItem(threadMailItemViewModel) as WinoListViewItem; + + // Try to get the inner WinoListView. + if (itemContainer != null) + { + itemContainer.IsExpanded = true; + + var innerListViewControl = itemContainer.GetWinoListViewControl(); + + if (innerListViewControl != null) + { + itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoListViewItem; + } + } + + break; + } + } + + itemContainer?.IsSelected = true; + + return itemContainer != null; + } +} diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListViewItem.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListViewItem.cs new file mode 100644 index 00000000..91ce816a --- /dev/null +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListViewItem.cs @@ -0,0 +1,103 @@ +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Mails; + +namespace Wino.Mail.WinUI.Controls.ListView; + +public partial class WinoListViewItem : ListViewItem +{ + public bool IsExpanded + { + get { return (bool)GetValue(IsExpandedProperty); } + set { SetValue(IsExpandedProperty, value); } + } + + public static readonly DependencyProperty IsExpandedProperty = DependencyProperty.Register(nameof(IsExpanded), typeof(bool), typeof(WinoListViewItem), new PropertyMetadata(false, OnIsExpandedChanged)); + + public WinoListViewItem() + { + DefaultStyleKey = typeof(WinoListViewItem); + + RegisterPropertyChangedCallback(IsSelectedProperty, OnIsSelectedChanged); + } + + private static void OnIsExpandedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WinoListViewItem item) + { + // Handle expansion state change if needed + } + } + + protected override void OnContentChanged(object oldContent, object newContent) + { + base.OnContentChanged(oldContent, newContent); + + if (oldContent is IMailListItem oldMailItem) + { + UnregisterSelectionCallback(oldMailItem); + } + + if (newContent is IMailListItem newMailItem) + { + IsSelected = newMailItem.IsSelected; + RegisterSelectionCallback(newMailItem); + } + } + + private void UnregisterSelectionCallback(IMailListItem mailItem) + { + mailItem.PropertyChanged -= MailPropChanged; + } + + private void RegisterSelectionCallback(IMailListItem mailItem) + { + mailItem.PropertyChanged += MailPropChanged; + } + + // From model + private void MailPropChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (sender is not IMailListItem mailItem) return; + + if (e.PropertyName == nameof(IMailListItem.IsSelected)) ApplySelectionForContainer(mailItem); + } + + // From container. + private void OnIsSelectedChanged(DependencyObject sender, DependencyProperty dp) + { + if (Content is IMailListItem mailItem) + { + ApplySelectionForModel(mailItem); + } + } + + private void ApplySelectionForModel(IMailListItem mailItem) + { + if (mailItem.IsSelected != IsSelected) + { + mailItem.IsSelected = IsSelected; + WeakReferenceMessenger.Default.Send(new SelectedItemsChangedMessage()); + } + } + + private void ApplySelectionForContainer(IMailListItem mailItem) + { + if (IsSelected != mailItem.IsSelected) + { + IsSelected = mailItem.IsSelected; + } + } + + public WinoListView? GetWinoListViewControl() + { + var expander = GetTemplateChild("ExpanderPart") as Expander; + + if (expander?.Content is ContentPresenter presenter) return VisualTreeHelper.GetChild(presenter, 0) as WinoListView; + + return null; + } +} diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml b/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml new file mode 100644 index 00000000..6aaa2501 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListViewStyles.xaml @@ -0,0 +1,66 @@ + + + + + + + + + + + +