27 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
Burak Kaan Köse feff929333 Bump version 2.0.1 - April 14. 2026-04-14 01:54:51 +02:00
Burak Kaan Köse aa16609f89 Add Windows share target draft attachment flow 2026-04-14 01:23:59 +02:00
Burak Kaan Köse 4bea53a667 Add custom theme deletion flow 2026-04-14 01:00:21 +02:00
Burak Kaan Köse b2ad4a1664 Fixing the Ui for reader & composer page. 2026-04-14 00:36:35 +02:00
Burak Kaan Köse dad3a51885 Remove debug print on preferences service. 2026-04-14 00:29:39 +02:00
Burak Kaan Köse 59ff0a1d7d Fix focused inbox not updating. 2026-04-14 00:29:29 +02:00
Burak Kaan Köse df19ab3196 Self contained. 2026-04-14 00:11:26 +02:00
Burak Kaan Köse c622858d2d Add initial mail sync range selection 2026-04-14 00:03:58 +02:00
Burak Kaan Köse 2e36772a4c Imap setup simplified and fixed the threading issues. 2026-04-13 23:11:35 +02:00
102 changed files with 3704 additions and 487 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.
@@ -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
{ {
@@ -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; }
}
@@ -112,6 +112,16 @@ public class MailAccount
/// </summary> /// </summary>
public DateTime? LastFolderStructureSyncDate { get; set; } public DateTime? LastFolderStructureSyncDate { get; set; }
/// <summary>
/// Gets or sets when the account was created in Wino.
/// </summary>
public DateTime? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the timespan used for the account's initial mail synchronization.
/// </summary>
public InitialSynchronizationRange InitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths;
/// <summary> /// <summary>
/// Gets whether the account can perform ProfileInformation sync type. /// Gets whether the account can perform ProfileInformation sync type.
/// </summary> /// </summary>
@@ -122,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,10 @@
namespace Wino.Core.Domain.Enums;
public enum InitialSynchronizationRange
{
SixMonths = 0,
ThreeMonths = 1,
NineMonths = 2,
OneYear = 3,
Everything = 4
}
@@ -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,
@@ -0,0 +1,23 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Extensions;
public static class InitialSynchronizationRangeExtensions
{
public static DateTime? ToCutoffDateUtc(this InitialSynchronizationRange range, DateTime utcNow)
{
var normalizedUtcNow = utcNow.Kind == DateTimeKind.Utc
? utcNow
: utcNow.ToUniversalTime();
return range switch
{
InitialSynchronizationRange.ThreeMonths => normalizedUtcNow.AddMonths(-3),
InitialSynchronizationRange.SixMonths => normalizedUtcNow.AddMonths(-6),
InitialSynchronizationRange.NineMonths => normalizedUtcNow.AddMonths(-9),
InitialSynchronizationRange.OneYear => normalizedUtcNow.AddYears(-1),
_ => null
};
}
}
@@ -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>
@@ -15,6 +15,7 @@ public interface INewThemeService : IInitializeAsync
Task<List<AppThemeBase>> GetAvailableThemesAsync(); Task<List<AppThemeBase>> GetAvailableThemesAsync();
Task<CustomThemeMetadata> CreateNewCustomThemeAsync(string themeName, string accentColor, byte[] wallpaperData); Task<CustomThemeMetadata> CreateNewCustomThemeAsync(string themeName, string accentColor, byte[] wallpaperData);
Task<List<CustomThemeMetadata>> GetCurrentCustomThemesAsync(); Task<List<CustomThemeMetadata>> GetCurrentCustomThemesAsync();
Task<bool> DeleteCustomThemeAsync(Guid themeId);
List<string> GetAvailableAccountColors(); List<string> GetAvailableAccountColors();
Task ApplyCustomThemeAsync(bool isInitializing); Task ApplyCustomThemeAsync(bool isInitializing);
@@ -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; }
}
@@ -0,0 +1,14 @@
#nullable enable
using System;
using Wino.Core.Domain.Models.Launch;
namespace Wino.Core.Domain.Interfaces;
public interface IShareActivationService
{
MailShareRequest? PendingShareRequest { get; set; }
MailShareRequest? ConsumePendingShareRequest();
void ClearPendingShareRequest();
void StagePendingComposeShareRequest(Guid draftUniqueId, MailShareRequest shareRequest);
MailShareRequest? ConsumePendingComposeShareRequest(Guid draftUniqueId);
}
@@ -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)
{
}
}
@@ -1,5 +1,10 @@
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts; namespace Wino.Core.Domain.Models.Accounts;
public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, SpecialImapProviderDetails SpecialImapProviderDetails, string AccountColorHex); public record AccountCreationDialogResult(
MailProviderType ProviderType,
string AccountName,
SpecialImapProviderDetails SpecialImapProviderDetails,
string AccountColorHex,
InitialSynchronizationRange InitialSynchronizationRange);
@@ -0,0 +1,17 @@
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts;
public sealed class InitialSynchronizationRangeOption
{
public InitialSynchronizationRange Range { get; }
public string DisplayText { get; }
public bool IsEverything => Range == InitialSynchronizationRange.Everything;
public InitialSynchronizationRangeOption(InitialSynchronizationRange range, string displayText)
{
Range = range;
DisplayText = displayText;
}
}
@@ -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,15 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Models.Common;
namespace Wino.Core.Domain.Models.Launch;
public sealed class MailShareRequest
{
public MailShareRequest(IReadOnlyList<SharedFile> files)
{
Files = files ?? throw new ArgumentNullException(nameof(files));
}
public IReadOnlyList<SharedFile> Files { get; }
}
@@ -0,0 +1,15 @@
using System;
namespace Wino.Core.Domain.Models.Launch;
public sealed class PendingComposeMailShareRequest
{
public PendingComposeMailShareRequest(Guid draftUniqueId, MailShareRequest shareRequest)
{
DraftUniqueId = draftUniqueId;
ShareRequest = shareRequest ?? throw new ArgumentNullException(nameof(shareRequest));
}
public Guid DraftUniqueId { get; }
public MailShareRequest ShareRequest { get; }
}
@@ -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,
@@ -23,6 +23,14 @@
"AccountCreationDialog_Initializing": "initializing", "AccountCreationDialog_Initializing": "initializing",
"AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.", "AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.",
"AccountCreationDialog_SigninIn": "Account information is being saved.", "AccountCreationDialog_SigninIn": "Account information is being saved.",
"AccountCreation_InitialSynchronization_Title": "Mail synchronization range",
"AccountCreation_InitialSynchronization_Description": "Choose how far back Wino should download your mail during the first synchronization.",
"AccountCreation_InitialSynchronization_3Months": "3 Months",
"AccountCreation_InitialSynchronization_6Months": "6 Months",
"AccountCreation_InitialSynchronization_9Months": "9 Months",
"AccountCreation_InitialSynchronization_Year": "Year",
"AccountCreation_InitialSynchronization_Everything": "Everything",
"AccountCreation_InitialSynchronization_EverythingWarning": "This will synchronize all your mails to your computer. Extensive use of disk storage is needed. This is not recommended. For optimal performance use smaller synchronization timespan and use online search to access your mails.",
"Purchased": "Purchased", "Purchased": "Purchased",
"AccountEditDialog_Message": "Account Name", "AccountEditDialog_Message": "Account Name",
"AccountEditDialog_Title": "Edit Account", "AccountEditDialog_Title": "Edit Account",
@@ -37,6 +45,8 @@
"AccountDetailsPage_TabMail": "Mail", "AccountDetailsPage_TabMail": "Mail",
"AccountDetailsPage_TabCalendar": "Calendar", "AccountDetailsPage_TabCalendar": "Calendar",
"AccountDetailsPage_CalendarListDescription": "Select a calendar to configure its settings", "AccountDetailsPage_CalendarListDescription": "Select a calendar to configure its settings",
"AccountDetailsPage_InitialSynchronization_Title": "Initial synchronization",
"AccountDetailsPage_InitialSynchronization_Description": "Wino synchronized your mails until {0} going back.",
"AddHyperlink": "Add", "AddHyperlink": "Add",
"AppCloseBackgroundSynchronizationWarningTitle": "Background Synchronization", "AppCloseBackgroundSynchronizationWarningTitle": "Background Synchronization",
"AppCloseStartupLaunchDisabledWarningMessageFirstLine": "Application has not been set to launch on Windows startup.", "AppCloseStartupLaunchDisabledWarningMessageFirstLine": "Application has not been set to launch on Windows startup.",
@@ -57,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",
@@ -203,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",
@@ -797,6 +810,10 @@
"SettingsConfigureSpecialFolders_Description": "Set folders with special functions. Folders such as Archive, Inbox, and Drafts are essential for Wino to function properly.", "SettingsConfigureSpecialFolders_Description": "Set folders with special functions. Folders such as Archive, Inbox, and Drafts are essential for Wino to function properly.",
"SettingsConfigureSpecialFolders_Title": "Configure System Folders", "SettingsConfigureSpecialFolders_Title": "Configure System Folders",
"SettingsCustomTheme_Description": "Create your own custom theme with custom wallpaper and accent color.", "SettingsCustomTheme_Description": "Create your own custom theme with custom wallpaper and accent color.",
"SettingsCustomTheme_DeleteConfirm_Message": "Delete custom theme \"{0}\"? Its saved wallpaper will also be removed from disk.",
"SettingsCustomTheme_DeleteConfirm_Title": "Delete Theme",
"SettingsCustomTheme_DeleteMissing": "This custom theme no longer exists.",
"SettingsCustomTheme_DeleteSuccess": "Custom theme \"{0}\" was deleted.",
"SettingsCustomTheme_Title": "Custom Theme", "SettingsCustomTheme_Title": "Custom Theme",
"SettingsDeleteAccount_Description": "Delete all e-mails and credentials associated with this account.", "SettingsDeleteAccount_Description": "Delete all e-mails and credentials associated with this account.",
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
@@ -861,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.",
@@ -1477,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() =>
@@ -174,8 +174,46 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel
} }
} }
[RelayCommand]
private async Task DeleteCustomThemeAsync(AppThemeBase theme)
{
if (theme == null || theme.AppThemeType != AppThemeType.Custom)
{
return;
}
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.SettingsCustomTheme_DeleteConfirm_Message, theme.ThemeName),
Translator.SettingsCustomTheme_DeleteConfirm_Title,
Translator.Buttons_Delete);
if (!shouldDelete)
{
return;
}
var isDeleted = await _newThemeService.DeleteCustomThemeAsync(theme.Id);
if (!isDeleted)
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Warning,
Translator.SettingsCustomTheme_DeleteMissing,
InfoBarMessageType.Warning);
return;
}
await InitializeSettingsAsync();
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Info,
string.Format(Translator.SettingsCustomTheme_DeleteSuccess, theme.ThemeName),
InfoBarMessageType.Success);
}
private void InitializeColors() private void InitializeColors()
{ {
Colors.Clear();
Colors.Add(new AppColorViewModel("#0078d7")); Colors.Add(new AppColorViewModel("#0078d7"));
Colors.Add(new AppColorViewModel("#00838c")); Colors.Add(new AppColorViewModel("#00838c"));
Colors.Add(new AppColorViewModel("#e3008c")); Colors.Add(new AppColorViewModel("#e3008c"));
@@ -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.
+17 -15
View File
@@ -81,9 +81,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
public override uint BatchModificationSize => 1000; public override uint BatchModificationSize => 1000;
/// <summary> /// <summary>
/// Maximum messages to fetch per folder during initial sync (1500). /// Legacy page size hint kept for compatibility with shared synchronizer contracts.
/// All messages are downloaded with METADATA ONLY - no raw MIME content. /// Gmail initial sync now downloads all messages inside the selected cutoff window.
/// Uses Gmail API's Metadata format which includes headers, labels, and snippet but NOT full message body.
/// </summary> /// </summary>
public override uint InitialMessageDownloadCountPerFolder => 1500; public override uint InitialMessageDownloadCountPerFolder => 1500;
@@ -304,13 +303,18 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
/// <summary> /// <summary>
/// Performs initial synchronization by downloading messages per-folder. /// Performs initial synchronization by downloading messages per-folder.
/// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally /// Messages are filtered by the account's configured initial synchronization cutoff date when present,
/// to avoid downloading the same message multiple times (Gmail messages can have multiple labels). /// and duplicates are avoided globally because Gmail messages can have multiple labels.
/// </summary> /// </summary>
private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken) private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
{ {
// Track all downloaded message IDs globally to avoid duplicate downloads // Track all downloaded message IDs globally to avoid duplicate downloads
var downloadedMessageIds = new HashSet<string>(); var downloadedMessageIds = new HashSet<string>();
var referenceDateUtc = Account.CreatedAt ?? DateTime.UtcNow;
var initialSynchronizationCutoffDateUtc = Account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc);
var queryText = initialSynchronizationCutoffDateUtc.HasValue
? $"after:{initialSynchronizationCutoffDateUtc.Value.ToUniversalTime():yyyy/MM/dd}"
: null;
_logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name); _logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name);
@@ -337,7 +341,6 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var folderDownloaded = 0; var folderDownloaded = 0;
string pageToken = null; string pageToken = null;
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
do do
{ {
@@ -345,8 +348,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var request = _gmailService.Users.Messages.List("me"); var request = _gmailService.Users.Messages.List("me");
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId }); request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500 request.MaxResults = 500; // API max is 500
request.PageToken = pageToken; request.PageToken = pageToken;
request.Q = queryText;
var response = await request.ExecuteAsync(cancellationToken); var response = await request.ExecuteAsync(cancellationToken);
@@ -373,19 +377,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
totalMessagesDownloaded += newMessageIds.Count; totalMessagesDownloaded += newMessageIds.Count;
} }
// Count all messages (including duplicates) toward the folder limit
remainingToDownload -= response.Messages.Count;
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)", _logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
folder.FolderName, newMessageIds.Count, folderDownloaded); folder.FolderName, newMessageIds.Count, folderDownloaded);
} }
pageToken = response.NextPageToken; pageToken = response.NextPageToken;
// Stop if we've processed enough messages for this folder or no more pages
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
break;
} while (!string.IsNullOrEmpty(pageToken)); } while (!string.IsNullOrEmpty(pageToken));
_logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded); _logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded);
@@ -762,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);
} }
@@ -943,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)
@@ -9,6 +9,7 @@ using MailKit.Search;
using MoreLinq; using MoreLinq;
using Serilog; using Serilog;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Extensions;
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;
@@ -252,9 +253,20 @@ public class UnifiedImapSynchronizer
.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, knownUidStructs, cancellationToken) .OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, knownUidStructs, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
var changedUids = await remoteFolder IList<UniqueId> changedUids;
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
.ConfigureAwait(false); if (folder.HighestModeSeq == 0)
{
changedUids = await remoteFolder
.SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken)
.ConfigureAwait(false);
}
else
{
changedUids = await remoteFolder
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
.ConfigureAwait(false);
}
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false); downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
@@ -308,25 +320,26 @@ public class UnifiedImapSynchronizer
{ {
IList<UniqueId> changedUids; IList<UniqueId> changedUids;
if (client.Capabilities.HasFlag(ImapCapabilities.Sort)) if (isInitialSync)
{ {
changedUids = await remoteFolder changedUids = await remoteFolder
.SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken) .SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
else else
{ {
changedUids = await remoteFolder if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken) {
.ConfigureAwait(false); changedUids = await remoteFolder
} .SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken)
.ConfigureAwait(false);
if (isInitialSync) }
{ else
changedUids = changedUids {
.OrderByDescending(a => a.Id) changedUids = await remoteFolder
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder) .SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
.ToList(); .ConfigureAwait(false);
}
} }
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false); downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
@@ -367,15 +380,12 @@ public class UnifiedImapSynchronizer
if (folder.HighestKnownUid == 0) if (folder.HighestKnownUid == 0)
{ {
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false); var initialUids = await remoteFolder
.SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken)
var initialUids = remoteUids .ConfigureAwait(false);
.OrderByDescending(a => a.Id)
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
.ToList();
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, initialUids, synchronizer, cancellationToken).ConfigureAwait(false); downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, initialUids, synchronizer, cancellationToken).ConfigureAwait(false);
UpdateHighestKnownUid(folder, remoteFolder, remoteUids.Select(a => a.Id)); UpdateHighestKnownUid(folder, remoteFolder, initialUids.Select(a => a.Id));
} }
else else
{ {
@@ -410,6 +420,22 @@ public class UnifiedImapSynchronizer
#region Shared Helpers #region Shared Helpers
private static SearchQuery BuildInitialSyncQuery(IImapSynchronizer synchronizer)
{
if (synchronizer is IBaseSynchronizer { Account: { } account })
{
var referenceDateUtc = account.CreatedAt ?? DateTime.UtcNow;
var cutoffDateUtc = account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc);
if (cutoffDateUtc.HasValue)
{
return SearchQuery.DeliveredAfter(cutoffDateUtc.Value.ToUniversalTime().Date);
}
}
return SearchQuery.All;
}
private async Task EnsureUidValidityStateAsync(MailItemFolder folder, IMailFolder remoteFolder) private async Task EnsureUidValidityStateAsync(MailItemFolder folder, IMailFolder remoteFolder)
{ {
if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity) if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity)
+240 -17
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;
@@ -55,14 +56,14 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
/// ///
/// SYNCHRONIZATION STRATEGY: /// SYNCHRONIZATION STRATEGY:
/// - Uses delta API for both initial and incremental sync /// - Uses delta API for both initial and incremental sync
/// - Initial sync: Downloads last 30 days of emails with metadata only /// - Initial sync: Downloads messages using the account's configured cutoff date with metadata only
/// - Incremental sync: Uses delta token to get only changes since last sync /// - Incremental sync: Uses delta token to get only changes since last sync
/// - Messages are downloaded with metadata only (no MIME content during sync) /// - Messages are downloaded with metadata only (no MIME content during sync)
/// - MIME files are downloaded on-demand when user explicitly reads a message /// - MIME files are downloaded on-demand when user explicitly reads a message
/// ///
/// Key implementation details: /// Key implementation details:
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization /// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
/// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter /// - DownloadMailsForInitialSyncAsync: Downloads messages using delta API with an optional cutoff filter
/// - ProcessDeltaChangesAsync: Processes incremental changes using delta token /// - ProcessDeltaChangesAsync: Processes incremental changes using delta token
/// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API /// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API
/// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata /// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata
@@ -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
@@ -343,9 +348,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// Check if we have a delta token // Check if we have a delta token
if (string.IsNullOrEmpty(folder.DeltaToken)) if (string.IsNullOrEmpty(folder.DeltaToken))
{ {
_logger.Debug("No delta token for folder {FolderName}. Starting initial sync (last 30 days).", folder.FolderName); _logger.Debug("No delta token for folder {FolderName}. Starting initial sync.", folder.FolderName);
// Download mails for initial sync (last 30 days) // Download mails for initial sync using the account's configured cutoff date.
await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
} }
else else
@@ -367,27 +372,37 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
} }
/// <summary> /// <summary>
/// Downloads mails for initial synchronization using Delta API with 30-day filter. /// Downloads mails for initial synchronization using Delta API with the account's configured cutoff date.
/// Downloads metadata only (no MIME content) for messages received in the last 30 days. /// Downloads metadata only (no MIME content) for messages received after that date.
/// </summary> /// </summary>
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken) private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
{ {
_logger.Debug("Starting initial mail download for folder {FolderName} (last 6 months)", folder.FolderName); _logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName);
try try
{ {
// Calculate date 6 months ago var referenceDateUtc = Account.CreatedAt ?? DateTime.UtcNow;
var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); var initialSynchronizationCutoffDateUtc = Account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc);
var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ"); var filterDate = initialSynchronizationCutoffDateUtc?.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ");
_logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName); if (filterDate != null)
{
_logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName);
}
else
{
_logger.Information("Downloading all available messages for folder {FolderName}", folder.FolderName);
}
// Use Delta API with receivedDateTime filter for last 6 months
var messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) => var messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
{ {
config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Select = outlookMessageSelectParameters;
config.QueryParameters.Orderby = ["receivedDateTime desc"]; config.QueryParameters.Orderby = ["receivedDateTime desc"];
config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}";
if (filterDate != null)
{
config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}";
}
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
var totalProcessed = 0; var totalProcessed = 0;
@@ -1142,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
{ {
@@ -1198,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);
@@ -1757,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)
@@ -1952,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);
@@ -2100,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
@@ -2155,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)
@@ -2357,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)
@@ -2664,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);
@@ -2702,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
@@ -2,24 +2,25 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Misc;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Misc;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Core.ViewModels.Data; using Wino.Core.ViewModels.Data;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
namespace Wino.Mail.ViewModels; namespace Wino.Mail.ViewModels;
@@ -101,6 +102,14 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
? $"ms-appx:///Assets/Providers/{Account.SpecialImapProvider}.png" ? $"ms-appx:///Assets/Providers/{Account.SpecialImapProvider}.png"
: $"ms-appx:///Assets/Providers/{Account?.ProviderType}.png"; : $"ms-appx:///Assets/Providers/{Account?.ProviderType}.png";
public string Address => Account?.Address ?? string.Empty; public string Address => Account?.Address ?? string.Empty;
public bool IsInitialSynchronizationSummaryVisible => Account?.CreatedAt.HasValue == true && Account.InitialSynchronizationRange != InitialSynchronizationRange.Everything;
public string InitialSynchronizationSummary => Account?.CreatedAt is not DateTime createdAtUtc
? string.Empty
: Account.InitialSynchronizationRange.ToCutoffDateUtc(createdAtUtc) is not DateTime cutoffDateUtc
? string.Empty
: string.Format(
Translator.AccountDetailsPage_InitialSynchronization_Description,
cutoffDateUtc.ToLocalTime().ToString("D", CultureInfo.CurrentUICulture));
public List<ImapAuthenticationMethodModel> AvailableAuthenticationMethods { get; } = public List<ImapAuthenticationMethodModel> AvailableAuthenticationMethods { get; } =
[ [
@@ -160,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(
@@ -363,13 +376,15 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount)); OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount));
OnPropertyChanged(nameof(ProviderIconPath)); OnPropertyChanged(nameof(ProviderIconPath));
OnPropertyChanged(nameof(Address)); OnPropertyChanged(nameof(Address));
OnPropertyChanged(nameof(IsInitialSynchronizationSummaryVisible));
OnPropertyChanged(nameof(InitialSynchronizationSummary));
} }
protected override async void OnPropertyChanged(PropertyChangedEventArgs e) protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
{ {
base.OnPropertyChanged(e); base.OnPropertyChanged(e);
if (!IsActive || !isLoaded) return; if (!isLoaded) return;
switch (e.PropertyName) switch (e.PropertyName)
{ {
@@ -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 });
@@ -170,6 +174,7 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
try try
{ {
CustomServerInformation customServerInformation = null; CustomServerInformation customServerInformation = null;
var accountCreatedAt = DateTime.UtcNow;
// Build account in memory // Build account in memory
_createdAccount = new MailAccount _createdAccount = new MailAccount
@@ -179,6 +184,8 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
Name = WizardContext.AccountName, Name = WizardContext.AccountName,
SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider, SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider,
AccountColorHex = WizardContext.AccountColorHex, AccountColorHex = WizardContext.AccountColorHex,
CreatedAt = accountCreatedAt,
InitialSynchronizationRange = WizardContext.SelectedInitialSynchronizationRange,
IsCalendarAccessGranted = true IsCalendarAccessGranted = true
}; };
@@ -226,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)
+24 -1
View File
@@ -19,6 +19,7 @@ using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models; using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Launch;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Extensions; using Wino.Core.Extensions;
using Wino.Core.Services; using Wino.Core.Services;
@@ -159,6 +160,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
public readonly IPreferencesService PreferencesService; public readonly IPreferencesService PreferencesService;
public readonly IContactService ContactService; public readonly IContactService ContactService;
public readonly ISmimeCertificateService _smimeCertificateService; public readonly ISmimeCertificateService _smimeCertificateService;
private readonly IShareActivationService _shareActivationService;
public ComposePageViewModel(IMailDialogService dialogService, public ComposePageViewModel(IMailDialogService dialogService,
IMailService mailService, IMailService mailService,
@@ -172,7 +174,8 @@ public partial class ComposePageViewModel : MailBaseViewModel,
IContactService contactService, IContactService contactService,
IFontService fontService, IFontService fontService,
IPreferencesService preferencesService, IPreferencesService preferencesService,
ISmimeCertificateService smimeCertificateService) ISmimeCertificateService smimeCertificateService,
IShareActivationService shareActivationService)
{ {
NativeAppService = nativeAppService; NativeAppService = nativeAppService;
ContactService = contactService; ContactService = contactService;
@@ -188,6 +191,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
_emailTemplateService = emailTemplateService; _emailTemplateService = emailTemplateService;
_worker = worker; _worker = worker;
_smimeCertificateService = smimeCertificateService; _smimeCertificateService = smimeCertificateService;
_shareActivationService = shareActivationService;
foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress)) foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress))
{ {
@@ -752,6 +756,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
await LoadAddressInfoAsync(replyingMime.Bcc, BCCItems); await LoadAddressInfoAsync(replyingMime.Bcc, BCCItems);
LoadAttachments(); LoadAttachments();
ApplyPendingSharedAttachments();
if (replyingMime.Cc.Any() || replyingMime.Bcc.Any()) if (replyingMime.Cc.Any() || replyingMime.Bcc.Any())
IsCCBCCVisible = true; IsCCBCCVisible = true;
@@ -783,6 +788,24 @@ public partial class ComposePageViewModel : MailBaseViewModel,
} }
} }
private void ApplyPendingSharedAttachments()
{
var draftUniqueId = CurrentMailDraftItem?.MailCopy?.UniqueId ?? Guid.Empty;
if (draftUniqueId == Guid.Empty)
return;
var shareRequest = _shareActivationService.ConsumePendingComposeShareRequest(draftUniqueId);
if (shareRequest?.Files == null || shareRequest.Files.Count == 0)
return;
foreach (var sharedFile in shareRequest.Files)
{
IncludedAttachments.Add(new MailAttachmentViewModel(sharedFile));
}
}
private async Task LoadAddressInfoAsync(InternetAddressList list, ObservableCollection<AccountContact> collection) private async Task LoadAddressInfoAsync(InternetAddressList list, ObservableCollection<AccountContact> collection)
{ {
foreach (var item in list) foreach (var item in list)
@@ -18,6 +18,9 @@ public partial class WelcomeWizardContext : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial string AccountColorHex { get; set; } public partial string AccountColorHex { get; set; }
[ObservableProperty]
public partial InitialSynchronizationRange SelectedInitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths;
// Special IMAP fields (iCloud/Yahoo) // Special IMAP fields (iCloud/Yahoo)
[ObservableProperty] [ObservableProperty]
public partial string DisplayName { get; set; } public partial string DisplayName { get; set; }
@@ -62,7 +65,8 @@ public partial class WelcomeWizardContext : ObservableObject
SelectedProvider.Type, SelectedProvider.Type,
AccountName, AccountName,
BuildSpecialImapProviderDetails(), BuildSpecialImapProviderDetails(),
AccountColorHex); AccountColorHex,
SelectedInitialSynchronizationRange);
} }
public void Reset() public void Reset()
@@ -70,6 +74,7 @@ public partial class WelcomeWizardContext : ObservableObject
SelectedProvider = null; SelectedProvider = null;
AccountName = null; AccountName = null;
AccountColorHex = null; AccountColorHex = null;
SelectedInitialSynchronizationRange = InitialSynchronizationRange.SixMonths;
DisplayName = null; DisplayName = null;
EmailAddress = null; EmailAddress = null;
AppSpecificPassword = null; AppSpecificPassword = null;
@@ -16,7 +16,6 @@ using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server; using Wino.Messaging.Server;
@@ -319,7 +318,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
try try
{ {
var minimalSettings = BuildMinimalSettingsOrThrow(); var minimalSettings = BuildMinimalSettingsOrThrow();
await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false); await AutoDiscoverAndApplySettingsAsync(minimalSettings);
_mailDialogService.InfoBarMessage( _mailDialogService.InfoBarMessage(
Translator.IMAPSetupDialog_ValidationSuccess_Title, Translator.IMAPSetupDialog_ValidationSuccess_Title,
@@ -399,7 +398,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
{ {
try try
{ {
await EnsureImapSettingsPreparedAsync().ConfigureAwait(false); await EnsureImapSettingsPreparedAsync();
var serverInformation = BuildServerInformation(); var serverInformation = BuildServerInformation();
@@ -407,12 +406,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
ValidateImapSettings(serverInformation); ValidateImapSettings(serverInformation);
ValidateCalendarModeSpecificSettings(serverInformation); ValidateCalendarModeSpecificSettings(serverInformation);
await ValidateImapConnectivityAsync(serverInformation).ConfigureAwait(false); await ValidateImapConnectivityAsync(serverInformation);
IsImapValidationSucceeded = true; IsImapValidationSucceeded = true;
if (serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) if (serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav)
{ {
await ValidateCalDavConnectivityAsync(serverInformation).ConfigureAwait(false); await ValidateCalDavConnectivityAsync(serverInformation);
IsCalDavValidationSucceeded = true; IsCalDavValidationSucceeded = true;
} }
else else
@@ -432,7 +431,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
return; return;
} }
await SaveEditFlowAsync(serverInformation).ConfigureAwait(false); await SaveEditFlowAsync(serverInformation);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -654,7 +653,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
return; return;
var minimalSettings = BuildMinimalSettingsOrThrow(); var minimalSettings = BuildMinimalSettingsOrThrow();
await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false); await AutoDiscoverAndApplySettingsAsync(minimalSettings);
if (!HasCompleteImapSettings()) if (!HasCompleteImapSettings())
throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed); throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed);
@@ -676,22 +675,25 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
if (serverInformation == null) if (serverInformation == null)
throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed); throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed);
ApplyServerInformation(serverInformation); await ExecuteUIThread(async () =>
if (IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav)
{ {
var discoveredCalDavUri = await _autoDiscoveryService.DiscoverCalDavServiceUriAsync(minimalSettings.Email).ConfigureAwait(false); ApplyServerInformation(serverInformation);
if (discoveredCalDavUri != null)
if (IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav)
{ {
CalDavServiceUrl = discoveredCalDavUri.ToString(); var discoveredCalDavUri = await _autoDiscoveryService.DiscoverCalDavServiceUriAsync(minimalSettings.Email);
if (discoveredCalDavUri != null)
{
CalDavServiceUrl = discoveredCalDavUri.ToString();
}
if (string.IsNullOrWhiteSpace(CalDavUsername))
CalDavUsername = minimalSettings.Email;
if (string.IsNullOrWhiteSpace(CalDavPassword))
CalDavPassword = minimalSettings.Password;
} }
});
if (string.IsNullOrWhiteSpace(CalDavUsername))
CalDavUsername = minimalSettings.Email;
if (string.IsNullOrWhiteSpace(CalDavPassword))
CalDavPassword = minimalSettings.Password;
}
} }
private async Task ValidateImapConnectivityAsync(CustomServerInformation serverInformation) private async Task ValidateImapConnectivityAsync(CustomServerInformation serverInformation)
{ {
@@ -995,7 +997,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
SpecialImapProvider = _editingSpecialImapProvider, SpecialImapProvider = _editingSpecialImapProvider,
IsCalendarAccessGranted = mode != ImapCalendarSupportMode.Disabled IsCalendarAccessGranted = mode != ImapCalendarSupportMode.Disabled
}, },
new AccountCreationDialogResult(MailProviderType.IMAP4, DisplayName.Trim(), providerDetails, string.Empty)); new AccountCreationDialogResult(
MailProviderType.IMAP4,
DisplayName.Trim(),
providerDetails,
string.Empty,
_wizardContext.SelectedInitialSynchronizationRange));
if (serverInformation == null) if (serverInformation == null)
return false; return false;
+83 -5
View File
@@ -15,8 +15,9 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.MenuItems; using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models; using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Launch;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
@@ -72,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;
@@ -84,6 +86,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
private readonly IMimeFileService _mimeFileService; private readonly IMimeFileService _mimeFileService;
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
private readonly IStoreUpdateService _storeUpdateService; private readonly IStoreUpdateService _storeUpdateService;
private readonly IShareActivationService _shareActivationService;
private readonly INativeAppService _nativeAppService; private readonly INativeAppService _nativeAppService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
@@ -97,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,
@@ -109,7 +113,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
IConfigurationService configurationService, IConfigurationService configurationService,
IStartupBehaviorService startupBehaviorService, IStartupBehaviorService startupBehaviorService,
IWebView2RuntimeValidatorService webView2RuntimeValidatorService, IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
IStoreUpdateService storeUpdateService) IStoreUpdateService storeUpdateService,
IShareActivationService shareActivationService)
{ {
StatePersistenceService = statePersistanceService; StatePersistenceService = statePersistanceService;
@@ -122,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;
@@ -131,6 +137,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
_winoRequestDelegator = winoRequestDelegator; _winoRequestDelegator = winoRequestDelegator;
_webView2RuntimeValidatorService = webView2RuntimeValidatorService; _webView2RuntimeValidatorService = webView2RuntimeValidatorService;
_storeUpdateService = storeUpdateService; _storeUpdateService = storeUpdateService;
_shareActivationService = shareActivationService;
} }
protected override void OnDispatcherAssigned() protected override void OnDispatcherAssigned()
@@ -274,6 +281,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
} }
await ProcessLaunchOptionsAsync(); await ProcessLaunchOptionsAsync();
await HandlePendingShareRequestAsync();
await ValidateWebView2RuntimeAsync(); await ValidateWebView2RuntimeAsync();
if (shouldRunStartupFlows && !Debugger.IsAttached) if (shouldRunStartupFlows && !Debugger.IsAttached)
@@ -716,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.
@@ -788,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.
@@ -844,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();
} }
@@ -943,6 +984,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
} }
public async Task CreateNewMailForAsync(MailAccount account) public async Task CreateNewMailForAsync(MailAccount account)
=> await CreateNewMailForAsync(account, null);
public async Task CreateNewMailForAsync(MailAccount account, MailShareRequest shareRequest)
{ {
if (account == null) return; if (account == null) return;
@@ -974,6 +1018,11 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(account.Id, draftOptions).ConfigureAwait(false); var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(account.Id, draftOptions).ConfigureAwait(false);
if (shareRequest?.Files?.Count > 0)
{
_shareActivationService.StagePendingComposeShareRequest(draftMailCopy.UniqueId, shareRequest);
}
var draftPreparationRequest = new DraftPreparationRequest(account, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason); var draftPreparationRequest = new DraftPreparationRequest(account, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason);
await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest); await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest);
} }
@@ -1034,6 +1083,35 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
await CreateNewMailForAsync(targetAccount); await CreateNewMailForAsync(targetAccount);
} }
public async Task HandlePendingShareRequestAsync()
{
var shareRequest = _shareActivationService.ConsumePendingShareRequest();
if (shareRequest?.Files == null || shareRequest.Files.Count == 0)
return;
var accounts = await _accountService.GetAccountsAsync();
if (!accounts.Any())
return;
MailAccount targetAccount = null;
if (accounts.Count == 1)
{
targetAccount = accounts[0];
}
else
{
targetAccount = await _dialogService.ShowAccountPickerDialogAsync(accounts);
}
if (targetAccount == null)
return;
await CreateNewMailForAsync(targetAccount, shareRequest);
}
private async Task RecreateMenuItemsAsync() private async Task RecreateMenuItemsAsync()
{ {
await _menuRefreshSemaphore.WaitAsync().ConfigureAwait(false); await _menuRefreshSemaphore.WaitAsync().ConfigureAwait(false);
@@ -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);
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain; using Wino.Core.Domain;
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.Accounts;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.ViewModels.Data; using Wino.Core.ViewModels.Data;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
@@ -22,13 +23,26 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
public List<IProviderDetail> Providers { get; private set; } = []; public List<IProviderDetail> Providers { get; private set; } = [];
public List<AppColorViewModel> AvailableColors { get; private set; } = []; public List<AppColorViewModel> AvailableColors { get; private set; } = [];
public List<InitialSynchronizationRangeOption> InitialSynchronizationRanges { get; } =
[
new(InitialSynchronizationRange.ThreeMonths, Translator.AccountCreation_InitialSynchronization_3Months),
new(InitialSynchronizationRange.SixMonths, Translator.AccountCreation_InitialSynchronization_6Months),
new(InitialSynchronizationRange.NineMonths, Translator.AccountCreation_InitialSynchronization_9Months),
new(InitialSynchronizationRange.OneYear, Translator.AccountCreation_InitialSynchronization_Year),
new(InitialSynchronizationRange.Everything, Translator.AccountCreation_InitialSynchronization_Everything)
];
[ObservableProperty] [ObservableProperty]
public partial IProviderDetail SelectedProvider { get; set; } public partial IProviderDetail SelectedProvider { get; set; }
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsColorSelected))]
public partial AppColorViewModel SelectedColor { get; set; } public partial AppColorViewModel SelectedColor { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsInitialSynchronizationWarningVisible))]
public partial InitialSynchronizationRangeOption SelectedInitialSynchronizationRange { get; set; }
[ObservableProperty] [ObservableProperty]
public partial string AccountName { get; set; } public partial string AccountName { get; set; }
@@ -36,6 +50,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
public partial bool CanProceed { get; set; } public partial bool CanProceed { get; set; }
public bool IsColorSelected => SelectedColor != null; public bool IsColorSelected => SelectedColor != null;
public bool IsInitialSynchronizationWarningVisible => SelectedInitialSynchronizationRange?.IsEverything == true;
public ProviderSelectionPageViewModel( public ProviderSelectionPageViewModel(
IProviderService providerService, IProviderService providerService,
@@ -45,6 +60,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
_providerService = providerService; _providerService = providerService;
_themeService = themeService; _themeService = themeService;
WizardContext = wizardContext; WizardContext = wizardContext;
SelectedInitialSynchronizationRange = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
} }
public override void OnNavigatedTo(NavigationMode mode, object parameters) public override void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -56,6 +72,10 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
.Select(hex => new AppColorViewModel(hex)) .Select(hex => new AppColorViewModel(hex))
.ToList(); .ToList();
SelectedInitialSynchronizationRange = InitialSynchronizationRanges
.FirstOrDefault(option => option.Range == WizardContext.SelectedInitialSynchronizationRange)
?? InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
// Restore from wizard context if navigating back // Restore from wizard context if navigating back
if (WizardContext.SelectedProvider != null) if (WizardContext.SelectedProvider != null)
{ {
@@ -71,9 +91,12 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
Validate(); Validate();
} }
partial void OnSelectedProviderChanged(IProviderDetail value) => Validate(); partial void OnSelectedProviderChanged(IProviderDetail value)
{
Validate();
}
partial void OnAccountNameChanged(string value) => Validate(); partial void OnAccountNameChanged(string value) => Validate();
partial void OnSelectedColorChanged(AppColorViewModel value) => OnPropertyChanged(nameof(IsColorSelected));
[RelayCommand] [RelayCommand]
private void ClearColor() => SelectedColor = null; private void ClearColor() => SelectedColor = null;
@@ -92,6 +115,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
WizardContext.SelectedProvider = SelectedProvider; WizardContext.SelectedProvider = SelectedProvider;
WizardContext.AccountName = AccountName?.Trim(); WizardContext.AccountName = AccountName?.Trim();
WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty; WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty;
WizardContext.SelectedInitialSynchronizationRange = SelectedInitialSynchronizationRange?.Range ?? InitialSynchronizationRange.SixMonths;
if (WizardContext.IsGenericImap) if (WizardContext.IsGenericImap)
{ {
+117
View File
@@ -15,6 +15,9 @@ using Microsoft.Windows.AppLifecycle;
using Microsoft.Windows.AppNotifications; 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.ShareTarget;
using Windows.Storage;
using Wino.Calendar.ViewModels; using Wino.Calendar.ViewModels;
using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core; using Wino.Core;
@@ -22,6 +25,8 @@ using Wino.Core.Domain;
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.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Launch;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
@@ -30,6 +35,7 @@ using Wino.Mail.Services;
using Wino.Mail.ViewModels; using Wino.Mail.ViewModels;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
using Wino.Mail.WinUI.Activation; using Wino.Mail.WinUI.Activation;
using Wino.Mail.WinUI.Extensions;
using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.WinUI.Models; using Wino.Mail.WinUI.Models;
using Wino.Mail.WinUI.Services; using Wino.Mail.WinUI.Services;
@@ -61,6 +67,7 @@ public partial class App : WinoApplication,
private bool _isExiting; private bool _isExiting;
private bool _activationInfrastructureInitialized; private bool _activationInfrastructureInitialized;
private int _initialNotificationActivationHandled; private int _initialNotificationActivationHandled;
private int _initialShareActivationHandled;
private CancellationTokenSource? _autoSynchronizationLoopCts; private CancellationTokenSource? _autoSynchronizationLoopCts;
private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1); private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1);
private readonly SemaphoreSlim _activationInfrastructureSemaphore = new(1, 1); private readonly SemaphoreSlim _activationInfrastructureSemaphore = new(1, 1);
@@ -357,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));
@@ -446,12 +454,26 @@ public partial class App : WinoApplication,
private bool TryMarkInitialNotificationActivationHandled() private bool TryMarkInitialNotificationActivationHandled()
=> Interlocked.Exchange(ref _initialNotificationActivationHandled, 1) == 0; => Interlocked.Exchange(ref _initialNotificationActivationHandled, 1) == 0;
private bool TryMarkInitialShareActivationHandled()
=> Interlocked.Exchange(ref _initialShareActivationHandled, 1) == 0;
protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{ {
base.OnLaunched(args); base.OnLaunched(args);
await EnsureActivationInfrastructureAsync(); await EnsureActivationInfrastructureAsync();
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activationArgs.Kind == ExtendedActivationKind.ShareTarget &&
TryMarkInitialShareActivationHandled())
{
LogActivation("Processing share target activation from OnLaunched.");
if (await HandleShareTargetActivationAsync(activationArgs, activateWindow: true))
return;
}
var hasAnyAccount = _hasConfiguredAccounts; var hasAnyAccount = _hasConfiguredAccounts;
if (!IsStartupTaskLaunch() && !hasAnyAccount) if (!IsStartupTaskLaunch() && !hasAnyAccount)
{ {
@@ -635,6 +657,89 @@ public partial class App : WinoApplication,
return HandleToastActivationAsync(toastArguments, userInput); return HandleToastActivationAsync(toastArguments, userInput);
} }
private async Task<bool> HandleShareTargetActivationAsync(AppActivationArguments activationArgs, bool activateWindow)
{
if (activationArgs.Kind != ExtendedActivationKind.ShareTarget ||
activationArgs.Data is not ShareTargetActivatedEventArgs shareTargetArgs)
{
return false;
}
var shareRequest = await ExtractMailShareRequestAsync(shareTargetArgs);
if (shareRequest?.Files == null || shareRequest.Files.Count == 0)
{
Services.GetRequiredService<IShareActivationService>().ClearPendingShareRequest();
return false;
}
var shareActivationService = Services.GetRequiredService<IShareActivationService>();
shareActivationService.PendingShareRequest = shareRequest;
if (!_hasConfiguredAccounts)
{
shareActivationService.ClearPendingShareRequest();
return false;
}
var shellWindowAlreadyExists = HasShellWindow();
await EnsureShellWindowAsync(WinoApplicationMode.Mail, activateWindow, suppressStartupFlows: true);
if (shellWindowAlreadyExists)
{
await Services.GetRequiredService<MailAppShellViewModel>().HandlePendingShareRequestAsync();
}
return true;
}
private async Task<MailShareRequest?> ExtractMailShareRequestAsync(ShareTargetActivatedEventArgs shareTargetArgs)
{
var shareOperation = shareTargetArgs.ShareOperation;
try
{
shareOperation.ReportStarted();
if (!shareOperation.Data.Contains(StandardDataFormats.StorageItems))
{
shareOperation.ReportCompleted();
return null;
}
var storageItems = await shareOperation.Data.GetStorageItemsAsync();
List<SharedFile> sharedFiles = [];
foreach (var storageFile in storageItems.OfType<StorageFile>())
{
sharedFiles.Add(await storageFile.ToSharedFileAsync());
}
shareOperation.ReportDataRetrieved();
shareOperation.ReportCompleted();
return sharedFiles.Count == 0
? null
: new MailShareRequest(sharedFiles);
}
catch (Exception ex)
{
LogActivation($"Failed to extract share target payload: {ex.GetType().Name} - {ex.Message}");
try
{
shareOperation.ReportError(ex.Message);
}
catch
{
// Ignore share reporting failures and fall back to normal launch flow.
}
return null;
}
}
private async Task<IWinoShellWindow?> EnsureShellWindowAsync(WinoApplicationMode mode, bool activateWindow, bool suppressStartupFlows = true) private async Task<IWinoShellWindow?> EnsureShellWindowAsync(WinoApplicationMode mode, bool activateWindow, bool suppressStartupFlows = true)
{ {
var windowManager = Services.GetRequiredService<IWinoWindowManager>(); var windowManager = Services.GetRequiredService<IWinoWindowManager>();
@@ -1217,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
}; };
@@ -1446,6 +1552,11 @@ public partial class App : WinoApplication,
LogActivation($"Processing redirected notification activation. Arguments: {toastArgs.Argument}"); LogActivation($"Processing redirected notification activation. Arguments: {toastArgs.Argument}");
_ = HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput); _ = HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput);
} }
else if (args.Kind == ExtendedActivationKind.ShareTarget)
{
LogActivation("Processing redirected share target activation.");
await HandleShareTargetActivationAsync(args, activateWindow: true);
}
else else
{ {
var shouldActivateWindow = true; var shouldActivateWindow = true;
@@ -1527,6 +1638,12 @@ public partial class App : WinoApplication,
} }
if (activationArgs.Kind == ExtendedActivationKind.ShareTarget)
{
mode = WinoApplicationMode.Mail;
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();
}
}
@@ -55,6 +55,7 @@
<Grid MinWidth="400" RowSpacing="12"> <Grid MinWidth="400" RowSpacing="12">
<Grid Visibility="{x:Bind IsProviderSelectionVisible, Mode=OneWay}"> <Grid Visibility="{x:Bind IsProviderSelectionVisible, Mode=OneWay}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
@@ -96,6 +97,48 @@
</Grid> </Grid>
<Border
x:Name="InitialSynchronizationPanel"
Grid.Row="1"
Margin="0,12,0,0"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Visibility="Collapsed">
<StackPanel Spacing="10">
<StackPanel Spacing="2">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_Title}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_Description}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ComboBox
x:Name="InitialSynchronizationComboBox"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind InitialSynchronizationRanges, Mode=OneWay}"
SelectionChanged="InitialSynchronizationSelectionChanged">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="accounts:InitialSynchronizationRangeOption">
<TextBlock Text="{x:Bind DisplayText}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<muxc:InfoBar
x:Name="InitialSynchronizationWarningBar"
IsOpen="True"
Message="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_EverythingWarning}"
Severity="Warning"
Title="{x:Bind domain:Translator.GeneralTitle_Warning}"
Visibility="Collapsed" />
</StackPanel>
</Border>
<ListView <ListView
Grid.Row="2" Grid.Row="2"
@@ -15,7 +15,7 @@ namespace Wino.Mail.WinUI.Dialogs;
public sealed partial class NewAccountDialog : ContentDialog public sealed partial class NewAccountDialog : ContentDialog
{ {
private readonly Dictionary<SpecialImapProvider, string> helpingLinks = new Dictionary<SpecialImapProvider, string>() private readonly Dictionary<SpecialImapProvider, string> helpingLinks = new()
{ {
{ SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" }, { SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" },
{ SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" }, { SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" },
@@ -27,7 +27,6 @@ public sealed partial class NewAccountDialog : ContentDialog
public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(AppColorViewModel), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedColorChanged))); public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(AppColorViewModel), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedColorChanged)));
public static readonly DependencyProperty SelectedCalendarModeIndexProperty = DependencyProperty.Register(nameof(SelectedCalendarModeIndex), typeof(int), typeof(NewAccountDialog), new PropertyMetadata(0)); public static readonly DependencyProperty SelectedCalendarModeIndexProperty = DependencyProperty.Register(nameof(SelectedCalendarModeIndex), typeof(int), typeof(NewAccountDialog), new PropertyMetadata(0));
public AppColorViewModel? SelectedColor public AppColorViewModel? SelectedColor
{ {
get { return (AppColorViewModel?)GetValue(SelectedColorProperty); } get { return (AppColorViewModel?)GetValue(SelectedColorProperty); }
@@ -49,7 +48,6 @@ public sealed partial class NewAccountDialog : ContentDialog
set { SetValue(SelectedMailProviderProperty, value); } set { SetValue(SelectedMailProviderProperty, value); }
} }
public bool IsProviderSelectionVisible public bool IsProviderSelectionVisible
{ {
get { return (bool)GetValue(IsProviderSelectionVisibleProperty); } get { return (bool)GetValue(IsProviderSelectionVisibleProperty); }
@@ -63,10 +61,16 @@ public sealed partial class NewAccountDialog : ContentDialog
} }
// List of available mail providers for now. // List of available mail providers for now.
public List<IProviderDetail> Providers { get; set; } = []; public List<IProviderDetail> Providers { get; set; } = [];
public List<AppColorViewModel> AvailableColors { get; set; } = []; public List<AppColorViewModel> AvailableColors { get; set; } = [];
public List<InitialSynchronizationRangeOption> InitialSynchronizationRanges { get; } =
[
new(InitialSynchronizationRange.ThreeMonths, Translator.AccountCreation_InitialSynchronization_3Months),
new(InitialSynchronizationRange.SixMonths, Translator.AccountCreation_InitialSynchronization_6Months),
new(InitialSynchronizationRange.NineMonths, Translator.AccountCreation_InitialSynchronization_9Months),
new(InitialSynchronizationRange.OneYear, Translator.AccountCreation_InitialSynchronization_Year),
new(InitialSynchronizationRange.Everything, Translator.AccountCreation_InitialSynchronization_Everything)
];
public List<string> CalendarModeOptions { get; } = public List<string> CalendarModeOptions { get; } =
[ [
Translator.ImapCalDavSettingsPage_CalendarModeCalDav, Translator.ImapCalDavSettingsPage_CalendarModeCalDav,
@@ -74,7 +78,6 @@ public sealed partial class NewAccountDialog : ContentDialog
Translator.ImapCalDavSettingsPage_CalendarModeDisabled Translator.ImapCalDavSettingsPage_CalendarModeDisabled
]; ];
public AccountCreationDialogResult? Result = null; public AccountCreationDialogResult? Result = null;
public NewAccountDialog() public NewAccountDialog()
@@ -85,6 +88,8 @@ public sealed partial class NewAccountDialog : ContentDialog
AvailableColors = themeService.Select(a => new AppColorViewModel(a)).ToList(); AvailableColors = themeService.Select(a => new AppColorViewModel(a)).ToList();
UpdateSelectedColor(); UpdateSelectedColor();
InitialSynchronizationComboBox.SelectedItem = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
UpdateInitialSynchronizationState();
} }
private static void OnSelectedProviderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) private static void OnSelectedProviderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
@@ -105,6 +110,19 @@ public sealed partial class NewAccountDialog : ContentDialog
SelectedColorEllipse.Fill = SelectedColor == null ? null : XamlHelpers.GetSolidColorBrushFromHex(SelectedColor.Hex); SelectedColorEllipse.Fill = SelectedColor == null ? null : XamlHelpers.GetSolidColorBrushFromHex(SelectedColor.Hex);
} }
private void UpdateInitialSynchronizationState()
{
InitialSynchronizationPanel.Visibility = SelectedMailProvider == null ? Visibility.Collapsed : Visibility.Visible;
var selectedOption = InitialSynchronizationComboBox.SelectedItem as InitialSynchronizationRangeOption;
InitialSynchronizationWarningBar.Visibility = selectedOption?.IsEverything == true ? Visibility.Visible : Visibility.Collapsed;
}
private InitialSynchronizationRange GetInitialSynchronizationRange()
{
var selectedRange = (InitialSynchronizationComboBox.SelectedItem as InitialSynchronizationRangeOption)?.Range
?? InitialSynchronizationRange.SixMonths;
return selectedRange;
}
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{ {
@@ -116,9 +134,11 @@ public sealed partial class NewAccountDialog : ContentDialog
if (SelectedMailProvider == null) if (SelectedMailProvider == null)
return; return;
var initialSynchronizationRange = GetInitialSynchronizationRange();
if (IsSpecialImapServerPartVisible) if (IsSpecialImapServerPartVisible)
{ {
// Special imap detail input. // Special IMAP detail input.
var calendarSupportMode = SelectedCalendarModeIndex switch var calendarSupportMode = SelectedCalendarModeIndex switch
{ {
1 => ImapCalendarSupportMode.LocalOnly, 1 => ImapCalendarSupportMode.LocalOnly,
@@ -132,7 +152,12 @@ public sealed partial class NewAccountDialog : ContentDialog
DisplayNameTextBox.Text.Trim(), DisplayNameTextBox.Text.Trim(),
SelectedMailProvider.SpecialImapProvider, SelectedMailProvider.SpecialImapProvider,
calendarSupportMode); calendarSupportMode);
Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), details, SelectedColor?.Hex ?? string.Empty); Result = new AccountCreationDialogResult(
SelectedMailProvider.Type,
AccountNameTextbox.Text.Trim(),
details,
SelectedColor?.Hex ?? string.Empty,
initialSynchronizationRange);
Hide(); Hide();
return; return;
@@ -140,11 +165,11 @@ public sealed partial class NewAccountDialog : ContentDialog
Validate(); Validate();
if (IsSecondaryButtonEnabled) if (IsPrimaryButtonEnabled)
{ {
if (SelectedMailProvider.SpecialImapProvider != SpecialImapProvider.None) if (SelectedMailProvider.SpecialImapProvider != SpecialImapProvider.None)
{ {
// This step requires app-sepcific password login for some providers. // This step requires app-specific password login for some providers.
args.Cancel = true; args.Cancel = true;
IsProviderSelectionVisible = false; IsProviderSelectionVisible = false;
@@ -154,7 +179,12 @@ public sealed partial class NewAccountDialog : ContentDialog
} }
else else
{ {
Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), null, SelectedColor?.Hex ?? string.Empty); Result = new AccountCreationDialogResult(
SelectedMailProvider.Type,
AccountNameTextbox.Text.Trim(),
null,
SelectedColor?.Hex ?? string.Empty,
initialSynchronizationRange);
Hide(); Hide();
} }
} }
@@ -167,6 +197,7 @@ public sealed partial class NewAccountDialog : ContentDialog
{ {
ValidateCreateButton(); ValidateCreateButton();
ValidateNames(); ValidateNames();
UpdateInitialSynchronizationState();
} }
// Returns whether we can create account or not. // Returns whether we can create account or not.
@@ -199,6 +230,9 @@ public sealed partial class NewAccountDialog : ContentDialog
private void ImapPasswordChanged(object sender, RoutedEventArgs e) => Validate(); private void ImapPasswordChanged(object sender, RoutedEventArgs e) => Validate();
private void InitialSynchronizationSelectionChanged(object sender, SelectionChangedEventArgs e)
=> UpdateInitialSynchronizationState();
private async void AppSpecificHelpButtonClicked(object sender, RoutedEventArgs e) private async void AppSpecificHelpButtonClicked(object sender, RoutedEventArgs e)
{ {
if (SelectedMailProvider == null || if (SelectedMailProvider == null ||
+10 -1
View File
@@ -23,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.0.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"/>
@@ -97,6 +97,15 @@
</uap:Protocol> </uap:Protocol>
</uap:Extension> </uap:Extension>
<!-- Share target activation -->
<uap:Extension Category="windows.shareTarget">
<uap:ShareTarget>
<uap:SupportedFileTypes>
<uap:SupportsAnyFileType />
</uap:SupportedFileTypes>
</uap:ShareTarget>
</uap:Extension>
<!-- File Assosication: EML --> <!-- File Assosication: EML -->
<uap:Extension Category="windows.fileTypeAssociation"> <uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="eml"> <uap:FileTypeAssociation Name="eml">
@@ -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),
@@ -587,6 +587,31 @@ public class NewThemeService : INewThemeService
return results; return results;
} }
public async Task<bool> DeleteCustomThemeAsync(Guid themeId)
{
var themeFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(CustomThemeFolderName, CreationCollisionOption.OpenIfExists);
var metadataFileName = $"{themeId}.json";
var themeItem = await themeFolder.TryGetItemAsync(metadataFileName);
if (themeItem == null)
{
return false;
}
if (currentApplicationThemeId == themeId)
{
currentApplicationThemeId = preDefinedThemes[0].Id;
_configurationService.Set(CurrentApplicationThemeKey, currentApplicationThemeId);
await ApplyCustomThemeAsync(false);
}
await DeleteThemeAssetIfExistsAsync(themeFolder, metadataFileName);
await DeleteThemeAssetIfExistsAsync(themeFolder, $"{themeId}.jpg");
await DeleteThemeAssetIfExistsAsync(themeFolder, $"{themeId}_preview.jpg");
return true;
}
private async Task<CustomThemeMetadata?> GetCustomMetadataAsync(IStorageFile file) private async Task<CustomThemeMetadata?> GetCustomMetadataAsync(IStorageFile file)
{ {
var fileContent = await FileIO.ReadTextAsync(file); var fileContent = await FileIO.ReadTextAsync(file);
@@ -594,6 +619,16 @@ public class NewThemeService : INewThemeService
return JsonSerializer.Deserialize(fileContent, DomainModelsJsonContext.Default.CustomThemeMetadata); return JsonSerializer.Deserialize(fileContent, DomainModelsJsonContext.Default.CustomThemeMetadata);
} }
private static async Task DeleteThemeAssetIfExistsAsync(StorageFolder themeFolder, string fileName)
{
var item = await themeFolder.TryGetItemAsync(fileName);
if (item != null)
{
await item.DeleteAsync();
}
}
public string GetSystemAccentColorHex() public string GetSystemAccentColorHex()
=> uiSettings.GetColorValue(UIColorType.Accent).ToHex(); => uiSettings.GetColorValue(UIColorType.Accent).ToHex();
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
@@ -37,7 +36,6 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
_configurationService.Set(propertyName, value ?? string.Empty); _configurationService.Set(propertyName, value ?? string.Empty);
OnPropertyChanged(propertyName); OnPropertyChanged(propertyName);
Debug.WriteLine($"PreferencesService -> {propertyName}:{value?.ToString()}");
} }
public MailRenderingOptions GetRenderingOptions() public MailRenderingOptions GetRenderingOptions()
@@ -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
@@ -4,267 +4,267 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract" xmlns:abstract="using:Wino.Views.Abstract"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Wino.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"> mc:Ignorable="d">
<ScrollViewer> <ScrollViewer>
<StackPanel <Grid
MaxWidth="980"
Padding="36,28,36,36" Padding="36,28,36,36"
HorizontalAlignment="Stretch" HorizontalAlignment="Center">
Spacing="24"> <StackPanel Spacing="20">
<StackPanel Spacing="4">
<!-- Page Header --> <TextBlock
<StackPanel Spacing="4"> FontSize="28"
<TextBlock FontWeight="SemiBold"
FontSize="28" Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" />
FontWeight="SemiBold" <TextBlock
Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" /> Foreground="{ThemeResource TextFillColorSecondaryBrush}"
<TextBlock Style="{StaticResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind ViewModel.SubtitleText, Mode=OneWay}"
Style="{StaticResource BodyTextBlockStyle}" TextWrapping="WrapWholeWords" />
Text="{x:Bind ViewModel.SubtitleText, Mode=OneWay}" <TextBlock
TextWrapping="WrapWholeWords" /> Foreground="{ThemeResource TextFillColorTertiaryBrush}"
<TextBlock Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorTertiaryBrush}" Text="{x:Bind ViewModel.ProviderHint, Mode=OneWay}"
Style="{StaticResource CaptionTextBlockStyle}" TextWrapping="WrapWholeWords"
Text="{x:Bind ViewModel.ProviderHint, Mode=OneWay}" Visibility="{x:Bind ViewModel.HasProviderHint, Mode=OneWay}" />
TextWrapping="WrapWholeWords"
Visibility="{x:Bind ViewModel.HasProviderHint, Mode=OneWay}" />
</StackPanel>
<!-- Setup Mode Selector -->
<SelectorBar x:Name="SetupModeSelector" SelectionChanged="OnSetupModeSelectionChanged">
<SelectorBarItem Icon="Library" Text="{x:Bind ViewModel.BasicTabText, Mode=OneWay}" />
<SelectorBarItem Icon="Setting" Text="{x:Bind ViewModel.AdvancedTabText, Mode=OneWay}" />
</SelectorBar>
<!-- Basic Setup Card -->
<Border
Padding="20"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsAdvancedSetupSelected), Mode=OneWay}">
<StackPanel Spacing="16">
<StackPanel Spacing="2">
<TextBlock
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.BasicSectionTitleText, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.BasicSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="{x:Bind ViewModel.DisplayNameHeaderText, Mode=OneWay}"
PlaceholderText="{x:Bind ViewModel.DisplayNamePlaceholderText, Mode=OneWay}"
Text="{x:Bind ViewModel.DisplayName, Mode=TwoWay}" />
<TextBox
Grid.Column="1"
Header="{x:Bind ViewModel.EmailAddressHeaderText, Mode=OneWay}"
PlaceholderText="{x:Bind ViewModel.EmailAddressPlaceholderText, Mode=OneWay}"
Text="{x:Bind ViewModel.EmailAddress, Mode=TwoWay}" />
</Grid>
<PasswordBox Header="{x:Bind ViewModel.PasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.Password, Mode=TwoWay}" />
<CheckBox Content="{x:Bind ViewModel.EnableCalendarSupportText, Mode=OneWay}" IsChecked="{x:Bind ViewModel.IsCalendarSupportEnabled, Mode=TwoWay}" />
<Button
HorizontalAlignment="Left"
Command="{x:Bind ViewModel.AutoDiscoverSettingsCommand}"
Content="{x:Bind ViewModel.AutoDiscoverButtonText, Mode=OneWay}"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel> </StackPanel>
</Border>
<!-- Advanced Setup Card --> <Border
<Border Padding="20"
Padding="20" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="1"
BorderThickness="1" CornerRadius="8">
CornerRadius="8" <StackPanel Spacing="16">
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsBasicSetupSelected), Mode=OneWay}"> <StackPanel Spacing="2">
<StackPanel Spacing="20">
<StackPanel Spacing="2">
<TextBlock
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.AdvancedSectionTitleText, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AdvancedSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<Grid ColumnSpacing="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Incoming (IMAP) Settings -->
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="6">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE896;" />
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.IncomingSectionTitleText, Mode=OneWay}" />
</StackPanel>
<TextBox Header="{x:Bind ViewModel.IncomingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.IncomingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.IncomingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.IncomingServerPassword, Mode=TwoWay}" />
<ComboBox
HorizontalAlignment="Stretch"
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
HorizontalAlignment="Stretch"
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
</Border>
<!-- Outgoing (SMTP) Settings -->
<Border
Grid.Column="1"
Padding="16"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="6">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE898;" />
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.OutgoingSectionTitleText, Mode=OneWay}" />
</StackPanel>
<TextBox Header="{x:Bind ViewModel.OutgoingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.OutgoingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.OutgoingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.OutgoingServerPassword, Mode=TwoWay}" />
<ComboBox
HorizontalAlignment="Stretch"
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
HorizontalAlignment="Stretch"
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- Calendar Settings Card -->
<Border
Padding="20"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="16">
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE787;" />
<TextBlock <TextBlock
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="{x:Bind ViewModel.CalendarSectionTitleText, Mode=OneWay}" /> Text="{x:Bind ViewModel.BasicSectionTitleText, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.BasicSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel> </StackPanel>
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox
Grid.Row="0"
Grid.Column="0"
Header="{x:Bind ViewModel.DisplayNameHeaderText, Mode=OneWay}"
PlaceholderText="{x:Bind ViewModel.DisplayNamePlaceholderText, Mode=OneWay}"
Text="{x:Bind ViewModel.DisplayName, Mode=TwoWay}" />
<TextBox
Grid.Row="0"
Grid.Column="1"
Header="{x:Bind ViewModel.EmailAddressHeaderText, Mode=OneWay}"
PlaceholderText="{x:Bind ViewModel.EmailAddressPlaceholderText, Mode=OneWay}"
Text="{x:Bind ViewModel.EmailAddress, Mode=TwoWay}" />
<PasswordBox
Grid.Row="1"
Grid.ColumnSpan="2"
Header="{x:Bind ViewModel.PasswordHeaderText, Mode=OneWay}"
Password="{x:Bind ViewModel.Password, Mode=TwoWay}" />
</Grid>
<CheckBox
Content="{x:Bind ViewModel.EnableCalendarSupportText, Mode=OneWay}"
IsChecked="{x:Bind ViewModel.IsCalendarSupportEnabled, Mode=TwoWay}" />
<StackPanel Spacing="8">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AdvancedSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<Button
HorizontalAlignment="Left"
Command="{x:Bind ViewModel.AutoDiscoverSettingsCommand}"
Content="{x:Bind ViewModel.AutoDiscoverButtonText, Mode=OneWay}"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border
Padding="20"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="16">
<StackPanel Spacing="2">
<TextBlock
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.AdvancedSectionTitleText, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AdvancedSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="6">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE896;" />
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.IncomingSectionTitleText, Mode=OneWay}" />
</StackPanel>
<TextBox Header="{x:Bind ViewModel.IncomingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.IncomingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.IncomingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.IncomingServerPassword, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
</Border>
<Border
Grid.Column="1"
Padding="16"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="6">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE898;" />
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.OutgoingSectionTitleText, Mode=OneWay}" />
</StackPanel>
<TextBox Header="{x:Bind ViewModel.OutgoingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.OutgoingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.OutgoingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.OutgoingServerPassword, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
</Border>
</Grid>
<Button
HorizontalAlignment="Right"
Command="{x:Bind ViewModel.TestImapConnectionCommand}"
Content="{x:Bind ViewModel.TestImapButtonText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border
Padding="20"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="16">
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE787;" />
<TextBlock
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.CalendarSectionTitleText, Mode=OneWay}" />
</StackPanel>
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.CalendarSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ComboBox
Header="{x:Bind ViewModel.CalendarModeHeaderText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalendarModeSelectionVisible, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableCalendarSupportModeTitles}"
SelectedIndex="{x:Bind ViewModel.SelectedCalendarSupportModeIndex, Mode=TwoWay}" />
<TextBlock <TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}" Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.CalendarSectionDescriptionText, Mode=OneWay}" Text="{x:Bind ViewModel.SelectedCalendarSupportDescription, Mode=OneWay}"
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
<HyperlinkButton
Command="{x:Bind ViewModel.ShowLocalCalendarExplanationCommand}"
Content="{x:Bind ViewModel.LocalCalendarLearnMoreText, Mode=OneWay}"
HorizontalAlignment="Left"
IsEnabled="{x:Bind ViewModel.IsLocalCalendarModeSelected, Mode=OneWay}" />
<Grid ColumnSpacing="12" Visibility="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="{x:Bind ViewModel.CalDavServiceUrlHeaderText, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavServiceUrl, Mode=TwoWay}" />
<TextBox
Grid.Column="1"
Header="{x:Bind ViewModel.CalDavUsernameHeaderText, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavUsername, Mode=TwoWay}" />
</Grid>
<PasswordBox
Header="{x:Bind ViewModel.CalDavPasswordHeaderText, Mode=OneWay}"
Password="{x:Bind ViewModel.CalDavPassword, Mode=TwoWay}"
Visibility="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}" />
<Button
HorizontalAlignment="Right"
Command="{x:Bind ViewModel.TestCalDavConnectionCommand}"
Content="{x:Bind ViewModel.TestCalDavButtonText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}" />
</StackPanel> </StackPanel>
</Border>
<ComboBox <Grid Margin="0,4,0,0" ColumnSpacing="8">
HorizontalAlignment="Stretch" <Grid.ColumnDefinitions>
Header="{x:Bind ViewModel.CalendarModeHeaderText, Mode=OneWay}" <ColumnDefinition Width="*" />
IsEnabled="{x:Bind ViewModel.IsCalendarModeSelectionVisible, Mode=OneWay}" <ColumnDefinition Width="Auto" />
ItemsSource="{x:Bind ViewModel.AvailableCalendarSupportModeTitles}" <ColumnDefinition Width="Auto" />
SelectedIndex="{x:Bind ViewModel.SelectedCalendarSupportModeIndex, Mode=TwoWay}" /> </Grid.ColumnDefinitions>
<Button
<TextBlock Grid.Column="1"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Command="{x:Bind ViewModel.CancelCommand}"
Style="{StaticResource CaptionTextBlockStyle}" Content="{x:Bind ViewModel.CancelButtonText, Mode=OneWay}" />
Text="{x:Bind ViewModel.SelectedCalendarSupportDescription, Mode=OneWay}" <Button
TextWrapping="WrapWholeWords" /> Grid.Column="2"
Command="{x:Bind ViewModel.SaveCommand}"
<HyperlinkButton Content="{x:Bind ViewModel.SaveButtonText, Mode=OneWay}"
Command="{x:Bind ViewModel.ShowLocalCalendarExplanationCommand}" Style="{ThemeResource AccentButtonStyle}" />
Content="{x:Bind ViewModel.LocalCalendarLearnMoreText, Mode=OneWay}" </Grid>
IsEnabled="{x:Bind ViewModel.IsLocalCalendarModeSelected, Mode=OneWay}" /> </StackPanel>
</Grid>
<Grid ColumnSpacing="12" Visibility="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="{x:Bind ViewModel.CalDavServiceUrlHeaderText, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavServiceUrl, Mode=TwoWay}" />
<TextBox
Grid.Column="1"
Header="{x:Bind ViewModel.CalDavUsernameHeaderText, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavUsername, Mode=TwoWay}" />
</Grid>
<PasswordBox
Header="{x:Bind ViewModel.CalDavPasswordHeaderText, Mode=OneWay}"
Password="{x:Bind ViewModel.CalDavPassword, Mode=TwoWay}"
Visibility="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Action Bar -->
<Grid Margin="0,4,0,0" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
Grid.Column="0"
Command="{x:Bind ViewModel.TestImapConnectionCommand}"
Content="{x:Bind ViewModel.TestImapButtonText, Mode=OneWay}" />
<Button
Grid.Column="1"
Command="{x:Bind ViewModel.TestCalDavConnectionCommand}"
Content="{x:Bind ViewModel.TestCalDavButtonText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}" />
<Button
Grid.Column="3"
Command="{x:Bind ViewModel.CancelCommand}"
Content="{x:Bind ViewModel.CancelButtonText, Mode=OneWay}" />
<Button
Grid.Column="4"
Command="{x:Bind ViewModel.SaveCommand}"
Content="{x:Bind ViewModel.SaveButtonText, Mode=OneWay}"
Style="{ThemeResource AccentButtonStyle}" />
</Grid>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</abstract:ImapCalDavSettingsPageAbstract> </abstract:ImapCalDavSettingsPageAbstract>
@@ -1,5 +1,3 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Views.Abstract; using Wino.Views.Abstract;
namespace Wino.Views; namespace Wino.Views;
@@ -10,22 +8,4 @@ public sealed partial class ImapCalDavSettingsPage : ImapCalDavSettingsPageAbstr
{ {
InitializeComponent(); InitializeComponent();
} }
private void OnSetupModeSelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs e)
{
ViewModel.SelectedSetupTabIndex = SetupModeSelector.SelectedItem == null ? 0 : SetupModeSelector.Items.IndexOf(SetupModeSelector.SelectedItem);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
var tabIndex = ViewModel.SelectedSetupTabIndex;
if (tabIndex < 0 || tabIndex >= SetupModeSelector.Items.Count)
{
tabIndex = 0;
}
SetupModeSelector.SelectedItem = SetupModeSelector.Items[tabIndex];
}
} }
+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();
@@ -10,15 +10,12 @@
xmlns:helpers="using:Wino.Helpers" xmlns:helpers="using:Wino.Helpers"
xmlns:interfaces="using:Wino.Core.Domain.Interfaces" xmlns:interfaces="using:Wino.Core.Domain.Interfaces"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
mc:Ignorable="d"> mc:Ignorable="d">
<ScrollViewer <ScrollViewer HorizontalAlignment="Center" VerticalScrollBarVisibility="Auto">
HorizontalAlignment="Center"
VerticalAlignment="Center"
VerticalScrollBarVisibility="Auto">
<StackPanel <StackPanel
MaxWidth="480" Margin="0,12"
Margin="0,24,0,24"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Spacing="20"> Spacing="20">
@@ -76,6 +73,50 @@
</Button> </Button>
</Grid> </Grid>
<Border
MaxWidth="600"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="10">
<StackPanel Spacing="2">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_Title}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_Description}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ListView
HorizontalAlignment="Center"
HorizontalContentAlignment="Stretch"
ItemsSource="{x:Bind ViewModel.InitialSynchronizationRanges, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedInitialSynchronizationRange, Mode=TwoWay}">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<ItemsStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemTemplate>
<DataTemplate x:DataType="accounts:InitialSynchronizationRangeOption">
<TextBlock Text="{x:Bind DisplayText}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<muxc:InfoBar
Title="{x:Bind domain:Translator.GeneralTitle_Warning}"
Margin="0,2,0,0"
IsOpen="True"
Message="{x:Bind domain:Translator.AccountCreation_InitialSynchronization_EverythingWarning}"
Severity="Warning"
Visibility="{x:Bind ViewModel.IsInitialSynchronizationWarningVisible, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Provider List --> <!-- Provider List -->
<ItemsView <ItemsView
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
@@ -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);
}
}
}
@@ -49,6 +49,68 @@
CompactTemplate="{StaticResource CompactDisplayModePreviewTemplate}" CompactTemplate="{StaticResource CompactDisplayModePreviewTemplate}"
MediumTemplate="{StaticResource MediumDisplayModePreviewTemplate}" MediumTemplate="{StaticResource MediumDisplayModePreviewTemplate}"
SpaciousTemplate="{StaticResource SpaciousDisplayModePreviewTemplate}" /> SpaciousTemplate="{StaticResource SpaciousDisplayModePreviewTemplate}" />
<DataTemplate x:Key="PersonalizationCustomAppThemeTemplate" x:DataType="personalization:CustomAppTheme">
<Grid
Width="175"
CornerRadius="6"
RowSpacing="0">
<Grid.ContextFlyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuFlyoutItem
Command="{Binding ElementName=root, Path=ViewModel.DeleteCustomThemeCommand}"
CommandParameter="{x:Bind}"
Text="{x:Bind domain:Translator.Buttons_Delete}">
<MenuFlyoutItem.Icon>
<SymbolIcon Symbol="Delete" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</Grid.ContextFlyout>
<Grid.RowDefinitions>
<RowDefinition Height="125" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid CornerRadius="6,6,0,0">
<Grid.Background>
<ImageBrush ImageSource="{x:Bind BackgroundPreviewImage}" />
</Grid.Background>
<Grid x:Name="AccentAssignedGrid" x:Load="{x:Bind IsAccentColorAssigned}">
<Grid
Width="20"
Height="20"
Margin="0,12,12,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(AccentColor)}" />
</Grid>
<Grid x:Name="AccentNotAssignedGrid" x:Load="{x:Bind IsAccentColorAssigned, Converter={StaticResource ReverseBooleanConverter}}">
<Grid
Width="20"
Height="20"
Margin="0,12,12,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Background="{ThemeResource SystemAccentColor}" />
</Grid>
</Grid>
<Grid Grid.Row="1" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<TextBlock
Padding="0,8"
HorizontalAlignment="Center"
Text="{x:Bind ThemeName}" />
</Grid>
</Grid>
</DataTemplate>
<coreSelectors:AppThemePreviewTemplateSelector
x:Key="PersonalizationAppThemePreviewTemplateSelector"
CustomAppTemplate="{StaticResource PersonalizationCustomAppThemeTemplate}"
PreDefinedThemeTemplate="{StaticResource PreDefinedAppThemeTemplate}"
SystemThemeTemplate="{StaticResource SystemAppThemeTemplate}" />
</Page.Resources> </Page.Resources>
<ScrollViewer> <ScrollViewer>
@@ -147,7 +209,7 @@
<!-- TODO: Group by custom/wino --> <!-- TODO: Group by custom/wino -->
<GridView <GridView
toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal" toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal"
ItemTemplateSelector="{StaticResource AppThemePreviewTemplateSelector}" ItemTemplateSelector="{StaticResource PersonalizationAppThemePreviewTemplateSelector}"
ItemsSource="{x:Bind ViewModel.AppThemes, Mode=OneWay}" ItemsSource="{x:Bind ViewModel.AppThemes, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedAppTheme, Mode=TwoWay}" /> SelectedItem="{x:Bind ViewModel.SelectedAppTheme, Mode=TwoWay}" />
</controls:SettingsCard> </controls:SettingsCard>
File diff suppressed because one or more lines are too long
+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}"
+3
View File
@@ -11,6 +11,9 @@
<PublishProfile>win-$(Platform).pubxml</PublishProfile> <PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling> <EnableMsixTooling>true</EnableMsixTooling>
<!-- Bundle the Windows App SDK and .NET runtime into per-architecture MSIX outputs. -->
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Single instancing --> <!-- Single instancing -->
+6 -1
View File
@@ -475,7 +475,7 @@ public class AccountService : BaseDatabaseService, IAccountService
public async Task UpdateAccountCustomServerInformationAsync(CustomServerInformation customServerInformation) public async Task UpdateAccountCustomServerInformationAsync(CustomServerInformation customServerInformation)
{ {
await Connection.UpdateAsync(customServerInformation, typeof(CustomServerInformation)).ConfigureAwait(false); await Connection.InsertOrReplaceAsync(customServerInformation, typeof(CustomServerInformation)).ConfigureAwait(false);
} }
public async Task UpdateAccountAliasesAsync(Guid accountId, List<MailAccountAlias> aliases) public async Task UpdateAccountAliasesAsync(Guid accountId, List<MailAccountAlias> aliases)
@@ -590,6 +590,11 @@ public class AccountService : BaseDatabaseService, IAccountService
{ {
Guard.IsNotNull(account); Guard.IsNotNull(account);
if (!account.CreatedAt.HasValue)
{
account.CreatedAt = DateTime.UtcNow;
}
var accountCount = await Connection.Table<MailAccount>().CountAsync(); var accountCount = await Connection.Table<MailAccount>().CountAsync();
// If there are no accounts before this one, set it as startup account. // If there are no accounts before this one, set it as startup account.
+31
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>(),
@@ -79,6 +81,22 @@ public class DatabaseService : IDatabaseService
{ {
await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false); await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false);
var accountColumns = await Connection.GetTableInfoAsync(nameof(MailAccount)).ConfigureAwait(false);
if (!accountColumns.Any(c => c.Name == nameof(MailAccount.CreatedAt)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(MailAccount)} ADD COLUMN {nameof(MailAccount.CreatedAt)} TEXT NULL")
.ConfigureAwait(false);
}
if (!accountColumns.Any(c => c.Name == nameof(MailAccount.InitialSynchronizationRange)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(MailAccount)} ADD COLUMN {nameof(MailAccount.InitialSynchronizationRange)} INTEGER NOT NULL DEFAULT {(int)InitialSynchronizationRange.SixMonths}")
.ConfigureAwait(false);
}
var folderColumns = await Connection.GetTableInfoAsync(nameof(MailItemFolder)).ConfigureAwait(false); var folderColumns = await Connection.GetTableInfoAsync(nameof(MailItemFolder)).ConfigureAwait(false);
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.HighestKnownUid))) if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.HighestKnownUid)))
@@ -152,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);
} }
@@ -203,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.
+2
View File
@@ -13,12 +13,14 @@ public static class ServicesContainerSetup
services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>(); services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>();
services.AddSingleton<IWinoLogger, WinoLogger>(); services.AddSingleton<IWinoLogger, WinoLogger>();
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>(); services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
services.AddSingleton<IShareActivationService, ShareActivationService>();
services.AddSingleton<IMimeFileService, MimeFileService>(); services.AddSingleton<IMimeFileService, MimeFileService>();
services.AddSingleton<ICalendarIcsFileService, CalendarIcsFileService>(); services.AddSingleton<ICalendarIcsFileService, CalendarIcsFileService>();
services.AddTransient<IMimeStorageService, MimeStorageService>(); services.AddTransient<IMimeStorageService, MimeStorageService>();
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>();
+70
View File
@@ -0,0 +1,70 @@
#nullable enable
using System;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Launch;
namespace Wino.Services;
public class ShareActivationService : IShareActivationService
{
private readonly object _syncRoot = new();
private MailShareRequest? _pendingShareRequest;
private PendingComposeMailShareRequest? _pendingComposeShareRequest;
public MailShareRequest? PendingShareRequest
{
get
{
lock (_syncRoot)
{
return _pendingShareRequest;
}
}
set
{
lock (_syncRoot)
{
_pendingShareRequest = value;
}
}
}
public MailShareRequest? ConsumePendingShareRequest()
{
lock (_syncRoot)
{
var pendingRequest = _pendingShareRequest;
_pendingShareRequest = null;
return pendingRequest;
}
}
public void ClearPendingShareRequest()
{
lock (_syncRoot)
{
_pendingShareRequest = null;
}
}
public void StagePendingComposeShareRequest(Guid draftUniqueId, MailShareRequest shareRequest)
{
lock (_syncRoot)
{
_pendingComposeShareRequest = new PendingComposeMailShareRequest(draftUniqueId, shareRequest);
}
}
public MailShareRequest? ConsumePendingComposeShareRequest(Guid draftUniqueId)
{
lock (_syncRoot)
{
if (_pendingComposeShareRequest?.DraftUniqueId != draftUniqueId)
return null;
var pendingRequest = _pendingComposeShareRequest.ShareRequest;
_pendingComposeShareRequest = null;
return pendingRequest;
}
}
}

Some files were not shown because too many files have changed in this diff Show More