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 @@
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+