Add configurable thread item sorting

This commit is contained in:
Burak Kaan Köse
2026-04-19 16:25:00 +02:00
parent bfbc3d40b3
commit 496c7735f7
9 changed files with 109 additions and 42 deletions
@@ -132,6 +132,11 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary> /// </summary>
bool IsThreadingEnabled { get; set; } bool IsThreadingEnabled { get; set; }
/// <summary>
/// Setting: Whether the newest message in a conversation should appear first.
/// </summary>
bool IsNewestThreadMailFirst { get; set; }
/// <summary> /// <summary>
/// Setting: Show sender pictures in mail list. /// Setting: Show sender pictures in mail list.
/// </summary> /// </summary>
@@ -1074,6 +1074,12 @@
"SettingsTaskbarBadge_Title": "Taskbar Badge", "SettingsTaskbarBadge_Title": "Taskbar Badge",
"SettingsThreads_Description": "Organize messages into conversation threads.", "SettingsThreads_Description": "Organize messages into conversation threads.",
"SettingsThreads_Title": "Conversation Threading", "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_Description": "Remove the link between accounts. his will not delete your accounts.",
"SettingsUnlinkAccounts_Title": "Unlink Accounts", "SettingsUnlinkAccounts_Title": "Unlink Accounts",
"SettingsMailRendering_ActionLabels_Title": "Action labels", "SettingsMailRendering_ActionLabels_Title": "Action labels",
@@ -39,6 +39,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
public event EventHandler<MailItemViewModel> MailItemRemoved; public event EventHandler<MailItemViewModel> MailItemRemoved;
public event EventHandler ItemSelectionChanged; public event EventHandler ItemSelectionChanged;
public Func<string, ThreadMailItemViewModel> ThreadItemFactory { get; set; } = static threadId => new ThreadMailItemViewModel(threadId, true);
private ListItemComparer listComparer = new(); private ListItemComparer listComparer = new();
@@ -457,7 +458,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private async Task CreateNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem) private async Task CreateNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem)
{ {
var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); var threadViewModel = ThreadItemFactory(item.MailCopy.ThreadId);
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
@@ -710,7 +711,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var threadableItems) && threadableItems.Count > 1) if (batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var threadableItems) && threadableItems.Count > 1)
{ {
// Create a new thread with all matching items - defer UI operations // 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 // Add emails without UI thread for now
foreach (var threadItem in threadableItems) foreach (var threadItem in threadableItems)
@@ -17,8 +17,9 @@ namespace Wino.Mail.ViewModels.Data;
public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation
{ {
private readonly string _threadId; private readonly string _threadId;
private readonly bool _isNewestEmailFirst;
private readonly HashSet<Guid> _uniqueIdSet = []; private readonly HashSet<Guid> _uniqueIdSet = [];
private MailItemViewModel _cachedLatestMailViewModel; private MailItemViewModel _cachedNewestMailViewModel;
private int _suspendChildPropertyNotificationsCount; private int _suspendChildPropertyNotificationsCount;
[ObservableProperty] [ObservableProperty]
@@ -53,27 +54,27 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// <summary> /// <summary>
/// Gets the latest email's subject for display /// Gets the latest email's subject for display
/// </summary> /// </summary>
public string Subject => latestMailViewModel?.MailCopy?.Subject; public string Subject => newestMailViewModel?.MailCopy?.Subject;
/// <summary> /// <summary>
/// Gets the latest email's sender name for display /// Gets the latest email's sender name for display
/// </summary> /// </summary>
public string FromName => latestMailViewModel?.MailCopy?.FromName ?? Translator.UnknownSender; public string FromName => newestMailViewModel?.MailCopy?.FromName ?? Translator.UnknownSender;
/// <summary> /// <summary>
/// Gets the latest email's creation date for sorting /// Gets the latest email's creation date for sorting
/// </summary> /// </summary>
public DateTime CreationDate => latestMailViewModel?.MailCopy?.CreationDate ?? DateTime.MinValue; public DateTime CreationDate => newestMailViewModel?.MailCopy?.CreationDate ?? DateTime.MinValue;
/// <summary> /// <summary>
/// Gets the latest email's sender address for display /// Gets the latest email's sender address for display
/// </summary> /// </summary>
public string FromAddress => latestMailViewModel?.FromAddress ?? string.Empty; public string FromAddress => newestMailViewModel?.FromAddress ?? string.Empty;
/// <summary> /// <summary>
/// Gets the preview text from the latest email /// Gets the preview text from the latest email
/// </summary> /// </summary>
public string PreviewText => latestMailViewModel?.PreviewText ?? string.Empty; public string PreviewText => newestMailViewModel?.PreviewText ?? string.Empty;
/// <summary> /// <summary>
/// Gets whether any email in this thread has attachments /// Gets whether any email in this thread has attachments
@@ -93,18 +94,18 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// <summary> /// <summary>
/// Gets whether the latest email is focused /// Gets whether the latest email is focused
/// </summary> /// </summary>
public bool IsFocused => latestMailViewModel?.IsFocused ?? false; public bool IsFocused => newestMailViewModel?.IsFocused ?? false;
/// <summary> /// <summary>
/// Gets whether all emails in this thread are read /// Gets whether all emails in this thread are read
/// </summary> /// </summary>
public bool IsRead => ThreadEmails.All(e => e.IsRead); 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;
/// <summary> /// <summary>
/// Gets whether any email in this thread is a draft /// Gets whether any email in this thread is a draft
@@ -114,58 +115,58 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// <summary> /// <summary>
/// Gets the draft ID from the latest email if it's a draft /// Gets the draft ID from the latest email if it's a draft
/// </summary> /// </summary>
public string DraftId => latestMailViewModel?.DraftId ?? string.Empty; public string DraftId => newestMailViewModel?.DraftId ?? string.Empty;
/// <summary> /// <summary>
/// Gets the ID from the latest email /// Gets the ID from the latest email
/// </summary> /// </summary>
public string Id => latestMailViewModel?.Id ?? string.Empty; public string Id => newestMailViewModel?.Id ?? string.Empty;
/// <summary> /// <summary>
/// Gets the importance of the latest email /// Gets the importance of the latest email
/// </summary> /// </summary>
public MailImportance Importance => latestMailViewModel?.Importance ?? MailImportance.Normal; public MailImportance Importance => newestMailViewModel?.Importance ?? MailImportance.Normal;
/// <summary> /// <summary>
/// Gets the thread ID from the latest email /// Gets the thread ID from the latest email
/// </summary> /// </summary>
public string ThreadId => latestMailViewModel?.ThreadId ?? _threadId; public string ThreadId => newestMailViewModel?.ThreadId ?? _threadId;
/// <summary> /// <summary>
/// Gets the message ID from the latest email /// Gets the message ID from the latest email
/// </summary> /// </summary>
public string MessageId => latestMailViewModel?.MessageId ?? string.Empty; public string MessageId => newestMailViewModel?.MessageId ?? string.Empty;
/// <summary> /// <summary>
/// Gets the references from the latest email /// Gets the references from the latest email
/// </summary> /// </summary>
public string References => latestMailViewModel?.References ?? string.Empty; public string References => newestMailViewModel?.References ?? string.Empty;
/// <summary> /// <summary>
/// Gets the in-reply-to from the latest email /// Gets the in-reply-to from the latest email
/// </summary> /// </summary>
public string InReplyTo => latestMailViewModel?.InReplyTo ?? string.Empty; public string InReplyTo => newestMailViewModel?.InReplyTo ?? string.Empty;
/// <summary> /// <summary>
/// Gets the file ID from the latest email /// Gets the file ID from the latest email
/// </summary> /// </summary>
public Guid FileId => latestMailViewModel?.FileId ?? Guid.Empty; public Guid FileId => newestMailViewModel?.FileId ?? Guid.Empty;
/// <summary> /// <summary>
/// Gets the folder ID from the latest email /// Gets the folder ID from the latest email
/// </summary> /// </summary>
public Guid FolderId => latestMailViewModel?.FolderId ?? Guid.Empty; public Guid FolderId => newestMailViewModel?.FolderId ?? Guid.Empty;
/// <summary> /// <summary>
/// Gets the unique ID from the latest email /// Gets the unique ID from the latest email
/// </summary> /// </summary>
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;
/// <summary> /// <summary>
/// Gets all emails in this thread (observable) /// Gets all emails in this thread (observable)
@@ -201,15 +202,16 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
[NotifyPropertyChangedFor(nameof(SenderContact))] [NotifyPropertyChangedFor(nameof(SenderContact))]
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = []; public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel; private MailItemViewModel newestMailViewModel => _cachedNewestMailViewModel;
public DateTime SortingDate => CreationDate; public DateTime SortingDate => CreationDate;
public string SortingName => FromName; public string SortingName => FromName;
public ThreadMailItemViewModel(string threadId) public ThreadMailItemViewModel(string threadId, bool isNewestEmailFirst)
{ {
_threadId = threadId; _threadId = threadId;
_isNewestEmailFirst = isNewestEmailFirst;
} }
internal void SuspendChildPropertyNotifications() => _suspendChildPropertyNotificationsCount++; internal void SuspendChildPropertyNotifications() => _suspendChildPropertyNotificationsCount++;
@@ -224,11 +226,21 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
private void RefreshLatestMailCache() private void RefreshLatestMailCache()
{ {
_cachedLatestMailViewModel = ThreadEmails _cachedNewestMailViewModel = ThreadEmails
.OrderByDescending(static item => item.MailCopy.CreationDate) .OrderByDescending(static item => item.MailCopy.CreationDate)
.FirstOrDefault(); .FirstOrDefault();
} }
public MailItemViewModel GetDefaultSelectedThreadEmail()
{
if (ThreadEmails.Count == 0)
{
return null;
}
return _isNewestEmailFirst ? ThreadEmails.FirstOrDefault() : ThreadEmails.LastOrDefault();
}
/// <summary> /// <summary>
/// Adds an email to this thread /// Adds an email to this thread
/// </summary> /// </summary>
@@ -237,11 +249,15 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
if (email.MailCopy.ThreadId != _threadId) if (email.MailCopy.ThreadId != _threadId)
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander 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; var insertIndex = 0;
for (int i = 0; i < ThreadEmails.Count; i++) 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; insertIndex = i;
break; break;
@@ -298,7 +314,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
if (e.PropertyName == nameof(MailItemViewModel.ThumbnailUpdatedEvent)) if (e.PropertyName == nameof(MailItemViewModel.ThumbnailUpdatedEvent))
{ {
if (ReferenceEquals(updatedMailItem, latestMailViewModel)) if (ReferenceEquals(updatedMailItem, newestMailViewModel))
{ {
OnPropertyChanged(nameof(ThumbnailUpdatedEvent)); OnPropertyChanged(nameof(ThumbnailUpdatedEvent));
} }
@@ -329,7 +345,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
if (changedFlags == MailCopyChangeFlags.None) if (changedFlags == MailCopyChangeFlags.None)
return; return;
var previousLatest = latestMailViewModel; var previousLatest = newestMailViewModel;
if (changedFlags == MailCopyChangeFlags.All || if (changedFlags == MailCopyChangeFlags.All ||
(changedFlags & MailCopyChangeFlags.CreationDate) != 0 || (changedFlags & MailCopyChangeFlags.CreationDate) != 0 ||
@@ -339,7 +355,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
RefreshLatestMailCache(); RefreshLatestMailCache();
} }
var currentLatest = latestMailViewModel; var currentLatest = newestMailViewModel;
var latestChanged = !ReferenceEquals(previousLatest, currentLatest); var latestChanged = !ReferenceEquals(previousLatest, currentLatest);
var updatesDisplayedLatest = changedFlags == MailCopyChangeFlags.All || var updatesDisplayedLatest = changedFlags == MailCopyChangeFlags.All ||
@@ -205,6 +205,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
SelectedFilterOption = FilterOptions[0]; SelectedFilterOption = FilterOptions[0];
SelectedSortingOption = SortingOptions[0]; SelectedSortingOption = SortingOptions[0];
MailCollection.ThreadItemFactory = threadId => new ThreadMailItemViewModel(threadId, PreferencesService.IsNewestThreadMailFirst);
MailListLength = statePersistenceService.MailListPaneLength; MailListLength = statePersistenceService.MailListPaneLength;
} }
@@ -42,6 +42,12 @@ public partial class MessageListPageViewModel : MailBaseViewModel
Translator.HoverActionOption_MoveJunk Translator.HoverActionOption_MoveJunk
]; ];
public List<string> ThreadItemSortingOptions { get; } =
[
Translator.SettingsThreadOrder_LastItemFirst,
Translator.SettingsThreadOrder_FirstItemFirst
];
public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; } = new DemoMailItemDisplayInformation(); public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; } = new DemoMailItemDisplayInformation();
public MailListDisplayMode SelectedMailSpacingMode => availableMailSpacingOptions[selectedMailSpacingIndex]; 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 #region Properties
private int leftHoverActionIndex; private int leftHoverActionIndex;
public int LeftHoverActionIndex public int LeftHoverActionIndex
@@ -128,6 +147,7 @@ public partial class MessageListPageViewModel : MailBaseViewModel
rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction); rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction);
selectedMailSpacingIndex = availableMailSpacingOptions.IndexOf(PreferencesService.MailItemDisplayMode); selectedMailSpacingIndex = availableMailSpacingOptions.IndexOf(PreferencesService.MailItemDisplayMode);
SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues<MailMarkAsOption>(), PreferencesService.MarkAsPreference); SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues<MailMarkAsOption>(), PreferencesService.MarkAsPreference);
selectedThreadItemSortingIndex = PreferencesService.IsNewestThreadMailFirst ? 0 : 1;
} }
[RelayCommand] [RelayCommand]
@@ -117,6 +117,12 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
set => SetPropertyAndSave(nameof(IsThreadingEnabled), value); set => SetPropertyAndSave(nameof(IsThreadingEnabled), value);
} }
public bool IsNewestThreadMailFirst
{
get => _configurationService.Get(nameof(IsNewestThreadMailFirst), true);
set => SetPropertyAndSave(nameof(IsNewestThreadMailFirst), value);
}
public bool IsMailListActionBarEnabled public bool IsMailListActionBarEnabled
{ {
get => _configurationService.Get(nameof(IsMailListActionBarEnabled), false); get => _configurationService.Get(nameof(IsMailListActionBarEnabled), false);
@@ -990,10 +990,10 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
else else
{ {
var firstChild = clickedThread.ThreadEmails.FirstOrDefault(); var defaultSelectedChild = clickedThread.GetDefaultSelectedThreadEmail();
if (firstChild != null) if (defaultSelectedChild != null)
{ {
firstChild.IsSelected = true; defaultSelectedChild.IsSelected = true;
} }
} }
File diff suppressed because one or more lines are too long