18 Commits

Author SHA1 Message Date
Burak Kaan Köse eb2335893c Bump 2.0.2 + changelog for beta. 2026-04-15 04:33:17 +02:00
Burak Kaan Köse 1a1d69be56 New beta release workflow. 2026-04-15 04:24:04 +02:00
Burak Kaan Köse c2540926f4 Fix an issue with "CommunityToolkit.WinUI.Extensions" package was restored using labsFeed instead of Nuget. 2026-04-15 04:23:52 +02:00
Burak Kaan Köse 9424fd9a16 Testing package version. 2026-04-15 04:01:47 +02:00
Burak Kaan Köse 89b48d3ac4 Moer updates on beta release pipeline. 2026-04-15 04:01:25 +02:00
Burak Kaan Köse 0bcc7a7647 Use existing version from the branch. 2026-04-15 03:37:47 +02:00
Burak Kaan Köse 260e1ab935 RID issue fix. 2026-04-15 03:17:34 +02:00
Burak Kaan Köse ccf7c0607b Remove cert password. 2026-04-15 03:06:18 +02:00
Burak Kaan Köse b8ce7e7422 Fix the incorrect feed for community toolkit extension thing. 2026-04-15 02:52:38 +02:00
Burak Kaan Köse 1365e42fd7 Explicit nuget.config for the workflow. 2026-04-15 02:46:06 +02:00
Burak Kaan Köse 0f160545ab Changelog. 2026-04-15 02:26:54 +02:00
Burak Kaan Köse 8481a5c7cd Better changelog handling for beta release workflow. 2026-04-15 02:24:11 +02:00
Burak Kaan Köse d32745fd67 Merge branch 'codex/manual-beta-release' 2026-04-15 02:14:52 +02:00
Burak Kaan Köse 470b2b8638 Add manual beta release workflow 2026-04-15 02:14:44 +02:00
Burak Kaan Köse 7e1731f4dc Fix compose initial focus behavior 2026-04-15 02:12:01 +02:00
Burak Kaan Köse aac9f9fec3 Merge branch 'codex/mail-categories-v1' 2026-04-15 01:18:12 +02:00
Burak Kaan Köse cf8fff8ef1 Add mail categories support 2026-04-15 01:18:07 +02:00
Burak Kaan Köse 0610096b78 Handle read-only calendars 2026-04-14 17:52:38 +02:00
82 changed files with 2720 additions and 329 deletions
+187
View File
@@ -0,0 +1,187 @@
name: Manual Beta Release
on:
workflow_dispatch:
inputs:
release_title:
description: Optional GitHub release title override
required: false
type: string
permissions:
contents: write
packages: read
jobs:
release-beta:
name: Build and publish beta release
runs-on: windows-latest
env:
PROJECT_PATH: Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
MANIFEST_PATH: Wino.Mail.WinUI/Package.appxmanifest
CHANGELOG_PATH: CHANGELOG.md
NUGET_CONFIG_PATH: ${{ github.workspace }}\nuget.config
PACKAGE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\package
RELEASE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\release
CERTIFICATE_PFX_PATH: ${{ github.workspace }}\artifacts\signing\beta-signing-cert.pfx
CERTIFICATE_CER_PATH: ${{ github.workspace }}\artifacts\release\Wino-Mail-Beta.cer
steps:
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Fetch tags from origin
shell: pwsh
run: git fetch origin --force --tags
- name: Validate release secrets
shell: pwsh
env:
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
run: |
if ([string]::IsNullOrWhiteSpace($env:BETA_SIGNING_CERT_PFX_BASE64)) {
throw "Missing required secret: BETA_SIGNING_CERT_PFX_BASE64"
}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compute beta version and release metadata
id: metadata
shell: pwsh
env:
RELEASE_TITLE_INPUT: ${{ github.event.inputs.release_title }}
run: |
$manifestPath = Join-Path $env:GITHUB_WORKSPACE $env:MANIFEST_PATH
if (-not (Test-Path $manifestPath)) {
throw "Package manifest not found: $manifestPath"
}
$changelogPath = Join-Path $env:GITHUB_WORKSPACE $env:CHANGELOG_PATH
if (-not (Test-Path $changelogPath)) {
throw "Release notes file not found: $changelogPath"
}
[xml]$manifest = Get-Content -LiteralPath $manifestPath
$identityNode = $manifest.Package.Identity
if (-not $identityNode) {
throw "Could not locate the Package/Identity node in $manifestPath"
}
$currentVersionText = [string]$identityNode.Version
if ($currentVersionText -notmatch '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)\.(?<revision>\d+)$') {
throw "Manifest version '$currentVersionText' is not a four-part numeric version."
}
$packageVersion = $currentVersionText
$releaseTag = "v$packageVersion"
$releaseTitleInput = $env:RELEASE_TITLE_INPUT
$releaseTitle = if ([string]::IsNullOrWhiteSpace($releaseTitleInput)) { $releaseTag } else { $releaseTitleInput.Trim() }
$headSha = (git rev-parse HEAD).Trim()
if ([string]::IsNullOrWhiteSpace($headSha)) {
throw "Failed to resolve the checked out commit SHA."
}
$notesInput = Get-Content -LiteralPath $changelogPath -Raw
if ([string]::IsNullOrWhiteSpace($notesInput)) {
throw "Release notes file is empty: $changelogPath"
}
$notesInput = $notesInput.Trim()
New-Item -ItemType Directory -Path $env:RELEASE_OUTPUT_DIR -Force | Out-Null
$releaseNotesPath = Join-Path $env:RELEASE_OUTPUT_DIR 'beta-release-notes.md'
$notesInput | Set-Content -LiteralPath $releaseNotesPath -Encoding utf8
"package_version=$packageVersion" >> $env:GITHUB_OUTPUT
"release_tag=$releaseTag" >> $env:GITHUB_OUTPUT
"release_title=$releaseTitle" >> $env:GITHUB_OUTPUT
"release_notes_path=$releaseNotesPath" >> $env:GITHUB_OUTPUT
"head_sha=$headSha" >> $env:GITHUB_OUTPUT
- name: Materialize signing certificate
shell: pwsh
env:
BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }}
run: |
$signingDir = Split-Path -Parent $env:CERTIFICATE_PFX_PATH
New-Item -ItemType Directory -Path $signingDir -Force | Out-Null
[IO.File]::WriteAllBytes($env:CERTIFICATE_PFX_PATH, [Convert]::FromBase64String($env:BETA_SIGNING_CERT_PFX_BASE64))
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:CERTIFICATE_PFX_PATH, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)
New-Item -ItemType Directory -Path (Split-Path -Parent $env:CERTIFICATE_CER_PATH) -Force | Out-Null
[IO.File]::WriteAllBytes($env:CERTIFICATE_CER_PATH, $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
- name: Restore WinUI project dependencies
shell: pwsh
run: |
if (-not (Test-Path $env:NUGET_CONFIG_PATH)) {
throw "NuGet config file not found: $env:NUGET_CONFIG_PATH"
}
dotnet restore $env:PROJECT_PATH `
--configfile $env:NUGET_CONFIG_PATH `
-p:Platform=x64 `
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH"
- name: Build MSIX bundle
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:PACKAGE_OUTPUT_DIR -Force | Out-Null
dotnet build $env:PROJECT_PATH `
--configuration Release `
--no-restore `
--configfile $env:NUGET_CONFIG_PATH `
/p:Platform=x64 `
/p:RestoreConfigFile="$env:NUGET_CONFIG_PATH" `
/p:GenerateAppxPackageOnBuild=true `
/p:UapAppxPackageBuildMode=SideloadOnly `
/p:AppxBundle=Always `
/p:AppxBundlePlatforms="x86|x64|arm64" `
/p:AppxPackageDir="$env:PACKAGE_OUTPUT_DIR\\" `
/p:AppxPackageVersion=${{ steps.metadata.outputs.package_version }} `
/p:PackageCertificateKeyFile="$env:CERTIFICATE_PFX_PATH" `
/p:PackageCertificatePassword= `
/p:PackageCertificateThumbprint= `
/p:AppxPackageSigningEnabled=true
- name: Collect packaged artifacts
id: package
shell: pwsh
run: |
$bundle = Get-ChildItem -Path $env:PACKAGE_OUTPUT_DIR -Recurse -Filter *.msixbundle | Select-Object -First 1
if (-not $bundle) {
throw "No .msixbundle file was generated under $env:PACKAGE_OUTPUT_DIR"
}
$releaseAssetPath = Join-Path $env:RELEASE_OUTPUT_DIR "Wino_${{ steps.metadata.outputs.package_version }}.zip"
if (Test-Path $releaseAssetPath) {
Remove-Item -LiteralPath $releaseAssetPath -Force
}
Compress-Archive -LiteralPath @($bundle.FullName, $env:CERTIFICATE_CER_PATH) -DestinationPath $releaseAssetPath -Force
"bundle_path=$($bundle.FullName)" >> $env:GITHUB_OUTPUT
"bundle_name=$($bundle.Name)" >> $env:GITHUB_OUTPUT
"release_asset_path=$releaseAssetPath" >> $env:GITHUB_OUTPUT
- name: Create GitHub prerelease
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.metadata.outputs.release_tag }}" `
"${{ steps.package.outputs.release_asset_path }}" `
--repo "${{ github.repository }}" `
--target "${{ steps.metadata.outputs.head_sha }}" `
--title "${{ steps.metadata.outputs.release_title }}" `
--notes-file "${{ steps.metadata.outputs.release_notes_path }}" `
--prerelease
+7
View File
@@ -0,0 +1,7 @@
Released on April 15. Second bugfix/improvements update for v2 beta testing.
- [Ability to delete custom themes](https://github.com/bkaankose/Wino-Mail/issues/844)
- [[Proposal] Reply/Reply all sets focus to the "To" line versus Body](https://github.com/bkaankose/Wino-Mail/issues/844274)
- Email categories. Online sync for Outlook, offline use for IMAP/Gmail.
- Handling of read-only calendars.
- Implemented a new Github action workflow to trigger beta releases on demand.
@@ -178,18 +178,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
await InitializeAccountCalendarsAsync(); await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar(); ValidateConfiguredNewEventCalendar();
if (activationContext?.Parameter is CalendarItemTarget calendarItemTarget)
{
NavigationService.Navigate(WinoPage.EventDetailsPage, calendarItemTarget);
return;
}
if (activationContext?.Parameter is CalendarPageNavigationArgs calendarPageNavigationArgs)
{
NavigationService.Navigate(WinoPage.CalendarPage, calendarPageNavigationArgs);
return;
}
TodayClicked(); TodayClicked();
} }
@@ -86,6 +86,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
get get
{ {
if (SelectedQuickEventAccountCalendar == null || if (SelectedQuickEventAccountCalendar == null ||
SelectedQuickEventAccountCalendar.IsReadOnly ||
SelectedQuickEventDate == null || SelectedQuickEventDate == null ||
string.IsNullOrWhiteSpace(EventName) || string.IsNullOrWhiteSpace(EventName) ||
string.IsNullOrWhiteSpace(SelectedStartTimeString) || string.IsNullOrWhiteSpace(SelectedStartTimeString) ||
@@ -204,6 +205,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null) if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null)
return; return;
if (DisplayDetailsCalendarItemViewModel.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent) if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent)
{ {
var confirmed = await _dialogService.ShowConfirmationDialogAsync( var confirmed = await _dialogService.ShowConfirmationDialogAsync(
@@ -460,6 +467,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))] [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))]
private async Task SaveQuickEventAsync() private async Task SaveQuickEventAsync()
{ {
if (SelectedQuickEventAccountCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
var composeResult = new CalendarEventComposeResult var composeResult = new CalendarEventComposeResult
@@ -553,6 +566,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
return; return;
} }
if (calendarItem.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
var normalizedTargetStart = calendarItem.IsAllDayEvent var normalizedTargetStart = calendarItem.IsAllDayEvent
? targetStart.Date ? targetStart.Date
: targetStart; : targetStart;
@@ -1195,6 +1214,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
if (targetItem == null) if (targetItem == null)
return; return;
if (targetItem.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
if (targetItem.IsRecurringParent) if (targetItem.IsRecurringParent)
{ {
var confirmed = await _dialogService.ShowConfirmationDialogAsync( var confirmed = await _dialogService.ShowConfirmationDialogAsync(
@@ -1221,6 +1246,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
if (targetItem == null || targetItem.ShowAs == showAs) if (targetItem == null || targetItem.ShowAs == showAs)
return; return;
if (targetItem.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
var originalItem = await _calendarService.GetCalendarItemAsync(targetItem.Id).ConfigureAwait(false); var originalItem = await _calendarService.GetCalendarItemAsync(targetItem.Id).ConfigureAwait(false);
var attendees = await _calendarService.GetAttendeesAsync(targetItem.Id).ConfigureAwait(false); var attendees = await _calendarService.GetAttendeesAsync(targetItem.Id).ConfigureAwait(false);
@@ -1245,6 +1276,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
if (targetItem == null) if (targetItem == null)
return; return;
if (targetItem.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
var operation = responseStatus switch var operation = responseStatus switch
{ {
CalendarItemStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent, CalendarItemStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent,
@@ -55,6 +55,12 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i); set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
} }
public bool IsReadOnly
{
get => AccountCalendar.IsReadOnly;
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
}
public bool IsSynchronizationEnabled public bool IsSynchronizationEnabled
{ {
get => AccountCalendar.IsSynchronizationEnabled; get => AccountCalendar.IsSynchronizationEnabled;
@@ -440,6 +440,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
try try
{ {
@@ -506,6 +511,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task DeleteAsync() private async Task DeleteAsync()
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
// If the event is a master recurring event, ask for confirmation // If the event is a master recurring event, ask for confirmation
if (CurrentEvent.IsRecurringParent) if (CurrentEvent.IsRecurringParent)
@@ -610,6 +620,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task SendRsvpResponse(AttendeeStatus status) private async Task SendRsvpResponse(AttendeeStatus status)
{ {
if (CurrentEvent == null) return; if (CurrentEvent == null) return;
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
try try
{ {
-1
View File
@@ -21,7 +21,6 @@ public static class Constants
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction); public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction); public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId); public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
public const string ToastCalendarSnoozeDurationMinutesKey = nameof(ToastCalendarSnoozeDurationMinutesKey);
public const string ToastModeKey = nameof(ToastModeKey); public const string ToastModeKey = nameof(ToastModeKey);
public const string ToastModeMail = nameof(ToastModeMail); public const string ToastModeMail = nameof(ToastModeMail);
public const string ToastModeCalendar = nameof(ToastModeCalendar); public const string ToastModeCalendar = nameof(ToastModeCalendar);
@@ -16,6 +16,7 @@ public class AccountCalendar : IAccountCalendar
public string SynchronizationDeltaToken { get; set; } public string SynchronizationDeltaToken { get; set; }
public string Name { get; set; } public string Name { get; set; }
public bool IsPrimary { get; set; } public bool IsPrimary { get; set; }
public bool IsReadOnly { get; set; }
public bool IsSynchronizationEnabled { get; set; } = true; public bool IsSynchronizationEnabled { get; set; } = true;
public bool IsExtended { get; set; } = true; public bool IsExtended { get; set; } = true;
public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy; public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy;
@@ -0,0 +1,25 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Mail;
public class MailCategory
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid MailAccountId { get; set; }
public string RemoteId { get; set; }
public string Name { get; set; }
public bool IsFavorite { get; set; }
public string BackgroundColorHex { get; set; }
public string TextColorHex { get; set; }
public MailCategorySource Source { get; set; } = MailCategorySource.Local;
}
@@ -0,0 +1,14 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Mail;
public class MailCategoryAssignment
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid MailCategoryId { get; set; }
public Guid MailCopyUniqueId { get; set; }
}
@@ -132,5 +132,10 @@ public class MailAccount
/// </summary> /// </summary>
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook; public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
/// <summary>
/// Gets whether the account can perform category definition sync type.
/// </summary>
public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook;
public override string ToString() => Name; public override string ToString() => Name;
} }
@@ -0,0 +1,7 @@
namespace Wino.Core.Domain.Enums;
public enum MailCategorySource
{
Local,
Outlook
}
+8
View File
@@ -13,6 +13,7 @@ public enum MailSynchronizerOperation
AlwaysMoveTo, AlwaysMoveTo,
MoveToFocused, MoveToFocused,
Archive, Archive,
UpdateCategories,
} }
public enum FolderSynchronizerOperation public enum FolderSynchronizerOperation
@@ -35,6 +36,13 @@ public enum CalendarSynchronizerOperation
TentativeEvent, TentativeEvent,
} }
public enum CategorySynchronizerOperation
{
CreateCategory,
UpdateCategory,
DeleteCategory,
}
// UI requests // UI requests
public enum MailOperation public enum MailOperation
{ {
@@ -3,6 +3,7 @@
public enum MailSynchronizationType public enum MailSynchronizationType
{ {
UpdateProfile, // Only update profile information UpdateProfile, // Only update profile information
Categories, // Only update mail categories
ExecuteRequests, // Run the queued requests, and then synchronize if needed. ExecuteRequests, // Run the queued requests, and then synchronize if needed.
FoldersOnly, // Only synchronize folder metadata. FoldersOnly, // Only synchronize folder metadata.
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders. InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
+1
View File
@@ -24,6 +24,7 @@ public enum WinoPage
AppPreferencesPage, AppPreferencesPage,
SettingOptionsPage, SettingOptionsPage,
AliasManagementPage, AliasManagementPage,
MailCategoryManagementPage,
ImapCalDavSettingsPage, ImapCalDavSettingsPage,
KeyboardShortcutsPage, KeyboardShortcutsPage,
CalendarPage, CalendarPage,
@@ -10,6 +10,7 @@ public interface IAccountCalendar
string TextColorHex { get; set; } string TextColorHex { get; set; }
string BackgroundColorHex { get; set; } string BackgroundColorHex { get; set; }
bool IsPrimary { get; set; } bool IsPrimary { get; set; }
bool IsReadOnly { get; set; }
bool IsSynchronizationEnabled { get; set; } bool IsSynchronizationEnabled { get; set; }
Guid AccountId { get; set; } Guid AccountId { get; set; }
string RemoteCalendarId { get; set; } string RemoteCalendarId { get; set; }
@@ -14,6 +14,22 @@ public interface IFolderMenuItem : IBaseFolderMenuItem
public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { } public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { }
public interface IMailCategoryMenuItem : IBaseFolderMenuItem
{
Entities.Mail.MailCategory MailCategory { get; }
string TextColorHex { get; }
string BackgroundColorHex { get; }
bool HasTextColor { get; }
}
public interface IMergedMailCategoryMenuItem : IBaseFolderMenuItem
{
IReadOnlyList<Entities.Mail.MailCategory> Categories { get; }
string TextColorHex { get; }
string BackgroundColorHex { get; }
bool HasTextColor { get; }
}
public interface IBaseFolderMenuItem : IMenuItem public interface IBaseFolderMenuItem : IMenuItem
{ {
string FolderName { get; } string FolderName { get; }
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Core.Domain.Interfaces;
public interface IMailCategoryService
{
Task<List<MailCategory>> GetCategoriesAsync(Guid accountId);
Task<List<MailCategory>> GetFavoriteCategoriesAsync(Guid accountId);
Task<MailCategory> GetCategoryAsync(Guid categoryId);
Task<bool> CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null);
Task<MailCategory> CreateCategoryAsync(MailCategory category);
Task UpdateCategoryAsync(MailCategory category);
Task DeleteCategoryAsync(Guid categoryId);
Task DeleteCategoriesAsync(Guid accountId);
Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite);
Task UpdateRemoteIdAsync(Guid categoryId, string remoteId);
Task ReplaceCategoriesAsync(Guid accountId, IEnumerable<MailCategory> categories);
Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable<string> categoryNames);
Task AssignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
Task UnassignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
Task<List<MailCategory>> GetCategoriesForMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds);
Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds);
Task<List<string>> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId);
Task<List<MailCopy>> GetMailCopiesForCategoryAsync(Guid categoryId);
Task<List<UnreadCategoryCountResult>> GetUnreadCategoryCountResultsAsync(IEnumerable<Guid> accountIds);
}
@@ -11,11 +11,13 @@ using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
public interface IMailDialogService : IDialogServiceBase public interface IMailDialogService : IDialogServiceBase
{ {
void ShowReadOnlyCalendarMessage();
Task<bool> ShowHardDeleteConfirmationAsync(); Task<bool> ShowHardDeleteConfirmationAsync();
Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService); Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService);
@@ -51,6 +53,13 @@ public interface IMailDialogService : IDialogServiceBase
/// <returns>Created alias model if not canceled.</returns> /// <returns>Created alias model if not canceled.</returns>
Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync(); Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync();
/// <summary>
/// Presents a dialog to the user for mail category creation/modification.
/// </summary>
#pragma warning disable CS8625
Task<MailCategoryDialogResult> ShowEditMailCategoryDialogAsync(MailCategory category = null);
#pragma warning restore CS8625
/// <summary> /// <summary>
/// Presents a dialog to the user to show email source. /// Presents a dialog to the user to show email source.
/// </summary> /// </summary>
@@ -72,3 +72,9 @@ public interface ICalendarActionRequest : IRequestBase
Guid? LocalCalendarItemId { get; } Guid? LocalCalendarItemId { get; }
CalendarSynchronizerOperation Operation { get; } CalendarSynchronizerOperation Operation { get; }
} }
public interface ICategoryActionRequest : IRequestBase
{
Guid AccountId { get; }
CategorySynchronizerOperation Operation { get; }
}
@@ -63,6 +63,12 @@ public interface ISynchronizationManager
Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId, Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Handles category synchronization for the given account.
/// </summary>
Task<MailSynchronizationResult> SynchronizeCategoriesAsync(Guid accountId,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Handles profile synchronization for the given account. /// Handles profile synchronization for the given account.
/// </summary> /// </summary>
@@ -1,4 +1,6 @@
using System.Threading.Tasks; using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
@@ -36,4 +38,9 @@ public interface IWinoRequestDelegator
/// </summary> /// </summary>
/// <param name="calendarOperationPreparationRequest">Calendar preparation request.</param> /// <param name="calendarOperationPreparationRequest">Calendar preparation request.</param>
Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest); Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest);
/// <summary>
/// Queues pre-built requests for a single account and triggers synchronization.
/// </summary>
Task ExecuteAsync(Guid accountId, IEnumerable<IRequestBase> requests);
} }
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.Domain.MenuItems;
public partial class MailCategoryMenuItem : MenuItemBase<MailCategory, IMenuItem>, IFolderMenuItem, IMailCategoryMenuItem
{
private IReadOnlyList<IMailItemFolder> _handlingFolders;
[ObservableProperty]
private int unreadItemCount;
public MailCategoryMenuItem(MailCategory category, MailAccount parentAccount, IEnumerable<IMailItemFolder> handlingFolders, IMenuItem parentMenuItem)
: base(category, category.Id, parentMenuItem)
{
ParentAccount = parentAccount;
_handlingFolders = handlingFolders?.ToList() ?? [];
}
public string FolderName => Parameter.Name;
public bool IsSynchronizationEnabled => false;
public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
public IEnumerable<IMailItemFolder> HandlingFolders => _handlingFolders;
public new ObservableCollection<IMenuItem> SubMenuItems { get; } = [];
public bool IsMoveTarget => true;
public bool IsSticky => false;
public bool IsSystemFolder => false;
public bool ShowUnreadCount => true;
public string AssignedAccountName => ParentAccount?.Name;
public MailAccount ParentAccount { get; private set; }
public string TextColorHex => Parameter.TextColorHex;
public string BackgroundColorHex => Parameter.BackgroundColorHex;
public bool HasTextColor => !string.IsNullOrWhiteSpace(Parameter.TextColorHex);
public MailCategory MailCategory => Parameter;
public void UpdateFolder(IMailItemFolder folder)
{
}
public void UpdateParentAccounnt(MailAccount account) => ParentAccount = account;
}
@@ -22,11 +22,13 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public IEnumerable<IAccountMenuItem> GetAllAccountMenuItems() public IEnumerable<IAccountMenuItem> GetAllAccountMenuItems()
{ {
foreach (var item in this) var rootItems = this.ToList();
foreach (var item in rootItems)
{ {
if (item is MergedAccountMenuItem mergedAccountMenuItem) if (item is MergedAccountMenuItem mergedAccountMenuItem)
{ {
foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType<IAccountMenuItem>()) foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType<IAccountMenuItem>().ToList())
{ {
yield return singleItem; yield return singleItem;
} }
@@ -40,9 +42,11 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public IEnumerable<IBaseFolderMenuItem> GetAllFolderMenuItems(Guid folderId) public IEnumerable<IBaseFolderMenuItem> GetAllFolderMenuItems(Guid folderId)
{ {
foreach (var item in this) var rootItems = this.ToList();
foreach (var item in rootItems)
{ {
if (item is IBaseFolderMenuItem folderMenuItem) if (item is IBaseFolderMenuItem folderMenuItem && item is not IMailCategoryMenuItem && item is not IMergedMailCategoryMenuItem)
{ {
if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId)) if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId))
{ {
@@ -50,7 +54,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
} }
else if (folderMenuItem.SubMenuItems.Any()) else if (folderMenuItem.SubMenuItems.Any())
{ {
foreach (var subItem in folderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>()) foreach (var subItem in folderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>().ToList())
{ {
if (subItem.HandlingFolders.Any(a => a.Id == folderId)) if (subItem.HandlingFolders.Any(a => a.Id == folderId))
{ {
@@ -65,8 +69,10 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value) public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
{ {
value = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId); var rootItems = this.ToList();
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Where(b => b.AccountId == accountId) != null);
value = rootItems.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId);
value ??= rootItems.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Any(b => b.AccountId == accountId));
return value != null; return value != null;
} }
@@ -74,7 +80,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items. // Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value) public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{ {
value = this.OfType<IBaseFolderMenuItem>() var rootItems = this.ToList();
value = rootItems.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem; .FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null; return value != null;
@@ -84,7 +92,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
// This will not look for the folders inside individual account menu items inside merged account menu item. // This will not look for the folders inside individual account menu items inside merged account menu item.
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value) public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
{ {
value = this.OfType<MergedAccountFolderMenuItem>() var rootItems = this.ToList();
value = rootItems.OfType<MergedAccountFolderMenuItem>()
.Where(a => a.MergedInbox.Id == mergedInboxId) .Where(a => a.MergedInbox.Id == mergedInboxId)
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType); .FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
@@ -93,11 +103,14 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value) public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
{ {
var rootItems = this.ToList();
// Root folders // Root folders
value = this.OfType<IBaseFolderMenuItem>() value = rootItems.OfType<IBaseFolderMenuItem>()
.Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
value ??= this.OfType<FolderMenuItem>() value ??= rootItems.OfType<FolderMenuItem>()
.SelectMany(a => a.SubMenuItems) .SelectMany(a => a.SubMenuItems)
.OfType<IBaseFolderMenuItem>() .OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
@@ -105,10 +118,23 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
return value != null; return value != null;
} }
public bool TryGetCategoryMenuItem(Guid categoryId, out IBaseFolderMenuItem value)
{
var rootItems = this.ToList();
value = rootItems.OfType<IMailCategoryMenuItem>()
.FirstOrDefault(a => a.MailCategory.Id == categoryId);
value ??= rootItems.OfType<IMergedMailCategoryMenuItem>()
.FirstOrDefault(a => a.Categories.Any(b => b.Id == categoryId)) as IBaseFolderMenuItem;
return value != null;
}
public void UpdateUnreadItemCountsToZero() public void UpdateUnreadItemCountsToZero()
{ {
// Handle the root folders. // Handle the root folders.
foreach (var item in this.OfType<IBaseFolderMenuItem>()) foreach (var item in this.OfType<IBaseFolderMenuItem>().ToList())
{ {
RecursivelyResetUnreadItemCount(item); RecursivelyResetUnreadItemCount(item);
} }
@@ -120,7 +146,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
if (baseFolderMenuItem.SubMenuItems == null) return; if (baseFolderMenuItem.SubMenuItems == null) return;
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>()) foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>().ToList())
{ {
RecursivelyResetUnreadItemCount(subMenuItem); RecursivelyResetUnreadItemCount(subMenuItem);
} }
@@ -128,7 +154,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value) public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{ {
value = this.OfType<IBaseFolderMenuItem>() var rootItems = this.ToList();
value = rootItems.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem; .FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null; return value != null;
@@ -142,11 +170,12 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId) public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
{ {
AccountMenuItem accountMenuItem = null; AccountMenuItem accountMenuItem = null;
var rootItems = this.ToList();
accountMenuItem = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId)); accountMenuItem = rootItems.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
// Look for the items inside the merged accounts if regular menu item is not found. // Look for the items inside the merged accounts if regular menu item is not found.
accountMenuItem ??= this.OfType<MergedAccountMenuItem>() accountMenuItem ??= rootItems.OfType<MergedAccountMenuItem>()
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems .FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
.OfType<AccountMenuItem>() .OfType<AccountMenuItem>()
.FirstOrDefault(a => a.AccountId == accountId); .FirstOrDefault(a => a.AccountId == accountId);
@@ -167,7 +196,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
/// <param name="isEnabled">Whether menu items should be enabled or disabled.</param> /// <param name="isEnabled">Whether menu items should be enabled or disabled.</param>
public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled) public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled)
{ {
var accountItems = this.Where(a => a is IAccountMenuItem).Cast<IAccountMenuItem>(); var accountItems = this.Where(a => a is IAccountMenuItem).Cast<IAccountMenuItem>().ToList();
await _dispatcher.ExecuteOnUIThread(() => await _dispatcher.ExecuteOnUIThread(() =>
{ {
@@ -192,6 +221,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
{ {
// Check root-level items. // Check root-level items.
var rootItem = this.OfType<IBaseFolderMenuItem>() var rootItem = this.OfType<IBaseFolderMenuItem>()
.Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
if (rootItem != null) if (rootItem != null)
@@ -201,7 +231,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
} }
// Check sub-items of root folders. // Check sub-items of root folders.
foreach (var rootFolder in this.OfType<IBaseFolderMenuItem>()) foreach (var rootFolder in this.OfType<IBaseFolderMenuItem>().ToList())
{ {
var subItem = rootFolder.SubMenuItems var subItem = rootFolder.SubMenuItems
.OfType<IBaseFolderMenuItem>() .OfType<IBaseFolderMenuItem>()
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.Domain.MenuItems;
public partial class MergedMailCategoryMenuItem : MenuItemBase<List<MailCategory>, IMenuItem>, IMergedAccountFolderMenuItem, IMergedMailCategoryMenuItem
{
private readonly IReadOnlyList<IMailItemFolder> _handlingFolders;
[ObservableProperty]
private int unreadItemCount;
public MergedMailCategoryMenuItem(List<MailCategory> categories, IEnumerable<IMailItemFolder> handlingFolders, MergedInbox mergedInbox)
: base(categories, null, null)
{
_handlingFolders = handlingFolders?.ToList() ?? [];
MergedInbox = mergedInbox;
}
public string FolderName => Parameter.FirstOrDefault()?.Name ?? string.Empty;
public bool IsSynchronizationEnabled => false;
public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
public IEnumerable<IMailItemFolder> HandlingFolders => _handlingFolders;
public bool IsMoveTarget => true;
public bool IsSticky => false;
public bool IsSystemFolder => false;
public bool ShowUnreadCount => true;
public string AssignedAccountName => MergedInbox?.Name;
public MergedInbox MergedInbox { get; }
public string TextColorHex => Parameter.FirstOrDefault()?.TextColorHex;
public string BackgroundColorHex => Parameter.FirstOrDefault()?.BackgroundColorHex;
public bool HasTextColor => !string.IsNullOrWhiteSpace(TextColorHex);
public IReadOnlyList<MailCategory> Categories => Parameter;
public void UpdateFolder(IMailItemFolder folder)
{
}
}
@@ -0,0 +1,10 @@
using System;
namespace Wino.Core.Domain.Models.Accounts;
public class UnreadCategoryCountResult
{
public Guid CategoryId { get; set; }
public Guid AccountId { get; set; }
public int UnreadItemCount { get; set; }
}
@@ -0,0 +1,3 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailCategoryColorOption(string BackgroundColorHex, string TextColorHex);
@@ -0,0 +1,3 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailCategoryDialogResult(string Name, string BackgroundColorHex, string TextColorHex);
@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace Wino.Core.Domain.Models.MailItem;
public static class MailCategoryPalette
{
public static IReadOnlyList<MailCategoryColorOption> DefaultOptions { get; } =
[
new("#FEE2E2", "#991B1B"),
new("#FECACA", "#7F1D1D"),
new("#FFEDD5", "#9A3412"),
new("#FED7AA", "#7C2D12"),
new("#FEF3C7", "#92400E"),
new("#FDE68A", "#78350F"),
new("#ECFCCB", "#3F6212"),
new("#D9F99D", "#365314"),
new("#DCFCE7", "#166534"),
new("#BBF7D0", "#14532D"),
new("#CCFBF1", "#115E59"),
new("#99F6E4", "#134E4A"),
new("#CFFAFE", "#155E75"),
new("#A5F3FC", "#164E63"),
new("#DBEAFE", "#1D4ED8"),
new("#BFDBFE", "#1E3A8A"),
new("#E0E7FF", "#4338CA"),
new("#DDD6FE", "#5B21B6"),
new("#F3E8FF", "#7E22CE"),
new("#FCE7F3", "#9D174D")
];
}
@@ -9,4 +9,5 @@ public record NewMailItemPackage(
MailCopy Copy, MailCopy Copy,
MimeMessage Mime, MimeMessage Mime,
string AssignedRemoteFolderId, string AssignedRemoteFolderId,
IReadOnlyList<AccountContact> ExtractedContacts = null); IReadOnlyList<AccountContact> ExtractedContacts = null,
IReadOnlyList<string> CategoryNames = null);
@@ -17,4 +17,8 @@ public record MailListInitializationOptions(IEnumerable<IMailItemFolder> Folders
List<MailCopy> PreFetchMailCopies = null, List<MailCopy> PreFetchMailCopies = null,
bool DeduplicateByServerId = false, bool DeduplicateByServerId = false,
int Skip = 0, int Skip = 0,
int Take = 0); int Take = 0)
{
public IReadOnlyList<Guid> CategoryIds { get; init; }
public bool IsCategoryView => CategoryIds?.Count > 0;
}
@@ -35,6 +35,10 @@ public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase<Cale
public virtual Guid? LocalCalendarItemId => Item?.Id; public virtual Guid? LocalCalendarItemId => Item?.Id;
} }
public abstract record CategoryRequestBase(Guid AccountId) : RequestBase<CategorySynchronizerOperation>, ICategoryActionRequest
{
}
public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest
{ {
public BatchCollection(IEnumerable<TRequestType> collection) : base(collection) public BatchCollection(IEnumerable<TRequestType> collection) : base(collection)
@@ -170,6 +170,7 @@ public static class SettingsNavigationInfoProvider
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage, WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage, WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage, WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage, WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage,
@@ -67,6 +67,7 @@
"BasicIMAPSetupDialog_Password": "Password", "BasicIMAPSetupDialog_Password": "Password",
"BasicIMAPSetupDialog_Title": "IMAP Account", "BasicIMAPSetupDialog_Title": "IMAP Account",
"Busy": "Busy", "Busy": "Busy",
"Buttons_Add": "Add",
"Buttons_AddAccount": "Add Account", "Buttons_AddAccount": "Add Account",
"Buttons_FixAccount": "Fix Account", "Buttons_FixAccount": "Fix Account",
"Buttons_AddNewAlias": "Add New Alias", "Buttons_AddNewAlias": "Add New Alias",
@@ -213,6 +214,8 @@
"CalendarEventDetails_Organizer": "Organizer", "CalendarEventDetails_Organizer": "Organizer",
"CalendarEventDetails_People": "People", "CalendarEventDetails_People": "People",
"CalendarEventDetails_ReadOnlyEvent": "Read-only event", "CalendarEventDetails_ReadOnlyEvent": "Read-only event",
"CalendarReadOnly_Title": "Read-only calendar",
"CalendarReadOnly_Message": "You can't update this calendar or its events. This calendar is read-only.",
"CalendarContextMenu_Respond": "Respond", "CalendarContextMenu_Respond": "Respond",
"CalendarEventDetails_Reminder": "Reminder", "CalendarEventDetails_Reminder": "Reminder",
"CalendarReminder_StartedHoursAgo": "Started {0} hours ago", "CalendarReminder_StartedHoursAgo": "Started {0} hours ago",
@@ -875,10 +878,28 @@
"SettingsManageAccountSettings_Title": "Manage Accounts", "SettingsManageAccountSettings_Title": "Manage Accounts",
"SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.", "SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.",
"SettingsManageAliases_Title": "Aliases", "SettingsManageAliases_Title": "Aliases",
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
"SettingsMailCategories_Title": "Categories",
"SettingsEditAccountDetails_Title": "Edit Account Details", "SettingsEditAccountDetails_Title": "Edit Account Details",
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.", "SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
"EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved", "EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved",
"EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.", "EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.",
"MailCategoryManagementPage_Title": "Categories",
"MailCategoryManagementPage_Description": "Create, edit, delete, and favorite categories for this account.",
"MailCategoryManagementPage_Empty": "No categories yet.",
"MailCategoryManagementPage_DeleteConfirmationTitle": "Delete Category",
"MailCategoryManagementPage_DeleteConfirmationMessage": "Delete category \"{0}\"?",
"MailCategoryManagementPage_RefreshConfirmationMessage": "This will delete all your local categories, and re-synchronize everything from the server. Do you want to continue?",
"MailCategoryMenuItem": "Category",
"MailCategoryDialog_CreateTitle": "Create category",
"MailCategoryDialog_EditTitle": "Edit category",
"MailCategoryDialog_Name": "Name",
"MailCategoryDialog_NamePlaceholder": "Category name",
"MailCategoryDialog_Color": "Color",
"MailCategoryDialog_InvalidNameTitle": "Category name required",
"MailCategoryDialog_InvalidNameMessage": "Enter a category name to continue.",
"MailCategoryDialog_DuplicateTitle": "Category already exists",
"MailCategoryDialog_DuplicateMessage": "A category with the same name already exists for this account.",
"SettingsManageLink_Description": "Move items to add new link or remove existing link.", "SettingsManageLink_Description": "Move items to add new link or remove existing link.",
"SettingsManageLink_Title": "Manage Link", "SettingsManageLink_Title": "Manage Link",
"SettingsMarkAsRead_Description": "Change what should happen to the selected item.", "SettingsMarkAsRead_Description": "Change what should happen to the selected item.",
@@ -1491,11 +1512,13 @@
"AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication", "AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication",
"AccountSetup_Step_SavingAccount": "Saving account information", "AccountSetup_Step_SavingAccount": "Saving account information",
"AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata", "AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata",
"AccountSetup_Step_SyncingCategories": "Synchronizing categories",
"AccountSetup_Step_SyncingAliases": "Synchronizing aliases", "AccountSetup_Step_SyncingAliases": "Synchronizing aliases",
"AccountSetup_Step_Finalizing": "Finalizing setup", "AccountSetup_Step_Finalizing": "Finalizing setup",
"AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.", "AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.",
"AccountSetup_SuccessMessage": "Your account has been set up successfully!", "AccountSetup_SuccessMessage": "Your account has been set up successfully!",
"AccountSetup_GoBackButton": "Go Back", "AccountSetup_GoBackButton": "Go Back",
"AccountSetup_TryAgainButton": "Try Again", "AccountSetup_TryAgainButton": "Try Again",
"Exception_FailedToSynchronizeCategories": "Failed to synchronize categories",
"ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab." "ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab."
} }
@@ -510,7 +510,8 @@ public class MailFetchingTests : IAsyncLifetime
preferencesService.Object, preferencesService.Object,
contactPictureFileService.Object); contactPictureFileService.Object);
var folderService = new FolderService(db, accountService); var mailCategoryService = new MailCategoryService(db);
var folderService = new FolderService(db, accountService, mailCategoryService);
var contactService = new ContactService(db); var contactService = new ContactService(db);
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
@@ -522,6 +523,7 @@ public class MailFetchingTests : IAsyncLifetime
signatureService.Object, signatureService.Object,
mimeFileService.Object, mimeFileService.Object,
preferencesService.Object, preferencesService.Object,
sentMailReceiptService); sentMailReceiptService,
mailCategoryService);
} }
} }
@@ -269,7 +269,8 @@ public class MailThreadingTests : IAsyncLifetime
preferencesService.Object, preferencesService.Object,
contactPictureFileService.Object); contactPictureFileService.Object);
var folderService = new FolderService(db, accountService); var mailCategoryService = new MailCategoryService(db);
var folderService = new FolderService(db, accountService, mailCategoryService);
var contactService = new ContactService(db); var contactService = new ContactService(db);
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
@@ -281,6 +282,7 @@ public class MailThreadingTests : IAsyncLifetime
signatureService.Object, signatureService.Object,
mimeFileService.Object, mimeFileService.Object,
preferencesService.Object, preferencesService.Object,
sentMailReceiptService); sentMailReceiptService,
mailCategoryService);
} }
} }
@@ -70,8 +70,9 @@ public sealed class OutlookSynchronizerRequestSuccessTests
var authenticator = new Mock<IAuthenticator>(MockBehavior.Loose); var authenticator = new Mock<IAuthenticator>(MockBehavior.Loose);
var errorFactory = new Mock<IOutlookSynchronizerErrorHandlerFactory>(MockBehavior.Loose); var errorFactory = new Mock<IOutlookSynchronizerErrorHandlerFactory>(MockBehavior.Loose);
var mailCategoryService = new Mock<IMailCategoryService>(MockBehavior.Loose);
return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object); return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object, mailCategoryService.Object);
} }
private static MailCopy CreateMailCopy() => private static MailCopy CreateMailCopy() =>
@@ -145,6 +145,8 @@ public static class GoogleIntegratorExtensions
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
TimeZone = calendarListEntry.TimeZone, TimeZone = calendarListEntry.TimeZone,
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(), IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
IsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase),
IsSynchronizationEnabled = true, IsSynchronizationEnabled = true,
}; };
@@ -190,6 +190,7 @@ public static class OutlookIntegratorExtensions
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
RemoteCalendarId = outlookCalendar.Id, RemoteCalendarId = outlookCalendar.Id,
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(), IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
IsReadOnly = !outlookCalendar.CanEdit.GetValueOrDefault(true),
Name = outlookCalendar.Name, Name = outlookCalendar.Name,
IsSynchronizationEnabled = true, IsSynchronizationEnabled = true,
IsExtended = true, IsExtended = true,
@@ -0,0 +1,10 @@
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Category;
public record MailCategoryCreateRequest(MailCategory Category) : CategoryRequestBase(Category.MailAccountId)
{
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.CreateCategory;
}
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Category;
public record MailCategoryDeleteRequest(
MailCategory Category,
string PreviousRemoteId,
IReadOnlyList<MailCategoryMessageUpdateTarget> AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
{
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.DeleteCategory;
}
@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace Wino.Core.Requests.Category;
public sealed record MailCategoryMessageUpdateTarget(string MessageId, IReadOnlyList<string> CategoryNames);
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Category;
public record MailCategoryUpdateRequest(
MailCategory Category,
string PreviousName,
string PreviousRemoteId,
IReadOnlyList<MailCategoryMessageUpdateTarget> AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
{
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.UpdateCategory;
}
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Mail;
public record MailCategoryAssignmentRequest(
MailCopy Item,
Guid MailCategoryId,
string CategoryName,
IReadOnlyList<string> CategoryNames,
bool IsAssigned) : MailRequestBase(Item), ICustomFolderSynchronizationRequest
{
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.UpdateCategories;
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public bool ExcludeMustHaveFolders => true;
}
public class BatchMailCategoryAssignmentRequest : BatchCollection<MailCategoryAssignmentRequest>
{
public BatchMailCategoryAssignmentRequest(IEnumerable<MailCategoryAssignmentRequest> collection) : base(collection)
{
}
}
@@ -370,6 +370,26 @@ public class SynchronizationManager : ISynchronizationManager, IRecipient<Accoun
return await SynchronizeMailAsync(options, cancellationToken); return await SynchronizeMailAsync(options, cancellationToken);
} }
/// <summary>
/// Handles category synchronization for the given account.
/// </summary>
/// <param name="accountId">Account ID to synchronize categories for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Synchronization result</returns>
public async Task<MailSynchronizationResult> SynchronizeCategoriesAsync(Guid accountId,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
var options = new MailSynchronizationOptions
{
AccountId = accountId,
Type = MailSynchronizationType.Categories
};
return await SynchronizeMailAsync(options, cancellationToken);
}
/// <summary> /// <summary>
/// Handles profile synchronization for the given account. /// Handles profile synchronization for the given account.
/// </summary> /// </summary>
+5 -2
View File
@@ -26,6 +26,7 @@ public class SynchronizerFactory : ISynchronizerFactory
private readonly ICalDavClient _calDavClient; private readonly ICalDavClient _calDavClient;
private readonly IAutoDiscoveryService _autoDiscoveryService; private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly ICalendarService _calendarService; private readonly ICalendarService _calendarService;
private readonly IMailCategoryService _mailCategoryService;
private readonly List<IWinoSynchronizerBase> synchronizerCache = new(); private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
@@ -41,7 +42,8 @@ public class SynchronizerFactory : ISynchronizerFactory
UnifiedImapSynchronizer unifiedImapSynchronizer, UnifiedImapSynchronizer unifiedImapSynchronizer,
ICalDavClient calDavClient, ICalDavClient calDavClient,
IAutoDiscoveryService autoDiscoveryService, IAutoDiscoveryService autoDiscoveryService,
ICalendarService calendarService) ICalendarService calendarService,
IMailCategoryService mailCategoryService)
{ {
_outlookChangeProcessor = outlookChangeProcessor; _outlookChangeProcessor = outlookChangeProcessor;
_gmailChangeProcessor = gmailChangeProcessor; _gmailChangeProcessor = gmailChangeProcessor;
@@ -56,6 +58,7 @@ public class SynchronizerFactory : ISynchronizerFactory
_calDavClient = calDavClient; _calDavClient = calDavClient;
_autoDiscoveryService = autoDiscoveryService; _autoDiscoveryService = autoDiscoveryService;
_calendarService = calendarService; _calendarService = calendarService;
_mailCategoryService = mailCategoryService;
} }
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId) public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
@@ -86,7 +89,7 @@ public class SynchronizerFactory : ISynchronizerFactory
{ {
case Domain.Enums.MailProviderType.Outlook: case Domain.Enums.MailProviderType.Outlook:
var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator; var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator;
return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory); return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory, _mailCategoryService);
case Domain.Enums.MailProviderType.Gmail: case Domain.Enums.MailProviderType.Gmail:
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator; var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory); return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
@@ -165,6 +165,13 @@ public class WinoRequestDelegator : IWinoRequestDelegator
if (calendarPreparationRequest == null) if (calendarPreparationRequest == null)
return; return;
var resolvedCalendar = await ResolveCalendarAsync(calendarPreparationRequest).ConfigureAwait(false);
if (resolvedCalendar?.IsReadOnly == true)
{
_dialogService.ShowReadOnlyCalendarMessage();
return;
}
IRequestBase request = calendarPreparationRequest.Operation switch IRequestBase request = calendarPreparationRequest.Operation switch
{ {
CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false), CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
@@ -200,6 +207,21 @@ public class WinoRequestDelegator : IWinoRequestDelegator
await QueueCalendarSynchronizationAsync(accountId); await QueueCalendarSynchronizationAsync(accountId);
} }
public async Task ExecuteAsync(Guid accountId, IEnumerable<IRequestBase> requests)
{
var requestList = requests?.Where(a => a != null).ToList() ?? [];
if (requestList.Count == 0)
return;
foreach (var request in requestList)
{
await QueueRequestAsync(request, accountId).ConfigureAwait(false);
}
await SendSyncActionsAddedAsync(requestList, accountId).ConfigureAwait(false);
await QueueSynchronizationAsync(accountId).ConfigureAwait(false);
}
private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest) private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
{ {
var composeResult = calendarPreparationRequest.ComposeResult var composeResult = calendarPreparationRequest.ComposeResult
@@ -212,6 +234,25 @@ public class WinoRequestDelegator : IWinoRequestDelegator
return new CreateCalendarEventRequest(composeResult, assignedCalendar); return new CreateCalendarEventRequest(composeResult, assignedCalendar);
} }
private async Task<AccountCalendar> ResolveCalendarAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
{
if (calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent)
{
var calendarId = calendarPreparationRequest.ComposeResult?.CalendarId ?? Guid.Empty;
return calendarId == Guid.Empty
? null
: await _calendarService.GetAccountCalendarAsync(calendarId).ConfigureAwait(false);
}
if (calendarPreparationRequest.CalendarItem?.AssignedCalendar is AccountCalendar assignedCalendar)
return assignedCalendar;
var fallbackCalendarId = calendarPreparationRequest.CalendarItem?.CalendarId ?? Guid.Empty;
return fallbackCalendarId == Guid.Empty
? null
: await _calendarService.GetAccountCalendarAsync(fallbackCalendarId).ConfigureAwait(false);
}
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage) private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
{ {
// For Outlook accounts, declined events are deleted by the server after synchronization. // For Outlook accounts, declined events are deleted by the server after synchronization.
+6 -1
View File
@@ -759,6 +759,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
existingLocalCalendar.BackgroundColorHex = resolvedColor; existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex); existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
existingLocalCalendar.IsReadOnly = !string.Equals(calendar.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(calendar.AccessRole, "writer", StringComparison.OrdinalIgnoreCase);
updatedCalendars.Add(existingLocalCalendar); updatedCalendars.Add(existingLocalCalendar);
} }
@@ -940,14 +942,17 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteGmailCalendarBackgroundColor(calendarListEntry), accountCalendar); var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteGmailCalendarBackgroundColor(calendarListEntry), accountCalendar);
var remoteTextColor = ColorHelpers.GetReadableTextColorHex(remoteBackgroundColor); var remoteTextColor = ColorHelpers.GetReadableTextColorHex(remoteBackgroundColor);
var remoteIsPrimary = string.Equals(calendarListEntry.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); var remoteIsPrimary = string.Equals(calendarListEntry.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
var remoteIsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase);
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase); bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
bool isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase); bool isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase);
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase); bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase); bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase);
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary; bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
bool isReadOnlyChanged = accountCalendar.IsReadOnly != remoteIsReadOnly;
return isNameChanged || isTimeZoneChanged || isBackgroundColorChanged || isTextColorChanged || isPrimaryChanged; return isNameChanged || isTimeZoneChanged || isBackgroundColorChanged || isTextColorChanged || isPrimaryChanged || isReadOnlyChanged;
} }
private static string GetRemoteGmailCalendarBackgroundColor(CalendarListEntry calendarListEntry) private static string GetRemoteGmailCalendarBackgroundColor(CalendarListEntry calendarListEntry)
+217 -4
View File
@@ -41,6 +41,7 @@ using Wino.Core.Integration.Processors;
using Wino.Core.Misc; using Wino.Core.Misc;
using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Category;
using Wino.Core.Requests.Folder; using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail; using Wino.Core.Requests.Mail;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -107,6 +108,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
"ParentFolderId", "ParentFolderId",
"InternetMessageId", "InternetMessageId",
"InternetMessageHeaders", "InternetMessageHeaders",
"Categories",
]; ];
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1); private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
@@ -116,6 +118,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
private readonly IOutlookChangeProcessor _outlookChangeProcessor; private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly GraphServiceClient _graphClient; private readonly GraphServiceClient _graphClient;
private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory; private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory;
private readonly IMailCategoryService _mailCategoryService;
private bool _isFolderStructureChanged; private bool _isFolderStructureChanged;
private readonly SemaphoreSlim _concurrentDownloadSemaphore = new(10); // Limit to 10 concurrent downloads private readonly SemaphoreSlim _concurrentDownloadSemaphore = new(10); // Limit to 10 concurrent downloads
@@ -123,7 +126,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
public OutlookSynchronizer(MailAccount account, public OutlookSynchronizer(MailAccount account,
IAuthenticator authenticator, IAuthenticator authenticator,
IOutlookChangeProcessor outlookChangeProcessor, IOutlookChangeProcessor outlookChangeProcessor,
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account, WeakReferenceMessenger.Default) IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory,
IMailCategoryService mailCategoryService) : base(account, WeakReferenceMessenger.Default)
{ {
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator); var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
@@ -138,6 +142,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
_outlookChangeProcessor = outlookChangeProcessor; _outlookChangeProcessor = outlookChangeProcessor;
_errorHandlingFactory = errorHandlingFactory; _errorHandlingFactory = errorHandlingFactory;
_mailCategoryService = mailCategoryService;
} }
#region MS Graph Handlers #region MS Graph Handlers
@@ -1152,6 +1157,11 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
_logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged); _logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged);
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false); await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
} }
if (item.Categories != null)
{
await ReplaceMailAssignmentsAsync(item.Id, item.Categories).ConfigureAwait(false);
}
} }
else else
{ {
@@ -1208,6 +1218,43 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
} }
} }
protected override async Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default)
{
var response = await _graphClient.Me.Outlook.MasterCategories
.GetAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var categories = response?.Value?
.Where(a => !string.IsNullOrWhiteSpace(a?.DisplayName))
.Select(a =>
{
var colorOption = GetMailCategoryColorOption(a.Color);
return new MailCategory
{
MailAccountId = Account.Id,
RemoteId = a.Id,
Name = a.DisplayName,
BackgroundColorHex = colorOption.BackgroundColorHex,
TextColorHex = colorOption.TextColorHex,
Source = MailCategorySource.Outlook
};
})
.ToList() ?? [];
await _mailCategoryService.ReplaceCategoriesAsync(Account.Id, categories).ConfigureAwait(false);
}
private async Task ReplaceMailAssignmentsAsync(string messageId, IEnumerable<string> categoryNames)
{
var localMailCopies = await _outlookChangeProcessor.GetMailCopiesAsync([messageId]).ConfigureAwait(false);
foreach (var localMailCopy in localMailCopies)
{
await _mailCategoryService.ReplaceMailAssignmentsAsync(Account.Id, localMailCopy.UniqueId, categoryNames ?? []).ConfigureAwait(false);
}
}
private async Task<OutlookSpecialFolderIdInformation> GetSpecialFolderIdsAsync(CancellationToken cancellationToken) private async Task<OutlookSpecialFolderIdInformation> GetSpecialFolderIdsAsync(CancellationToken cancellationToken)
{ {
var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
@@ -1767,6 +1814,87 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return Move(batchMoveRequest); return Move(batchMoveRequest);
} }
public override List<IRequestBundle<RequestInformation>> UpdateCategories(BatchMailCategoryAssignmentRequest request)
=> ForEachRequest(request, item => CreateMessageCategoryPatchRequest(item.Item.Id, item.CategoryNames));
public override List<IRequestBundle<RequestInformation>> CreateCategory(MailCategoryCreateRequest request)
{
var outlookCategory = new OutlookCategory
{
DisplayName = request.Category.Name,
Color = GetOutlookCategoryColor(request.Category)
};
var requestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(outlookCategory);
return [new HttpRequestBundle<RequestInformation>(requestInfo, request)];
}
public override List<IRequestBundle<RequestInformation>> UpdateCategory(MailCategoryUpdateRequest request)
{
if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
return CreateCategory(new MailCategoryCreateRequest(request.Category));
var hasNameChanged = !string.Equals(request.PreviousName, request.Category.Name, StringComparison.Ordinal);
if (!hasNameChanged)
{
var requestInfo = _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToPatchRequestInformation(new OutlookCategory
{
Color = GetOutlookCategoryColor(request.Category)
});
return [new HttpRequestBundle<RequestInformation>(requestInfo, request)];
}
var bundles = new List<IRequestBundle<RequestInformation>>();
var createRequestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(new OutlookCategory
{
DisplayName = request.Category.Name,
Color = GetOutlookCategoryColor(request.Category)
});
bundles.Add(new HttpRequestBundle<RequestInformation>(createRequestInfo, request));
foreach (var target in request.AffectedMessages ?? [])
{
bundles.Add(new HttpRequestBundle<RequestInformation>(
CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
request));
}
bundles.Add(new HttpRequestBundle<RequestInformation>(
_graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
request));
return bundles;
}
public override List<IRequestBundle<RequestInformation>> DeleteCategory(MailCategoryDeleteRequest request)
{
if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
return [];
var bundles = new List<IRequestBundle<RequestInformation>>();
foreach (var target in request.AffectedMessages ?? [])
{
bundles.Add(new HttpRequestBundle<RequestInformation>(
CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
request));
}
bundles.Add(new HttpRequestBundle<RequestInformation>(
_graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
request));
return bundles;
}
private RequestInformation CreateMessageCategoryPatchRequest(string messageId, IReadOnlyList<string> categoryNames)
=> _graphClient.Me.Messages[messageId].ToPatchRequestInformation(new Message
{
Categories = categoryNames?.ToList() ?? []
});
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem, public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
MailKit.ITransferProgress transferProgress = null, MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -1962,7 +2090,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
for (int i = 0; i < itemCount; i++) for (int i = 0; i < itemCount; i++)
{ {
var bundle = batch.ElementAt(i); var bundle = batch.ElementAt(i);
requiresSerial |= bundle.UIChangeRequest is SendDraftRequest; requiresSerial |= bundle.UIChangeRequest is SendDraftRequest
or MailCategoryUpdateRequest
or MailCategoryDeleteRequest;
// UI changes are already applied in ExecuteNativeRequestsAsync before batching. // UI changes are already applied in ExecuteNativeRequestsAsync before batching.
var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest); var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest);
@@ -2110,7 +2240,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|| request is ChangeFlagRequest || request is ChangeFlagRequest
|| request is MarkReadRequest || request is MarkReadRequest
|| request is ArchiveRequest || request is ArchiveRequest
|| request is MailCategoryAssignmentRequest
|| request is RenameFolderRequest || request is RenameFolderRequest
|| request is MailCategoryUpdateRequest
|| request is MailCategoryDeleteRequest
|| request is DeleteFolderRequest || request is DeleteFolderRequest
|| request is AcceptEventRequest || request is AcceptEventRequest
|| request is DeclineEventRequest || request is DeclineEventRequest
@@ -2165,6 +2298,26 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return; return;
await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false); await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false);
return;
}
if (bundle?.UIChangeRequest is MailCategoryCreateRequest createCategoryRequest)
{
var createdCategoryId = json?["id"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(createdCategoryId))
{
await _mailCategoryService.UpdateRemoteIdAsync(createCategoryRequest.Category.Id, createdCategoryId).ConfigureAwait(false);
}
return;
}
if (bundle?.UIChangeRequest is MailCategoryUpdateRequest updateCategoryRequest)
{
var updatedCategoryId = json?["id"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(updatedCategoryId))
{
await _mailCategoryService.UpdateRemoteIdAsync(updateCategoryRequest.Category.Id, updatedCategoryId).ConfigureAwait(false);
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -2367,11 +2520,68 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// Outlook messages can only be assigned to 1 folder at a time. // Outlook messages can only be assigned to 1 folder at a time.
// Therefore we don't need to create multiple copies of the same message for different folders. // Therefore we don't need to create multiple copies of the same message for different folders.
var contacts = ExtractContactsFromOutlookMessage(message); var contacts = ExtractContactsFromOutlookMessage(message);
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts); var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts, message.Categories);
return [package]; return [package];
} }
private static MailCategoryColorOption GetMailCategoryColorOption(CategoryColor? color)
=> color switch
{
CategoryColor.Preset0 => new("#FEE2E2", "#991B1B"),
CategoryColor.Preset1 => new("#FFEDD5", "#9A3412"),
CategoryColor.Preset2 => new("#FEF3C7", "#92400E"),
CategoryColor.Preset3 => new("#ECFCCB", "#3F6212"),
CategoryColor.Preset4 => new("#DCFCE7", "#166534"),
CategoryColor.Preset5 => new("#CCFBF1", "#115E59"),
CategoryColor.Preset6 => new("#CFFAFE", "#155E75"),
CategoryColor.Preset7 => new("#DBEAFE", "#1D4ED8"),
CategoryColor.Preset8 => new("#E0E7FF", "#4338CA"),
CategoryColor.Preset9 => new("#F3E8FF", "#7E22CE"),
CategoryColor.Preset10 => new("#FCE7F3", "#9D174D"),
CategoryColor.Preset11 => new("#FECACA", "#7F1D1D"),
CategoryColor.Preset12 => new("#FED7AA", "#7C2D12"),
CategoryColor.Preset13 => new("#FDE68A", "#78350F"),
CategoryColor.Preset14 => new("#D9F99D", "#365314"),
CategoryColor.Preset15 => new("#BBF7D0", "#14532D"),
CategoryColor.Preset16 => new("#99F6E4", "#134E4A"),
CategoryColor.Preset17 => new("#A5F3FC", "#164E63"),
CategoryColor.Preset18 => new("#BFDBFE", "#1E3A8A"),
CategoryColor.Preset19 => new("#DDD6FE", "#5B21B6"),
CategoryColor.Preset20 => new("#E5E7EB", "#374151"),
CategoryColor.Preset21 => new("#D1D5DB", "#1F2937"),
CategoryColor.Preset22 => new("#F3F4F6", "#111827"),
CategoryColor.Preset23 => new("#E2E8F0", "#334155"),
CategoryColor.Preset24 => new("#F8FAFC", "#475569"),
_ => new("#E5E7EB", "#374151")
};
private static CategoryColor GetOutlookCategoryColor(MailCategory category)
=> (category.BackgroundColorHex?.ToUpperInvariant(), category.TextColorHex?.ToUpperInvariant()) switch
{
("#FEE2E2", "#991B1B") => CategoryColor.Preset0,
("#FFEDD5", "#9A3412") => CategoryColor.Preset1,
("#FEF3C7", "#92400E") => CategoryColor.Preset2,
("#ECFCCB", "#3F6212") => CategoryColor.Preset3,
("#DCFCE7", "#166534") => CategoryColor.Preset4,
("#CCFBF1", "#115E59") => CategoryColor.Preset5,
("#CFFAFE", "#155E75") => CategoryColor.Preset6,
("#DBEAFE", "#1D4ED8") => CategoryColor.Preset7,
("#E0E7FF", "#4338CA") => CategoryColor.Preset8,
("#F3E8FF", "#7E22CE") => CategoryColor.Preset9,
("#FCE7F3", "#9D174D") => CategoryColor.Preset10,
("#FECACA", "#7F1D1D") => CategoryColor.Preset11,
("#FED7AA", "#7C2D12") => CategoryColor.Preset12,
("#FDE68A", "#78350F") => CategoryColor.Preset13,
("#D9F99D", "#365314") => CategoryColor.Preset14,
("#BBF7D0", "#14532D") => CategoryColor.Preset15,
("#99F6E4", "#134E4A") => CategoryColor.Preset16,
("#A5F3FC", "#164E63") => CategoryColor.Preset17,
("#BFDBFE", "#1E3A8A") => CategoryColor.Preset18,
("#DDD6FE", "#5B21B6") => CategoryColor.Preset19,
_ => CategoryColor.Preset0
};
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken) private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
{ {
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null) if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
@@ -2674,6 +2884,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{ {
existingLocalCalendar.Name = calendar.Name; existingLocalCalendar.Name = calendar.Name;
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
existingLocalCalendar.IsReadOnly = !calendar.CanEdit.GetValueOrDefault(true);
existingLocalCalendar.BackgroundColorHex = resolvedColor; existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex); existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
@@ -2712,12 +2923,14 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var remoteCalendarName = calendar.Name; var remoteCalendarName = calendar.Name;
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteOutlookCalendarBackgroundColor(calendar), accountCalendar); var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteOutlookCalendarBackgroundColor(calendar), accountCalendar);
var remoteIsPrimary = string.Equals(calendar.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); var remoteIsPrimary = string.Equals(calendar.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
var remoteIsReadOnly = !calendar.CanEdit.GetValueOrDefault(true);
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase); bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase); bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary; bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
bool isReadOnlyChanged = accountCalendar.IsReadOnly != remoteIsReadOnly;
return isNameChanged || isBackgroundColorChanged || isPrimaryChanged; return isNameChanged || isBackgroundColorChanged || isPrimaryChanged || isReadOnlyChanged;
} }
private static string GetRemoteOutlookCalendarBackgroundColor(Calendar calendar) private static string GetRemoteOutlookCalendarBackgroundColor(Calendar calendar)
@@ -20,6 +20,7 @@ using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Category;
using Wino.Core.Requests.Folder; using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail; using Wino.Core.Requests.Mail;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -63,6 +64,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
/// Only available for Gmail right now. /// Only available for Gmail right now.
/// </summary> /// </summary>
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask; protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
protected virtual Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
/// <summary> /// <summary>
/// Queues all mail ids for initial synchronization for a specific folder. /// Queues all mail ids for initial synchronization for a specific folder.
@@ -194,6 +196,9 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
case MailSynchronizerOperation.Archive: case MailSynchronizerOperation.Archive:
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>()))); nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
break; break;
case MailSynchronizerOperation.UpdateCategories:
nativeRequests.AddRange(UpdateCategories(new BatchMailCategoryAssignmentRequest(group.Cast<MailCategoryAssignmentRequest>())));
break;
default: default:
break; break;
} }
@@ -221,6 +226,23 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
break; break;
} }
} }
else if (key is CategorySynchronizerOperation categorySynchronizerOperation)
{
switch (categorySynchronizerOperation)
{
case CategorySynchronizerOperation.CreateCategory:
nativeRequests.AddRange(CreateCategory(group.ElementAt(0) as MailCategoryCreateRequest));
break;
case CategorySynchronizerOperation.UpdateCategory:
nativeRequests.AddRange(UpdateCategory(group.ElementAt(0) as MailCategoryUpdateRequest));
break;
case CategorySynchronizerOperation.DeleteCategory:
nativeRequests.AddRange(DeleteCategory(group.ElementAt(0) as MailCategoryDeleteRequest));
break;
default:
break;
}
}
} }
changeRequestQueue.Clear(); changeRequestQueue.Clear();
@@ -322,6 +344,30 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
} }
} }
// Category definition sync.
if (options.Type == MailSynchronizationType.Categories)
{
if (!Account.IsCategorySyncSupported) return MailSynchronizationResult.Empty;
try
{
await SynchronizeCategoriesAsync(activeSynchronizationCancellationToken);
return FinalizeMailResult(MailSynchronizationResult.Empty);
}
catch (AuthenticationAttentionException)
{
throw;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to update categories for {Name}", Account.Name);
CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "CategorySync"));
return FinalizeMailResult(MailSynchronizationResult.Failed(ex));
}
}
if (shouldDelayExecution) if (shouldDelayExecution)
{ {
await Task.Delay(maxExecutionDelay); await Task.Delay(maxExecutionDelay);
@@ -526,6 +572,16 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
/// <returns>New synchronization options with minimal HTTP effort.</returns> /// <returns>New synchronization options with minimal HTTP effort.</returns>
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId) private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId)
{ {
if (requests.All(a => a is ICategoryActionRequest or MailCategoryAssignmentRequest))
{
return new MailSynchronizationOptions
{
AccountId = Account.Id,
Id = existingSynchronizationId,
Type = MailSynchronizationType.FoldersOnly
};
}
List<Guid> synchronizationFolderIds = requests List<Guid> synchronizationFolderIds = requests
.Where(a => a is ICustomFolderSynchronizationRequest) .Where(a => a is ICustomFolderSynchronizationRequest)
.Cast<ICustomFolderSynchronizationRequest>() .Cast<ICustomFolderSynchronizationRequest>()
@@ -602,6 +658,10 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> UpdateCategories(BatchMailCategoryAssignmentRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> CreateCategory(MailCategoryCreateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> UpdateCategory(MailCategoryUpdateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> DeleteCategory(MailCategoryDeleteRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
#endregion #endregion
@@ -169,6 +169,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private void EditAliases() private void EditAliases()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id)); => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id));
[RelayCommand]
private void EditCategories()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id));
[RelayCommand] [RelayCommand]
private void EditImapCalDavSettings() private void EditImapCalDavSettings()
=> Messenger.Send(new BreadcrumbNavigationRequested( => Messenger.Send(new BreadcrumbNavigationRequested(
@@ -81,6 +81,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders });
if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook)
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories });
}
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
@@ -229,6 +233,16 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
throw new Exception(Translator.Exception_FailedToSynchronizeFolders); throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
SetCurrentStepSucceeded(); SetCurrentStepSucceeded();
// Step: Categories
if (_createdAccount.IsCategorySyncSupported)
{
SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories);
var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id);
if (categoryResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeCategories);
SetCurrentStepSucceeded();
}
// Step: Calendar metadata // Step: Calendar metadata
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
if (_createdAccount.IsCalendarAccessGranted) if (_createdAccount.IsCalendarAccessGranted)
+39 -3
View File
@@ -73,6 +73,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem; public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
private readonly IMailCategoryService _mailCategoryService;
private readonly IConfigurationService _configurationService; private readonly IConfigurationService _configurationService;
private readonly IStartupBehaviorService _startupBehaviorService; private readonly IStartupBehaviorService _startupBehaviorService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
@@ -99,6 +100,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
IMimeFileService mimeFileService, IMimeFileService mimeFileService,
INativeAppService nativeAppService, INativeAppService nativeAppService,
IMailService mailService, IMailService mailService,
IMailCategoryService mailCategoryService,
IAccountService accountService, IAccountService accountService,
IContextMenuItemService contextMenuItemService, IContextMenuItemService contextMenuItemService,
IStoreRatingService storeRatingService, IStoreRatingService storeRatingService,
@@ -125,6 +127,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
_mimeFileService = mimeFileService; _mimeFileService = mimeFileService;
_nativeAppService = nativeAppService; _nativeAppService = nativeAppService;
_mailService = mailService; _mailService = mailService;
_mailCategoryService = mailCategoryService;
_folderService = folderService; _folderService = folderService;
_accountService = accountService; _accountService = accountService;
_contextMenuItemService = contextMenuItemService; _contextMenuItemService = contextMenuItemService;
@@ -721,7 +724,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
{ {
await HandleCreateNewMailAsync(); await HandleCreateNewMailAsync();
} }
else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem && baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget)) else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem &&
(clickedMenuItem is IMailCategoryMenuItem or IMergedMailCategoryMenuItem || baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget)))
{ {
// Don't navigate to base folders that contain non-move target folders. // Don't navigate to base folders that contain non-move target folders.
// Theory: This is a special folder like Categories or More. Don't navigate to it. // Theory: This is a special folder like Categories or More. Don't navigate to it.
@@ -793,11 +797,20 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
{ {
// Get visible account menu items, ordered by merged accounts at the last. // Get visible account menu items, ordered by merged accounts at the last.
// We will update the unread counts for all single accounts and trigger UI refresh for merged menu items. // We will update the unread counts for all single accounts and trigger UI refresh for merged menu items.
var accountMenuItems = MenuItems.GetAllAccountMenuItems().OrderBy(a => a.HoldingAccounts.Count()); List<IAccountMenuItem> accountMenuItems = null;
await ExecuteUIThread(() =>
{
accountMenuItems = MenuItems
.GetAllAccountMenuItems()
.OrderBy(a => a.HoldingAccounts.Count())
.ToList();
});
// Individually get all single accounts' unread counts. // Individually get all single accounts' unread counts.
var accountIds = accountMenuItems.OfType<AccountMenuItem>().Select(a => a.AccountId); var accountIds = accountMenuItems.OfType<AccountMenuItem>().Select(a => a.AccountId).ToList();
var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false); var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false);
var unreadCategoryCountResult = await _mailCategoryService.GetUnreadCategoryCountResultsAsync(accountIds).ConfigureAwait(false);
// Recursively update all folders' unread counts to 0. // Recursively update all folders' unread counts to 0.
// Query above only returns unread counts that exists. We need to reset the rest to 0 first. // Query above only returns unread counts that exists. We need to reset the rest to 0 first.
@@ -849,6 +862,29 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
} }
} }
foreach (var unreadCategoryCount in unreadCategoryCountResult)
{
if (MenuItems.TryGetCategoryMenuItem(unreadCategoryCount.CategoryId, out var categoryMenuItem))
{
if (categoryMenuItem is IMergedMailCategoryMenuItem mergedCategoryMenuItem)
{
await ExecuteUIThread(() =>
{
categoryMenuItem.UnreadItemCount = unreadCategoryCountResult
.Where(a => mergedCategoryMenuItem.Categories.Any(b => b.Id == a.CategoryId))
.Sum(a => a.UnreadItemCount);
});
}
else
{
await ExecuteUIThread(() =>
{
categoryMenuItem.UnreadItemCount = unreadCategoryCount.UnreadItemCount;
});
}
}
}
// Update unread badge after all unread counts are updated. // Update unread badge after all unread counts are updated.
await _notificationBuilder.UpdateTaskbarIconBadgeAsync(); await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
} }
@@ -0,0 +1,251 @@
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.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Category;
using Wino.Core.Services;
namespace Wino.Mail.ViewModels;
public partial class MailCategoryManagementPageViewModel : MailBaseViewModel
{
private readonly IMailCategoryService _mailCategoryService;
private readonly IAccountService _accountService;
private readonly IMailDialogService _dialogService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanRefresh))]
public partial MailAccount Account { get; set; }
public ObservableCollection<MailCategory> Categories { get; } = [];
public bool CanRefresh => Account?.ProviderType == MailProviderType.Outlook;
public bool HasCategories => Categories.Count > 0;
public MailCategoryManagementPageViewModel(
IMailCategoryService mailCategoryService,
IAccountService accountService,
IMailDialogService dialogService,
IWinoRequestDelegator winoRequestDelegator)
{
_mailCategoryService = mailCategoryService;
_accountService = accountService;
_dialogService = dialogService;
_winoRequestDelegator = winoRequestDelegator;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is not Guid accountId)
return;
Account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
if (Account != null)
{
await LoadCategoriesAsync().ConfigureAwait(false);
}
}
[RelayCommand]
private Task AddCategoryAsync()
=> CreateOrUpdateCategoryAsync();
[RelayCommand]
private async Task RefreshCategoriesAsync()
{
if (!CanRefresh)
return;
var shouldContinue = await _dialogService.ShowConfirmationDialogAsync(
Translator.MailCategoryManagementPage_RefreshConfirmationMessage,
Translator.Buttons_Refresh,
Translator.Buttons_Refresh).ConfigureAwait(false);
if (!shouldContinue)
return;
await _mailCategoryService.DeleteCategoriesAsync(Account.Id).ConfigureAwait(false);
await SynchronizationManager.Instance.SynchronizeCategoriesAsync(Account.Id).ConfigureAwait(false);
await LoadCategoriesAsync().ConfigureAwait(false);
}
public Task EditCategoryAsync(MailCategory category)
=> CreateOrUpdateCategoryAsync(category);
public async Task DeleteCategoryAsync(MailCategory category)
{
if (category == null)
return;
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.MailCategoryManagementPage_DeleteConfirmationMessage, category.Name),
Translator.MailCategoryManagementPage_DeleteConfirmationTitle,
Translator.Buttons_Delete).ConfigureAwait(false);
if (!shouldDelete)
return;
var deleteRequest = await BuildDeleteCategoryRequestAsync(category).ConfigureAwait(false);
await _mailCategoryService.DeleteCategoryAsync(category.Id).ConfigureAwait(false);
await QueueOutlookCategoryRequestsAsync(deleteRequest).ConfigureAwait(false);
await LoadCategoriesAsync().ConfigureAwait(false);
}
public async Task SetFavoriteAsync(MailCategory category, bool isFavorite)
{
if (category == null)
return;
await _mailCategoryService.ToggleFavoriteAsync(category.Id, isFavorite).ConfigureAwait(false);
await LoadCategoriesAsync().ConfigureAwait(false);
}
private async Task CreateOrUpdateCategoryAsync(MailCategory existingCategory = null)
{
var dialogResult = await _dialogService.ShowEditMailCategoryDialogAsync(existingCategory).ConfigureAwait(false);
if (dialogResult == null)
return;
if (string.IsNullOrWhiteSpace(dialogResult.Name))
{
await _dialogService.ShowMessageAsync(
Translator.MailCategoryDialog_InvalidNameMessage,
Translator.MailCategoryDialog_InvalidNameTitle,
WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
return;
}
var normalizedName = dialogResult.Name.Trim();
var categoryIdToExclude = existingCategory?.Id;
var alreadyExists = await _mailCategoryService.CategoryNameExistsAsync(Account.Id, normalizedName, categoryIdToExclude).ConfigureAwait(false);
if (alreadyExists)
{
await _dialogService.ShowMessageAsync(
Translator.MailCategoryDialog_DuplicateMessage,
Translator.MailCategoryDialog_DuplicateTitle,
WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
return;
}
if (existingCategory == null)
{
var newCategory = new MailCategory
{
Id = Guid.NewGuid(),
MailAccountId = Account.Id,
Name = normalizedName,
BackgroundColorHex = dialogResult.BackgroundColorHex,
TextColorHex = dialogResult.TextColorHex,
Source = Account.ProviderType == MailProviderType.Outlook ? MailCategorySource.Outlook : MailCategorySource.Local
};
await _mailCategoryService.CreateCategoryAsync(newCategory).ConfigureAwait(false);
if (Account.ProviderType == MailProviderType.Outlook)
{
await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(newCategory)]).ConfigureAwait(false);
}
}
else
{
var previousName = existingCategory.Name;
var previousRemoteId = existingCategory.RemoteId;
existingCategory.Name = normalizedName;
existingCategory.BackgroundColorHex = dialogResult.BackgroundColorHex;
existingCategory.TextColorHex = dialogResult.TextColorHex;
await _mailCategoryService.UpdateCategoryAsync(existingCategory).ConfigureAwait(false);
if (Account.ProviderType == MailProviderType.Outlook)
{
if (string.IsNullOrWhiteSpace(previousRemoteId))
{
await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(existingCategory)]).ConfigureAwait(false);
}
else
{
var affectedMessages = await BuildAffectedMessageTargetsAsync(existingCategory.Id).ConfigureAwait(false);
var updateRequest = new MailCategoryUpdateRequest(existingCategory, previousName, previousRemoteId, affectedMessages);
await _winoRequestDelegator.ExecuteAsync(Account.Id, [updateRequest]).ConfigureAwait(false);
}
}
}
await LoadCategoriesAsync().ConfigureAwait(false);
}
private async Task<MailCategoryDeleteRequest> BuildDeleteCategoryRequestAsync(MailCategory category)
{
if (category == null || Account?.ProviderType != MailProviderType.Outlook)
return null;
var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(category.Id).ConfigureAwait(false);
var affectedMessages = new List<MailCategoryMessageUpdateTarget>();
foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
{
var remainingNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
var categoryNames = remainingNames
.Where(a => !string.Equals(a, category.Name, StringComparison.OrdinalIgnoreCase))
.ToList();
affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
}
return new MailCategoryDeleteRequest(category, category.RemoteId, affectedMessages);
}
private async Task<IReadOnlyList<MailCategoryMessageUpdateTarget>> BuildAffectedMessageTargetsAsync(Guid categoryId)
{
var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(categoryId).ConfigureAwait(false);
var affectedMessages = new List<MailCategoryMessageUpdateTarget>();
foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
{
var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
}
return affectedMessages;
}
private Task QueueOutlookCategoryRequestsAsync(params IRequestBase[] requests)
=> Account?.ProviderType == MailProviderType.Outlook && requests.Any(a => a != null)
? _winoRequestDelegator.ExecuteAsync(Account.Id, requests.Where(a => a != null))
: Task.CompletedTask;
private async Task LoadCategoriesAsync()
{
var categories = await _mailCategoryService.GetCategoriesAsync(Account.Id).ConfigureAwait(false);
await ExecuteUIThread(() =>
{
Categories.Clear();
foreach (var category in categories)
{
Categories.Add(category);
}
OnPropertyChanged(nameof(HasCategories));
});
}
}
+110 -21
View File
@@ -24,6 +24,7 @@ using Wino.Core.Domain.Models.Menus;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Reader; using Wino.Core.Domain.Models.Reader;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Mail;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Mail.ViewModels.Collections; using Wino.Mail.ViewModels.Collections;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
@@ -77,6 +78,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
private readonly INotificationBuilder _notificationBuilder; private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
private readonly IContextMenuItemService _contextMenuItemService; private readonly IContextMenuItemService _contextMenuItemService;
private readonly IMailCategoryService _mailCategoryService;
private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly IKeyPressService _keyPressService; private readonly IKeyPressService _keyPressService;
private readonly IWinoLogger _winoLogger; private readonly IWinoLogger _winoLogger;
@@ -156,6 +158,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronize))] [NotifyPropertyChangedFor(nameof(CanSynchronize))]
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))] [NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
[NotifyPropertyChangedFor(nameof(IsCategoryView))]
[NotifyPropertyChangedFor(nameof(IsSyncButtonVisible))]
public partial IBaseFolderMenuItem ActiveFolder { get; set; } public partial IBaseFolderMenuItem ActiveFolder { get; set; }
[ObservableProperty] [ObservableProperty]
@@ -172,6 +176,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
INotificationBuilder notificationBuilder, INotificationBuilder notificationBuilder,
IFolderService folderService, IFolderService folderService,
IContextMenuItemService contextMenuItemService, IContextMenuItemService contextMenuItemService,
IMailCategoryService mailCategoryService,
IWinoRequestDelegator winoRequestDelegator, IWinoRequestDelegator winoRequestDelegator,
IKeyPressService keyPressService, IKeyPressService keyPressService,
IPreferencesService preferencesService, IPreferencesService preferencesService,
@@ -185,6 +190,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
_mimeFileService = mimeFileService; _mimeFileService = mimeFileService;
_folderService = folderService; _folderService = folderService;
_contextMenuItemService = contextMenuItemService; _contextMenuItemService = contextMenuItemService;
_mailCategoryService = mailCategoryService;
_winoRequestDelegator = winoRequestDelegator; _winoRequestDelegator = winoRequestDelegator;
_keyPressService = keyPressService; _keyPressService = keyPressService;
@@ -277,9 +283,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
} }
public bool CanSynchronize => !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled; public bool CanSynchronize => !IsCategoryView && !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false; public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive; public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
public bool IsSyncButtonVisible => !IsCategoryView;
public string SelectedMessageText => IsDragInProgress public string SelectedMessageText => IsDragInProgress
? string.Format(Translator.MailsDragging, DraggingItemsCount) ? string.Format(Translator.MailsDragging, DraggingItemsCount)
@@ -396,9 +404,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
else else
{ {
if (IsCategoryView)
{
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
}
// Merged folders don't support focused feature. // Merged folders don't support focused feature.
else if (ActiveFolder is IMergedAccountFolderMenuItem)
if (ActiveFolder is IMergedAccountFolderMenuItem)
{ {
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null)); PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
} }
@@ -545,7 +556,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[RelayCommand] [RelayCommand]
private async Task EnableFolderSynchronizationAsync() private async Task EnableFolderSynchronizationAsync()
{ {
if (ActiveFolder == null) return; if (ActiveFolder == null || IsCategoryView) return;
foreach (var folder in ActiveFolder.HandlingFolders) foreach (var folder in ActiveFolder.HandlingFolders)
{ {
@@ -561,13 +572,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
Debug.WriteLine("Loading more..."); Debug.WriteLine("Loading more...");
await ExecuteUIThread(() => { IsInitializingFolder = true; }); await ExecuteUIThread(() => { IsInitializingFolder = true; });
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, var initializationOptions = CreateInitializationOptions(
SelectedFilterOption.Type, IsInSearchMode ? SearchQuery : string.Empty,
SelectedSortingOption.Type, MailCollection.MailCopyIdHashSet);
PreferencesService.IsThreadingEnabled,
SelectedFolderPivot.IsFocused,
IsInSearchMode ? SearchQuery : string.Empty,
MailCollection.MailCopyIdHashSet);
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false); var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
@@ -674,6 +681,60 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<MailItemViewModel> contextMailItems) public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<MailItemViewModel> contextMailItems)
=> _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy)); => _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy));
public async Task<(IReadOnlyList<MailCategory> Categories, IReadOnlyCollection<Guid> AssignedCategoryIds)> GetAvailableCategoriesAsync(IEnumerable<MailItemViewModel> targetItems)
{
var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
if (targetList.Count == 0)
return ([], []);
var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
if (accountIds.Count != 1)
return ([], []);
var accountId = accountIds[0];
var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
var categories = await _mailCategoryService.GetCategoriesAsync(accountId).ConfigureAwait(false);
var assignedCategoryIds = await _mailCategoryService.GetAssignedCategoryIdsForAllAsync(uniqueIds).ConfigureAwait(false);
return (categories, assignedCategoryIds);
}
public async Task ToggleCategoryAssignmentAsync(MailCategory category, IEnumerable<MailItemViewModel> targetItems, bool isAssignedToAll)
{
var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
if (category == null || targetList.Count == 0)
return;
var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
if (accountIds.Count != 1)
return;
var accountId = accountIds[0];
var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
if (isAssignedToAll)
{
await _mailCategoryService.UnassignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
}
else
{
await _mailCategoryService.AssignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
}
if (targetList.First().MailCopy.AssignedAccount.ProviderType != MailProviderType.Outlook)
return;
var requests = new List<IRequestBase>();
foreach (var mailItem in targetList.Select(a => a.MailCopy).DistinctBy(a => a.UniqueId))
{
var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailItem.UniqueId).ConfigureAwait(false);
requests.Add(new MailCategoryAssignmentRequest(mailItem, category.Id, category.Name, categoryNames, !isAssignedToAll));
}
await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false);
}
private bool ShouldPreventItemAdd(MailCopy mailItem) private bool ShouldPreventItemAdd(MailCopy mailItem)
{ {
bool condition = mailItem.IsRead bool condition = mailItem.IsRead
@@ -691,7 +752,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft; => ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
private bool BelongsToActiveFolder(MailCopy mailItem) private bool BelongsToActiveFolder(MailCopy mailItem)
=> mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true; => !IsCategoryView && mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
private bool ShouldIncludeByThread(MailCopy mailItem) private bool ShouldIncludeByThread(MailCopy mailItem)
=> PreferencesService.IsThreadingEnabled => PreferencesService.IsThreadingEnabled
@@ -1069,6 +1130,38 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
} }
private MailListInitializationOptions CreateInitializationOptions(
string searchQuery,
System.Collections.Concurrent.ConcurrentDictionary<Guid, bool> existingUniqueIds,
List<MailCopy> preFetchedMailCopies = null,
bool deduplicateByServerId = false)
{
var options = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
SelectedFilterOption.Type,
SelectedSortingOption.Type,
PreferencesService.IsThreadingEnabled,
SelectedFolderPivot.IsFocused,
searchQuery,
existingUniqueIds,
preFetchedMailCopies,
DeduplicateByServerId: deduplicateByServerId);
if (!IsCategoryView)
return options;
var categoryIds = ActiveFolder switch
{
IMailCategoryMenuItem singleCategoryMenuItem => new List<Guid> { singleCategoryMenuItem.MailCategory.Id },
IMergedMailCategoryMenuItem mergedCategoryMenuItem => mergedCategoryMenuItem.Categories.Select(a => a.Id).ToList(),
_ => []
};
return options with
{
CategoryIds = categoryIds
};
}
[RelayCommand] [RelayCommand]
private async Task PerformOnlineSearchAsync() private async Task PerformOnlineSearchAsync()
{ {
@@ -1218,15 +1311,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
} }
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, var initializationOptions = CreateInitializationOptions(
SelectedFilterOption.Type, isDoingOnlineSearch ? string.Empty : SearchQuery,
SelectedSortingOption.Type, MailCollection.MailCopyIdHashSet,
PreferencesService.IsThreadingEnabled, onlineSearchItems,
SelectedFolderPivot.IsFocused, isDoingOnlineSearch);
isDoingOnlineSearch ? string.Empty : SearchQuery,
MailCollection.MailCopyIdHashSet,
onlineSearchItems,
DeduplicateByServerId: isDoingOnlineSearch);
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
@@ -1,4 +1,4 @@
using System; using Microsoft.Windows.AppNotifications;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
@@ -49,31 +49,8 @@ internal static class ToastActivationResolver
return true; return true;
} }
public static bool TryResolveMode(NotificationArguments toastArguments, out WinoApplicationMode mode)
{
mode = WinoApplicationMode.Mail;
if (!toastArguments.TryGetValue(Constants.ToastModeKey, out string toastMode))
return false;
if (string.Equals(toastMode, Constants.ToastModeCalendar, StringComparison.OrdinalIgnoreCase))
{
mode = WinoApplicationMode.Calendar;
return true;
}
if (string.Equals(toastMode, Constants.ToastModeMail, StringComparison.OrdinalIgnoreCase))
{
mode = WinoApplicationMode.Mail;
return true;
}
return false;
}
private static bool ContainsKnownToastKey(NotificationArguments toastArguments) private static bool ContainsKnownToastKey(NotificationArguments toastArguments)
=> toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string _) || => toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string _) ||
toastArguments.TryGetValue(Constants.ToastModeKey, out string _) ||
toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string _) || toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string _) ||
toastArguments.TryGetValue(Constants.ToastActionKey, out string _); toastArguments.TryGetValue(Constants.ToastActionKey, out string _);
} }
+45 -111
View File
@@ -4,8 +4,8 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching; using Microsoft.UI.Dispatching;
@@ -16,6 +16,7 @@ using Microsoft.Windows.AppNotifications;
using MimeKit.Cryptography; using MimeKit.Cryptography;
using Windows.ApplicationModel.Activation; using Windows.ApplicationModel.Activation;
using Windows.ApplicationModel.DataTransfer; using Windows.ApplicationModel.DataTransfer;
using Windows.ApplicationModel.DataTransfer.ShareTarget;
using Windows.Storage; using Windows.Storage;
using Wino.Calendar.ViewModels; using Wino.Calendar.ViewModels;
using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Interfaces;
@@ -363,6 +364,7 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(StoragePageViewModel)); services.AddTransient(typeof(StoragePageViewModel));
services.AddTransient(typeof(WinoAccountManagementPageViewModel)); services.AddTransient(typeof(WinoAccountManagementPageViewModel));
services.AddTransient(typeof(AliasManagementPageViewModel)); services.AddTransient(typeof(AliasManagementPageViewModel));
services.AddTransient(typeof(MailCategoryManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel)); services.AddTransient(typeof(ContactsPageViewModel));
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel)); services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
services.AddTransient(typeof(EmailTemplatesPageViewModel)); services.AddTransient(typeof(EmailTemplatesPageViewModel));
@@ -542,20 +544,23 @@ public partial class App : WinoApplication,
{ {
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activationArgs.Kind == ExtendedActivationKind.Launch && if (activationArgs.Kind != ExtendedActivationKind.AppNotification ||
activationArgs.Data is ILaunchActivatedEventArgs launchArgs && activationArgs.Data is not AppNotificationActivatedEventArgs toastArgs ||
ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments) && !TryMarkInitialNotificationActivationHandled())
TryMarkInitialNotificationActivationHandled())
{ {
LogActivation($"Processing initial toast launch activation from application startup. Arguments: {launchArgs.Arguments}"); return;
await EnsureActivationInfrastructureAsync();
await HandleToastActivationAsync(launchToastArguments);
} }
LogActivation($"Processing initial notification activation from application startup. Arguments: {toastArgs.Argument}");
await EnsureActivationInfrastructureAsync();
await HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput);
} }
private void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args) private void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
{ {
// AppNotification callbacks are not guaranteed to run on the UI thread.
// Marshal toast handling to the window dispatcher before touching window APIs.
if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args.Argument, args.UserInput)) == true) if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args.Argument, args.UserInput)) == true)
return; return;
@@ -565,7 +570,19 @@ public partial class App : WinoApplication,
private void TryRegisterAppNotifications() private void TryRegisterAppNotifications()
{ {
// Classic targeted toasts use normal launch activation instead of COM toast activators. var notificationManager = AppNotificationManager.Default;
notificationManager.NotificationInvoked -= AppNotificationInvoked;
notificationManager.NotificationInvoked += AppNotificationInvoked;
try
{
notificationManager.Register();
}
catch (Exception ex)
{
LogActivation($"App notification registration failed: {ex.GetType().Name} - {ex.Message}");
}
} }
/// <summary> /// <summary>
@@ -595,7 +612,7 @@ public partial class App : WinoApplication,
if (calendarAction == Constants.ToastCalendarSnoozeAction) if (calendarAction == Constants.ToastCalendarSnoozeAction)
{ {
await HandleCalendarToastSnoozeAsync(toastArguments, userInput, calendarItemId); await HandleCalendarToastSnoozeAsync(userInput, calendarItemId);
return; return;
} }
@@ -640,26 +657,6 @@ public partial class App : WinoApplication,
return HandleToastActivationAsync(toastArguments, userInput); return HandleToastActivationAsync(toastArguments, userInput);
} }
private static int? GetToastSnoozeDurationMinutes(NotificationArguments toastArguments, IDictionary<string, string>? userInput)
{
if (toastArguments.TryGetValue(Constants.ToastCalendarSnoozeDurationMinutesKey, out var snoozeDurationValue) &&
int.TryParse(snoozeDurationValue, out var snoozeDurationMinutes) &&
snoozeDurationMinutes > 0)
{
return snoozeDurationMinutes;
}
if (userInput != null &&
userInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) &&
int.TryParse(selectedValue, out snoozeDurationMinutes) &&
snoozeDurationMinutes > 0)
{
return snoozeDurationMinutes;
}
return null;
}
private async Task<bool> HandleShareTargetActivationAsync(AppActivationArguments activationArgs, bool activateWindow) private async Task<bool> HandleShareTargetActivationAsync(AppActivationArguments activationArgs, bool activateWindow)
{ {
if (activationArgs.Kind != ExtendedActivationKind.ShareTarget || if (activationArgs.Kind != ExtendedActivationKind.ShareTarget ||
@@ -814,59 +811,9 @@ public partial class App : WinoApplication,
navigationService.Navigate(WinoPage.EventDetailsPage, target); navigationService.Navigate(WinoPage.EventDetailsPage, target);
} }
private async Task<object?> TryCreateToastNavigationParameterAsync(NotificationArguments toastArguments) private async Task HandleCalendarToastSnoozeAsync(IDictionary<string, string>? userInput, Guid calendarItemId)
{ {
if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) && if (!TryGetSnoozeDurationMinutes(userInput, out var snoozeDurationMinutes))
calendarAction == Constants.ToastCalendarNavigateAction &&
toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) &&
Guid.TryParse(calendarItemIdString, out Guid calendarItemId))
{
var calendarService = Services.GetRequiredService<ICalendarService>();
var calendarItem = await calendarService.GetCalendarItemAsync(calendarItemId);
if (calendarItem != null)
{
return new CalendarItemTarget(calendarItem, CalendarEventTargetType.Single);
}
}
return null;
}
private async Task<(WinoApplicationMode Mode, object? Parameter)?> TryResolveToastActivationTargetAsync(AppActivationArguments activationArgs)
{
NotificationArguments? toastArguments = null;
if (activationArgs.Kind == ExtendedActivationKind.Launch &&
activationArgs.Data is ILaunchActivatedEventArgs launchArgs &&
ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments))
{
toastArguments = launchToastArguments;
}
else if (activationArgs.Kind == ExtendedActivationKind.AppNotification &&
activationArgs.Data is AppNotificationActivatedEventArgs appNotificationArgs &&
ToastActivationResolver.TryParse(appNotificationArgs.Argument, out var appNotificationToastArguments))
{
toastArguments = appNotificationToastArguments;
}
else if (activationArgs.Data is ToastNotificationActivatedEventArgs classicToastArgs &&
ToastActivationResolver.TryParse(classicToastArgs.Argument, out var classicToastArguments))
{
toastArguments = classicToastArguments;
}
if (toastArguments == null ||
!ToastActivationResolver.TryResolveMode(toastArguments, out var mode))
{
return null;
}
return (mode, await TryCreateToastNavigationParameterAsync(toastArguments));
}
private async Task HandleCalendarToastSnoozeAsync(NotificationArguments toastArguments, IDictionary<string, string>? userInput, Guid calendarItemId)
{
if (!TryGetSnoozeDurationMinutes(toastArguments, userInput, out var snoozeDurationMinutes))
return; return;
var calendarService = Services.GetRequiredService<ICalendarService>(); var calendarService = Services.GetRequiredService<ICalendarService>();
@@ -890,13 +837,20 @@ public partial class App : WinoApplication,
await nativeAppService.LaunchUriAsync(joinUri); await nativeAppService.LaunchUriAsync(joinUri);
} }
private bool TryGetSnoozeDurationMinutes(NotificationArguments toastArguments, IDictionary<string, string>? userInput, out int snoozeDurationMinutes) private bool TryGetSnoozeDurationMinutes(IDictionary<string, string>? userInput, out int snoozeDurationMinutes)
{ {
snoozeDurationMinutes = GetToastSnoozeDurationMinutes(toastArguments, userInput) snoozeDurationMinutes = _preferencesService?.DefaultSnoozeDurationInMinutes ?? 0;
?? _preferencesService?.DefaultSnoozeDurationInMinutes
?? 0;
return snoozeDurationMinutes > 0; if (userInput == null ||
!userInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) ||
selectedValue == null)
{
return snoozeDurationMinutes > 0;
}
var selectedText = selectedValue.ToString();
return int.TryParse(selectedText, out snoozeDurationMinutes) && snoozeDurationMinutes > 0;
} }
/// <summary> /// <summary>
@@ -1368,6 +1322,7 @@ public partial class App : WinoApplication,
return synchronizationType switch return synchronizationType switch
{ {
MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases, MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases,
MailSynchronizationType.Categories => Translator.Exception_FailedToSynchronizeCategories,
MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation, MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation,
_ => Translator.Exception_FailedToSynchronizeFolders _ => Translator.Exception_FailedToSynchronizeFolders
}; };
@@ -1631,22 +1586,7 @@ public partial class App : WinoApplication,
} }
else if (TryResolveActivationMode(args, _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail, out var redirectedMode)) else if (TryResolveActivationMode(args, _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail, out var redirectedMode))
{ {
var navigationService = Services.GetRequiredService<INavigationService>(); shellWindow.HandleAppActivation(AppEntryConstants.GetModeLaunchArgument(redirectedMode));
var toastActivationTarget = await TryResolveToastActivationTargetAsync(args);
if (toastActivationTarget is { Parameter: CalendarItemTarget calendarTarget })
{
navigationService.ChangeApplicationMode(toastActivationTarget.Value.Mode, new ShellModeActivationContext
{
SuppressStartupFlows = true,
Parameter = calendarTarget
});
navigationService.Navigate(WinoPage.EventDetailsPage, calendarTarget);
}
else
{
shellWindow.HandleAppActivation(AppEntryConstants.GetModeLaunchArgument(redirectedMode));
}
} }
} }
@@ -1659,6 +1599,7 @@ public partial class App : WinoApplication,
} }
} }
// Dispatch to UI thread since this is called from Program.OnActivated.
if (MainWindow?.DispatcherQueue.TryEnqueue(() => _ = HandleRedirectedActivationAsync()) == true) if (MainWindow?.DispatcherQueue.TryEnqueue(() => _ = HandleRedirectedActivationAsync()) == true)
return; return;
@@ -1703,13 +1644,6 @@ public partial class App : WinoApplication,
return true; return true;
} }
if (activationArgs.Data is ToastNotificationActivatedEventArgs classicToastArgs &&
ToastActivationResolver.TryParse(classicToastArgs.Argument, out var classicToastArguments) &&
ToastActivationResolver.TryResolveMode(classicToastArguments, out mode))
{
return true;
}
if (activationArgs.Kind == ExtendedActivationKind.File && if (activationArgs.Kind == ExtendedActivationKind.File &&
activationArgs.Data is IFileActivatedEventArgs fileArgs) activationArgs.Data is IFileActivatedEventArgs fileArgs)
{ {
@@ -296,12 +296,18 @@ public sealed partial class WebViewEditorControl : Control, IDisposable, IEditor
return; return;
} }
if (focusControlAsWell)
{
Focus(FocusState.Programmatic);
_chromium.Focus(FocusState.Programmatic);
_chromium.Focus(FocusState.Keyboard);
}
await _chromium.ExecuteScriptSafeAsync("focusEditor();"); await _chromium.ExecuteScriptSafeAsync("focusEditor();");
if (focusControlAsWell) if (focusControlAsWell)
{ {
_chromium.Focus(FocusState.Keyboard); _chromium.Focus(FocusState.Keyboard);
_chromium.Focus(FocusState.Programmatic);
} }
} }
@@ -0,0 +1,60 @@
<ContentDialog
x:Class="Wino.Dialogs.EditMailCategoryDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:mailModels="using:Wino.Core.Domain.Models.MailItem"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DefaultButton="Primary"
PrimaryButtonClick="SaveClicked"
PrimaryButtonText="{x:Bind domain:Translator.Buttons_Save, Mode=OneTime}"
SecondaryButtonClick="CancelClicked"
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel, Mode=OneTime}"
Style="{StaticResource WinoDialogStyle}"
mc:Ignorable="d">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMinWidth">520</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">520</x:Double>
</ContentDialog.Resources>
<StackPanel Spacing="16">
<TextBox
x:Name="CategoryNameTextBox"
Header="{x:Bind domain:Translator.MailCategoryDialog_Name, Mode=OneTime}"
PlaceholderText="{x:Bind domain:Translator.MailCategoryDialog_NamePlaceholder, Mode=OneTime}"
Text="{x:Bind CategoryName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="CategoryNameTextChanged" />
<StackPanel Spacing="8">
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind domain:Translator.MailCategoryDialog_Color, Mode=OneTime}" />
<GridView
x:Name="ColorsGridView"
IsItemClickEnabled="True"
ItemClick="ColorOptionClicked"
ItemsSource="{x:Bind AvailableColors, Mode=OneWay}"
SelectedItem="{x:Bind SelectedColor, Mode=TwoWay}"
SelectionMode="Single">
<GridView.ItemTemplate>
<DataTemplate x:DataType="mailModels:MailCategoryColorOption">
<Border
Width="100"
Padding="8,6"
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}"
BorderBrush="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
BorderThickness="1"
CornerRadius="10">
<TextBlock
HorizontalAlignment="Center"
Foreground="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
Text="Preview" />
</Border>
</DataTemplate>
</GridView.ItemTemplate>
</GridView>
</StackPanel>
</StackPanel>
</ContentDialog>
@@ -0,0 +1,59 @@
using System.Linq;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Dialogs;
public sealed partial class EditMailCategoryDialog : ContentDialog
{
public MailCategoryDialogResult? Result { get; private set; }
public string CategoryName { get; set; }
public MailCategoryColorOption? SelectedColor { get; set; }
public System.Collections.Generic.IReadOnlyList<MailCategoryColorOption> AvailableColors => MailCategoryPalette.DefaultOptions;
public EditMailCategoryDialog(MailCategory? category = null)
{
InitializeComponent();
Title = category == null ? Translator.MailCategoryDialog_CreateTitle : Translator.MailCategoryDialog_EditTitle;
CategoryName = category?.Name ?? string.Empty;
SelectedColor = MailCategoryPalette.DefaultOptions.FirstOrDefault(a =>
a.BackgroundColorHex == category?.BackgroundColorHex &&
a.TextColorHex == category?.TextColorHex) ?? MailCategoryPalette.DefaultOptions.First();
IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryName);
}
private void CategoryNameTextChanged(object sender, TextChangedEventArgs e)
=> IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text) && SelectedColor != null;
private void ColorOptionClicked(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is MailCategoryColorOption option)
{
SelectedColor = option;
ColorsGridView.SelectedItem = option;
IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text);
}
}
private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
if (SelectedColor == null)
{
args.Cancel = true;
return;
}
Result = new MailCategoryDialogResult(CategoryNameTextBox.Text?.Trim(), SelectedColor.BackgroundColorHex, SelectedColor.TextColorHex);
Hide();
}
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
Hide();
}
}
+66 -38
View File
@@ -6,8 +6,10 @@
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10" xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap10 rescap"> IgnorableNamespaces="uap uap10 rescap com desktop">
<!-- Publisher Cache Folders --> <!-- Publisher Cache Folders -->
<Extensions> <Extensions>
@@ -21,7 +23,7 @@
<Identity <Identity
Name="58272BurakKSE.WinoMailPreview" Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911" Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="2.0.1.0" /> Version="2.0.2.0" />
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> <mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
@@ -45,15 +47,16 @@
Executable="$targetnametoken$.exe" Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$" EntryPoint="$targetentrypoint$"
uap10:Parameters="--wino-mail"> uap10:Parameters="--wino-mail">
<uap:VisualElements <uap:VisualElements
DisplayName="Wino Mail" DisplayName="Wino Mail"
Description="Wino Mail" Description="Wino.Mail.WinUI"
BackgroundColor="transparent" BackgroundColor="transparent"
Square150x150Logo="Assets\AppEntries\MailAssets\Square150x150Logo.png" Square150x150Logo="Assets\AppEntries\MailAssets\Square150x150Logo.png"
Square44x44Logo="Assets\AppEntries\MailAssets\Square44x44Logo.png"> Square44x44Logo="Assets\AppEntries\MailAssets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\AppEntries\MailAssets\Wide310x150Logo.png" Square71x71Logo="Assets\AppEntries\MailAssets\SmallTile.png" Square310x310Logo="Assets\AppEntries\MailAssets\LargeTile.png"/> <uap:DefaultTile Wide310x150Logo="Assets\AppEntries\MailAssets\Wide310x150Logo.png" Square71x71Logo="Assets\AppEntries\MailAssets\SmallTile.png" Square310x310Logo="Assets\AppEntries\MailAssets\LargeTile.png"/>
<uap:SplashScreen Image="Assets\AppEntries\MailAssets\SplashScreen.png" /> <uap:SplashScreen Image="Assets\AppEntries\MailAssets\SplashScreen.png" />
</uap:VisualElements> </uap:VisualElements>
<Extensions> <Extensions>
<uap5:Extension Category="windows.startupTask"> <uap5:Extension Category="windows.startupTask">
<uap5:StartupTask <uap5:StartupTask
@@ -62,6 +65,19 @@
DisplayName="Wino Startup Service" /> DisplayName="Wino Startup Service" />
</uap5:Extension> </uap5:Extension>
<!-- App notification activation -->
<desktop:Extension Category="windows.toastNotificationActivation">
<desktop:ToastNotificationActivation ToastActivatorCLSID="72c6d2d0-2538-44fe-a1b1-499f47bb1181" />
</desktop:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="----AppNotificationActivated:" DisplayName="Toast activator">
<com:Class Id="72c6d2d0-2538-44fe-a1b1-499f47bb1181" DisplayName="Toast activator"/>
</com:ExeServer>
</com:ComServer>
</com:Extension>
<!-- Protocol activation: mailto --> <!-- Protocol activation: mailto -->
<uap:Extension Category="windows.protocol"> <uap:Extension Category="windows.protocol">
<uap:Protocol Name="mailto" /> <uap:Protocol Name="mailto" />
@@ -102,43 +118,55 @@
</Extensions> </Extensions>
</Application> </Application>
<Application Id="CalendarApp" <Application Id="CalendarApp"
Executable="$targetnametoken$.exe" Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$" EntryPoint="$targetentrypoint$"
uap10:Parameters="--wino-calendar"> uap10:Parameters="--wino-calendar">
<uap:VisualElements <uap:VisualElements
DisplayName="Wino Calendar" DisplayName="Wino Calendar"
Description="Wino Calendar" Description="Wino.Mail.WinUI.Calendar"
BackgroundColor="transparent" BackgroundColor="transparent"
Square150x150Logo="Assets\AppEntries\CalendarAssets\Square150x150Logo.png" Square150x150Logo="Assets\AppEntries\CalendarAssets\Square150x150Logo.png"
Square44x44Logo="Assets\AppEntries\CalendarAssets\Square44x44Logo.png"> Square44x44Logo="Assets\AppEntries\CalendarAssets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\AppEntries\CalendarAssets\Wide310x150Logo.png" Square71x71Logo="Assets\AppEntries\CalendarAssets\SmallTile.png" Square310x310Logo="Assets\AppEntries\CalendarAssets\LargeTile.png"/> <uap:DefaultTile Wide310x150Logo="Assets\AppEntries\CalendarAssets\Wide310x150Logo.png" Square71x71Logo="Assets\AppEntries\CalendarAssets\SmallTile.png" Square310x310Logo="Assets\AppEntries\CalendarAssets\LargeTile.png"/>
<uap:SplashScreen Image="Assets\AppEntries\CalendarAssets\SplashScreen.png" /> <uap:SplashScreen Image="Assets\AppEntries\CalendarAssets\SplashScreen.png" />
</uap:VisualElements> </uap:VisualElements>
<Extensions> <Extensions>
<uap:Extension Category="windows.protocol"> <desktop:Extension Category="windows.toastNotificationActivation">
<uap:Protocol Name="webcal"> <desktop:ToastNotificationActivation ToastActivatorCLSID="44c05d2b-aa1d-4e59-9d7d-8b4c8607cb8d" />
<uap:DisplayName>Calendar Protocol</uap:DisplayName> </desktop:Extension>
</uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.protocol"> <com:Extension Category="windows.comServer">
<uap:Protocol Name="webcals"> <com:ComServer>
<uap:DisplayName>Calendar Protocol (Secure)</uap:DisplayName> <com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="----AppNotificationActivated:" DisplayName="Calendar toast activator">
</uap:Protocol> <com:Class Id="44c05d2b-aa1d-4e59-9d7d-8b4c8607cb8d" DisplayName="Calendar toast activator"/>
</uap:Extension> </com:ExeServer>
</com:ComServer>
</com:Extension>
<uap:Extension Category="windows.fileTypeAssociation"> <uap:Extension Category="windows.protocol">
<uap:FileTypeAssociation Name="ics"> <uap:Protocol Name="webcal">
<uap:Logo>Assets\AppEntries\CalendarAssets\Square44x44Logo.png</uap:Logo> <uap:DisplayName>Calendar Protocol</uap:DisplayName>
<uap:SupportedFileTypes> </uap:Protocol>
<uap:FileType>.ics</uap:FileType> </uap:Extension>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation> <uap:Extension Category="windows.protocol">
</uap:Extension> <uap:Protocol Name="webcals">
</Extensions> <uap:DisplayName>Calendar Protocol (Secure)</uap:DisplayName>
</Application> </uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="ics">
<uap:Logo>Assets\AppEntries\CalendarAssets\Square44x44Logo.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.ics</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
</Applications> </Applications>
<Capabilities> <Capabilities>
-7
View File
@@ -207,13 +207,6 @@ public class Program
: true; : true;
} }
if (args.Data is Windows.ApplicationModel.Activation.ToastNotificationActivatedEventArgs classicToastArgs)
{
return ToastActivationResolver.TryParse(classicToastArgs.Argument, out var toastArguments)
? ToastActivationResolver.ShouldBringToForeground(toastArguments)
: true;
}
if (args.Kind == ExtendedActivationKind.Launch && if (args.Kind == ExtendedActivationKind.Launch &&
args.Data is Windows.ApplicationModel.Activation.ILaunchActivatedEventArgs launchArgs && args.Data is Windows.ApplicationModel.Activation.ILaunchActivatedEventArgs launchArgs &&
ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments)) ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments))
@@ -26,6 +26,7 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
public DataTemplate NewMailTemplate { get; set; } = null!; public DataTemplate NewMailTemplate { get; set; } = null!;
public DataTemplate CalendarNewEventTemplate { get; set; } = null!; public DataTemplate CalendarNewEventTemplate { get; set; } = null!;
public DataTemplate CategoryItemsTemplate { get; set; } = null!; public DataTemplate CategoryItemsTemplate { get; set; } = null!;
public DataTemplate MergedCategoryItemsTemplate { get; set; } = null!;
public DataTemplate FixAuthenticationIssueTemplate { get; set; } = null!; public DataTemplate FixAuthenticationIssueTemplate { get; set; } = null!;
public DataTemplate FixMissingFolderConfigTemplate { get; set; } = null!; public DataTemplate FixMissingFolderConfigTemplate { get; set; } = null!;
@@ -58,6 +59,10 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
return MergedAccountTemplate; return MergedAccountTemplate;
else if (item is MergedAccountMoreFolderMenuItem) else if (item is MergedAccountMoreFolderMenuItem)
return MergedAccountMoreExpansionItemTemplate; return MergedAccountMoreExpansionItemTemplate;
else if (item is MailCategoryMenuItem)
return CategoryItemsTemplate;
else if (item is MergedMailCategoryMenuItem)
return MergedCategoryItemsTemplate;
else if (item is MergedAccountFolderMenuItem) else if (item is MergedAccountFolderMenuItem)
return MergedAccountFolderTemplate; return MergedAccountFolderTemplate;
else if (item is FolderMenuItem) else if (item is FolderMenuItem)
+22 -3
View File
@@ -6,7 +6,6 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
@@ -15,11 +14,12 @@ using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.WinUI.Extensions;
using Wino.Mail.WinUI.Services;
using Wino.Dialogs; using Wino.Dialogs;
using Wino.Mail.Dialogs; using Wino.Mail.Dialogs;
using Wino.Mail.WinUI.Extensions;
using Wino.Mail.WinUI.Services;
using Wino.Messaging.Server; using Wino.Messaging.Server;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -40,6 +40,12 @@ public class DialogService : DialogServiceBase, IMailDialogService
_winoAccountDataSyncService = winoAccountDataSyncService; _winoAccountDataSyncService = winoAccountDataSyncService;
} }
public void ShowReadOnlyCalendarMessage()
=> InfoBarMessage(
Translator.CalendarReadOnly_Title,
Translator.CalendarReadOnly_Message,
InfoBarMessageType.Warning);
public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync() public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync()
{ {
var createAccountAliasDialog = new CreateAccountAliasDialog() var createAccountAliasDialog = new CreateAccountAliasDialog()
@@ -52,6 +58,19 @@ public class DialogService : DialogServiceBase, IMailDialogService
return createAccountAliasDialog; return createAccountAliasDialog;
} }
#pragma warning disable CS8625
public async Task<MailCategoryDialogResult> ShowEditMailCategoryDialogAsync(MailCategory category = null)
#pragma warning restore CS8625
{
var dialog = new EditMailCategoryDialog(category)
{
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
};
await HandleDialogPresentationAsync(dialog);
return dialog.Result;
}
public async Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService) public async Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService)
{ {
try try
@@ -82,6 +82,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.ReadComposePanePage, WinoPage.ReadComposePanePage,
WinoPage.AppPreferencesPage, WinoPage.AppPreferencesPage,
WinoPage.AliasManagementPage, WinoPage.AliasManagementPage,
WinoPage.MailCategoryManagementPage,
WinoPage.ImapCalDavSettingsPage, WinoPage.ImapCalDavSettingsPage,
WinoPage.KeyboardShortcutsPage, WinoPage.KeyboardShortcutsPage,
WinoPage.SignatureAndEncryptionPage, WinoPage.SignatureAndEncryptionPage,
@@ -150,6 +151,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.SettingOptionsPage => typeof(SettingOptionsPage), WinoPage.SettingOptionsPage => typeof(SettingOptionsPage),
WinoPage.AppPreferencesPage => typeof(AppPreferencesPage), WinoPage.AppPreferencesPage => typeof(AppPreferencesPage),
WinoPage.AliasManagementPage => typeof(AliasManagementPage), WinoPage.AliasManagementPage => typeof(AliasManagementPage),
WinoPage.MailCategoryManagementPage => typeof(MailCategoryManagementPage),
WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage), WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage),
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
WinoPage.ContactsPage => typeof(ContactsPage), WinoPage.ContactsPage => typeof(ContactsPage),
+47 -43
View File
@@ -5,7 +5,8 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI.Notifications; using Microsoft.Windows.AppNotifications;
using Microsoft.Windows.AppNotifications.Builder;
using Serilog; using Serilog;
using Windows.Data.Xml.Dom; using Windows.Data.Xml.Dom;
using Windows.UI.Notifications; using Windows.UI.Notifications;
@@ -75,9 +76,9 @@ public class NotificationBuilder : INotificationBuilder
builder.AddText(Translator.Notifications_MultipleNotificationsTitle); builder.AddText(Translator.Notifications_MultipleNotificationsTitle);
builder.AddText(string.Format(Translator.Notifications_MultipleNotificationsMessage, mailCount)); builder.AddText(string.Format(Translator.Notifications_MultipleNotificationsMessage, mailCount));
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
builder.AddAudio(new Uri("ms-winsoundevent:Notification.Mail")); builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Mail"));
ShowNotification(builder, WinoApplicationMode.Mail); ShowNotification(builder);
} }
else else
{ {
@@ -145,9 +146,9 @@ public class NotificationBuilder : INotificationBuilder
{ {
try try
{ {
ToastNotificationManager.History.Remove(mailUniqueId.ToString()); AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().GetAwaiter().GetResult();
} }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) catch (ArgumentException)
{ {
} }
catch (Exception ex) catch (Exception ex)
@@ -163,12 +164,11 @@ public class NotificationBuilder : INotificationBuilder
builder.AddText(string.Format(Translator.Exception_AccountNeedsAttention_Message, account.Name)); builder.AddText(string.Format(Translator.Exception_AccountNeedsAttention_Message, account.Name));
builder.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()); builder.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString());
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
builder.AddButton(new ToastButton() builder.AddButton(new AppNotificationButton(Translator.Buttons_FixAccount)
.SetContent(Translator.Buttons_FixAccount)
.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()) .AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)); .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail));
ShowNotification(builder, WinoApplicationMode.Mail); ShowNotification(builder);
} }
public void CreateWebView2RuntimeMissingNotification() public void CreateWebView2RuntimeMissingNotification()
@@ -178,7 +178,7 @@ public class NotificationBuilder : INotificationBuilder
builder.AddText(Translator.Exception_WebView2RuntimeMissing_Message); builder.AddText(Translator.Exception_WebView2RuntimeMissing_Message);
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
ShowNotification(builder, WinoApplicationMode.Mail); ShowNotification(builder);
} }
public void CreateStoreUpdateNotification() public void CreateStoreUpdateNotification()
@@ -189,7 +189,7 @@ public class NotificationBuilder : INotificationBuilder
builder.AddArgument(Constants.ToastStoreUpdateActionKey, Constants.ToastStoreUpdateActionInstall); builder.AddArgument(Constants.ToastStoreUpdateActionKey, Constants.ToastStoreUpdateActionInstall);
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
ShowNotification(builder, WinoApplicationMode.Mail, "store-update-available"); ShowNotification(builder, "store-update-available");
} }
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
@@ -197,7 +197,7 @@ public class NotificationBuilder : INotificationBuilder
if (calendarItem == null) if (calendarItem == null)
return Task.CompletedTask; return Task.CompletedTask;
var builder = CreateBuilder(ToastScenario.Reminder); var builder = CreateBuilder(AppNotificationScenario.Reminder);
var localStart = calendarItem.GetLocalStartDate(); var localStart = calendarItem.GetLocalStartDate();
var reminderContext = GetCalendarReminderContext(localStart, DateTime.Now); var reminderContext = GetCalendarReminderContext(localStart, DateTime.Now);
@@ -210,7 +210,7 @@ public class NotificationBuilder : INotificationBuilder
builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction); builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction);
builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()); builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString());
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar);
builder.AddAudio(new Uri("ms-winsoundevent:Notification.Reminder")); builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Reminder"));
var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes(
reminderDurationInSeconds, reminderDurationInSeconds,
@@ -223,33 +223,40 @@ public class NotificationBuilder : INotificationBuilder
? preferredSnoozeMinutes ? preferredSnoozeMinutes
: allowedSnoozeMinutes[0]; : allowedSnoozeMinutes[0];
builder.AddButton(new ToastButton() var selectionBox = new AppNotificationComboBox(Constants.ToastCalendarSnoozeDurationInputId)
.SetContent(Translator.CalendarReminder_SnoozeAction) .SetSelectedItem(defaultSnoozeMinutes.ToString());
.SetImageUri(GetNotificationIconUri("calendar-snooze"))
foreach (var snoozeMinutes in allowedSnoozeMinutes)
{
selectionBox.AddItem(
snoozeMinutes.ToString(),
string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes));
}
builder.AddComboBox(selectionBox);
builder.AddButton(new AppNotificationButton(Translator.CalendarReminder_SnoozeAction)
.SetIcon(GetNotificationIconUri("calendar-snooze"))
.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction) .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction)
.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString())
.AddArgument(Constants.ToastCalendarSnoozeDurationMinutesKey, defaultSnoozeMinutes.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar));
} }
builder.AddButton(new ToastButton() builder.AddButton(new AppNotificationButton(Translator.Buttons_Open)
.SetContent(Translator.Buttons_Open)
.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction) .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction)
.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar));
if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out _)) if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out _))
{ {
builder.AddButton(new ToastButton() builder.AddButton(new AppNotificationButton(Translator.CalendarEventDetails_JoinOnline)
.SetContent(Translator.CalendarEventDetails_JoinOnline) .SetIcon(GetNotificationIconUri("calendar-join"))
.SetImageUri(GetNotificationIconUri("calendar-join"))
.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarJoinOnlineAction) .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarJoinOnlineAction)
.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar));
} }
var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}"; var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}";
ShowNotification(builder, WinoApplicationMode.Calendar, tag); ShowNotification(builder, tag);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -271,10 +278,10 @@ public class NotificationBuilder : INotificationBuilder
await stream.WriteAsync(bytes); await stream.WriteAsync(bytes);
} }
builder.AddAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), ToastGenericAppLogoCrop.Default); builder.SetAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), AppNotificationImageCrop.Default);
} }
builder.AddCustomTimeStamp(mailItem.CreationDate.ToLocalTime()); builder.SetTimeStamp(mailItem.CreationDate.ToLocalTime());
builder.AddText(mailItem.FromName); builder.AddText(mailItem.FromName);
builder.AddText(mailItem.Subject); builder.AddText(mailItem.Subject);
builder.AddText(mailItem.PreviewText); builder.AddText(mailItem.PreviewText);
@@ -284,9 +291,9 @@ public class NotificationBuilder : INotificationBuilder
builder.AddButton(GetMarkAsReadButton(mailItem.UniqueId)); builder.AddButton(GetMarkAsReadButton(mailItem.UniqueId));
builder.AddButton(GetDeleteButton(mailItem.UniqueId)); builder.AddButton(GetDeleteButton(mailItem.UniqueId));
builder.AddButton(GetArchiveButton(mailItem.UniqueId)); builder.AddButton(GetArchiveButton(mailItem.UniqueId));
builder.AddAudio(new Uri("ms-winsoundevent:Notification.Mail")); builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Mail"));
ShowNotification(builder, WinoApplicationMode.Mail, mailItem.UniqueId.ToString()); ShowNotification(builder, mailItem.UniqueId.ToString());
} }
private void UpdateBadge(string applicationId, int? badgeCount) private void UpdateBadge(string applicationId, int? badgeCount)
@@ -340,43 +347,40 @@ public class NotificationBuilder : INotificationBuilder
return string.Format(Translator.CalendarReminder_StartedMinutesAgo, minutesAgo); return string.Format(Translator.CalendarReminder_StartedMinutesAgo, minutesAgo);
} }
private ToastButton GetArchiveButton(Guid mailUniqueId) private AppNotificationButton GetArchiveButton(Guid mailUniqueId)
=> new ToastButton() => new AppNotificationButton(Translator.MailOperation_Archive)
.SetContent(Translator.MailOperation_Archive) .SetIcon(GetNotificationIconUri("mail-archive"))
.SetImageUri(GetNotificationIconUri("mail-archive"))
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
.AddArgument(Constants.ToastActionKey, MailOperation.Archive.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.Archive.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
private ToastButton GetDeleteButton(Guid mailUniqueId) private AppNotificationButton GetDeleteButton(Guid mailUniqueId)
=> new ToastButton() => new AppNotificationButton(Translator.MailOperation_Delete)
.SetContent(Translator.MailOperation_Delete) .SetIcon(GetNotificationIconUri("mail-delete"))
.SetImageUri(GetNotificationIconUri("mail-delete"))
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
.AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
private ToastButton GetMarkAsReadButton(Guid mailUniqueId) private AppNotificationButton GetMarkAsReadButton(Guid mailUniqueId)
=> new ToastButton() => new AppNotificationButton(Translator.MailOperation_MarkAsRead)
.SetContent(Translator.MailOperation_MarkAsRead) .SetIcon(GetNotificationIconUri("mail-markread"))
.SetImageUri(GetNotificationIconUri("mail-markread"))
.AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString())
.AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead.ToString())
.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
private static ToastContentBuilder CreateBuilder(ToastScenario scenario = ToastScenario.Default) private static AppNotificationBuilder CreateBuilder(AppNotificationScenario scenario = AppNotificationScenario.Default)
=> new ToastContentBuilder().SetToastScenario(scenario); => new AppNotificationBuilder().SetScenario(scenario);
private static void ShowNotification(ToastContentBuilder builder, WinoApplicationMode mode, string? tag = null) private static void ShowNotification(AppNotificationBuilder builder, string? tag = null)
{ {
var notification = new ToastNotification(builder.GetXml()); var notification = builder.BuildNotification();
if (!string.IsNullOrWhiteSpace(tag)) if (!string.IsNullOrWhiteSpace(tag))
{ {
notification.Tag = tag; notification.Tag = tag;
} }
ToastNotificationManager.CreateToastNotifier(AppEntryConstants.GetAppUserModelId(mode)).Show(notification); AppNotificationManager.Default.Show(notification);
} }
private static Uri GetNotificationIconUri(string iconName) private static Uri GetNotificationIconUri(string iconName)
@@ -0,0 +1,6 @@
using Wino.Mail.ViewModels;
using Wino.Mail.WinUI;
namespace Wino.Views.Abstract;
public abstract class MailCategoryManagementPageAbstract : BasePage<MailCategoryManagementPageViewModel> { }
File diff suppressed because one or more lines are too long
@@ -210,12 +210,12 @@
Background="{ThemeResource DividerStrokeColorDefaultBrush}" /> Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
<!-- Test Notification --> <!-- Test Notification -->
<Button Command="{x:Bind ViewModel.CreateTestNotificationCommand}" Style="{StaticResource TransparentActionButtonStyle}"> <!--<Button Command="{x:Bind ViewModel.CreateTestNotificationCommand}" Style="{StaticResource TransparentActionButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<coreControls:WinoFontIcon FontSize="16" Icon="Reminder" /> <coreControls:WinoFontIcon FontSize="16" Icon="Reminder" />
<TextBlock VerticalAlignment="Center" Text="Test notification" /> <TextBlock VerticalAlignment="Center" Text="Test notification" />
</StackPanel> </StackPanel>
</Button> </Button>-->
<!-- Edit Series --> <!-- Edit Series -->
<Border <Border
+73 -11
View File
@@ -38,7 +38,10 @@ public sealed partial class ComposePage : ComposePageAbstract,
IPopoutClient, IPopoutClient,
IRecipient<ApplicationThemeChanged> IRecipient<ApplicationThemeChanged>
{ {
private const int InitialFocusRetryCount = 3;
private bool _isPoppedOut; private bool _isPoppedOut;
private bool _isInitialFocusHandled;
public bool SupportsPopOut => !_isPoppedOut; public bool SupportsPopOut => !_isPoppedOut;
public event EventHandler<PopOutRequestedEventArgs>? PopOutRequested; public event EventHandler<PopOutRequestedEventArgs>? PopOutRequested;
@@ -307,7 +310,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
_disposables.Add(WebViewEditor); _disposables.Add(WebViewEditor);
ViewModel.GetHTMLBodyFunction = WebViewEditor.GetHtmlBodyAsync; ViewModel.GetHTMLBodyFunction = WebViewEditor.GetHtmlBodyAsync;
ViewModel.RenderHtmlBodyAsyncFunc = WebViewEditor.RenderHtmlAsync; ViewModel.RenderHtmlBodyAsyncFunc = RenderComposeHtmlAsync;
} }
private void ShowCCBCCClicked(object sender, RoutedEventArgs e) private void ShowCCBCCClicked(object sender, RoutedEventArgs e)
@@ -373,9 +376,10 @@ public sealed partial class ComposePage : ComposePageAbstract,
{ {
if (draftMailItemViewModel == null || !draftMailItemViewModel.IsDraft) return; if (draftMailItemViewModel == null || !draftMailItemViewModel.IsDraft) return;
// Reset the initial focus flag so ToBox gets focus for the new draft. // Reset the initial focus flag for the newly loaded draft.
isInitialFocusHandled = false; _isInitialFocusHandled = false;
await ViewModel.RefreshDraftAsync(draftMailItemViewModel); await ViewModel.RefreshDraftAsync(draftMailItemViewModel);
await ApplyInitialFocusAsync();
} }
private void ImportanceClicked(object sender, RoutedEventArgs e) private void ImportanceClicked(object sender, RoutedEventArgs e)
@@ -434,21 +438,19 @@ public sealed partial class ComposePage : ComposePageAbstract,
} }
} }
// Hack: Tokenizing text box losing focus somehow on page Loaded and shifting focus to this element.
// For once we'll switch back to it once CCBBCGotFocus element got focus.
private bool isInitialFocusHandled = false;
private void ComposerLoaded(object sender, RoutedEventArgs e) private void ComposerLoaded(object sender, RoutedEventArgs e)
{ {
ToBox.Focus(FocusState.Programmatic); if (ShouldFocusRecipients())
{
ToBox.Focus(FocusState.Programmatic);
}
} }
private void CCBBCGotFocus(object sender, RoutedEventArgs e) private void CCBBCGotFocus(object sender, RoutedEventArgs e)
{ {
if (!isInitialFocusHandled) if (ShouldFocusRecipients() && !_isInitialFocusHandled)
{ {
isInitialFocusHandled = true; _isInitialFocusHandled = true;
ToBox.Focus(FocusState.Programmatic); ToBox.Focus(FocusState.Programmatic);
} }
} }
@@ -555,4 +557,64 @@ public sealed partial class ComposePage : ComposePageAbstract,
} }
finally { deferral.Complete(); } finally { deferral.Complete(); }
} }
private bool ShouldFocusRecipients()
=> !ShouldFocusEditor();
private bool ShouldFocusEditor()
{
var inReplyTo = ViewModel.CurrentMimeMessage?.InReplyTo;
if (string.IsNullOrWhiteSpace(inReplyTo))
{
inReplyTo = ViewModel.CurrentMailDraftItem?.MailCopy?.InReplyTo;
}
if (string.IsNullOrWhiteSpace(inReplyTo) && ViewModel.CurrentMimeMessage?.Headers.Contains(HeaderId.InReplyTo) == true)
{
inReplyTo = ViewModel.CurrentMimeMessage.Headers[HeaderId.InReplyTo];
}
return !string.IsNullOrWhiteSpace(inReplyTo);
}
private async Task ApplyInitialFocusAsync()
{
if (_isInitialFocusHandled)
{
return;
}
_isInitialFocusHandled = true;
for (var attempt = 0; attempt < InitialFocusRetryCount; attempt++)
{
if (ShouldFocusEditor())
{
await WebViewEditor.FocusEditorAsync(true);
if (FocusManager.GetFocusedElement(XamlRoot) is WebView2)
{
return;
}
}
else
{
ToBox.Focus(FocusState.Programmatic);
if (FocusManager.GetFocusedElement(XamlRoot) == ToBox)
{
return;
}
}
await Task.Delay(TimeSpan.FromMilliseconds(50));
}
}
private async Task RenderComposeHtmlAsync(string html)
{
await WebViewEditor.RenderHtmlAsync(html);
await ApplyInitialFocusAsync();
}
} }
+4 -1
View File
@@ -253,7 +253,8 @@
BorderThickness="0" BorderThickness="0"
Command="{x:Bind ViewModel.SyncFolderCommand}" Command="{x:Bind ViewModel.SyncFolderCommand}"
IsEnabled="{x:Bind ViewModel.CanSynchronize, Mode=OneWay}" IsEnabled="{x:Bind ViewModel.CanSynchronize, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}"> ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}"
Visibility="{x:Bind ViewModel.IsSyncButtonVisible, Mode=OneWay}">
<coreControls:WinoFontIcon FontSize="14" Icon="Sync" /> <coreControls:WinoFontIcon FontSize="14" Icon="Sync" />
</Button> </Button>
<ToggleButton <ToggleButton
@@ -290,9 +291,11 @@
</StackPanel> </StackPanel>
<InfoBar <InfoBar
x:Name="SyncDisabledInfoBar"
Title="{x:Bind domain:Translator.InfoBarTitle_SynchronizationDisabledFolder}" Title="{x:Bind domain:Translator.InfoBarTitle_SynchronizationDisabledFolder}"
Grid.Row="0" Grid.Row="0"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
x:Load="{x:Bind ViewModel.IsSyncButtonVisible, Mode=OneWay}"
IsClosable="True" IsClosable="True"
IsOpen="{x:Bind ViewModel.IsFolderSynchronizationEnabled, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}" IsOpen="{x:Bind ViewModel.IsFolderSynchronizationEnabled, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}"
Message="{x:Bind domain:Translator.InfoBarMessage_SynchronizationDisabledFolder}" Message="{x:Bind domain:Translator.InfoBarMessage_SynchronizationDisabledFolder}"
+85 -11
View File
@@ -1,6 +1,6 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Collections; using CommunityToolkit.Mvvm.Collections;
@@ -18,6 +18,7 @@ using Windows.Foundation;
using Windows.System; using Windows.System;
using Wino.Controls; using Wino.Controls;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
@@ -27,7 +28,6 @@ using Wino.Mail.ViewModels.Data;
using Wino.Mail.ViewModels.Messages; using Wino.Mail.ViewModels.Messages;
using Wino.Mail.WinUI; using Wino.Mail.WinUI;
using Wino.Mail.WinUI.Controls.ListView; using Wino.Mail.WinUI.Controls.ListView;
using Wino.Mail.WinUI.Extensions;
using Wino.Mail.WinUI.Helpers; using Wino.Mail.WinUI.Helpers;
using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.WinUI.Models; using Wino.Mail.WinUI.Models;
@@ -246,14 +246,27 @@ public sealed partial class MailListPage : MailListPageAbstract,
// Default to all selected items. // Default to all selected items.
targetItems = ViewModel.MailCollection.SelectedItems; targetItems = ViewModel.MailCollection.SelectedItems;
var availableActions = ViewModel.GetAvailableMailActions(targetItems); var availableActions = ViewModel.GetAvailableMailActions(targetItems);
var (availableCategories, assignedCategoryIds) = await ViewModel.GetAvailableCategoriesAsync(targetItems);
if (availableActions == null || !availableActions.Any()) return; if (availableActions == null || !availableActions.Any()) return;
var clickedOperation = await GetMailOperationFromFlyoutAsync(availableActions, control, p.X, p.Y); var clickedAction = await GetMailContextActionFromFlyoutAsync(
availableActions,
availableCategories,
assignedCategoryIds,
control,
p.X,
p.Y);
if (clickedOperation == null) return; if (clickedAction == null) return;
var prepRequest = new MailOperationPreperationRequest(clickedOperation.Operation, targetItems.Select(a => a.MailCopy)); if (clickedAction.Category != null)
{
await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll);
return;
}
var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy));
await ViewModel.ExecuteMailOperationAsync(prepRequest); await ViewModel.ExecuteMailOperationAsync(prepRequest);
} }
@@ -296,14 +309,68 @@ public sealed partial class MailListPage : MailListPageAbstract,
}); });
} }
private async Task<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions, private async Task<MailContextAction?> GetMailContextActionFromFlyoutAsync(
UIElement showAtElement, IEnumerable<MailOperationMenuItem> availableActions,
double x, IReadOnlyList<MailCategory> availableCategories,
double y) IReadOnlyCollection<Guid> assignedCategoryIds,
UIElement showAtElement,
double x,
double y)
{ {
var source = new TaskCompletionSource<MailOperationMenuItem>(); var source = new TaskCompletionSource<MailContextAction?>();
var flyout = new MenuFlyout();
var flyout = new MailOperationFlyout(availableActions, source); foreach (var action in availableActions)
{
if (action.Operation == MailOperation.Seperator)
{
flyout.Items.Add(new MenuFlyoutSeparator());
continue;
}
var menuFlyoutItem = new MailOperationMenuFlyoutItem(action, clicked =>
{
source.TrySetResult(new MailContextAction(clicked));
flyout.Hide();
});
flyout.Items.Add(menuFlyoutItem);
}
if (availableCategories?.Count > 0)
{
if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator)
{
flyout.Items.Add(new MenuFlyoutSeparator());
}
var categorySubItem = new MenuFlyoutSubItem
{
Text = Translator.MailCategoryMenuItem
};
foreach (var category in availableCategories)
{
var wasAssignedToAll = assignedCategoryIds.Contains(category.Id);
var categoryItem = new ToggleMenuFlyoutItem
{
Text = category.Name,
IsChecked = wasAssignedToAll
};
categoryItem.Click += (_, _) =>
{
source.TrySetResult(new MailContextAction(category, wasAssignedToAll));
flyout.Hide();
};
categorySubItem.Items.Add(categoryItem);
}
flyout.Items.Add(categorySubItem);
}
flyout.Closing += (_, _) => source.TrySetResult(null);
flyout.ShowAt(showAtElement, new FlyoutShowOptions() flyout.ShowAt(showAtElement, new FlyoutShowOptions()
{ {
@@ -314,6 +381,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
return await source.Task; return await source.Task;
} }
private sealed record MailContextAction(MailOperationMenuItem Operation, MailCategory Category = null, bool IsCategoryAssignedToAll = false)
{
public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this(null, category, isCategoryAssignedToAll)
{
}
}
async void IRecipient<ClearMailSelectionsRequested>.Receive(ClearMailSelectionsRequested message) async void IRecipient<ClearMailSelectionsRequested>.Receive(ClearMailSelectionsRequested message)
{ {
await ViewModel.MailCollection.UnselectAllAsync(); await ViewModel.MailCollection.UnselectAllAsync();
@@ -0,0 +1,169 @@
<abstract:MailCategoryManagementPageAbstract
x:Class="Wino.Views.Settings.MailCategoryManagementPage"
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:Wino.Controls"
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:mail="using:Wino.Core.Domain.Entities.Mail"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="root"
mc:Ignorable="d">
<Page.Resources>
<DataTemplate x:Key="MailCategoryTemplate" x:DataType="mail:MailCategory">
<Grid
Margin="0,0,0,12"
Padding="0,0,0,12"
ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid
VerticalAlignment="Center"
ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="28"
Height="28"
VerticalAlignment="Top"
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}"
BorderBrush="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
BorderThickness="1"
CornerRadius="8" />
<StackPanel
Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
<TextBlock
FontWeight="SemiBold"
Foreground="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind BackgroundColorHex}" />
<Run Text=" / " />
<Run Text="{x:Bind TextColorHex}" />
</TextBlock>
</StackPanel>
</Grid>
<ToggleButton
Grid.Column="1"
Checked="FavoriteCategoryChecked"
IsChecked="{x:Bind IsFavorite, Mode=OneWay}"
Tag="{x:Bind}"
Unchecked="FavoriteCategoryUnchecked">
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE208;" />
</ToggleButton>
<Button
Grid.Column="2"
Click="EditCategoryClicked"
Tag="{x:Bind}">
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE70F;" />
</Button>
<Button
Grid.Column="3"
Click="DeleteCategoryClicked"
Tag="{x:Bind}">
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE74D;" />
</Button>
<Rectangle
Grid.ColumnSpan="5"
Height="1"
Margin="0,12,0,0"
VerticalAlignment="Bottom"
Fill="{ThemeResource CardStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
</Page.Resources>
<ListView
ItemTemplate="{StaticResource MailCategoryTemplate}"
ItemsSource="{x:Bind ViewModel.Categories, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemContainerTransitions>
<TransitionCollection>
<NavigationThemeTransition />
</TransitionCollection>
</ListView.ItemContainerTransitions>
<ListView.Header>
<Grid
Padding="16,0,24,20"
ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Style="{StaticResource InformationAreaGridStyle}">
<TextBlock HorizontalTextAlignment="Center" TextWrapping="WrapWholeWords">
<Run FontWeight="SemiBold" Text="{x:Bind domain:Translator.MailCategoryManagementPage_Title, Mode=OneTime}" />
<LineBreak />
<Run Text="{x:Bind domain:Translator.MailCategoryManagementPage_Description, Mode=OneTime}" />
</TextBlock>
</Grid>
<Button
Grid.Column="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Command="{x:Bind ViewModel.RefreshCategoriesCommand}"
Visibility="{x:Bind ViewModel.CanRefresh, Mode=OneWay}">
<StackPanel Spacing="6">
<FontIcon
HorizontalAlignment="Center"
Glyph="&#xE72C;" />
<TextBlock
HorizontalAlignment="Center"
Text="{x:Bind domain:Translator.Buttons_Refresh, Mode=OneTime}" />
</StackPanel>
</Button>
<Button
Grid.Column="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Command="{x:Bind ViewModel.AddCategoryCommand}">
<StackPanel Spacing="6">
<FontIcon
HorizontalAlignment="Center"
Glyph="&#xE710;" />
<TextBlock
HorizontalAlignment="Center"
Text="{x:Bind domain:Translator.Buttons_Add, Mode=OneTime}" />
</StackPanel>
</Button>
</Grid>
</ListView.Header>
<ListView.Footer>
<TextBlock
Margin="0,12,0,0"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.MailCategoryManagementPage_Empty, Mode=OneTime}"
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.HasCategories), Mode=OneWay}" />
</ListView.Footer>
</ListView>
</abstract:MailCategoryManagementPageAbstract>
@@ -0,0 +1,47 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Wino.Core.Domain.Entities.Mail;
using Wino.Views.Abstract;
namespace Wino.Views.Settings;
public sealed partial class MailCategoryManagementPage : MailCategoryManagementPageAbstract
{
public MailCategoryManagementPage()
{
InitializeComponent();
}
private async void FavoriteCategoryChecked(object sender, RoutedEventArgs e)
{
if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category)
{
await ViewModel.SetFavoriteAsync(category, true);
}
}
private async void FavoriteCategoryUnchecked(object sender, RoutedEventArgs e)
{
if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category)
{
await ViewModel.SetFavoriteAsync(category, false);
}
}
private async void EditCategoryClicked(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is MailCategory category)
{
await ViewModel.EditCategoryAsync(category);
}
}
private async void DeleteCategoryClicked(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is MailCategory category)
{
await ViewModel.DeleteCategoryAsync(category);
}
}
}
+89
View File
@@ -240,6 +240,93 @@
</coreControls:WinoNavigationViewItem> </coreControls:WinoNavigationViewItem>
</DataTemplate> </DataTemplate>
<DataTemplate x:Key="MailCategoryMenuTemplate" x:DataType="menu:MailCategoryMenuItem">
<coreControls:WinoNavigationViewItem
MinHeight="40"
DataContext="{x:Bind}"
FontWeight="{x:Bind helpers:XamlHelpers.GetFontWeightByChildSelectedState(IsSelected), Mode=OneWay}"
IsSelected="{x:Bind IsSelected, Mode=TwoWay}"
SelectsOnInvoked="True"
ToolTipService.ToolTip="{x:Bind FolderName, Mode=OneWay}">
<muxc:NavigationViewItem.Icon>
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
Foreground="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}"
Glyph="&#xE8EC;" />
</muxc:NavigationViewItem.Icon>
<muxc:NavigationViewItem.InfoBadge>
<muxc:InfoBadge
Background="{StaticResource SystemAccentColor}"
Foreground="White"
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(UnreadItemCount), Mode=OneWay}"
Value="{x:Bind UnreadItemCount, Mode=OneWay}" />
</muxc:NavigationViewItem.InfoBadge>
<muxc:NavigationViewItem.Content>
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
FontWeight="{x:Bind helpers:XamlHelpers.GetFontWeightBySyncState(IsSelected), Mode=OneWay}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind FolderName, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</muxc:NavigationViewItem.Content>
</coreControls:WinoNavigationViewItem>
</DataTemplate>
<DataTemplate x:Key="MergedMailCategoryMenuTemplate" x:DataType="menu:MergedMailCategoryMenuItem">
<coreControls:WinoNavigationViewItem
MinHeight="40"
DataContext="{x:Bind}"
FontWeight="{x:Bind helpers:XamlHelpers.GetFontWeightByChildSelectedState(IsSelected), Mode=OneWay}"
IsSelected="{x:Bind IsSelected, Mode=TwoWay}"
SelectsOnInvoked="True"
ToolTipService.ToolTip="{x:Bind FolderName, Mode=OneWay}">
<muxc:NavigationViewItem.Icon>
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
Foreground="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
Glyph="&#xE8EC;" />
</muxc:NavigationViewItem.Icon>
<muxc:NavigationViewItem.InfoBadge>
<muxc:InfoBadge
Background="{StaticResource SystemAccentColor}"
Foreground="White"
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(UnreadItemCount), Mode=OneWay}"
Value="{x:Bind UnreadItemCount, Mode=OneWay}" />
</muxc:NavigationViewItem.InfoBadge>
<muxc:NavigationViewItem.Content>
<Grid
MaxHeight="36"
Padding="2"
VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="10"
Height="10"
Margin="0,0,8,0"
VerticalAlignment="Center"
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}"
BorderBrush="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
BorderThickness="1"
CornerRadius="3" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
FontWeight="{x:Bind helpers:XamlHelpers.GetFontWeightBySyncState(IsSelected), Mode=OneWay}"
Foreground="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(TextColorHex), Mode=OneWay}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind FolderName, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
</muxc:NavigationViewItem.Content>
</coreControls:WinoNavigationViewItem>
</DataTemplate>
<!-- Merged Inbox --> <!-- Merged Inbox -->
<DataTemplate x:Key="MergedAccountTemplate" x:DataType="menu:MergedAccountMenuItem"> <DataTemplate x:Key="MergedAccountTemplate" x:DataType="menu:MergedAccountMenuItem">
<controls:AccountNavigationItem <controls:AccountNavigationItem
@@ -393,6 +480,7 @@
<coreSelectors:NavigationMenuTemplateSelector <coreSelectors:NavigationMenuTemplateSelector
x:Key="NavigationMenuTemplateSelector" x:Key="NavigationMenuTemplateSelector"
CategoryItemsTemplate="{StaticResource MailCategoryMenuTemplate}"
ClickableAccountMenuTemplate="{StaticResource ClickableAccountMenuTemplate}" ClickableAccountMenuTemplate="{StaticResource ClickableAccountMenuTemplate}"
FixAuthenticationIssueTemplate="{StaticResource FixAuthenticationIssueTemplate}" FixAuthenticationIssueTemplate="{StaticResource FixAuthenticationIssueTemplate}"
FixMissingFolderConfigTemplate="{StaticResource FixMissingFolderConfig}" FixMissingFolderConfigTemplate="{StaticResource FixMissingFolderConfig}"
@@ -400,6 +488,7 @@
MergedAccountFolderTemplate="{StaticResource MergedAccountFolderMenuItemTemplate}" MergedAccountFolderTemplate="{StaticResource MergedAccountFolderMenuItemTemplate}"
MergedAccountMoreExpansionItemTemplate="{StaticResource MergedAccountMoreFolderItemTemplate}" MergedAccountMoreExpansionItemTemplate="{StaticResource MergedAccountMoreFolderItemTemplate}"
MergedAccountTemplate="{StaticResource MergedAccountTemplate}" MergedAccountTemplate="{StaticResource MergedAccountTemplate}"
MergedCategoryItemsTemplate="{StaticResource MergedMailCategoryMenuTemplate}"
NewMailTemplate="{StaticResource CreateNewMailTemplate}" NewMailTemplate="{StaticResource CreateNewMailTemplate}"
RatingItemTemplate="{StaticResource RatingItemTemplate}" RatingItemTemplate="{StaticResource RatingItemTemplate}"
SeperatorTemplate="{StaticResource SeperatorTemplate}" SeperatorTemplate="{StaticResource SeperatorTemplate}"
-1
View File
@@ -185,7 +185,6 @@
<PackageReference Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" /> <PackageReference Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" /> <PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Lottie" /> <PackageReference Include="CommunityToolkit.WinUI.Lottie" />
<PackageReference Include="CommunityToolkit.WinUI.Notifications" />
<PackageReference Include="Microsoft.Graphics.Win2D" /> <PackageReference Include="Microsoft.Graphics.Win2D" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
+15
View File
@@ -49,6 +49,8 @@ public class DatabaseService : IDatabaseService
{ {
await Task.WhenAll( await Task.WhenAll(
Connection.CreateTableAsync<MailCopy>(), Connection.CreateTableAsync<MailCopy>(),
Connection.CreateTableAsync<MailCategory>(),
Connection.CreateTableAsync<MailCategoryAssignment>(),
Connection.CreateTableAsync<MailItemFolder>(), Connection.CreateTableAsync<MailItemFolder>(),
Connection.CreateTableAsync<MailAccount>(), Connection.CreateTableAsync<MailAccount>(),
Connection.CreateTableAsync<AccountContact>(), Connection.CreateTableAsync<AccountContact>(),
@@ -168,6 +170,13 @@ public class DatabaseService : IDatabaseService
.ConfigureAwait(false); .ConfigureAwait(false);
} }
if (!accountCalendarColumns.Any(c => c.Name == nameof(AccountCalendar.IsReadOnly)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(AccountCalendar)} ADD COLUMN {nameof(AccountCalendar.IsReadOnly)} INTEGER NOT NULL DEFAULT 0")
.ConfigureAwait(false);
}
await Connection.ExecuteAsync("DROP TABLE IF EXISTS WinoAccountAddOnCache").ConfigureAwait(false); await Connection.ExecuteAsync("DROP TABLE IF EXISTS WinoAccountAddOnCache").ConfigureAwait(false);
} }
@@ -219,6 +228,12 @@ SET {nameof(KeyboardShortcut.Action)} =
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_MessageId ON MailCopy(MessageId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_MessageId ON MailCopy(MessageId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_FolderId_IsRead ON MailCopy(FolderId, IsRead)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_FolderId_IsRead ON MailCopy(FolderId, IsRead)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_CreationDate ON MailCopy(CreationDate)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_CreationDate ON MailCopy(CreationDate)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId ON MailCategory(MailAccountId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_Name ON MailCategory(MailAccountId, Name)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_IsFavorite ON MailCategory(MailAccountId, IsFavorite)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCategoryId ON MailCategoryAssignment(MailCategoryId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCopyUniqueId ON MailCategoryAssignment(MailCopyUniqueId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailCategoryAssignment_Category_MailCopy ON MailCategoryAssignment(MailCategoryId, MailCopyUniqueId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId ON MailItemFolder(MailAccountId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId ON MailItemFolder(MailAccountId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId_RemoteFolderId ON MailItemFolder(MailAccountId, RemoteFolderId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId_RemoteFolderId ON MailItemFolder(MailAccountId, RemoteFolderId)").ConfigureAwait(false);
+60 -1
View File
@@ -22,6 +22,7 @@ namespace Wino.Services;
public class FolderService : BaseDatabaseService, IFolderService public class FolderService : BaseDatabaseService, IFolderService
{ {
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IMailCategoryService _mailCategoryService;
private readonly ILogger _logger = Log.ForContext<FolderService>(); private readonly ILogger _logger = Log.ForContext<FolderService>();
private readonly SpecialFolderType[] gmailCategoryFolderTypes = private readonly SpecialFolderType[] gmailCategoryFolderTypes =
@@ -34,9 +35,11 @@ public class FolderService : BaseDatabaseService, IFolderService
]; ];
public FolderService(IDatabaseService databaseService, public FolderService(IDatabaseService databaseService,
IAccountService accountService) : base(databaseService) IAccountService accountService,
IMailCategoryService mailCategoryService) : base(databaseService)
{ {
_accountService = accountService; _accountService = accountService;
_mailCategoryService = mailCategoryService;
} }
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky) public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
@@ -269,6 +272,9 @@ public class FolderService : BaseDatabaseService, IFolderService
} }
} }
var favoriteCategories = await GetFavoriteCategoryMenuItemsAsync(mailAccount, folders, accountMenuItem).ConfigureAwait(false);
preparedFolderMenuItems.AddRange(favoriteCategories);
// Only add category folder if it's Gmail. // Only add category folder if it's Gmail.
if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem); if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem);
@@ -309,9 +315,62 @@ public class FolderService : BaseDatabaseService, IFolderService
preparedFolderMenuItems.Add(menuItem); preparedFolderMenuItems.Add(menuItem);
} }
var favoriteCategories = await GetMergedFavoriteCategoryMenuItemsAsync(holdingAccounts, allAccountFolders, mergedAccountFolderMenuItem.Parameter).ConfigureAwait(false);
preparedFolderMenuItems.AddRange(favoriteCategories);
return preparedFolderMenuItems; return preparedFolderMenuItems;
} }
private async Task<IEnumerable<IMenuItem>> GetFavoriteCategoryMenuItemsAsync(MailAccount account, IEnumerable<IMailItemFolder> handlingFolders, IMenuItem parentMenuItem)
{
var favoriteCategories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false);
if (!favoriteCategories.Any())
return [];
var availableFolders = handlingFolders
.Where(a => a.IsMoveTarget)
.Cast<IMailItemFolder>()
.ToList();
return favoriteCategories
.Select(category => (IMenuItem)new MailCategoryMenuItem(category, account, availableFolders, parentMenuItem))
.ToList();
}
private async Task<IEnumerable<IMenuItem>> GetMergedFavoriteCategoryMenuItemsAsync(IEnumerable<MailAccount> holdingAccounts, IEnumerable<IEnumerable<MailItemFolder>> allAccountFolders, MergedInbox mergedInbox)
{
var categoriesByAccount = new List<(MailAccount Account, List<MailCategory> Categories)>();
foreach (var account in holdingAccounts)
{
var categories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false);
if (categories.Any())
{
categoriesByAccount.Add((account, categories));
}
}
if (!categoriesByAccount.Any())
return [];
var handlingFolders = allAccountFolders
.SelectMany(a => a)
.Where(a => a.IsMoveTarget)
.Cast<IMailItemFolder>()
.ToList();
return categoriesByAccount
.SelectMany(a => a.Categories)
.GroupBy(a => NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase)
.Select(group => (IMenuItem)new MergedMailCategoryMenuItem(group.ToList(), handlingFolders, mergedInbox))
.OrderBy(item => ((MergedMailCategoryMenuItem)item).FolderName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static string NormalizeCategoryName(string name)
=> name?.Trim() ?? string.Empty;
private HashSet<SpecialFolderType> FindCommonFolders(List<List<MailItemFolder>> lists) private HashSet<SpecialFolderType> FindCommonFolders(List<List<MailItemFolder>> lists)
{ {
var allSpecialTypesExceptOther = Enum.GetValues<SpecialFolderType>().Cast<SpecialFolderType>().Where(a => a != SpecialFolderType.Other).ToList(); var allSpecialTypesExceptOther = Enum.GetValues<SpecialFolderType>().Cast<SpecialFolderType>().Where(a => a != SpecialFolderType.Other).ToList();
+358
View File
@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.UI;
namespace Wino.Services;
public class MailCategoryService : BaseDatabaseService, IMailCategoryService
{
public MailCategoryService(IDatabaseService databaseService) : base(databaseService)
{
}
public Task<List<MailCategory>> GetCategoriesAsync(Guid accountId)
=> Connection.QueryAsync<MailCategory>(
$"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? ORDER BY {nameof(MailCategory.IsFavorite)} DESC, {nameof(MailCategory.Name)} COLLATE NOCASE",
accountId);
public Task<List<MailCategory>> GetFavoriteCategoriesAsync(Guid accountId)
=> Connection.QueryAsync<MailCategory>(
$"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategory.IsFavorite)} = 1 ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE",
accountId);
public Task<MailCategory> GetCategoryAsync(Guid categoryId)
=> Connection.FindAsync<MailCategory>(categoryId);
public async Task<bool> CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null)
{
var normalizedName = NormalizeCategoryName(name);
if (string.IsNullOrWhiteSpace(normalizedName))
return false;
var sql = $"SELECT COUNT(*) FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND lower(trim({nameof(MailCategory.Name)})) = ?";
var parameters = new List<object> { accountId, normalizedName.ToLowerInvariant() };
if (excludedCategoryId.HasValue)
{
sql += $" AND {nameof(MailCategory.Id)} <> ?";
parameters.Add(excludedCategoryId.Value);
}
return await Connection.ExecuteScalarAsync<int>(sql, parameters.ToArray()).ConfigureAwait(false) > 0;
}
public async Task<MailCategory> CreateCategoryAsync(MailCategory category)
{
category.Id = category.Id == Guid.Empty ? Guid.NewGuid() : category.Id;
category.Name = NormalizeCategoryName(category.Name);
await Connection.InsertAsync(category, typeof(MailCategory)).ConfigureAwait(false);
NotifyCategoryStructureChanged(category.MailAccountId);
return category;
}
public async Task UpdateCategoryAsync(MailCategory category)
{
category.Name = NormalizeCategoryName(category.Name);
await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false);
NotifyCategoryStructureChanged(category.MailAccountId);
}
public async Task DeleteCategoryAsync(Guid categoryId)
{
var category = await GetCategoryAsync(categoryId).ConfigureAwait(false);
if (category == null)
return;
await Connection.ExecuteAsync($"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ?", categoryId).ConfigureAwait(false);
await Connection.DeleteAsync<MailCategory>(categoryId).ConfigureAwait(false);
NotifyCategoryStructureChanged(category.MailAccountId);
}
public async Task DeleteCategoriesAsync(Guid accountId)
{
var categories = await GetCategoriesAsync(accountId).ConfigureAwait(false);
if (categories.Count == 0)
return;
var categoryIds = categories.Select(a => a.Id).ToList();
var placeholders = string.Join(",", categoryIds.Select(_ => "?"));
var deleteAssignmentsSql = $"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} IN ({placeholders})";
await Connection.ExecuteAsync(deleteAssignmentsSql, categoryIds.Cast<object>().ToArray()).ConfigureAwait(false);
await Connection.Table<MailCategory>().DeleteAsync(a => a.MailAccountId == accountId).ConfigureAwait(false);
NotifyCategoryStructureChanged(accountId);
}
public async Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite)
{
var category = await GetCategoryAsync(categoryId).ConfigureAwait(false);
if (category == null || category.IsFavorite == isFavorite)
return;
category.IsFavorite = isFavorite;
await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false);
NotifyCategoryStructureChanged(category.MailAccountId);
}
public async Task UpdateRemoteIdAsync(Guid categoryId, string remoteId)
{
var category = await GetCategoryAsync(categoryId).ConfigureAwait(false);
if (category == null)
return;
category.RemoteId = remoteId;
await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false);
}
public async Task ReplaceCategoriesAsync(Guid accountId, IEnumerable<MailCategory> categories)
{
var existingCategories = await GetCategoriesAsync(accountId).ConfigureAwait(false);
var existingByRemoteId = existingCategories
.Where(a => !string.IsNullOrWhiteSpace(a.RemoteId))
.ToDictionary(a => a.RemoteId, StringComparer.OrdinalIgnoreCase);
var existingByName = existingCategories
.GroupBy(a => NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase)
.ToDictionary(a => a.Key, a => a.First(), StringComparer.OrdinalIgnoreCase);
var incomingCategories = categories?.ToList() ?? [];
var preservedIds = new HashSet<Guid>();
foreach (var incoming in incomingCategories)
{
incoming.MailAccountId = accountId;
incoming.Id = incoming.Id == Guid.Empty ? Guid.NewGuid() : incoming.Id;
incoming.Name = NormalizeCategoryName(incoming.Name);
MailCategory existing = null;
if (!string.IsNullOrWhiteSpace(incoming.RemoteId) && existingByRemoteId.TryGetValue(incoming.RemoteId, out var byRemote))
{
existing = byRemote;
}
else if (existingByName.TryGetValue(incoming.Name, out var byName))
{
existing = byName;
}
if (existing == null)
{
await Connection.InsertAsync(incoming, typeof(MailCategory)).ConfigureAwait(false);
preservedIds.Add(incoming.Id);
}
else
{
incoming.Id = existing.Id;
incoming.IsFavorite = existing.IsFavorite;
await Connection.UpdateAsync(incoming, typeof(MailCategory)).ConfigureAwait(false);
preservedIds.Add(existing.Id);
}
}
var categoryIdsToDelete = existingCategories
.Where(a => !preservedIds.Contains(a.Id))
.Select(a => a.Id)
.ToList();
if (categoryIdsToDelete.Count > 0)
{
var placeholders = string.Join(",", categoryIdsToDelete.Select(_ => "?"));
await Connection.ExecuteAsync(
$"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} IN ({placeholders})",
categoryIdsToDelete.Cast<object>().ToArray()).ConfigureAwait(false);
foreach (var categoryId in categoryIdsToDelete)
{
await Connection.DeleteAsync<MailCategory>(categoryId).ConfigureAwait(false);
}
}
NotifyCategoryStructureChanged(accountId);
}
public async Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable<string> categoryNames)
{
var normalizedNames = categoryNames?
.Select(NormalizeCategoryName)
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
var availableCategories = await GetCategoriesAsync(accountId).ConfigureAwait(false);
var categoryIds = availableCategories
.Where(a => normalizedNames.Contains(NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase))
.Select(a => a.Id)
.ToHashSet();
var existingAssignments = await Connection.QueryAsync<MailCategoryAssignment>(
$"SELECT * FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} = ?",
mailCopyUniqueId).ConfigureAwait(false);
var assignmentsToDelete = existingAssignments.Where(a => !categoryIds.Contains(a.MailCategoryId)).ToList();
var existingIds = existingAssignments.Select(a => a.MailCategoryId).ToHashSet();
var assignmentsToAdd = categoryIds.Where(a => !existingIds.Contains(a)).ToList();
foreach (var assignment in assignmentsToDelete)
{
await Connection.DeleteAsync<MailCategoryAssignment>(assignment.Id).ConfigureAwait(false);
}
foreach (var categoryId in assignmentsToAdd)
{
await Connection.InsertAsync(new MailCategoryAssignment
{
Id = Guid.NewGuid(),
MailCategoryId = categoryId,
MailCopyUniqueId = mailCopyUniqueId
}, typeof(MailCategoryAssignment)).ConfigureAwait(false);
}
WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(accountId));
}
public async Task AssignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds)
{
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
if (uniqueIds.Count == 0)
return;
var category = await GetCategoryAsync(categoryId).ConfigureAwait(false);
if (category == null)
return;
var placeholders = string.Join(",", uniqueIds.Select(_ => "?"));
var query = $"SELECT * FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ? AND {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders})";
var existingAssignments = await Connection.QueryAsync<MailCategoryAssignment>(
query,
[categoryId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
var existingUniqueIds = existingAssignments.Select(a => a.MailCopyUniqueId).ToHashSet();
foreach (var uniqueId in uniqueIds.Where(a => !existingUniqueIds.Contains(a)))
{
await Connection.InsertAsync(new MailCategoryAssignment
{
Id = Guid.NewGuid(),
MailCategoryId = categoryId,
MailCopyUniqueId = uniqueId
}, typeof(MailCategoryAssignment)).ConfigureAwait(false);
}
WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(category.MailAccountId));
}
public async Task UnassignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds)
{
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
if (uniqueIds.Count == 0)
return;
var category = await GetCategoryAsync(categoryId).ConfigureAwait(false);
if (category == null)
return;
var placeholders = string.Join(",", uniqueIds.Select(_ => "?"));
await Connection.ExecuteAsync(
$"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ? AND {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders})",
[categoryId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(category.MailAccountId));
}
public async Task<List<MailCategory>> GetCategoriesForMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds)
{
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
if (uniqueIds.Count == 0)
return [];
var placeholders = string.Join(",", uniqueIds.Select(_ => "?"));
var sql = $"SELECT DISTINCT MailCategory.* FROM {nameof(MailCategory)} " +
$"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCategoryId)} " +
$"WHERE {nameof(MailCategory)}.{nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders}) " +
$"ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE";
return await Connection.QueryAsync<MailCategory>(
sql,
[accountId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
}
public async Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds)
{
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
if (uniqueIds.Count == 0)
return [];
var placeholders = string.Join(",", uniqueIds.Select(_ => "?"));
var sql = $"SELECT {nameof(MailCategoryAssignment.MailCategoryId)} " +
$"FROM {nameof(MailCategoryAssignment)} " +
$"WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders}) " +
$"GROUP BY {nameof(MailCategoryAssignment.MailCategoryId)} " +
$"HAVING COUNT(DISTINCT {nameof(MailCategoryAssignment.MailCopyUniqueId)}) = ?";
return await Connection.QueryScalarsAsync<Guid>(
sql,
[.. uniqueIds.Cast<object>(), uniqueIds.Count]).ConfigureAwait(false);
}
public async Task<List<string>> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId)
{
var sql = $"SELECT {nameof(MailCategory.Name)} " +
$"FROM {nameof(MailCategory)} " +
$"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment.MailCategoryId)} " +
$"WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} = ? " +
$"ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE";
return await Connection.QueryScalarsAsync<string>(sql, mailCopyUniqueId).ConfigureAwait(false);
}
public async Task<List<MailCopy>> GetMailCopiesForCategoryAsync(Guid categoryId)
{
var sql = $"SELECT {nameof(MailCopy)}.* " +
$"FROM {nameof(MailCopy)} " +
$"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCopy)}.{nameof(MailCopy.UniqueId)} = {nameof(MailCategoryAssignment.MailCopyUniqueId)} " +
$"WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ?";
return await Connection.QueryAsync<MailCopy>(sql, categoryId).ConfigureAwait(false);
}
public Task<List<UnreadCategoryCountResult>> GetUnreadCategoryCountResultsAsync(IEnumerable<Guid> accountIds)
{
var accountIdList = accountIds?.Distinct().ToList() ?? [];
if (accountIdList.Count == 0)
return Task.FromResult(new List<UnreadCategoryCountResult>());
var placeholders = string.Join(",", accountIdList.Select(_ => "?"));
var sql =
$"SELECT MailCategory.{nameof(MailCategory.Id)} as {nameof(UnreadCategoryCountResult.CategoryId)}, " +
$"MailCategory.{nameof(MailCategory.MailAccountId)} as {nameof(UnreadCategoryCountResult.AccountId)}, " +
$"COUNT(DISTINCT MailCopy.{nameof(MailCopy.UniqueId)}) as {nameof(UnreadCategoryCountResult.UnreadItemCount)} " +
$"FROM {nameof(MailCategory)} " +
$"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCategoryId)} " +
$"INNER JOIN {nameof(MailCopy)} ON {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} = {nameof(MailCopy)}.{nameof(MailCopy.UniqueId)} " +
$"WHERE MailCategory.{nameof(MailCategory.MailAccountId)} IN ({placeholders}) AND MailCopy.{nameof(MailCopy.IsRead)} = 0 " +
$"GROUP BY MailCategory.{nameof(MailCategory.Id)}";
return Connection.QueryAsync<UnreadCategoryCountResult>(sql, accountIdList.Cast<object>().ToArray());
}
private void NotifyCategoryStructureChanged(Guid accountId)
{
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false));
WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(accountId));
}
private static string NormalizeCategoryName(string name)
=> name?.Trim() ?? string.Empty;
}
+24 -4
View File
@@ -32,6 +32,7 @@ public class MailService : BaseDatabaseService, IMailService
private readonly IMimeFileService _mimeFileService; private readonly IMimeFileService _mimeFileService;
private readonly IPreferencesService _preferencesService; private readonly IPreferencesService _preferencesService;
private readonly ISentMailReceiptService _sentMailReceiptService; private readonly ISentMailReceiptService _sentMailReceiptService;
private readonly IMailCategoryService _mailCategoryService;
private readonly ILogger _logger = Log.ForContext<MailService>(); private readonly ILogger _logger = Log.ForContext<MailService>();
@@ -42,7 +43,8 @@ public class MailService : BaseDatabaseService, IMailService
ISignatureService signatureService, ISignatureService signatureService,
IMimeFileService mimeFileService, IMimeFileService mimeFileService,
IPreferencesService preferencesService, IPreferencesService preferencesService,
ISentMailReceiptService sentMailReceiptService) : base(databaseService) ISentMailReceiptService sentMailReceiptService,
IMailCategoryService mailCategoryService) : base(databaseService)
{ {
_folderService = folderService; _folderService = folderService;
_contactService = contactService; _contactService = contactService;
@@ -51,6 +53,7 @@ public class MailService : BaseDatabaseService, IMailService
_mimeFileService = mimeFileService; _mimeFileService = mimeFileService;
_preferencesService = preferencesService; _preferencesService = preferencesService;
_sentMailReceiptService = sentMailReceiptService; _sentMailReceiptService = sentMailReceiptService;
_mailCategoryService = mailCategoryService;
} }
public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions) public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions)
@@ -171,7 +174,9 @@ public class MailService : BaseDatabaseService, IMailService
private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options) private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options)
{ {
var sql = new StringBuilder(); var sql = new StringBuilder();
sql.Append("SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id"); sql.Append(options.IsCategoryView
? "SELECT DISTINCT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id INNER JOIN MailCategoryAssignment ON MailCopy.UniqueId = MailCategoryAssignment.MailCopyUniqueId"
: "SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id");
var whereClauses = new List<string>(); var whereClauses = new List<string>();
var parameters = new List<object>(); var parameters = new List<object>();
@@ -181,6 +186,13 @@ public class MailService : BaseDatabaseService, IMailService
whereClauses.Add($"MailCopy.FolderId IN ({folderPlaceholders})"); whereClauses.Add($"MailCopy.FolderId IN ({folderPlaceholders})");
parameters.AddRange(options.Folders.Select(f => (object)f.Id)); parameters.AddRange(options.Folders.Select(f => (object)f.Id));
if (options.IsCategoryView)
{
var categoryPlaceholders = string.Join(",", options.CategoryIds.Select(_ => "?"));
whereClauses.Add($"MailCategoryAssignment.MailCategoryId IN ({categoryPlaceholders})");
parameters.AddRange(options.CategoryIds.Select(a => (object)a));
}
// Filter type // Filter type
switch (options.FilterType) switch (options.FilterType)
{ {
@@ -338,7 +350,7 @@ public class MailService : BaseDatabaseService, IMailService
{ {
List<MailCopy> mails; List<MailCopy> mails;
if (options.PreFetchMailCopies != null) if (options.PreFetchMailCopies != null && !options.IsCategoryView)
{ {
mails = ApplyOptionsToPreFetchedMails(options); mails = ApplyOptionsToPreFetchedMails(options);
} }
@@ -398,7 +410,7 @@ public class MailService : BaseDatabaseService, IMailService
mails.RemoveAll(m => m.AssignedAccount == null || m.AssignedFolder == null); mails.RemoveAll(m => m.AssignedAccount == null || m.AssignedFolder == null);
await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false); await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false);
if (!options.CreateThreads || mails.Count == 0) if (!options.CreateThreads || mails.Count == 0 || options.IsCategoryView)
return [.. mails]; return [.. mails];
// 6. Expand threads: one batch query for all sibling mails across all threads. // 6. Expand threads: one batch query for all sibling mails across all threads.
@@ -727,6 +739,7 @@ public class MailService : BaseDatabaseService, IMailService
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); _logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
await Connection.DeleteAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false); await Connection.DeleteAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false);
await Connection.ExecuteAsync("DELETE FROM MailCategoryAssignment WHERE MailCopyUniqueId = ?", mailCopy.UniqueId).ConfigureAwait(false);
// If there are no more copies exists of the same mail, delete the MIME file as well. // If there are no more copies exists of the same mail, delete the MIME file as well.
var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false); var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false);
@@ -965,6 +978,7 @@ public class MailService : BaseDatabaseService, IMailService
mailCopy.UniqueId = existingCopyItem.UniqueId; mailCopy.UniqueId = existingCopyItem.UniqueId;
await UpdateMailAsync(mailCopy).ConfigureAwait(false); await UpdateMailAsync(mailCopy).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -981,6 +995,7 @@ public class MailService : BaseDatabaseService, IMailService
} }
await InsertMailAsync(mailCopy).ConfigureAwait(false); await InsertMailAsync(mailCopy).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -1017,6 +1032,11 @@ public class MailService : BaseDatabaseService, IMailService
await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false); await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false);
} }
private Task ReplaceMailCategoriesForPackageAsync(Guid accountId, MailCopy mailCopy, NewMailItemPackage package)
=> package?.CategoryNames == null
? Task.CompletedTask
: _mailCategoryService.ReplaceMailAssignmentsAsync(accountId, mailCopy.UniqueId, package.CategoryNames);
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions, MailAccountAlias selectedAlias) private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions, MailAccountAlias selectedAlias)
{ {
// This unique id is stored in mime headers for Wino to identify remote message with local copy. // This unique id is stored in mime headers for Wino to identify remote message with local copy.
+1
View File
@@ -20,6 +20,7 @@ public static class ServicesContainerSetup
services.AddTransient<ICalendarService, CalendarService>(); services.AddTransient<ICalendarService, CalendarService>();
services.AddTransient<IMailService, MailService>(); services.AddTransient<IMailService, MailService>();
services.AddTransient<IMailCategoryService, MailCategoryService>();
services.AddTransient<ISentMailReceiptService, SentMailReceiptService>(); services.AddTransient<ISentMailReceiptService, SentMailReceiptService>();
services.AddTransient<IFolderService, FolderService>(); services.AddTransient<IFolderService, FolderService>();
services.AddTransient<IAccountService, AccountService>(); services.AddTransient<IAccountService, AccountService>();
+1 -1
View File
@@ -14,13 +14,13 @@
<!-- key value for <packageSource> should match key values from <packageSources> element --> <!-- key value for <packageSource> should match key values from <packageSources> element -->
<packageSource key="nuget"> <packageSource key="nuget">
<package pattern="*" /> <package pattern="*" />
<package pattern="CommunityToolkit.WinUI.Extensions" />
<!-- <package pattern="CommunityToolkit.Common" /> <!-- <package pattern="CommunityToolkit.Common" />
<package pattern="Microsoft.*" /> <package pattern="Microsoft.*" />
<package pattern="Newtonsoft.Json" /> --> <package pattern="Newtonsoft.Json" /> -->
</packageSource> </packageSource>
<packageSource key="labsFeed"> <packageSource key="labsFeed">
<package pattern="CommunityToolkit.Labs.*" /> <package pattern="CommunityToolkit.Labs.*" />
<package pattern="CommunityToolkit.WinUI.Extensions" />
</packageSource> </packageSource>
</packageSourceMapping> </packageSourceMapping>
</configuration> </configuration>