Compare commits
27 Commits
v2.0
..
beta/2.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2335893c | |||
| 1a1d69be56 | |||
| c2540926f4 | |||
| 9424fd9a16 | |||
| 89b48d3ac4 | |||
| 0bcc7a7647 | |||
| 260e1ab935 | |||
| ccf7c0607b | |||
| b8ce7e7422 | |||
| 1365e42fd7 | |||
| 0f160545ab | |||
| 8481a5c7cd | |||
| d32745fd67 | |||
| 470b2b8638 | |||
| 7e1731f4dc | |||
| aac9f9fec3 | |||
| cf8fff8ef1 | |||
| 0610096b78 | |||
| feff929333 | |||
| aa16609f89 | |||
| 4bea53a667 | |||
| b2ad4a1664 | |||
| dad3a51885 | |||
| 59ff0a1d7d | |||
| df19ab3196 | |||
| c622858d2d | |||
| 2e36772a4c |
@@ -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
|
||||
@@ -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
|
||||
{
|
||||
if (SelectedQuickEventAccountCalendar == null ||
|
||||
SelectedQuickEventAccountCalendar.IsReadOnly ||
|
||||
SelectedQuickEventDate == null ||
|
||||
string.IsNullOrWhiteSpace(EventName) ||
|
||||
string.IsNullOrWhiteSpace(SelectedStartTimeString) ||
|
||||
@@ -204,6 +205,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null)
|
||||
return;
|
||||
|
||||
if (DisplayDetailsCalendarItemViewModel.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent)
|
||||
{
|
||||
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||
@@ -460,6 +467,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))]
|
||||
private async Task SaveQuickEventAsync()
|
||||
{
|
||||
if (SelectedQuickEventAccountCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
|
||||
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
|
||||
var composeResult = new CalendarEventComposeResult
|
||||
@@ -553,6 +566,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarItem.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTargetStart = calendarItem.IsAllDayEvent
|
||||
? targetStart.Date
|
||||
: targetStart;
|
||||
@@ -1195,6 +1214,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
if (targetItem == null)
|
||||
return;
|
||||
|
||||
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetItem.IsRecurringParent)
|
||||
{
|
||||
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||
@@ -1221,6 +1246,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
if (targetItem == null || targetItem.ShowAs == showAs)
|
||||
return;
|
||||
|
||||
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
var originalItem = await _calendarService.GetCalendarItemAsync(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)
|
||||
return;
|
||||
|
||||
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
var operation = responseStatus switch
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => AccountCalendar.IsReadOnly;
|
||||
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
|
||||
}
|
||||
|
||||
public bool IsSynchronizationEnabled
|
||||
{
|
||||
get => AccountCalendar.IsSynchronizationEnabled;
|
||||
|
||||
@@ -440,6 +440,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (CurrentEvent == null) return;
|
||||
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -506,6 +511,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (CurrentEvent == null) return;
|
||||
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the event is a master recurring event, ask for confirmation
|
||||
if (CurrentEvent.IsRecurringParent)
|
||||
@@ -610,6 +620,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
private async Task SendRsvpResponse(AttendeeStatus status)
|
||||
{
|
||||
if (CurrentEvent == null) return;
|
||||
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public class AccountCalendar : IAccountCalendar
|
||||
public string SynchronizationDeltaToken { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
public bool IsReadOnly { get; set; }
|
||||
public bool IsSynchronizationEnabled { get; set; } = true;
|
||||
public bool IsExtended { get; set; } = true;
|
||||
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>
|
||||
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>
|
||||
/// Gets whether the account can perform ProfileInformation sync type.
|
||||
/// </summary>
|
||||
@@ -122,5 +132,10 @@ public class MailAccount
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public enum MailSynchronizerOperation
|
||||
AlwaysMoveTo,
|
||||
MoveToFocused,
|
||||
Archive,
|
||||
UpdateCategories,
|
||||
}
|
||||
|
||||
public enum FolderSynchronizerOperation
|
||||
@@ -35,6 +36,13 @@ public enum CalendarSynchronizerOperation
|
||||
TentativeEvent,
|
||||
}
|
||||
|
||||
public enum CategorySynchronizerOperation
|
||||
{
|
||||
CreateCategory,
|
||||
UpdateCategory,
|
||||
DeleteCategory,
|
||||
}
|
||||
|
||||
// UI requests
|
||||
public enum MailOperation
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
public enum MailSynchronizationType
|
||||
{
|
||||
UpdateProfile, // Only update profile information
|
||||
Categories, // Only update mail categories
|
||||
ExecuteRequests, // Run the queued requests, and then synchronize if needed.
|
||||
FoldersOnly, // Only synchronize folder metadata.
|
||||
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
|
||||
|
||||
@@ -24,6 +24,7 @@ public enum WinoPage
|
||||
AppPreferencesPage,
|
||||
SettingOptionsPage,
|
||||
AliasManagementPage,
|
||||
MailCategoryManagementPage,
|
||||
ImapCalDavSettingsPage,
|
||||
KeyboardShortcutsPage,
|
||||
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 BackgroundColorHex { get; set; }
|
||||
bool IsPrimary { get; set; }
|
||||
bool IsReadOnly { get; set; }
|
||||
bool IsSynchronizationEnabled { get; set; }
|
||||
Guid AccountId { get; set; }
|
||||
string RemoteCalendarId { get; set; }
|
||||
|
||||
@@ -14,6 +14,22 @@ public interface IFolderMenuItem : 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
|
||||
{
|
||||
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.Calendar;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
public interface IMailDialogService : IDialogServiceBase
|
||||
{
|
||||
void ShowReadOnlyCalendarMessage();
|
||||
Task<bool> ShowHardDeleteConfirmationAsync();
|
||||
Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService);
|
||||
|
||||
@@ -51,6 +53,13 @@ public interface IMailDialogService : IDialogServiceBase
|
||||
/// <returns>Created alias model if not canceled.</returns>
|
||||
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>
|
||||
/// Presents a dialog to the user to show email source.
|
||||
/// </summary>
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface INewThemeService : IInitializeAsync
|
||||
Task<List<AppThemeBase>> GetAvailableThemesAsync();
|
||||
Task<CustomThemeMetadata> CreateNewCustomThemeAsync(string themeName, string accentColor, byte[] wallpaperData);
|
||||
Task<List<CustomThemeMetadata>> GetCurrentCustomThemesAsync();
|
||||
Task<bool> DeleteCustomThemeAsync(Guid themeId);
|
||||
List<string> GetAvailableAccountColors();
|
||||
Task ApplyCustomThemeAsync(bool isInitializing);
|
||||
|
||||
|
||||
@@ -72,3 +72,9 @@ public interface ICalendarActionRequest : IRequestBase
|
||||
Guid? LocalCalendarItemId { 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,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Handles category synchronization for the given account.
|
||||
/// </summary>
|
||||
Task<MailSynchronizationResult> SynchronizeCategoriesAsync(Guid accountId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Handles profile synchronization for the given account.
|
||||
/// </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.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
@@ -36,4 +38,9 @@ public interface IWinoRequestDelegator
|
||||
/// </summary>
|
||||
/// <param name="calendarOperationPreparationRequest">Calendar preparation request.</param>
|
||||
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()
|
||||
{
|
||||
foreach (var item in this)
|
||||
var rootItems = this.ToList();
|
||||
|
||||
foreach (var item in rootItems)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -40,9 +42,11 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -50,7 +54,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
}
|
||||
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))
|
||||
{
|
||||
@@ -65,8 +69,10 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
|
||||
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
|
||||
{
|
||||
value = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId);
|
||||
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Where(b => b.AccountId == accountId) != null);
|
||||
var rootItems = this.ToList();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
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;
|
||||
|
||||
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.
|
||||
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)
|
||||
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
|
||||
|
||||
@@ -93,11 +103,14 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
|
||||
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
|
||||
{
|
||||
var rootItems = this.ToList();
|
||||
|
||||
// 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));
|
||||
|
||||
value ??= this.OfType<FolderMenuItem>()
|
||||
value ??= rootItems.OfType<FolderMenuItem>()
|
||||
.SelectMany(a => a.SubMenuItems)
|
||||
.OfType<IBaseFolderMenuItem>()
|
||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
||||
@@ -105,10 +118,23 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
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()
|
||||
{
|
||||
// Handle the root folders.
|
||||
foreach (var item in this.OfType<IBaseFolderMenuItem>())
|
||||
foreach (var item in this.OfType<IBaseFolderMenuItem>().ToList())
|
||||
{
|
||||
RecursivelyResetUnreadItemCount(item);
|
||||
}
|
||||
@@ -120,7 +146,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
|
||||
if (baseFolderMenuItem.SubMenuItems == null) return;
|
||||
|
||||
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>())
|
||||
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>().ToList())
|
||||
{
|
||||
RecursivelyResetUnreadItemCount(subMenuItem);
|
||||
}
|
||||
@@ -128,7 +154,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
|
||||
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;
|
||||
|
||||
return value != null;
|
||||
@@ -142,11 +170,12 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
|
||||
{
|
||||
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.
|
||||
accountMenuItem ??= this.OfType<MergedAccountMenuItem>()
|
||||
accountMenuItem ??= rootItems.OfType<MergedAccountMenuItem>()
|
||||
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
|
||||
.OfType<AccountMenuItem>()
|
||||
.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>
|
||||
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(() =>
|
||||
{
|
||||
@@ -192,6 +221,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
{
|
||||
// Check root-level items.
|
||||
var rootItem = this.OfType<IBaseFolderMenuItem>()
|
||||
.Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
|
||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
||||
|
||||
if (rootItem != null)
|
||||
@@ -201,7 +231,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
}
|
||||
|
||||
// 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
|
||||
.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;
|
||||
|
||||
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,
|
||||
MimeMessage Mime,
|
||||
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,
|
||||
bool DeduplicateByServerId = false,
|
||||
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 abstract record CategoryRequestBase(Guid AccountId) : RequestBase<CategorySynchronizerOperation>, ICategoryActionRequest
|
||||
{
|
||||
}
|
||||
|
||||
public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest
|
||||
{
|
||||
public BatchCollection(IEnumerable<TRequestType> collection) : base(collection)
|
||||
|
||||
@@ -170,6 +170,7 @@ public static class SettingsNavigationInfoProvider
|
||||
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
|
||||
WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage,
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
"AccountCreationDialog_Initializing": "initializing",
|
||||
"AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.",
|
||||
"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",
|
||||
"AccountEditDialog_Message": "Account Name",
|
||||
"AccountEditDialog_Title": "Edit Account",
|
||||
@@ -37,6 +45,8 @@
|
||||
"AccountDetailsPage_TabMail": "Mail",
|
||||
"AccountDetailsPage_TabCalendar": "Calendar",
|
||||
"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",
|
||||
"AppCloseBackgroundSynchronizationWarningTitle": "Background Synchronization",
|
||||
"AppCloseStartupLaunchDisabledWarningMessageFirstLine": "Application has not been set to launch on Windows startup.",
|
||||
@@ -57,6 +67,7 @@
|
||||
"BasicIMAPSetupDialog_Password": "Password",
|
||||
"BasicIMAPSetupDialog_Title": "IMAP Account",
|
||||
"Busy": "Busy",
|
||||
"Buttons_Add": "Add",
|
||||
"Buttons_AddAccount": "Add Account",
|
||||
"Buttons_FixAccount": "Fix Account",
|
||||
"Buttons_AddNewAlias": "Add New Alias",
|
||||
@@ -203,6 +214,8 @@
|
||||
"CalendarEventDetails_Organizer": "Organizer",
|
||||
"CalendarEventDetails_People": "People",
|
||||
"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",
|
||||
"CalendarEventDetails_Reminder": "Reminder",
|
||||
"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_Title": "Configure System Folders",
|
||||
"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",
|
||||
"SettingsDeleteAccount_Description": "Delete all e-mails and credentials associated with this account.",
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
@@ -861,10 +878,28 @@
|
||||
"SettingsManageAccountSettings_Title": "Manage Accounts",
|
||||
"SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.",
|
||||
"SettingsManageAliases_Title": "Aliases",
|
||||
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
|
||||
"SettingsMailCategories_Title": "Categories",
|
||||
"SettingsEditAccountDetails_Title": "Edit Account Details",
|
||||
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
|
||||
"EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved",
|
||||
"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_Title": "Manage Link",
|
||||
"SettingsMarkAsRead_Description": "Change what should happen to the selected item.",
|
||||
@@ -1477,11 +1512,13 @@
|
||||
"AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication",
|
||||
"AccountSetup_Step_SavingAccount": "Saving account information",
|
||||
"AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata",
|
||||
"AccountSetup_Step_SyncingCategories": "Synchronizing categories",
|
||||
"AccountSetup_Step_SyncingAliases": "Synchronizing aliases",
|
||||
"AccountSetup_Step_Finalizing": "Finalizing setup",
|
||||
"AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.",
|
||||
"AccountSetup_SuccessMessage": "Your account has been set up successfully!",
|
||||
"AccountSetup_GoBackButton": "Go Back",
|
||||
"AccountSetup_TryAgainButton": "Try Again",
|
||||
"Exception_FailedToSynchronizeCategories": "Failed to synchronize categories",
|
||||
"ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab."
|
||||
}
|
||||
|
||||
@@ -510,7 +510,8 @@ public class MailFetchingTests : IAsyncLifetime
|
||||
preferencesService.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 sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
||||
|
||||
@@ -522,6 +523,7 @@ public class MailFetchingTests : IAsyncLifetime
|
||||
signatureService.Object,
|
||||
mimeFileService.Object,
|
||||
preferencesService.Object,
|
||||
sentMailReceiptService);
|
||||
sentMailReceiptService,
|
||||
mailCategoryService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,8 @@ public class MailThreadingTests : IAsyncLifetime
|
||||
preferencesService.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 sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
||||
|
||||
@@ -281,6 +282,7 @@ public class MailThreadingTests : IAsyncLifetime
|
||||
signatureService.Object,
|
||||
mimeFileService.Object,
|
||||
preferencesService.Object,
|
||||
sentMailReceiptService);
|
||||
sentMailReceiptService,
|
||||
mailCategoryService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,9 @@ public sealed class OutlookSynchronizerRequestSuccessTests
|
||||
|
||||
var authenticator = new Mock<IAuthenticator>(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() =>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
Colors.Clear();
|
||||
Colors.Add(new AppColorViewModel("#0078d7"));
|
||||
Colors.Add(new AppColorViewModel("#00838c"));
|
||||
Colors.Add(new AppColorViewModel("#e3008c"));
|
||||
|
||||
@@ -145,6 +145,8 @@ public static class GoogleIntegratorExtensions
|
||||
Id = Guid.NewGuid(),
|
||||
TimeZone = calendarListEntry.TimeZone,
|
||||
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
|
||||
IsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase),
|
||||
IsSynchronizationEnabled = true,
|
||||
};
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ public static class OutlookIntegratorExtensions
|
||||
Id = Guid.NewGuid(),
|
||||
RemoteCalendarId = outlookCalendar.Id,
|
||||
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
|
||||
IsReadOnly = !outlookCalendar.CanEdit.GetValueOrDefault(true),
|
||||
Name = outlookCalendar.Name,
|
||||
IsSynchronizationEnabled = 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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Handles profile synchronization for the given account.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
private readonly ICalDavClient _calDavClient;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
|
||||
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
||||
|
||||
@@ -41,7 +42,8 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
UnifiedImapSynchronizer unifiedImapSynchronizer,
|
||||
ICalDavClient calDavClient,
|
||||
IAutoDiscoveryService autoDiscoveryService,
|
||||
ICalendarService calendarService)
|
||||
ICalendarService calendarService,
|
||||
IMailCategoryService mailCategoryService)
|
||||
{
|
||||
_outlookChangeProcessor = outlookChangeProcessor;
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
@@ -56,6 +58,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
_calDavClient = calDavClient;
|
||||
_autoDiscoveryService = autoDiscoveryService;
|
||||
_calendarService = calendarService;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
}
|
||||
|
||||
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
||||
@@ -86,7 +89,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
{
|
||||
case Domain.Enums.MailProviderType.Outlook:
|
||||
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:
|
||||
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||
|
||||
@@ -165,6 +165,13 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
if (calendarPreparationRequest == null)
|
||||
return;
|
||||
|
||||
var resolvedCalendar = await ResolveCalendarAsync(calendarPreparationRequest).ConfigureAwait(false);
|
||||
if (resolvedCalendar?.IsReadOnly == true)
|
||||
{
|
||||
_dialogService.ShowReadOnlyCalendarMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
IRequestBase request = calendarPreparationRequest.Operation switch
|
||||
{
|
||||
CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
|
||||
@@ -200,6 +207,21 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
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)
|
||||
{
|
||||
var composeResult = calendarPreparationRequest.ComposeResult
|
||||
@@ -212,6 +234,25 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
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)
|
||||
{
|
||||
// For Outlook accounts, declined events are deleted by the server after synchronization.
|
||||
|
||||
@@ -81,9 +81,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
public override uint BatchModificationSize => 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum messages to fetch per folder during initial sync (1500).
|
||||
/// All messages are downloaded with METADATA ONLY - no raw MIME content.
|
||||
/// Uses Gmail API's Metadata format which includes headers, labels, and snippet but NOT full message body.
|
||||
/// Legacy page size hint kept for compatibility with shared synchronizer contracts.
|
||||
/// Gmail initial sync now downloads all messages inside the selected cutoff window.
|
||||
/// </summary>
|
||||
public override uint InitialMessageDownloadCountPerFolder => 1500;
|
||||
|
||||
@@ -304,13 +303,18 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
/// <summary>
|
||||
/// Performs initial synchronization by downloading messages per-folder.
|
||||
/// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally
|
||||
/// to avoid downloading the same message multiple times (Gmail messages can have multiple labels).
|
||||
/// Messages are filtered by the account's configured initial synchronization cutoff date when present,
|
||||
/// and duplicates are avoided globally because Gmail messages can have multiple labels.
|
||||
/// </summary>
|
||||
private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Track all downloaded message IDs globally to avoid duplicate downloads
|
||||
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);
|
||||
|
||||
@@ -337,7 +341,6 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
var folderDownloaded = 0;
|
||||
string pageToken = null;
|
||||
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
|
||||
|
||||
do
|
||||
{
|
||||
@@ -345,8 +348,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
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.Q = queryText;
|
||||
|
||||
var response = await request.ExecuteAsync(cancellationToken);
|
||||
|
||||
@@ -373,19 +377,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
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)",
|
||||
folder.FolderName, newMessageIds.Count, folderDownloaded);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
_logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded);
|
||||
@@ -762,6 +759,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||
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);
|
||||
}
|
||||
@@ -943,14 +942,17 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteGmailCalendarBackgroundColor(calendarListEntry), accountCalendar);
|
||||
var remoteTextColor = ColorHelpers.GetReadableTextColorHex(remoteBackgroundColor);
|
||||
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 isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase);
|
||||
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
||||
bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase);
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ using MailKit.Search;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
@@ -252,9 +253,20 @@ public class UnifiedImapSynchronizer
|
||||
.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, knownUidStructs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IList<UniqueId> changedUids;
|
||||
|
||||
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);
|
||||
|
||||
@@ -308,25 +320,26 @@ public class UnifiedImapSynchronizer
|
||||
{
|
||||
IList<UniqueId> changedUids;
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
if (isInitialSync)
|
||||
{
|
||||
changedUids = await remoteFolder
|
||||
.SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken)
|
||||
.SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isInitialSync)
|
||||
{
|
||||
changedUids = changedUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
changedUids = await remoteFolder
|
||||
.SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], 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);
|
||||
@@ -367,15 +380,12 @@ public class UnifiedImapSynchronizer
|
||||
|
||||
if (folder.HighestKnownUid == 0)
|
||||
{
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var initialUids = remoteUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
var initialUids = await remoteFolder
|
||||
.SearchAsync(BuildInitialSyncQuery(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
|
||||
{
|
||||
@@ -410,6 +420,22 @@ public class UnifiedImapSynchronizer
|
||||
|
||||
#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)
|
||||
{
|
||||
if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity)
|
||||
|
||||
@@ -41,6 +41,7 @@ using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Category;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Messaging.UI;
|
||||
@@ -55,14 +56,14 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
||||
///
|
||||
/// SYNCHRONIZATION STRATEGY:
|
||||
/// - 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
|
||||
/// - Messages are downloaded with metadata only (no MIME content during sync)
|
||||
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
||||
///
|
||||
/// Key implementation details:
|
||||
/// - 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
|
||||
/// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API
|
||||
/// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata
|
||||
@@ -107,6 +108,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
"ParentFolderId",
|
||||
"InternetMessageId",
|
||||
"InternetMessageHeaders",
|
||||
"Categories",
|
||||
];
|
||||
|
||||
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
|
||||
@@ -116,6 +118,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
||||
private readonly GraphServiceClient _graphClient;
|
||||
private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
private bool _isFolderStructureChanged;
|
||||
|
||||
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,
|
||||
IAuthenticator authenticator,
|
||||
IOutlookChangeProcessor outlookChangeProcessor,
|
||||
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account, WeakReferenceMessenger.Default)
|
||||
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory,
|
||||
IMailCategoryService mailCategoryService) : base(account, WeakReferenceMessenger.Default)
|
||||
{
|
||||
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
|
||||
|
||||
@@ -138,6 +142,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
_outlookChangeProcessor = outlookChangeProcessor;
|
||||
_errorHandlingFactory = errorHandlingFactory;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
}
|
||||
|
||||
#region MS Graph Handlers
|
||||
@@ -343,9 +348,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
// Check if we have a delta token
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -367,27 +372,37 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads mails for initial synchronization using Delta API with 30-day filter.
|
||||
/// Downloads metadata only (no MIME content) for messages received in the last 30 days.
|
||||
/// Downloads mails for initial synchronization using Delta API with the account's configured cutoff date.
|
||||
/// Downloads metadata only (no MIME content) for messages received after that date.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
// Calculate date 6 months ago
|
||||
var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6);
|
||||
var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var referenceDateUtc = Account.CreatedAt ?? DateTime.UtcNow;
|
||||
var initialSynchronizationCutoffDateUtc = Account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc);
|
||||
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) =>
|
||||
{
|
||||
config.QueryParameters.Select = outlookMessageSelectParameters;
|
||||
config.QueryParameters.Orderby = ["receivedDateTime desc"];
|
||||
config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}";
|
||||
|
||||
if (filterDate != null)
|
||||
{
|
||||
config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}";
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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);
|
||||
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (item.Categories != null)
|
||||
{
|
||||
await ReplaceMailAssignmentsAsync(item.Id, item.Categories).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
||||
@@ -1757,6 +1814,87 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
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,
|
||||
MailKit.ITransferProgress transferProgress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -1952,7 +2090,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
for (int i = 0; i < itemCount; 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.
|
||||
var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest);
|
||||
@@ -2100,7 +2240,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|| request is ChangeFlagRequest
|
||||
|| request is MarkReadRequest
|
||||
|| request is ArchiveRequest
|
||||
|| request is MailCategoryAssignmentRequest
|
||||
|| request is RenameFolderRequest
|
||||
|| request is MailCategoryUpdateRequest
|
||||
|| request is MailCategoryDeleteRequest
|
||||
|| request is DeleteFolderRequest
|
||||
|| request is AcceptEventRequest
|
||||
|| request is DeclineEventRequest
|
||||
@@ -2155,6 +2298,26 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
return;
|
||||
|
||||
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)
|
||||
@@ -2357,11 +2520,68 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
// 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.
|
||||
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];
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||
@@ -2664,6 +2884,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
existingLocalCalendar.Name = calendar.Name;
|
||||
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
existingLocalCalendar.IsReadOnly = !calendar.CanEdit.GetValueOrDefault(true);
|
||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||
|
||||
@@ -2702,12 +2923,14 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
var remoteCalendarName = calendar.Name;
|
||||
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteOutlookCalendarBackgroundColor(calendar), accountCalendar);
|
||||
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 isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
||||
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)
|
||||
|
||||
@@ -20,6 +20,7 @@ using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Category;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Messaging.UI;
|
||||
@@ -63,6 +64,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// Only available for Gmail right now.
|
||||
/// </summary>
|
||||
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
|
||||
protected virtual Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
|
||||
break;
|
||||
case MailSynchronizerOperation.UpdateCategories:
|
||||
nativeRequests.AddRange(UpdateCategories(new BatchMailCategoryAssignmentRequest(group.Cast<MailCategoryAssignmentRequest>())));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -221,6 +226,23 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
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();
|
||||
@@ -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)
|
||||
{
|
||||
await Task.Delay(maxExecutionDelay);
|
||||
@@ -526,6 +572,16 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// <returns>New synchronization options with minimal HTTP effort.</returns>
|
||||
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
|
||||
.Where(a => a is 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>> 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>> 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
|
||||
|
||||
|
||||
@@ -2,24 +2,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Core.ViewModels.Data;
|
||||
using Wino.Mail.ViewModels.Data;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
using Wino.Messaging.Client.Navigation;
|
||||
|
||||
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?.ProviderType}.png";
|
||||
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; } =
|
||||
[
|
||||
@@ -160,6 +169,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
|
||||
private void EditAliases()
|
||||
=> 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]
|
||||
private void EditImapCalDavSettings()
|
||||
=> Messenger.Send(new BreadcrumbNavigationRequested(
|
||||
@@ -363,13 +376,15 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
|
||||
OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount));
|
||||
OnPropertyChanged(nameof(ProviderIconPath));
|
||||
OnPropertyChanged(nameof(Address));
|
||||
OnPropertyChanged(nameof(IsInitialSynchronizationSummaryVisible));
|
||||
OnPropertyChanged(nameof(InitialSynchronizationSummary));
|
||||
}
|
||||
|
||||
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
base.OnPropertyChanged(e);
|
||||
|
||||
if (!IsActive || !isLoaded) return;
|
||||
if (!isLoaded) return;
|
||||
|
||||
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_SavingAccount });
|
||||
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_SyncingAliases });
|
||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
|
||||
@@ -170,6 +174,7 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
|
||||
try
|
||||
{
|
||||
CustomServerInformation customServerInformation = null;
|
||||
var accountCreatedAt = DateTime.UtcNow;
|
||||
|
||||
// Build account in memory
|
||||
_createdAccount = new MailAccount
|
||||
@@ -179,6 +184,8 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
|
||||
Name = WizardContext.AccountName,
|
||||
SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider,
|
||||
AccountColorHex = WizardContext.AccountColorHex,
|
||||
CreatedAt = accountCreatedAt,
|
||||
InitialSynchronizationRange = WizardContext.SelectedInitialSynchronizationRange,
|
||||
IsCalendarAccessGranted = true
|
||||
};
|
||||
|
||||
@@ -226,6 +233,16 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
|
||||
throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
|
||||
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
|
||||
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
|
||||
if (_createdAccount.IsCalendarAccessGranted)
|
||||
|
||||
@@ -19,6 +19,7 @@ using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models;
|
||||
using Wino.Core.Domain.Models.Launch;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Services;
|
||||
@@ -159,6 +160,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
public readonly IPreferencesService PreferencesService;
|
||||
public readonly IContactService ContactService;
|
||||
public readonly ISmimeCertificateService _smimeCertificateService;
|
||||
private readonly IShareActivationService _shareActivationService;
|
||||
|
||||
public ComposePageViewModel(IMailDialogService dialogService,
|
||||
IMailService mailService,
|
||||
@@ -172,7 +174,8 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
IContactService contactService,
|
||||
IFontService fontService,
|
||||
IPreferencesService preferencesService,
|
||||
ISmimeCertificateService smimeCertificateService)
|
||||
ISmimeCertificateService smimeCertificateService,
|
||||
IShareActivationService shareActivationService)
|
||||
{
|
||||
NativeAppService = nativeAppService;
|
||||
ContactService = contactService;
|
||||
@@ -188,6 +191,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
_emailTemplateService = emailTemplateService;
|
||||
_worker = worker;
|
||||
_smimeCertificateService = smimeCertificateService;
|
||||
_shareActivationService = shareActivationService;
|
||||
|
||||
foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress))
|
||||
{
|
||||
@@ -752,6 +756,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
await LoadAddressInfoAsync(replyingMime.Bcc, BCCItems);
|
||||
|
||||
LoadAttachments();
|
||||
ApplyPendingSharedAttachments();
|
||||
|
||||
if (replyingMime.Cc.Any() || replyingMime.Bcc.Any())
|
||||
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)
|
||||
{
|
||||
foreach (var item in list)
|
||||
|
||||
@@ -18,6 +18,9 @@ public partial class WelcomeWizardContext : ObservableObject
|
||||
[ObservableProperty]
|
||||
public partial string AccountColorHex { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial InitialSynchronizationRange SelectedInitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths;
|
||||
|
||||
// Special IMAP fields (iCloud/Yahoo)
|
||||
[ObservableProperty]
|
||||
public partial string DisplayName { get; set; }
|
||||
@@ -62,7 +65,8 @@ public partial class WelcomeWizardContext : ObservableObject
|
||||
SelectedProvider.Type,
|
||||
AccountName,
|
||||
BuildSpecialImapProviderDetails(),
|
||||
AccountColorHex);
|
||||
AccountColorHex,
|
||||
SelectedInitialSynchronizationRange);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
@@ -70,6 +74,7 @@ public partial class WelcomeWizardContext : ObservableObject
|
||||
SelectedProvider = null;
|
||||
AccountName = null;
|
||||
AccountColorHex = null;
|
||||
SelectedInitialSynchronizationRange = InitialSynchronizationRange.SixMonths;
|
||||
DisplayName = null;
|
||||
EmailAddress = null;
|
||||
AppSpecificPassword = null;
|
||||
|
||||
@@ -16,7 +16,6 @@ using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Mail.ViewModels.Data;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
using Wino.Messaging.Client.Navigation;
|
||||
using Wino.Messaging.Server;
|
||||
|
||||
@@ -319,7 +318,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
try
|
||||
{
|
||||
var minimalSettings = BuildMinimalSettingsOrThrow();
|
||||
await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false);
|
||||
await AutoDiscoverAndApplySettingsAsync(minimalSettings);
|
||||
|
||||
_mailDialogService.InfoBarMessage(
|
||||
Translator.IMAPSetupDialog_ValidationSuccess_Title,
|
||||
@@ -399,7 +398,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureImapSettingsPreparedAsync().ConfigureAwait(false);
|
||||
await EnsureImapSettingsPreparedAsync();
|
||||
|
||||
var serverInformation = BuildServerInformation();
|
||||
|
||||
@@ -407,12 +406,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
ValidateImapSettings(serverInformation);
|
||||
ValidateCalendarModeSpecificSettings(serverInformation);
|
||||
|
||||
await ValidateImapConnectivityAsync(serverInformation).ConfigureAwait(false);
|
||||
await ValidateImapConnectivityAsync(serverInformation);
|
||||
IsImapValidationSucceeded = true;
|
||||
|
||||
if (serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav)
|
||||
{
|
||||
await ValidateCalDavConnectivityAsync(serverInformation).ConfigureAwait(false);
|
||||
await ValidateCalDavConnectivityAsync(serverInformation);
|
||||
IsCalDavValidationSucceeded = true;
|
||||
}
|
||||
else
|
||||
@@ -432,7 +431,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveEditFlowAsync(serverInformation).ConfigureAwait(false);
|
||||
await SaveEditFlowAsync(serverInformation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -654,7 +653,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
return;
|
||||
|
||||
var minimalSettings = BuildMinimalSettingsOrThrow();
|
||||
await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false);
|
||||
await AutoDiscoverAndApplySettingsAsync(minimalSettings);
|
||||
|
||||
if (!HasCompleteImapSettings())
|
||||
throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed);
|
||||
@@ -676,22 +675,25 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
if (serverInformation == null)
|
||||
throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed);
|
||||
|
||||
ApplyServerInformation(serverInformation);
|
||||
|
||||
if (IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav)
|
||||
await ExecuteUIThread(async () =>
|
||||
{
|
||||
var discoveredCalDavUri = await _autoDiscoveryService.DiscoverCalDavServiceUriAsync(minimalSettings.Email).ConfigureAwait(false);
|
||||
if (discoveredCalDavUri != null)
|
||||
ApplyServerInformation(serverInformation);
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -995,7 +997,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
||||
SpecialImapProvider = _editingSpecialImapProvider,
|
||||
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)
|
||||
return false;
|
||||
|
||||
@@ -15,8 +15,9 @@ using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.MenuItems;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
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.Navigation;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -72,6 +73,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
|
||||
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
private readonly IConfigurationService _configurationService;
|
||||
private readonly IStartupBehaviorService _startupBehaviorService;
|
||||
private readonly IAccountService _accountService;
|
||||
@@ -84,6 +86,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
private readonly IMimeFileService _mimeFileService;
|
||||
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
|
||||
private readonly IStoreUpdateService _storeUpdateService;
|
||||
private readonly IShareActivationService _shareActivationService;
|
||||
|
||||
private readonly INativeAppService _nativeAppService;
|
||||
private readonly IMailService _mailService;
|
||||
@@ -97,6 +100,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
IMimeFileService mimeFileService,
|
||||
INativeAppService nativeAppService,
|
||||
IMailService mailService,
|
||||
IMailCategoryService mailCategoryService,
|
||||
IAccountService accountService,
|
||||
IContextMenuItemService contextMenuItemService,
|
||||
IStoreRatingService storeRatingService,
|
||||
@@ -109,7 +113,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
IConfigurationService configurationService,
|
||||
IStartupBehaviorService startupBehaviorService,
|
||||
IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
|
||||
IStoreUpdateService storeUpdateService)
|
||||
IStoreUpdateService storeUpdateService,
|
||||
IShareActivationService shareActivationService)
|
||||
{
|
||||
StatePersistenceService = statePersistanceService;
|
||||
|
||||
@@ -122,6 +127,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
_mimeFileService = mimeFileService;
|
||||
_nativeAppService = nativeAppService;
|
||||
_mailService = mailService;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
_folderService = folderService;
|
||||
_accountService = accountService;
|
||||
_contextMenuItemService = contextMenuItemService;
|
||||
@@ -131,6 +137,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
_winoRequestDelegator = winoRequestDelegator;
|
||||
_webView2RuntimeValidatorService = webView2RuntimeValidatorService;
|
||||
_storeUpdateService = storeUpdateService;
|
||||
_shareActivationService = shareActivationService;
|
||||
}
|
||||
|
||||
protected override void OnDispatcherAssigned()
|
||||
@@ -274,6 +281,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
}
|
||||
|
||||
await ProcessLaunchOptionsAsync();
|
||||
await HandlePendingShareRequestAsync();
|
||||
await ValidateWebView2RuntimeAsync();
|
||||
|
||||
if (shouldRunStartupFlows && !Debugger.IsAttached)
|
||||
@@ -716,7 +724,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
{
|
||||
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.
|
||||
// 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.
|
||||
// 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.
|
||||
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 unreadCategoryCountResult = await _mailCategoryService.GetUnreadCategoryCountResultsAsync(accountIds).ConfigureAwait(false);
|
||||
|
||||
// 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.
|
||||
@@ -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.
|
||||
await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
|
||||
}
|
||||
@@ -943,6 +984,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
}
|
||||
|
||||
public async Task CreateNewMailForAsync(MailAccount account)
|
||||
=> await CreateNewMailForAsync(account, null);
|
||||
|
||||
public async Task CreateNewMailForAsync(MailAccount account, MailShareRequest shareRequest)
|
||||
{
|
||||
if (account == null) return;
|
||||
|
||||
@@ -974,6 +1018,11 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
|
||||
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);
|
||||
await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest);
|
||||
}
|
||||
@@ -1034,6 +1083,35 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
||||
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()
|
||||
{
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ using Wino.Core.Domain.Models.Menus;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Domain.Models.Reader;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Mail.ViewModels.Collections;
|
||||
using Wino.Mail.ViewModels.Data;
|
||||
@@ -77,6 +78,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
private readonly INotificationBuilder _notificationBuilder;
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IContextMenuItemService _contextMenuItemService;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||
private readonly IKeyPressService _keyPressService;
|
||||
private readonly IWinoLogger _winoLogger;
|
||||
@@ -156,6 +158,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
||||
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
|
||||
[NotifyPropertyChangedFor(nameof(IsCategoryView))]
|
||||
[NotifyPropertyChangedFor(nameof(IsSyncButtonVisible))]
|
||||
public partial IBaseFolderMenuItem ActiveFolder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -172,6 +176,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
INotificationBuilder notificationBuilder,
|
||||
IFolderService folderService,
|
||||
IContextMenuItemService contextMenuItemService,
|
||||
IMailCategoryService mailCategoryService,
|
||||
IWinoRequestDelegator winoRequestDelegator,
|
||||
IKeyPressService keyPressService,
|
||||
IPreferencesService preferencesService,
|
||||
@@ -185,6 +190,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
_mimeFileService = mimeFileService;
|
||||
_folderService = folderService;
|
||||
_contextMenuItemService = contextMenuItemService;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
_winoRequestDelegator = winoRequestDelegator;
|
||||
_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 IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
|
||||
public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
|
||||
public bool IsSyncButtonVisible => !IsCategoryView;
|
||||
|
||||
public string SelectedMessageText => IsDragInProgress
|
||||
? string.Format(Translator.MailsDragging, DraggingItemsCount)
|
||||
@@ -396,9 +404,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsCategoryView)
|
||||
{
|
||||
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
|
||||
}
|
||||
// Merged folders don't support focused feature.
|
||||
|
||||
if (ActiveFolder is IMergedAccountFolderMenuItem)
|
||||
else if (ActiveFolder is IMergedAccountFolderMenuItem)
|
||||
{
|
||||
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
|
||||
}
|
||||
@@ -545,7 +556,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
[RelayCommand]
|
||||
private async Task EnableFolderSynchronizationAsync()
|
||||
{
|
||||
if (ActiveFolder == null) return;
|
||||
if (ActiveFolder == null || IsCategoryView) return;
|
||||
|
||||
foreach (var folder in ActiveFolder.HandlingFolders)
|
||||
{
|
||||
@@ -561,13 +572,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
Debug.WriteLine("Loading more...");
|
||||
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
||||
|
||||
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
|
||||
SelectedFilterOption.Type,
|
||||
SelectedSortingOption.Type,
|
||||
PreferencesService.IsThreadingEnabled,
|
||||
SelectedFolderPivot.IsFocused,
|
||||
IsInSearchMode ? SearchQuery : string.Empty,
|
||||
MailCollection.MailCopyIdHashSet);
|
||||
var initializationOptions = CreateInitializationOptions(
|
||||
IsInSearchMode ? SearchQuery : string.Empty,
|
||||
MailCollection.MailCopyIdHashSet);
|
||||
|
||||
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
|
||||
|
||||
@@ -674,6 +681,60 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<MailItemViewModel> contextMailItems)
|
||||
=> _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)
|
||||
{
|
||||
bool condition = mailItem.IsRead
|
||||
@@ -691,7 +752,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
|
||||
|
||||
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)
|
||||
=> 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]
|
||||
private async Task PerformOnlineSearchAsync()
|
||||
{
|
||||
@@ -1218,15 +1311,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
}
|
||||
}
|
||||
|
||||
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
|
||||
SelectedFilterOption.Type,
|
||||
SelectedSortingOption.Type,
|
||||
PreferencesService.IsThreadingEnabled,
|
||||
SelectedFolderPivot.IsFocused,
|
||||
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
||||
MailCollection.MailCopyIdHashSet,
|
||||
onlineSearchItems,
|
||||
DeduplicateByServerId: isDoingOnlineSearch);
|
||||
var initializationOptions = CreateInitializationOptions(
|
||||
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
||||
MailCollection.MailCopyIdHashSet,
|
||||
onlineSearchItems,
|
||||
isDoingOnlineSearch);
|
||||
|
||||
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.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<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]
|
||||
public partial IProviderDetail SelectedProvider { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsColorSelected))]
|
||||
public partial AppColorViewModel SelectedColor { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsInitialSynchronizationWarningVisible))]
|
||||
public partial InitialSynchronizationRangeOption SelectedInitialSynchronizationRange { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string AccountName { get; set; }
|
||||
|
||||
@@ -36,6 +50,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
|
||||
public partial bool CanProceed { get; set; }
|
||||
|
||||
public bool IsColorSelected => SelectedColor != null;
|
||||
public bool IsInitialSynchronizationWarningVisible => SelectedInitialSynchronizationRange?.IsEverything == true;
|
||||
|
||||
public ProviderSelectionPageViewModel(
|
||||
IProviderService providerService,
|
||||
@@ -45,6 +60,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
|
||||
_providerService = providerService;
|
||||
_themeService = themeService;
|
||||
WizardContext = wizardContext;
|
||||
SelectedInitialSynchronizationRange = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
|
||||
}
|
||||
|
||||
public override void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||
@@ -56,6 +72,10 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
|
||||
.Select(hex => new AppColorViewModel(hex))
|
||||
.ToList();
|
||||
|
||||
SelectedInitialSynchronizationRange = InitialSynchronizationRanges
|
||||
.FirstOrDefault(option => option.Range == WizardContext.SelectedInitialSynchronizationRange)
|
||||
?? InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
|
||||
|
||||
// Restore from wizard context if navigating back
|
||||
if (WizardContext.SelectedProvider != null)
|
||||
{
|
||||
@@ -71,9 +91,12 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
|
||||
Validate();
|
||||
}
|
||||
|
||||
partial void OnSelectedProviderChanged(IProviderDetail value) => Validate();
|
||||
partial void OnSelectedProviderChanged(IProviderDetail value)
|
||||
{
|
||||
Validate();
|
||||
}
|
||||
|
||||
partial void OnAccountNameChanged(string value) => Validate();
|
||||
partial void OnSelectedColorChanged(AppColorViewModel value) => OnPropertyChanged(nameof(IsColorSelected));
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearColor() => SelectedColor = null;
|
||||
@@ -92,6 +115,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel
|
||||
WizardContext.SelectedProvider = SelectedProvider;
|
||||
WizardContext.AccountName = AccountName?.Trim();
|
||||
WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty;
|
||||
WizardContext.SelectedInitialSynchronizationRange = SelectedInitialSynchronizationRange?.Range ?? InitialSynchronizationRange.SixMonths;
|
||||
|
||||
if (WizardContext.IsGenericImap)
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@ using Microsoft.Windows.AppLifecycle;
|
||||
using Microsoft.Windows.AppNotifications;
|
||||
using MimeKit.Cryptography;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.ApplicationModel.DataTransfer.ShareTarget;
|
||||
using Windows.Storage;
|
||||
using Wino.Calendar.ViewModels;
|
||||
using Wino.Calendar.ViewModels.Interfaces;
|
||||
using Wino.Core;
|
||||
@@ -22,6 +25,8 @@ using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
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.Navigation;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -30,6 +35,7 @@ using Wino.Mail.Services;
|
||||
using Wino.Mail.ViewModels;
|
||||
using Wino.Mail.ViewModels.Data;
|
||||
using Wino.Mail.WinUI.Activation;
|
||||
using Wino.Mail.WinUI.Extensions;
|
||||
using Wino.Mail.WinUI.Interfaces;
|
||||
using Wino.Mail.WinUI.Models;
|
||||
using Wino.Mail.WinUI.Services;
|
||||
@@ -61,6 +67,7 @@ public partial class App : WinoApplication,
|
||||
private bool _isExiting;
|
||||
private bool _activationInfrastructureInitialized;
|
||||
private int _initialNotificationActivationHandled;
|
||||
private int _initialShareActivationHandled;
|
||||
private CancellationTokenSource? _autoSynchronizationLoopCts;
|
||||
private readonly SemaphoreSlim _autoSynchronizationSemaphore = 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(WinoAccountManagementPageViewModel));
|
||||
services.AddTransient(typeof(AliasManagementPageViewModel));
|
||||
services.AddTransient(typeof(MailCategoryManagementPageViewModel));
|
||||
services.AddTransient(typeof(ContactsPageViewModel));
|
||||
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
|
||||
services.AddTransient(typeof(EmailTemplatesPageViewModel));
|
||||
@@ -446,12 +454,26 @@ public partial class App : WinoApplication,
|
||||
private bool TryMarkInitialNotificationActivationHandled()
|
||||
=> 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)
|
||||
{
|
||||
base.OnLaunched(args);
|
||||
|
||||
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;
|
||||
if (!IsStartupTaskLaunch() && !hasAnyAccount)
|
||||
{
|
||||
@@ -635,6 +657,89 @@ public partial class App : WinoApplication,
|
||||
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)
|
||||
{
|
||||
var windowManager = Services.GetRequiredService<IWinoWindowManager>();
|
||||
@@ -1217,6 +1322,7 @@ public partial class App : WinoApplication,
|
||||
return synchronizationType switch
|
||||
{
|
||||
MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases,
|
||||
MailSynchronizationType.Categories => Translator.Exception_FailedToSynchronizeCategories,
|
||||
MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation,
|
||||
_ => Translator.Exception_FailedToSynchronizeFolders
|
||||
};
|
||||
@@ -1446,6 +1552,11 @@ public partial class App : WinoApplication,
|
||||
LogActivation($"Processing redirected notification activation. Arguments: {toastArgs.Argument}");
|
||||
_ = HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput);
|
||||
}
|
||||
else if (args.Kind == ExtendedActivationKind.ShareTarget)
|
||||
{
|
||||
LogActivation("Processing redirected share target activation.");
|
||||
await HandleShareTargetActivationAsync(args, activateWindow: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
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 &&
|
||||
activationArgs.Data is IFileActivatedEventArgs fileArgs)
|
||||
{
|
||||
|
||||
@@ -296,12 +296,18 @@ public sealed partial class WebViewEditorControl : Control, IDisposable, IEditor
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusControlAsWell)
|
||||
{
|
||||
Focus(FocusState.Programmatic);
|
||||
_chromium.Focus(FocusState.Programmatic);
|
||||
_chromium.Focus(FocusState.Keyboard);
|
||||
}
|
||||
|
||||
await _chromium.ExecuteScriptSafeAsync("focusEditor();");
|
||||
|
||||
if (focusControlAsWell)
|
||||
{
|
||||
_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 Visibility="{x:Bind IsProviderSelectionVisible, Mode=OneWay}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
@@ -96,6 +97,48 @@
|
||||
|
||||
</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
|
||||
Grid.Row="2"
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Wino.Mail.WinUI.Dialogs;
|
||||
|
||||
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.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 SelectedCalendarModeIndexProperty = DependencyProperty.Register(nameof(SelectedCalendarModeIndex), typeof(int), typeof(NewAccountDialog), new PropertyMetadata(0));
|
||||
|
||||
|
||||
public AppColorViewModel? SelectedColor
|
||||
{
|
||||
get { return (AppColorViewModel?)GetValue(SelectedColorProperty); }
|
||||
@@ -49,7 +48,6 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
set { SetValue(SelectedMailProviderProperty, value); }
|
||||
}
|
||||
|
||||
|
||||
public bool IsProviderSelectionVisible
|
||||
{
|
||||
get { return (bool)GetValue(IsProviderSelectionVisibleProperty); }
|
||||
@@ -63,10 +61,16 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
}
|
||||
|
||||
// List of available mail providers for now.
|
||||
|
||||
public List<IProviderDetail> Providers { 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; } =
|
||||
[
|
||||
Translator.ImapCalDavSettingsPage_CalendarModeCalDav,
|
||||
@@ -74,7 +78,6 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
Translator.ImapCalDavSettingsPage_CalendarModeDisabled
|
||||
];
|
||||
|
||||
|
||||
public AccountCreationDialogResult? Result = null;
|
||||
|
||||
public NewAccountDialog()
|
||||
@@ -85,6 +88,8 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
AvailableColors = themeService.Select(a => new AppColorViewModel(a)).ToList();
|
||||
|
||||
UpdateSelectedColor();
|
||||
InitialSynchronizationComboBox.SelectedItem = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths);
|
||||
UpdateInitialSynchronizationState();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -116,9 +134,11 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
if (SelectedMailProvider == null)
|
||||
return;
|
||||
|
||||
var initialSynchronizationRange = GetInitialSynchronizationRange();
|
||||
|
||||
if (IsSpecialImapServerPartVisible)
|
||||
{
|
||||
// Special imap detail input.
|
||||
// Special IMAP detail input.
|
||||
var calendarSupportMode = SelectedCalendarModeIndex switch
|
||||
{
|
||||
1 => ImapCalendarSupportMode.LocalOnly,
|
||||
@@ -132,7 +152,12 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
DisplayNameTextBox.Text.Trim(),
|
||||
SelectedMailProvider.SpecialImapProvider,
|
||||
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();
|
||||
|
||||
return;
|
||||
@@ -140,11 +165,11 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
|
||||
Validate();
|
||||
|
||||
if (IsSecondaryButtonEnabled)
|
||||
if (IsPrimaryButtonEnabled)
|
||||
{
|
||||
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;
|
||||
|
||||
IsProviderSelectionVisible = false;
|
||||
@@ -154,7 +179,12 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -167,6 +197,7 @@ public sealed partial class NewAccountDialog : ContentDialog
|
||||
{
|
||||
ValidateCreateButton();
|
||||
ValidateNames();
|
||||
UpdateInitialSynchronizationState();
|
||||
}
|
||||
|
||||
// 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 InitialSynchronizationSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
=> UpdateInitialSynchronizationState();
|
||||
|
||||
private async void AppSpecificHelpButtonClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedMailProvider == null ||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<Identity
|
||||
Name="58272BurakKSE.WinoMailPreview"
|
||||
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"/>
|
||||
|
||||
@@ -97,6 +97,15 @@
|
||||
</uap:Protocol>
|
||||
</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 -->
|
||||
<uap:Extension Category="windows.fileTypeAssociation">
|
||||
<uap:FileTypeAssociation Name="eml">
|
||||
|
||||
@@ -26,6 +26,7 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
|
||||
public DataTemplate NewMailTemplate { get; set; } = null!;
|
||||
public DataTemplate CalendarNewEventTemplate { get; set; } = null!;
|
||||
public DataTemplate CategoryItemsTemplate { get; set; } = null!;
|
||||
public DataTemplate MergedCategoryItemsTemplate { get; set; } = null!;
|
||||
public DataTemplate FixAuthenticationIssueTemplate { get; set; } = null!;
|
||||
public DataTemplate FixMissingFolderConfigTemplate { get; set; } = null!;
|
||||
|
||||
@@ -58,6 +59,10 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
|
||||
return MergedAccountTemplate;
|
||||
else if (item is MergedAccountMoreFolderMenuItem)
|
||||
return MergedAccountMoreExpansionItemTemplate;
|
||||
else if (item is MailCategoryMenuItem)
|
||||
return CategoryItemsTemplate;
|
||||
else if (item is MergedMailCategoryMenuItem)
|
||||
return MergedCategoryItemsTemplate;
|
||||
else if (item is MergedAccountFolderMenuItem)
|
||||
return MergedAccountFolderTemplate;
|
||||
else if (item is FolderMenuItem)
|
||||
|
||||
@@ -6,7 +6,6 @@ using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
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.Calendar;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Mail.WinUI.Extensions;
|
||||
using Wino.Mail.WinUI.Services;
|
||||
using Wino.Dialogs;
|
||||
using Wino.Mail.Dialogs;
|
||||
using Wino.Mail.WinUI.Extensions;
|
||||
using Wino.Mail.WinUI.Services;
|
||||
using Wino.Messaging.Server;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
@@ -40,6 +40,12 @@ public class DialogService : DialogServiceBase, IMailDialogService
|
||||
_winoAccountDataSyncService = winoAccountDataSyncService;
|
||||
}
|
||||
|
||||
public void ShowReadOnlyCalendarMessage()
|
||||
=> InfoBarMessage(
|
||||
Translator.CalendarReadOnly_Title,
|
||||
Translator.CalendarReadOnly_Message,
|
||||
InfoBarMessageType.Warning);
|
||||
|
||||
public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync()
|
||||
{
|
||||
var createAccountAliasDialog = new CreateAccountAliasDialog()
|
||||
@@ -52,6 +58,19 @@ public class DialogService : DialogServiceBase, IMailDialogService
|
||||
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)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -82,6 +82,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
||||
WinoPage.ReadComposePanePage,
|
||||
WinoPage.AppPreferencesPage,
|
||||
WinoPage.AliasManagementPage,
|
||||
WinoPage.MailCategoryManagementPage,
|
||||
WinoPage.ImapCalDavSettingsPage,
|
||||
WinoPage.KeyboardShortcutsPage,
|
||||
WinoPage.SignatureAndEncryptionPage,
|
||||
@@ -150,6 +151,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
||||
WinoPage.SettingOptionsPage => typeof(SettingOptionsPage),
|
||||
WinoPage.AppPreferencesPage => typeof(AppPreferencesPage),
|
||||
WinoPage.AliasManagementPage => typeof(AliasManagementPage),
|
||||
WinoPage.MailCategoryManagementPage => typeof(MailCategoryManagementPage),
|
||||
WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage),
|
||||
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
|
||||
WinoPage.ContactsPage => typeof(ContactsPage),
|
||||
|
||||
@@ -587,6 +587,31 @@ public class NewThemeService : INewThemeService
|
||||
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)
|
||||
{
|
||||
var fileContent = await FileIO.ReadTextAsync(file);
|
||||
@@ -594,6 +619,16 @@ public class NewThemeService : INewThemeService
|
||||
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()
|
||||
=> uiSettings.GetColorValue(UIColorType.Accent).ToHex();
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
@@ -37,7 +36,6 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
|
||||
_configurationService.Set(propertyName, value ?? string.Empty);
|
||||
|
||||
OnPropertyChanged(propertyName);
|
||||
Debug.WriteLine($"PreferencesService -> {propertyName}:{value?.ToString()}");
|
||||
}
|
||||
|
||||
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:abstract="using:Wino.Views.Abstract"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:helpers="using:Wino.Helpers"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel
|
||||
<Grid
|
||||
MaxWidth="980"
|
||||
Padding="36,28,36,36"
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="24">
|
||||
|
||||
<!-- Page Header -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource BodyTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.SubtitleText, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.ProviderHint, 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}" />
|
||||
HorizontalAlignment="Center">
|
||||
<StackPanel Spacing="20">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource BodyTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.SubtitleText, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.ProviderHint, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ViewModel.HasProviderHint, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Advanced Setup Card -->
|
||||
<Border
|
||||
Padding="20"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsBasicSetupSelected), Mode=OneWay}">
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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.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>
|
||||
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.CalendarSectionDescriptionText, Mode=OneWay}"
|
||||
Text="{x:Bind ViewModel.SelectedCalendarSupportDescription, Mode=OneWay}"
|
||||
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>
|
||||
</Border>
|
||||
|
||||
<ComboBox
|
||||
HorizontalAlignment="Stretch"
|
||||
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
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.SelectedCalendarSupportDescription, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
|
||||
<HyperlinkButton
|
||||
Command="{x:Bind ViewModel.ShowLocalCalendarExplanationCommand}"
|
||||
Content="{x:Bind ViewModel.LocalCalendarLearnMoreText, Mode=OneWay}"
|
||||
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}" />
|
||||
</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>
|
||||
<Grid Margin="0,4,0,0" ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Command="{x:Bind ViewModel.CancelCommand}"
|
||||
Content="{x:Bind ViewModel.CancelButtonText, Mode=OneWay}" />
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Command="{x:Bind ViewModel.SaveCommand}"
|
||||
Content="{x:Bind ViewModel.SaveButtonText, Mode=OneWay}"
|
||||
Style="{ThemeResource AccentButtonStyle}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</abstract:ImapCalDavSettingsPageAbstract>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Wino.Views.Abstract;
|
||||
|
||||
namespace Wino.Views;
|
||||
@@ -10,22 +8,4 @@ public sealed partial class ImapCalDavSettingsPage : ImapCalDavSettingsPageAbstr
|
||||
{
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
IPopoutClient,
|
||||
IRecipient<ApplicationThemeChanged>
|
||||
{
|
||||
private const int InitialFocusRetryCount = 3;
|
||||
|
||||
private bool _isPoppedOut;
|
||||
private bool _isInitialFocusHandled;
|
||||
|
||||
public bool SupportsPopOut => !_isPoppedOut;
|
||||
public event EventHandler<PopOutRequestedEventArgs>? PopOutRequested;
|
||||
@@ -307,7 +310,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
_disposables.Add(WebViewEditor);
|
||||
|
||||
ViewModel.GetHTMLBodyFunction = WebViewEditor.GetHtmlBodyAsync;
|
||||
ViewModel.RenderHtmlBodyAsyncFunc = WebViewEditor.RenderHtmlAsync;
|
||||
ViewModel.RenderHtmlBodyAsyncFunc = RenderComposeHtmlAsync;
|
||||
}
|
||||
|
||||
private void ShowCCBCCClicked(object sender, RoutedEventArgs e)
|
||||
@@ -373,9 +376,10 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
{
|
||||
if (draftMailItemViewModel == null || !draftMailItemViewModel.IsDraft) return;
|
||||
|
||||
// Reset the initial focus flag so ToBox gets focus for the new draft.
|
||||
isInitialFocusHandled = false;
|
||||
// Reset the initial focus flag for the newly loaded draft.
|
||||
_isInitialFocusHandled = false;
|
||||
await ViewModel.RefreshDraftAsync(draftMailItemViewModel);
|
||||
await ApplyInitialFocusAsync();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
ToBox.Focus(FocusState.Programmatic);
|
||||
if (ShouldFocusRecipients())
|
||||
{
|
||||
ToBox.Focus(FocusState.Programmatic);
|
||||
}
|
||||
}
|
||||
|
||||
private void CCBBCGotFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!isInitialFocusHandled)
|
||||
if (ShouldFocusRecipients() && !_isInitialFocusHandled)
|
||||
{
|
||||
isInitialFocusHandled = true;
|
||||
_isInitialFocusHandled = true;
|
||||
ToBox.Focus(FocusState.Programmatic);
|
||||
}
|
||||
}
|
||||
@@ -555,4 +557,64 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,8 @@
|
||||
BorderThickness="0"
|
||||
Command="{x:Bind ViewModel.SyncFolderCommand}"
|
||||
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" />
|
||||
</Button>
|
||||
<ToggleButton
|
||||
@@ -290,9 +291,11 @@
|
||||
</StackPanel>
|
||||
|
||||
<InfoBar
|
||||
x:Name="SyncDisabledInfoBar"
|
||||
Title="{x:Bind domain:Translator.InfoBarTitle_SynchronizationDisabledFolder}"
|
||||
Grid.Row="0"
|
||||
Grid.ColumnSpan="3"
|
||||
x:Load="{x:Bind ViewModel.IsSyncButtonVisible, Mode=OneWay}"
|
||||
IsClosable="True"
|
||||
IsOpen="{x:Bind ViewModel.IsFolderSynchronizationEnabled, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}"
|
||||
Message="{x:Bind domain:Translator.InfoBarMessage_SynchronizationDisabledFolder}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Collections;
|
||||
@@ -18,6 +18,7 @@ using Windows.Foundation;
|
||||
using Windows.System;
|
||||
using Wino.Controls;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
@@ -27,7 +28,6 @@ using Wino.Mail.ViewModels.Data;
|
||||
using Wino.Mail.ViewModels.Messages;
|
||||
using Wino.Mail.WinUI;
|
||||
using Wino.Mail.WinUI.Controls.ListView;
|
||||
using Wino.Mail.WinUI.Extensions;
|
||||
using Wino.Mail.WinUI.Helpers;
|
||||
using Wino.Mail.WinUI.Interfaces;
|
||||
using Wino.Mail.WinUI.Models;
|
||||
@@ -246,14 +246,27 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
// Default to all selected items.
|
||||
targetItems = ViewModel.MailCollection.SelectedItems;
|
||||
var availableActions = ViewModel.GetAvailableMailActions(targetItems);
|
||||
var (availableCategories, assignedCategoryIds) = await ViewModel.GetAvailableCategoriesAsync(targetItems);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -296,14 +309,68 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<MailOperationMenuItem> GetMailOperationFromFlyoutAsync(IEnumerable<MailOperationMenuItem> availableActions,
|
||||
UIElement showAtElement,
|
||||
double x,
|
||||
double y)
|
||||
private async Task<MailContextAction?> GetMailContextActionFromFlyoutAsync(
|
||||
IEnumerable<MailOperationMenuItem> availableActions,
|
||||
IReadOnlyList<MailCategory> availableCategories,
|
||||
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()
|
||||
{
|
||||
@@ -314,6 +381,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
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)
|
||||
{
|
||||
await ViewModel.MailCollection.UnselectAllAsync();
|
||||
|
||||
@@ -10,15 +10,12 @@
|
||||
xmlns:helpers="using:Wino.Helpers"
|
||||
xmlns:interfaces="using:Wino.Core.Domain.Interfaces"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollViewer
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ScrollViewer HorizontalAlignment="Center" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel
|
||||
MaxWidth="480"
|
||||
Margin="0,24,0,24"
|
||||
Margin="0,12"
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="20">
|
||||
|
||||
@@ -76,6 +73,50 @@
|
||||
</Button>
|
||||
</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 -->
|
||||
<ItemsView
|
||||
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="" />
|
||||
</ToggleButton>
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Click="EditCategoryClicked"
|
||||
Tag="{x:Bind}">
|
||||
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Click="DeleteCategoryClicked"
|
||||
Tag="{x:Bind}">
|
||||
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
<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}"
|
||||
MediumTemplate="{StaticResource MediumDisplayModePreviewTemplate}"
|
||||
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>
|
||||
|
||||
<ScrollViewer>
|
||||
@@ -147,7 +209,7 @@
|
||||
<!-- TODO: Group by custom/wino -->
|
||||
<GridView
|
||||
toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal"
|
||||
ItemTemplateSelector="{StaticResource AppThemePreviewTemplateSelector}"
|
||||
ItemTemplateSelector="{StaticResource PersonalizationAppThemePreviewTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.AppThemes, Mode=OneWay}"
|
||||
SelectedItem="{x:Bind ViewModel.SelectedAppTheme, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -240,6 +240,93 @@
|
||||
</coreControls:WinoNavigationViewItem>
|
||||
</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="" />
|
||||
</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="" />
|
||||
</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 -->
|
||||
<DataTemplate x:Key="MergedAccountTemplate" x:DataType="menu:MergedAccountMenuItem">
|
||||
<controls:AccountNavigationItem
|
||||
@@ -393,6 +480,7 @@
|
||||
|
||||
<coreSelectors:NavigationMenuTemplateSelector
|
||||
x:Key="NavigationMenuTemplateSelector"
|
||||
CategoryItemsTemplate="{StaticResource MailCategoryMenuTemplate}"
|
||||
ClickableAccountMenuTemplate="{StaticResource ClickableAccountMenuTemplate}"
|
||||
FixAuthenticationIssueTemplate="{StaticResource FixAuthenticationIssueTemplate}"
|
||||
FixMissingFolderConfigTemplate="{StaticResource FixMissingFolderConfig}"
|
||||
@@ -400,6 +488,7 @@
|
||||
MergedAccountFolderTemplate="{StaticResource MergedAccountFolderMenuItemTemplate}"
|
||||
MergedAccountMoreExpansionItemTemplate="{StaticResource MergedAccountMoreFolderItemTemplate}"
|
||||
MergedAccountTemplate="{StaticResource MergedAccountTemplate}"
|
||||
MergedCategoryItemsTemplate="{StaticResource MergedMailCategoryMenuTemplate}"
|
||||
NewMailTemplate="{StaticResource CreateNewMailTemplate}"
|
||||
RatingItemTemplate="{StaticResource RatingItemTemplate}"
|
||||
SeperatorTemplate="{StaticResource SeperatorTemplate}"
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<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>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<!-- Single instancing -->
|
||||
|
||||
@@ -475,7 +475,7 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
|
||||
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)
|
||||
@@ -590,6 +590,11 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
{
|
||||
Guard.IsNotNull(account);
|
||||
|
||||
if (!account.CreatedAt.HasValue)
|
||||
{
|
||||
account.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var accountCount = await Connection.Table<MailAccount>().CountAsync();
|
||||
|
||||
// If there are no accounts before this one, set it as startup account.
|
||||
|
||||
@@ -49,6 +49,8 @@ public class DatabaseService : IDatabaseService
|
||||
{
|
||||
await Task.WhenAll(
|
||||
Connection.CreateTableAsync<MailCopy>(),
|
||||
Connection.CreateTableAsync<MailCategory>(),
|
||||
Connection.CreateTableAsync<MailCategoryAssignment>(),
|
||||
Connection.CreateTableAsync<MailItemFolder>(),
|
||||
Connection.CreateTableAsync<MailAccount>(),
|
||||
Connection.CreateTableAsync<AccountContact>(),
|
||||
@@ -79,6 +81,22 @@ public class DatabaseService : IDatabaseService
|
||||
{
|
||||
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);
|
||||
|
||||
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.HighestKnownUid)))
|
||||
@@ -152,6 +170,13 @@ public class DatabaseService : IDatabaseService
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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_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_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_RemoteFolderId ON MailItemFolder(MailAccountId, RemoteFolderId)").ConfigureAwait(false);
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace Wino.Services;
|
||||
public class FolderService : BaseDatabaseService, IFolderService
|
||||
{
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
private readonly ILogger _logger = Log.ForContext<FolderService>();
|
||||
|
||||
private readonly SpecialFolderType[] gmailCategoryFolderTypes =
|
||||
@@ -34,9 +35,11 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
];
|
||||
|
||||
public FolderService(IDatabaseService databaseService,
|
||||
IAccountService accountService) : base(databaseService)
|
||||
IAccountService accountService,
|
||||
IMailCategoryService mailCategoryService) : base(databaseService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
}
|
||||
|
||||
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.
|
||||
if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem);
|
||||
|
||||
@@ -309,9 +315,62 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
preparedFolderMenuItems.Add(menuItem);
|
||||
}
|
||||
|
||||
var favoriteCategories = await GetMergedFavoriteCategoryMenuItemsAsync(holdingAccounts, allAccountFolders, mergedAccountFolderMenuItem.Parameter).ConfigureAwait(false);
|
||||
preparedFolderMenuItems.AddRange(favoriteCategories);
|
||||
|
||||
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)
|
||||
{
|
||||
var allSpecialTypesExceptOther = Enum.GetValues<SpecialFolderType>().Cast<SpecialFolderType>().Where(a => a != SpecialFolderType.Other).ToList();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
private readonly IMimeFileService _mimeFileService;
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private readonly ISentMailReceiptService _sentMailReceiptService;
|
||||
private readonly IMailCategoryService _mailCategoryService;
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<MailService>();
|
||||
|
||||
@@ -42,7 +43,8 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
ISignatureService signatureService,
|
||||
IMimeFileService mimeFileService,
|
||||
IPreferencesService preferencesService,
|
||||
ISentMailReceiptService sentMailReceiptService) : base(databaseService)
|
||||
ISentMailReceiptService sentMailReceiptService,
|
||||
IMailCategoryService mailCategoryService) : base(databaseService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_contactService = contactService;
|
||||
@@ -51,6 +53,7 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
_mimeFileService = mimeFileService;
|
||||
_preferencesService = preferencesService;
|
||||
_sentMailReceiptService = sentMailReceiptService;
|
||||
_mailCategoryService = mailCategoryService;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 parameters = new List<object>();
|
||||
@@ -181,6 +186,13 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
whereClauses.Add($"MailCopy.FolderId IN ({folderPlaceholders})");
|
||||
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
|
||||
switch (options.FilterType)
|
||||
{
|
||||
@@ -338,7 +350,7 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
{
|
||||
List<MailCopy> mails;
|
||||
|
||||
if (options.PreFetchMailCopies != null)
|
||||
if (options.PreFetchMailCopies != null && !options.IsCategoryView)
|
||||
{
|
||||
mails = ApplyOptionsToPreFetchedMails(options);
|
||||
}
|
||||
@@ -398,7 +410,7 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
mails.RemoveAll(m => m.AssignedAccount == null || m.AssignedFolder == null);
|
||||
await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false);
|
||||
|
||||
if (!options.CreateThreads || mails.Count == 0)
|
||||
if (!options.CreateThreads || mails.Count == 0 || options.IsCategoryView)
|
||||
return [.. mails];
|
||||
|
||||
// 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);
|
||||
|
||||
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.
|
||||
var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false);
|
||||
@@ -965,6 +978,7 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
mailCopy.UniqueId = existingCopyItem.UniqueId;
|
||||
|
||||
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
|
||||
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
||||
await _sentMailReceiptService.TrackSentMailAsync(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 ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
||||
await _sentMailReceiptService.TrackSentMailAsync(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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
|
||||
|
||||
@@ -13,12 +13,14 @@ public static class ServicesContainerSetup
|
||||
services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>();
|
||||
services.AddSingleton<IWinoLogger, WinoLogger>();
|
||||
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
|
||||
services.AddSingleton<IShareActivationService, ShareActivationService>();
|
||||
services.AddSingleton<IMimeFileService, MimeFileService>();
|
||||
services.AddSingleton<ICalendarIcsFileService, CalendarIcsFileService>();
|
||||
services.AddTransient<IMimeStorageService, MimeStorageService>();
|
||||
|
||||
services.AddTransient<ICalendarService, CalendarService>();
|
||||
services.AddTransient<IMailService, MailService>();
|
||||
services.AddTransient<IMailCategoryService, MailCategoryService>();
|
||||
services.AddTransient<ISentMailReceiptService, SentMailReceiptService>();
|
||||
services.AddTransient<IFolderService, FolderService>();
|
||||
services.AddTransient<IAccountService, AccountService>();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user