Add per-account folder customization page (#855)

Introduce a dedicated settings page that lets users reorder, hide,
and pin/unpin folders per account. Folders are organized into Pinned,
Categories (Gmail only), and More sections with drag-to-reorder via
ListView. New Order column on MailItemFolder persists the custom
layout; the default sort falls back to alphabetic when no custom
order is set. A reset action wipes all customization in a single
transaction and restores system-folder stickiness.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Burak Kaan Köse
2026-04-16 14:07:17 +02:00
committed by GitHub
parent 0b136b3d66
commit 98eed39fe6
17 changed files with 599 additions and 10 deletions
@@ -24,6 +24,11 @@ public class MailItemFolder : IMailItemFolder
public bool IsSynchronizationEnabled { get; set; }
public bool IsHidden { get; set; }
public bool ShowUnreadCount { get; set; }
// User-defined ordering within its navigation section (Pinned / Categories / More).
// 0 means "no custom order set" — the folder falls back to the default sort
// (alphabetic for More, canonical SpecialFolderType order as a tiebreak for Pinned).
public int Order { get; set; }
public DateTime? LastSynchronizedDate { get; set; }
// For IMAP
+1
View File
@@ -12,6 +12,7 @@ public enum WinoPage
ContactsPage,
MailRenderingPage,
AccountDetailsPage,
FolderCustomizationPage,
MergedAccountDetailsPage,
ManageAccountsPage,
AccountManagementPage,
@@ -22,6 +22,25 @@ public interface IFolderService
Task<int> GetFolderNotificationBadgeAsync(Guid folderId);
Task ChangeStickyStatusAsync(Guid folderId, bool isSticky);
/// <summary>
/// Toggles a folder's visibility in the navigation menu.
/// Hidden folders are still synchronized if sync is enabled.
/// </summary>
Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden);
/// <summary>
/// Persists a new custom ordering for the given folders.
/// The first id becomes Order=1, second Order=2, etc.
/// Caller is responsible for notifying the shell to refresh.
/// </summary>
Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList<Guid> orderedFolderIds);
/// <summary>
/// Wipes every user folder customization for the account: clears custom Order,
/// un-hides folders, and restores IsSticky on system folders.
/// </summary>
Task ResetFolderCustomizationAsync(Guid accountId);
Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration);
Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled);
Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount);
@@ -176,6 +176,7 @@ public static class SettingsNavigationInfoProvider
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
WinoPage.FolderCustomizationPage => WinoPage.ManageAccountsPage,
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
@@ -885,6 +885,21 @@
"SettingsManageAliases_Title": "Aliases",
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
"SettingsMailCategories_Title": "Categories",
"FolderCustomization_Title": "Customize folder list",
"FolderCustomization_Description": "Reorder, hide, or pin folders for this account.",
"FolderCustomization_EntryCardTitle": "Customize folder list",
"FolderCustomization_EntryCardDescription": "Arrange pinned folders, hide folders you don't use, and reorder the More section.",
"FolderCustomization_SectionPinned": "Pinned",
"FolderCustomization_SectionCategories": "Categories",
"FolderCustomization_SectionMore": "More",
"FolderCustomization_Pin": "Pin to top",
"FolderCustomization_Unpin": "Move to More",
"FolderCustomization_Show": "Show in navigation",
"FolderCustomization_Hide": "Hide from navigation",
"FolderCustomization_Reset": "Reset to defaults",
"FolderCustomization_ResetConfirmTitle": "Reset folder layout",
"FolderCustomization_ResetConfirmMessage": "This will clear any custom folder order and restore the default alphabetic layout for this account. Hidden folders will become visible again. Continue?",
"FolderCustomization_EmptySection": "Drag folders here to add them to this section.",
"SettingsEditAccountDetails_Title": "Edit Account Details",
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
"SettingsAccountDetails_NavigationTitle": "{0} details",
@@ -173,6 +173,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private void EditCategories()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id));
[RelayCommand]
private void CustomizeFolderList()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.FolderCustomization_Title, WinoPage.FolderCustomizationPage, Account.Id));
[RelayCommand]
private void EditImapCalDavSettings()
=> Messenger.Send(new BreadcrumbNavigationRequested(
@@ -0,0 +1,26 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Mail.ViewModels.Data;
/// <summary>
/// Per-folder row shown on the Folder Customization page. Wraps the underlying
/// <see cref="MailItemFolder"/> entity and exposes observable flags for binding.
/// </summary>
public partial class FolderCustomizationItemViewModel : ObservableObject
{
public MailItemFolder Folder { get; }
[ObservableProperty]
public partial bool IsHidden { get; set; }
public string FolderName => Folder.FolderName;
public bool IsSystemFolder => Folder.IsSystemFolder;
public Core.Domain.Enums.SpecialFolderType SpecialFolderType => Folder.SpecialFolderType;
public FolderCustomizationItemViewModel(MailItemFolder folder)
{
Folder = folder;
IsHidden = folder.IsHidden;
}
}
@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels;
/// <summary>
/// Backs the per-account Folder Customization page — lets the user reorder,
/// pin/unpin, and hide folders for a single real (non-merged) account.
/// </summary>
public partial class FolderCustomizationPageViewModel : MailBaseViewModel
{
private readonly IMailDialogService _dialogService;
private readonly IFolderService _folderService;
private readonly IAccountService _accountService;
private static readonly SpecialFolderType[] GmailCategorySubTypes =
[
SpecialFolderType.Promotions,
SpecialFolderType.Social,
SpecialFolderType.Updates,
SpecialFolderType.Forums,
SpecialFolderType.Personal
];
private Guid _accountId;
private bool _isLoaded;
[ObservableProperty]
public partial string AccountName { get; set; }
[ObservableProperty]
public partial bool IsGmailAccount { get; set; }
public ObservableCollection<FolderCustomizationItemViewModel> PinnedFolders { get; } = [];
public ObservableCollection<FolderCustomizationItemViewModel> CategoryFolders { get; } = [];
public ObservableCollection<FolderCustomizationItemViewModel> MoreFolders { get; } = [];
public FolderCustomizationPageViewModel(IMailDialogService dialogService,
IFolderService folderService,
IAccountService accountService)
{
_dialogService = dialogService;
_folderService = folderService;
_accountService = accountService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is not Guid accountId)
return;
_accountId = accountId;
var account = await _accountService.GetAccountAsync(accountId);
if (account == null) return;
AccountName = account.Name;
IsGmailAccount = account.ProviderType == MailProviderType.Gmail;
await LoadFoldersAsync();
_isLoaded = true;
}
private async Task LoadFoldersAsync()
{
PinnedFolders.Clear();
CategoryFolders.Clear();
MoreFolders.Clear();
var allFolders = await _folderService.GetFoldersAsync(_accountId);
// Skip the Gmail "Categories" virtual bucket entity — Categories are rendered
// as an inline section, not as a regular folder row.
foreach (var folder in allFolders.Where(f => f.SpecialFolderType != SpecialFolderType.Category))
{
var item = new FolderCustomizationItemViewModel(folder);
if (IsGmailAccount && GmailCategorySubTypes.Contains(folder.SpecialFolderType))
{
CategoryFolders.Add(item);
}
else if (folder.IsSticky)
{
PinnedFolders.Add(item);
}
else
{
MoreFolders.Add(item);
}
}
}
[RelayCommand]
private async Task ResetAsync()
{
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
Translator.FolderCustomization_ResetConfirmMessage,
Translator.FolderCustomization_ResetConfirmTitle,
Translator.FolderCustomization_Reset);
if (!confirmed) return;
await _folderService.ResetFolderCustomizationAsync(_accountId);
await LoadFoldersAsync();
}
/// <summary>
/// Called by the view after a drag-reorder or pin/unpin change. Persists the
/// complete new layout and hidden state for this account.
/// </summary>
public async Task PersistLayoutAsync()
{
if (!_isLoaded) return;
// Reconcile IsSticky: Pinned rows become sticky, everything in More loses sticky.
// Categories (Gmail virtual group children) keep their own rules.
var touchedFolders = new List<MailItemFolder>();
foreach (var item in PinnedFolders)
{
if (!item.Folder.IsSticky)
{
item.Folder.IsSticky = true;
touchedFolders.Add(item.Folder);
}
}
foreach (var item in MoreFolders)
{
if (item.Folder.IsSticky)
{
item.Folder.IsSticky = false;
touchedFolders.Add(item.Folder);
}
}
foreach (var folder in touchedFolders)
{
await _folderService.ChangeStickyStatusAsync(folder.Id, folder.IsSticky);
}
// Persist the new order: Pinned first, then Categories, then More. The
// concrete number assigned is only meaningful relative to others in the
// same account; we still number them globally for simplicity.
var orderedIds = new List<Guid>();
orderedIds.AddRange(PinnedFolders.Select(a => a.Folder.Id));
orderedIds.AddRange(CategoryFolders.Select(a => a.Folder.Id));
orderedIds.AddRange(MoreFolders.Select(a => a.Folder.Id));
await _folderService.UpdateFolderOrdersAsync(_accountId, orderedIds);
}
public async Task ToggleHiddenAsync(FolderCustomizationItemViewModel item)
{
if (item == null) return;
item.IsHidden = !item.IsHidden;
item.Folder.IsHidden = item.IsHidden;
await _folderService.ChangeFolderHiddenStatusAsync(item.Folder.Id, item.IsHidden);
}
public async Task TogglePinAsync(FolderCustomizationItemViewModel item)
{
if (item == null) return;
// Categories sub-items cannot be pinned individually; they always travel
// with the virtual Categories group.
if (CategoryFolders.Contains(item)) return;
if (PinnedFolders.Contains(item))
{
PinnedFolders.Remove(item);
MoreFolders.Insert(0, item);
}
else if (MoreFolders.Contains(item))
{
MoreFolders.Remove(item);
PinnedFolders.Add(item);
}
await PersistLayoutAsync();
}
}
+1
View File
@@ -368,6 +368,7 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(ImapCalDavSettingsPageViewModel));
services.AddTransient(typeof(AccountDetailsPageViewModel));
services.AddTransient(typeof(FolderCustomizationPageViewModel));
services.AddTransient(typeof(SignatureManagementPageViewModel));
services.AddTransient(typeof(MessageListPageViewModel));
services.AddTransient(typeof(MailNotificationSettingsPageViewModel));
+4
View File
@@ -274,6 +274,10 @@ public static class XamlHelpers
};
}
// Segoe Fluent icon glyphs for the show/hide toggle on the folder
// customization page. E7B3 = "Hide" (eye with slash), E7B2 = "RedEye".
public static string GetHideGlyph(bool isHidden) => isHidden ? "\uE7B3" : "\uE7B2";
public static WinoIconGlyph GetSpecialFolderPathIconGeometry(SpecialFolderType specialFolderType)
{
return specialFolderType switch
@@ -74,6 +74,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.ManageAccountsPage,
WinoPage.AccountManagementPage,
WinoPage.AccountDetailsPage,
WinoPage.FolderCustomizationPage,
WinoPage.MergedAccountDetailsPage,
WinoPage.SignatureManagementPage,
WinoPage.AboutPage,
@@ -136,6 +137,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.None => null,
WinoPage.IdlePage => typeof(IdlePage),
WinoPage.AccountDetailsPage => typeof(AccountDetailsPage),
WinoPage.FolderCustomizationPage => typeof(FolderCustomizationPage),
WinoPage.MergedAccountDetailsPage => typeof(MergedAccountDetailsPage),
WinoPage.AccountManagementPage => typeof(AccountManagementPage),
WinoPage.ManageAccountsPage => typeof(AccountManagementPage),
@@ -0,0 +1,6 @@
using Wino.Mail.WinUI;
using Wino.Mail.ViewModels;
namespace Wino.Views.Abstract;
public abstract class FolderCustomizationPageAbstract : BasePage<FolderCustomizationPageViewModel> { }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
<abstract:FolderCustomizationPageAbstract
x:Class="Wino.Views.FolderCustomizationPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:data="using:Wino.Mail.ViewModels.Data"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Page.Resources>
<DataTemplate x:Key="FolderRowTemplate" x:DataType="data:FolderCustomizationItemViewModel">
<Grid
Padding="8"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="6"
ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
VerticalAlignment="Center"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE700;" />
<coreControls:WinoFontIcon
Grid.Column="1"
VerticalAlignment="Center"
FontSize="18"
Icon="{x:Bind helpers:XamlHelpers.GetSpecialFolderPathIconGeometry(SpecialFolderType)}" />
<TextBlock
Grid.Column="2"
VerticalAlignment="Center"
Text="{x:Bind FolderName}"
TextTrimming="CharacterEllipsis" />
<Button
Grid.Column="3"
Click="HideToggle_Click"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="{x:Bind domain:Translator.FolderCustomization_Hide}">
<FontIcon FontSize="16" Glyph="{x:Bind helpers:XamlHelpers.GetHideGlyph(IsHidden), Mode=OneWay}" />
</Button>
<Button
Grid.Column="4"
Click="PinToggle_Click"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="{x:Bind domain:Translator.FolderCustomization_Pin}">
<FontIcon FontSize="16" Glyph="&#xE718;" />
</Button>
</Grid>
</DataTemplate>
</Page.Resources>
<ScrollViewer>
<Grid Padding="16,12" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.FolderCustomization_Title}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.FolderCustomization_Description}"
TextWrapping="Wrap" />
</StackPanel>
<Button
Grid.Column="1"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.ResetCommand}"
Content="{x:Bind domain:Translator.FolderCustomization_Reset}" />
</Grid>
<Expander
Grid.Row="1"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
IsExpanded="True">
<Expander.Header>
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon FontSize="16" Glyph="&#xE718;" />
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionPinned}" />
</StackPanel>
</Expander.Header>
<ListView
AllowDrop="True"
CanReorderItems="True"
DropCompleted="ListView_DropCompleted"
ItemTemplate="{StaticResource FolderRowTemplate}"
ItemsSource="{x:Bind ViewModel.PinnedFolders}"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="4" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</Expander>
<Expander
Grid.Row="2"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
IsExpanded="True"
Visibility="{x:Bind ViewModel.IsGmailAccount, Mode=OneWay}">
<Expander.Header>
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon FontSize="16" Glyph="&#xE8EC;" />
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionCategories}" />
</StackPanel>
</Expander.Header>
<ListView
AllowDrop="True"
CanReorderItems="True"
DropCompleted="ListView_DropCompleted"
ItemTemplate="{StaticResource FolderRowTemplate}"
ItemsSource="{x:Bind ViewModel.CategoryFolders}"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="4" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</Expander>
<Expander
Grid.Row="3"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
IsExpanded="True">
<Expander.Header>
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon FontSize="16" Glyph="&#xE712;" />
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionMore}" />
</StackPanel>
</Expander.Header>
<ListView
AllowDrop="True"
CanReorderItems="True"
DropCompleted="ListView_DropCompleted"
ItemTemplate="{StaticResource FolderRowTemplate}"
ItemsSource="{x:Bind ViewModel.MoreFolders}"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="4" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</Expander>
</Grid>
</ScrollViewer>
</abstract:FolderCustomizationPageAbstract>
@@ -0,0 +1,38 @@
using System.Collections.ObjectModel;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Mail.ViewModels.Data;
using Wino.Views.Abstract;
namespace Wino.Views;
public sealed partial class FolderCustomizationPage : FolderCustomizationPageAbstract
{
public FolderCustomizationPage()
{
InitializeComponent();
}
private async void ListView_DropCompleted(UIElement sender, Microsoft.UI.Xaml.Controls.Primitives.DropCompletedEventArgs args)
{
// ListView.CanReorderItems automatically mutates the backing
// ObservableCollection; persist the new order here.
await ViewModel.PersistLayoutAsync();
}
private async void PinToggle_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element && element.DataContext is FolderCustomizationItemViewModel item)
{
await ViewModel.TogglePinAsync(item);
}
}
private async void HideToggle_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element && element.DataContext is FolderCustomizationItemViewModel item)
{
await ViewModel.ToggleHiddenAsync(item);
}
}
}
+7
View File
@@ -113,6 +113,13 @@ public class DatabaseService : IDatabaseService
.ConfigureAwait(false);
}
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.Order)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(MailItemFolder)} ADD COLUMN \"{nameof(MailItemFolder.Order)}\" INTEGER NOT NULL DEFAULT 0")
.ConfigureAwait(false);
}
var customServerColumns = await Connection.GetTableInfoAsync(nameof(CustomServerInformation)).ConfigureAwait(false);
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavServiceUrl)))
+74 -10
View File
@@ -45,6 +45,60 @@ public class FolderService : BaseDatabaseService, IFolderService
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsSticky = ? WHERE Id = ?", isSticky, folderId);
public async Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden)
{
await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsHidden = ? WHERE Id = ?", isHidden, folderId);
var folder = await GetFolderAsync(folderId).ConfigureAwait(false);
if (folder != null)
{
Messenger.Send(new AccountFolderConfigurationUpdated(folder.MailAccountId));
}
}
public async Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList<Guid> orderedFolderIds)
{
if (orderedFolderIds == null || orderedFolderIds.Count == 0) return;
await Connection.RunInTransactionAsync(conn =>
{
for (int i = 0; i < orderedFolderIds.Count; i++)
{
conn.Execute("UPDATE MailItemFolder SET \"Order\" = ? WHERE Id = ? AND MailAccountId = ?",
i + 1, orderedFolderIds[i], accountId);
}
}).ConfigureAwait(false);
Messenger.Send(new AccountFolderConfigurationUpdated(accountId));
}
public async Task ResetFolderCustomizationAsync(Guid accountId)
{
await Connection.RunInTransactionAsync(conn =>
{
conn.Execute("UPDATE MailItemFolder SET \"Order\" = 0, IsHidden = 0 WHERE MailAccountId = ?", accountId);
// Restore system folder stickiness. Category-type folders are virtual stickies too.
conn.Execute(
"UPDATE MailItemFolder SET IsSticky = 1 WHERE MailAccountId = ? AND (IsSystemFolder = 1 OR SpecialFolderType = ?)",
accountId, (int)SpecialFolderType.Category);
}).ConfigureAwait(false);
Messenger.Send(new AccountFolderConfigurationUpdated(accountId));
}
/// <summary>
/// Orders folders by user-set Order first (customized entries ahead of uncustomized ones),
/// then falls back to alphabetic folder name (culture-aware), then to SpecialFolderType
/// as a final canonical tiebreak.
/// </summary>
private static IOrderedEnumerable<MailItemFolder> ApplyFolderSort(IEnumerable<MailItemFolder> folders)
=> folders
.OrderBy(a => a.Order == 0 ? 1 : 0)
.ThenBy(a => a.Order)
.ThenBy(a => a.FolderName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(a => a.SpecialFolderType);
public async Task<int> GetFolderNotificationBadgeAsync(Guid folderId)
{
var folder = await GetFolderAsync(folderId);
@@ -104,8 +158,10 @@ public class FolderService : BaseDatabaseService, IFolderService
if (!includeHiddenFolders)
folderQuery = folderQuery.Where(a => !a.IsHidden);
// Load child folders for each folder.
var allFolders = await folderQuery.OrderBy(a => a.SpecialFolderType).ToListAsync();
// Load child folders for each folder, applying user-defined ordering with
// alphabetic fallback for folders the user hasn't explicitly re-ordered.
var rawFolders = await folderQuery.ToListAsync();
var allFolders = ApplyFolderSort(rawFolders).ToList();
if (allFolders.Any())
{
@@ -235,7 +291,7 @@ public class FolderService : BaseDatabaseService, IFolderService
var mailAccount = accountMenuItem.HoldingAccounts.First();
var listingFolders = folders.OrderBy(a => a.SpecialFolderType);
var listingFolders = ApplyFolderSort(folders);
var moreFolder = MailItemFolder.CreateMoreFolder();
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
@@ -394,10 +450,12 @@ public class FolderService : BaseDatabaseService, IFolderService
if (folder == null)
return null;
var childFolders = await Connection.Table<MailItemFolder>()
var childFoldersRaw = await Connection.Table<MailItemFolder>()
.Where(a => a.ParentRemoteFolderId == folder.RemoteFolderId && a.MailAccountId == folder.MailAccountId)
.ToListAsync();
var childFolders = ApplyFolderSort(childFoldersRaw).ToList();
foreach (var childFolder in childFolders)
{
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
@@ -416,16 +474,20 @@ public class FolderService : BaseDatabaseService, IFolderService
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
public Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
public async Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
{
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? ORDER BY SpecialFolderType";
return Connection.QueryAsync<MailItemFolder>(query, accountId);
// Ordering is applied in managed code so that StringComparer.CurrentCultureIgnoreCase
// is honored. SQLite's default ORDER BY is not culture-aware.
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ?";
var rows = await Connection.QueryAsync<MailItemFolder>(query, accountId).ConfigureAwait(false);
return ApplyFolderSort(rows).ToList();
}
public Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
public async Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
{
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? AND IsHidden = ? ORDER BY SpecialFolderType";
return Connection.QueryAsync<MailItemFolder>(query, accountId, 0);
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? AND IsHidden = ?";
var rows = await Connection.QueryAsync<MailItemFolder>(query, accountId, 0).ConfigureAwait(false);
return ApplyFolderSort(rows).ToList();
}
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
@@ -528,6 +590,8 @@ public class FolderService : BaseDatabaseService, IFolderService
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
folder.TextColorHex = existingFolder.TextColorHex;
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
folder.Order = existingFolder.Order;
folder.IsHidden = existingFolder.IsHidden;
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);