diff --git a/Wino.Core.Domain/Interfaces/IPreferencesService.cs b/Wino.Core.Domain/Interfaces/IPreferencesService.cs index 7134e44d..b82e00e4 100644 --- a/Wino.Core.Domain/Interfaces/IPreferencesService.cs +++ b/Wino.Core.Domain/Interfaces/IPreferencesService.cs @@ -132,6 +132,11 @@ public interface IPreferencesService : INotifyPropertyChanged /// bool IsThreadingEnabled { get; set; } + /// + /// Setting: Whether the newest message in a conversation should appear first. + /// + bool IsNewestThreadMailFirst { get; set; } + /// /// Setting: Show sender pictures in mail list. /// diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 418a6da1..78fc4b1f 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1074,6 +1074,12 @@ "SettingsTaskbarBadge_Title": "Taskbar Badge", "SettingsThreads_Description": "Organize messages into conversation threads.", "SettingsThreads_Title": "Conversation Threading", + "SettingsThreads_Enabled_Description": "Group related messages into a single conversation.", + "SettingsThreads_Enabled_Title": "Enable conversation threading", + "SettingsThreadOrder_Description": "Choose how items are ordered inside a conversation thread.", + "SettingsThreadOrder_Title": "Thread item sorting", + "SettingsThreadOrder_LastItemFirst": "Last item first", + "SettingsThreadOrder_FirstItemFirst": "First item first", "SettingsUnlinkAccounts_Description": "Remove the link between accounts. his will not delete your accounts.", "SettingsUnlinkAccounts_Title": "Unlink Accounts", "SettingsMailRendering_ActionLabels_Title": "Action labels", diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 4fa1391b..df2410ad 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -39,6 +39,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient MailItemRemoved; public event EventHandler ItemSelectionChanged; + public Func ThreadItemFactory { get; set; } = static threadId => new ThreadMailItemViewModel(threadId, true); private ListItemComparer listComparer = new(); @@ -457,7 +458,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient group, MailItemViewModel item, MailCopy addedItem) { - var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); + var threadViewModel = ThreadItemFactory(item.MailCopy.ThreadId); await ExecuteUIThread(() => { @@ -710,7 +711,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient 1) { // Create a new thread with all matching items - defer UI operations - var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); + var threadViewModel = ThreadItemFactory(item.MailCopy.ThreadId); // Add emails without UI thread for now foreach (var threadItem in threadableItems) diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 7508058a..4af29f66 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -17,8 +17,9 @@ namespace Wino.Mail.ViewModels.Data; public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation { private readonly string _threadId; + private readonly bool _isNewestEmailFirst; private readonly HashSet _uniqueIdSet = []; - private MailItemViewModel _cachedLatestMailViewModel; + private MailItemViewModel _cachedNewestMailViewModel; private int _suspendChildPropertyNotificationsCount; [ObservableProperty] @@ -53,27 +54,27 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// /// Gets the latest email's subject for display /// - public string Subject => latestMailViewModel?.MailCopy?.Subject; + public string Subject => newestMailViewModel?.MailCopy?.Subject; /// /// Gets the latest email's sender name for display /// - public string FromName => latestMailViewModel?.MailCopy?.FromName ?? Translator.UnknownSender; + public string FromName => newestMailViewModel?.MailCopy?.FromName ?? Translator.UnknownSender; /// /// Gets the latest email's creation date for sorting /// - public DateTime CreationDate => latestMailViewModel?.MailCopy?.CreationDate ?? DateTime.MinValue; + public DateTime CreationDate => newestMailViewModel?.MailCopy?.CreationDate ?? DateTime.MinValue; /// /// Gets the latest email's sender address for display /// - public string FromAddress => latestMailViewModel?.FromAddress ?? string.Empty; + public string FromAddress => newestMailViewModel?.FromAddress ?? string.Empty; /// /// Gets the preview text from the latest email /// - public string PreviewText => latestMailViewModel?.PreviewText ?? string.Empty; + public string PreviewText => newestMailViewModel?.PreviewText ?? string.Empty; /// /// Gets whether any email in this thread has attachments @@ -93,18 +94,18 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// /// Gets whether the latest email is focused /// - public bool IsFocused => latestMailViewModel?.IsFocused ?? false; + public bool IsFocused => newestMailViewModel?.IsFocused ?? false; /// /// Gets whether all emails in this thread are read /// public bool IsRead => ThreadEmails.All(e => e.IsRead); - public bool HasReadReceiptTracking => latestMailViewModel?.HasReadReceiptTracking ?? false; + public bool HasReadReceiptTracking => newestMailViewModel?.HasReadReceiptTracking ?? false; - public bool IsReadReceiptAcknowledged => latestMailViewModel?.IsReadReceiptAcknowledged ?? false; + public bool IsReadReceiptAcknowledged => newestMailViewModel?.IsReadReceiptAcknowledged ?? false; - public string ReadReceiptDisplayText => latestMailViewModel?.ReadReceiptDisplayText ?? string.Empty; + public string ReadReceiptDisplayText => newestMailViewModel?.ReadReceiptDisplayText ?? string.Empty; /// /// Gets whether any email in this thread is a draft @@ -114,58 +115,58 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// /// Gets the draft ID from the latest email if it's a draft /// - public string DraftId => latestMailViewModel?.DraftId ?? string.Empty; + public string DraftId => newestMailViewModel?.DraftId ?? string.Empty; /// /// Gets the ID from the latest email /// - public string Id => latestMailViewModel?.Id ?? string.Empty; + public string Id => newestMailViewModel?.Id ?? string.Empty; /// /// Gets the importance of the latest email /// - public MailImportance Importance => latestMailViewModel?.Importance ?? MailImportance.Normal; + public MailImportance Importance => newestMailViewModel?.Importance ?? MailImportance.Normal; /// /// Gets the thread ID from the latest email /// - public string ThreadId => latestMailViewModel?.ThreadId ?? _threadId; + public string ThreadId => newestMailViewModel?.ThreadId ?? _threadId; /// /// Gets the message ID from the latest email /// - public string MessageId => latestMailViewModel?.MessageId ?? string.Empty; + public string MessageId => newestMailViewModel?.MessageId ?? string.Empty; /// /// Gets the references from the latest email /// - public string References => latestMailViewModel?.References ?? string.Empty; + public string References => newestMailViewModel?.References ?? string.Empty; /// /// Gets the in-reply-to from the latest email /// - public string InReplyTo => latestMailViewModel?.InReplyTo ?? string.Empty; + public string InReplyTo => newestMailViewModel?.InReplyTo ?? string.Empty; /// /// Gets the file ID from the latest email /// - public Guid FileId => latestMailViewModel?.FileId ?? Guid.Empty; + public Guid FileId => newestMailViewModel?.FileId ?? Guid.Empty; /// /// Gets the folder ID from the latest email /// - public Guid FolderId => latestMailViewModel?.FolderId ?? Guid.Empty; + public Guid FolderId => newestMailViewModel?.FolderId ?? Guid.Empty; /// /// Gets the unique ID from the latest email /// - public Guid UniqueId => latestMailViewModel?.UniqueId ?? Guid.Empty; + public Guid UniqueId => newestMailViewModel?.UniqueId ?? Guid.Empty; - public Guid? ContactPictureFileId => latestMailViewModel?.MailCopy?.SenderContact?.ContactPictureFileId; + public Guid? ContactPictureFileId => newestMailViewModel?.MailCopy?.SenderContact?.ContactPictureFileId; - public bool ThumbnailUpdatedEvent => latestMailViewModel?.ThumbnailUpdatedEvent ?? false; + public bool ThumbnailUpdatedEvent => newestMailViewModel?.ThumbnailUpdatedEvent ?? false; - public AccountContact SenderContact => latestMailViewModel?.MailCopy?.SenderContact; + public AccountContact SenderContact => newestMailViewModel?.MailCopy?.SenderContact; /// /// Gets all emails in this thread (observable) @@ -201,15 +202,16 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(SenderContact))] public partial ObservableCollection ThreadEmails { get; set; } = []; - private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel; + private MailItemViewModel newestMailViewModel => _cachedNewestMailViewModel; public DateTime SortingDate => CreationDate; public string SortingName => FromName; - public ThreadMailItemViewModel(string threadId) + public ThreadMailItemViewModel(string threadId, bool isNewestEmailFirst) { _threadId = threadId; + _isNewestEmailFirst = isNewestEmailFirst; } internal void SuspendChildPropertyNotifications() => _suspendChildPropertyNotificationsCount++; @@ -224,11 +226,21 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte private void RefreshLatestMailCache() { - _cachedLatestMailViewModel = ThreadEmails + _cachedNewestMailViewModel = ThreadEmails .OrderByDescending(static item => item.MailCopy.CreationDate) .FirstOrDefault(); } + public MailItemViewModel GetDefaultSelectedThreadEmail() + { + if (ThreadEmails.Count == 0) + { + return null; + } + + return _isNewestEmailFirst ? ThreadEmails.FirstOrDefault() : ThreadEmails.LastOrDefault(); + } + /// /// Adds an email to this thread /// @@ -237,11 +249,15 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte if (email.MailCopy.ThreadId != _threadId) throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'"); - // Insert email in sorted order by CreationDate (newest first, oldest last) + // Insert email in sorted order by CreationDate based on the configured thread direction. var insertIndex = 0; for (int i = 0; i < ThreadEmails.Count; i++) { - if (ThreadEmails[i].MailCopy.CreationDate < email.MailCopy.CreationDate) + bool shouldInsertBefore = _isNewestEmailFirst + ? ThreadEmails[i].MailCopy.CreationDate < email.MailCopy.CreationDate + : ThreadEmails[i].MailCopy.CreationDate > email.MailCopy.CreationDate; + + if (shouldInsertBefore) { insertIndex = i; break; @@ -298,7 +314,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte if (e.PropertyName == nameof(MailItemViewModel.ThumbnailUpdatedEvent)) { - if (ReferenceEquals(updatedMailItem, latestMailViewModel)) + if (ReferenceEquals(updatedMailItem, newestMailViewModel)) { OnPropertyChanged(nameof(ThumbnailUpdatedEvent)); } @@ -329,7 +345,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte if (changedFlags == MailCopyChangeFlags.None) return; - var previousLatest = latestMailViewModel; + var previousLatest = newestMailViewModel; if (changedFlags == MailCopyChangeFlags.All || (changedFlags & MailCopyChangeFlags.CreationDate) != 0 || @@ -339,7 +355,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte RefreshLatestMailCache(); } - var currentLatest = latestMailViewModel; + var currentLatest = newestMailViewModel; var latestChanged = !ReferenceEquals(previousLatest, currentLatest); var updatesDisplayedLatest = changedFlags == MailCopyChangeFlags.All || diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 9e5887a2..d89c2206 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -205,6 +205,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, SelectedFilterOption = FilterOptions[0]; SelectedSortingOption = SortingOptions[0]; + MailCollection.ThreadItemFactory = threadId => new ThreadMailItemViewModel(threadId, PreferencesService.IsNewestThreadMailFirst); MailListLength = statePersistenceService.MailListPaneLength; } diff --git a/Wino.Mail.ViewModels/MessageListPageViewModel.cs b/Wino.Mail.ViewModels/MessageListPageViewModel.cs index 019f3d3c..2695e4fe 100644 --- a/Wino.Mail.ViewModels/MessageListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MessageListPageViewModel.cs @@ -42,6 +42,12 @@ public partial class MessageListPageViewModel : MailBaseViewModel Translator.HoverActionOption_MoveJunk ]; + public List ThreadItemSortingOptions { get; } = + [ + Translator.SettingsThreadOrder_LastItemFirst, + Translator.SettingsThreadOrder_FirstItemFirst + ]; + public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; } = new DemoMailItemDisplayInformation(); public MailListDisplayMode SelectedMailSpacingMode => availableMailSpacingOptions[selectedMailSpacingIndex]; @@ -73,6 +79,19 @@ public partial class MessageListPageViewModel : MailBaseViewModel } } + private int selectedThreadItemSortingIndex; + public int SelectedThreadItemSortingIndex + { + get => selectedThreadItemSortingIndex; + set + { + if (SetProperty(ref selectedThreadItemSortingIndex, value) && value >= 0) + { + PreferencesService.IsNewestThreadMailFirst = value == 0; + } + } + } + #region Properties private int leftHoverActionIndex; public int LeftHoverActionIndex @@ -128,6 +147,7 @@ public partial class MessageListPageViewModel : MailBaseViewModel rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction); selectedMailSpacingIndex = availableMailSpacingOptions.IndexOf(PreferencesService.MailItemDisplayMode); SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues(), PreferencesService.MarkAsPreference); + selectedThreadItemSortingIndex = PreferencesService.IsNewestThreadMailFirst ? 0 : 1; } [RelayCommand] diff --git a/Wino.Mail.WinUI/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs index 0abced6c..2a396999 100644 --- a/Wino.Mail.WinUI/Services/PreferencesService.cs +++ b/Wino.Mail.WinUI/Services/PreferencesService.cs @@ -117,6 +117,12 @@ public class PreferencesService(IConfigurationService configurationService) : Ob set => SetPropertyAndSave(nameof(IsThreadingEnabled), value); } + public bool IsNewestThreadMailFirst + { + get => _configurationService.Get(nameof(IsNewestThreadMailFirst), true); + set => SetPropertyAndSave(nameof(IsNewestThreadMailFirst), value); + } + public bool IsMailListActionBarEnabled { get => _configurationService.Get(nameof(IsMailListActionBarEnabled), false); diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs index 960e4ef1..1e3cb57f 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs @@ -990,10 +990,10 @@ public sealed partial class MailListPage : MailListPageAbstract, } else { - var firstChild = clickedThread.ThreadEmails.FirstOrDefault(); - if (firstChild != null) + var defaultSelectedChild = clickedThread.GetDefaultSelectedThreadEmail(); + if (defaultSelectedChild != null) { - firstChild.IsSelected = true; + defaultSelectedChild.IsSelected = true; } } diff --git a/Wino.Mail.WinUI/Views/Settings/MessageListPage.xaml b/Wino.Mail.WinUI/Views/Settings/MessageListPage.xaml index dac301a9..4e97845d 100644 --- a/Wino.Mail.WinUI/Views/Settings/MessageListPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/MessageListPage.xaml @@ -100,13 +100,25 @@ - - + + - - - - + + + + + + + + + +