Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f7e0236a | |||
| e13aaadc78 | |||
| 94675eee9a | |||
| b3360ecd76 | |||
| 4ca26cb131 | |||
| 7c0f8d4bb4 | |||
| 999d8cde73 | |||
| 1a1d69be56 | |||
| c2540926f4 | |||
| 9424fd9a16 | |||
| 89b48d3ac4 | |||
| 0bcc7a7647 | |||
| 260e1ab935 | |||
| ccf7c0607b | |||
| b8ce7e7422 | |||
| 1365e42fd7 | |||
| 0f160545ab | |||
| 8481a5c7cd | |||
| d32745fd67 | |||
| 470b2b8638 | |||
| 7e1731f4dc | |||
| aac9f9fec3 | |||
| cf8fff8ef1 | |||
| 0610096b78 |
@@ -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
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
name: PR WinUI Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-winui:
|
|
||||||
name: Build project (${{ matrix.platform }})
|
|
||||||
if: github.event.pull_request.draft == false
|
|
||||||
runs-on: windows-latest
|
|
||||||
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: x86
|
|
||||||
rid: win-x86
|
|
||||||
- platform: x64
|
|
||||||
rid: win-x64
|
|
||||||
- platform: ARM64
|
|
||||||
rid: win-arm64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET SDK
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 10.0.x
|
|
||||||
source-url: https://nuget.pkg.github.com/bkaankose/index.json
|
|
||||||
env:
|
|
||||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Restore WinUI project dependencies
|
|
||||||
run: dotnet restore Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configfile nuget.config -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }}
|
|
||||||
|
|
||||||
- name: Build WinUI project
|
|
||||||
run: dotnet build Wino.Mail.WinUI/Wino.Mail.WinUI.csproj --configuration Release --no-restore -p:Platform=${{ matrix.platform }} -p:RuntimeIdentifier=${{ matrix.rid }} -p:GenerateAppxPackageOnBuild=false -p:AppxPackageSigningEnabled=false
|
|
||||||
|
|
||||||
core-tests:
|
|
||||||
name: Run Core tests
|
|
||||||
if: github.event.pull_request.draft == false
|
|
||||||
runs-on: windows-latest
|
|
||||||
continue-on-error: ${{ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET SDK
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 10.0.x
|
|
||||||
source-url: https://nuget.pkg.github.com/bkaankose/index.json
|
|
||||||
env:
|
|
||||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Restore Core test projects
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj" | ForEach-Object { $_.FullName }
|
|
||||||
if (-not $coreTests) {
|
|
||||||
throw "No Core test projects were found."
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($project in $coreTests) {
|
|
||||||
dotnet restore $project --configfile nuget.config
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Run Core test projects
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
New-Item -ItemType Directory -Path TestResults -Force | Out-Null
|
|
||||||
$coreTests = Get-ChildItem -Path . -Recurse -Filter "*Core*.Tests.csproj"
|
|
||||||
if (-not $coreTests) {
|
|
||||||
throw "No Core test projects were found."
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($project in $coreTests) {
|
|
||||||
$name = $project.BaseName
|
|
||||||
dotnet test $project.FullName --configuration Release --no-restore --verbosity normal --logger "trx;LogFileName=$name.trx" --results-directory TestResults
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Upload Core test result artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: core-test-results
|
|
||||||
path: TestResults/*.trx
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Publish Core test report
|
|
||||||
if: always()
|
|
||||||
uses: EnricoMi/publish-unit-test-result-action/windows@v2
|
|
||||||
with:
|
|
||||||
trx_files: TestResults/*.trx
|
|
||||||
check_name: Core test results
|
|
||||||
|
|
||||||
enforce-for-non-maintainers:
|
|
||||||
name: Enforce required checks (non-maintainers)
|
|
||||||
if: github.event.pull_request.draft == false && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- build-winui
|
|
||||||
- core-tests
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Fail when build or tests fail for non-maintainers
|
|
||||||
if: needs.build-winui.result != 'success' || needs.core-tests.result != 'success'
|
|
||||||
run: |
|
|
||||||
echo "WinUI build and Core tests must pass for non-maintainer pull requests."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Confirm build and test success for non-maintainers
|
|
||||||
run: echo "WinUI build and Core tests passed."
|
|
||||||
@@ -150,6 +150,8 @@ private string searchQuery = string.Empty;
|
|||||||
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
|
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
|
||||||
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
|
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
|
||||||
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
||||||
|
- `ConfigureAwait(false)` continues execution on a background thread. Any UI-bound property change, `INotifyPropertyChanged` notification, collection mutation, or similar UI-facing state update after that point must be marshaled back with `ExecuteUIThread(...)` or the appropriate dispatcher call, otherwise the app can crash.
|
||||||
|
- Messenger messages are raised from a background thread by default, while UI control event handlers such as `Button.Click` start on the UI thread. Be deliberate when combining dispatcher usage with `ConfigureAwait(false)` so post-await UI updates always return to the UI thread.
|
||||||
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
|
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
|
||||||
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
|
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
|
||||||
- Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML.
|
- Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML.
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
|||||||
|
|
||||||
var activationContext = parameters as ShellModeActivationContext;
|
var activationContext = parameters as ShellModeActivationContext;
|
||||||
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
|
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
|
||||||
|
var navigationArgs = activationContext?.Parameter as CalendarPageNavigationArgs;
|
||||||
|
|
||||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||||
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
|
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
|
||||||
@@ -178,19 +179,14 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
|||||||
await InitializeAccountCalendarsAsync();
|
await InitializeAccountCalendarsAsync();
|
||||||
ValidateConfiguredNewEventCalendar();
|
ValidateConfiguredNewEventCalendar();
|
||||||
|
|
||||||
if (activationContext?.Parameter is CalendarItemTarget calendarItemTarget)
|
if (navigationArgs != null)
|
||||||
{
|
{
|
||||||
NavigationService.Navigate(WinoPage.EventDetailsPage, calendarItemTarget);
|
NavigationService.Navigate(WinoPage.CalendarPage, navigationArgs);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
else if (shouldRunStartupFlows || _calendarPageViewModel.CurrentVisibleRange == null)
|
||||||
if (activationContext?.Parameter is CalendarPageNavigationArgs calendarPageNavigationArgs)
|
|
||||||
{
|
{
|
||||||
NavigationService.Navigate(WinoPage.CalendarPage, calendarPageNavigationArgs);
|
TodayClicked();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TodayClicked();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (SelectedQuickEventAccountCalendar == null ||
|
if (SelectedQuickEventAccountCalendar == null ||
|
||||||
|
SelectedQuickEventAccountCalendar.IsReadOnly ||
|
||||||
SelectedQuickEventDate == null ||
|
SelectedQuickEventDate == null ||
|
||||||
string.IsNullOrWhiteSpace(EventName) ||
|
string.IsNullOrWhiteSpace(EventName) ||
|
||||||
string.IsNullOrWhiteSpace(SelectedStartTimeString) ||
|
string.IsNullOrWhiteSpace(SelectedStartTimeString) ||
|
||||||
@@ -204,6 +205,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null)
|
if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (DisplayDetailsCalendarItemViewModel.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent)
|
if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent)
|
||||||
{
|
{
|
||||||
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
@@ -460,6 +467,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))]
|
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))]
|
||||||
private async Task SaveQuickEventAsync()
|
private async Task SaveQuickEventAsync()
|
||||||
{
|
{
|
||||||
|
if (SelectedQuickEventAccountCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
|
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
|
||||||
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
|
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
|
||||||
var composeResult = new CalendarEventComposeResult
|
var composeResult = new CalendarEventComposeResult
|
||||||
@@ -553,6 +566,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (calendarItem.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var normalizedTargetStart = calendarItem.IsAllDayEvent
|
var normalizedTargetStart = calendarItem.IsAllDayEvent
|
||||||
? targetStart.Date
|
? targetStart.Date
|
||||||
: targetStart;
|
: targetStart;
|
||||||
@@ -615,7 +634,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ApplyDisplayRequestAsync(CalendarDisplayRequest request, bool forceReload = false)
|
public async Task ApplyDisplayRequestAsync(CalendarDisplayRequest request, bool forceReload = false, CalendarItemTarget pendingTarget = null)
|
||||||
{
|
{
|
||||||
var lifetimeVersion = CurrentPageLifetimeVersion;
|
var lifetimeVersion = CurrentPageLifetimeVersion;
|
||||||
var hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false);
|
var hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false);
|
||||||
@@ -699,6 +718,11 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
await _notificationBuilder.ClearCalendarTaskbarBadgeAsync().ConfigureAwait(false);
|
await _notificationBuilder.ClearCalendarTaskbarBadgeAsync().ConfigureAwait(false);
|
||||||
_isCalendarBadgeClearedForPageLifetime = true;
|
_isCalendarBadgeClearedForPageLifetime = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadSucceeded && pendingTarget != null && IsPageActive(lifetimeVersion))
|
||||||
|
{
|
||||||
|
await NavigateToPendingCalendarTargetAsync(pendingTarget).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ReloadCurrentVisibleRangeAsync()
|
public Task ReloadCurrentVisibleRangeAsync()
|
||||||
@@ -726,6 +750,31 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
NavigateEvent(new CalendarItemViewModel(calendarItem), CalendarEventTargetType.Single);
|
NavigateEvent(new CalendarItemViewModel(calendarItem), CalendarEventTargetType.Single);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToPendingCalendarTargetAsync(CalendarItemTarget target)
|
||||||
|
{
|
||||||
|
CalendarItemViewModel calendarItemViewModel = null;
|
||||||
|
|
||||||
|
if (_loadedCalendarItems.TryGetValue(target.Item.Id, out var loadedCalendarItemViewModel))
|
||||||
|
{
|
||||||
|
calendarItemViewModel = loadedCalendarItemViewModel;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var targetItem = await _calendarService.GetCalendarItemTargetAsync(target).ConfigureAwait(false);
|
||||||
|
if (targetItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
targetItem.AssignedCalendar ??= AccountCalendarStateService.ActiveCalendars.FirstOrDefault(calendar => calendar.Id == targetItem.CalendarId);
|
||||||
|
calendarItemViewModel = new CalendarItemViewModel(targetItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
DisplayDetailsCalendarItemViewModel = calendarItemViewModel;
|
||||||
|
NavigateEvent(calendarItemViewModel, target.TargetType);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<CalendarItemViewModel>> LoadCalendarItemsAsync(DateRange loadedDateWindow, long lifetimeVersion)
|
private async Task<List<CalendarItemViewModel>> LoadCalendarItemsAsync(DateRange loadedDateWindow, long lifetimeVersion)
|
||||||
{
|
{
|
||||||
var loadedItems = new Dictionary<Guid, CalendarItemViewModel>();
|
var loadedItems = new Dictionary<Guid, CalendarItemViewModel>();
|
||||||
@@ -800,7 +849,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async void Receive(LoadCalendarMessage message)
|
public async void Receive(LoadCalendarMessage message)
|
||||||
=> await ApplyDisplayRequestAsync(message.DisplayRequest, message.ForceReload);
|
=> await ApplyDisplayRequestAsync(message.DisplayRequest, message.ForceReload, message.PendingTarget);
|
||||||
|
|
||||||
public void Receive(CalendarSettingsUpdatedMessage message)
|
public void Receive(CalendarSettingsUpdatedMessage message)
|
||||||
{
|
{
|
||||||
@@ -1195,6 +1244,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
if (targetItem == null)
|
if (targetItem == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetItem.IsRecurringParent)
|
if (targetItem.IsRecurringParent)
|
||||||
{
|
{
|
||||||
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
@@ -1221,6 +1276,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
if (targetItem == null || targetItem.ShowAs == showAs)
|
if (targetItem == null || targetItem.ShowAs == showAs)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var originalItem = await _calendarService.GetCalendarItemAsync(targetItem.Id).ConfigureAwait(false);
|
var originalItem = await _calendarService.GetCalendarItemAsync(targetItem.Id).ConfigureAwait(false);
|
||||||
var attendees = await _calendarService.GetAttendeesAsync(targetItem.Id).ConfigureAwait(false);
|
var attendees = await _calendarService.GetAttendeesAsync(targetItem.Id).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -1245,6 +1306,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
if (targetItem == null)
|
if (targetItem == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (targetItem.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var operation = responseStatus switch
|
var operation = responseStatus switch
|
||||||
{
|
{
|
||||||
CalendarItemStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent,
|
CalendarItemStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent,
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend
|
|||||||
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
|
set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsReadOnly
|
||||||
|
{
|
||||||
|
get => AccountCalendar.IsReadOnly;
|
||||||
|
set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i);
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSynchronizationEnabled
|
public bool IsSynchronizationEnabled
|
||||||
{
|
{
|
||||||
get => AccountCalendar.IsSynchronizationEnabled;
|
get => AccountCalendar.IsSynchronizationEnabled;
|
||||||
|
|||||||
@@ -440,6 +440,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (CurrentEvent == null) return;
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -506,6 +511,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
private async Task DeleteAsync()
|
private async Task DeleteAsync()
|
||||||
{
|
{
|
||||||
if (CurrentEvent == null) return;
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the event is a master recurring event, ask for confirmation
|
// If the event is a master recurring event, ask for confirmation
|
||||||
if (CurrentEvent.IsRecurringParent)
|
if (CurrentEvent.IsRecurringParent)
|
||||||
@@ -610,6 +620,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
|||||||
private async Task SendRsvpResponse(AttendeeStatus status)
|
private async Task SendRsvpResponse(AttendeeStatus status)
|
||||||
{
|
{
|
||||||
if (CurrentEvent == null) return;
|
if (CurrentEvent == null) return;
|
||||||
|
if (CurrentEvent.AssignedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ public static class Constants
|
|||||||
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
|
public const string ToastCalendarJoinOnlineAction = nameof(ToastCalendarJoinOnlineAction);
|
||||||
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
|
public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction);
|
||||||
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
|
public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId);
|
||||||
public const string ToastCalendarSnoozeDurationMinutesKey = nameof(ToastCalendarSnoozeDurationMinutesKey);
|
|
||||||
public const string ToastModeKey = nameof(ToastModeKey);
|
public const string ToastModeKey = nameof(ToastModeKey);
|
||||||
public const string ToastModeMail = nameof(ToastModeMail);
|
public const string ToastModeMail = nameof(ToastModeMail);
|
||||||
public const string ToastModeCalendar = nameof(ToastModeCalendar);
|
public const string ToastModeCalendar = nameof(ToastModeCalendar);
|
||||||
|
public const string ToastDismissActionKey = nameof(ToastDismissActionKey);
|
||||||
public const string ToastStoreUpdateActionKey = nameof(ToastStoreUpdateActionKey);
|
public const string ToastStoreUpdateActionKey = nameof(ToastStoreUpdateActionKey);
|
||||||
public const string ToastStoreUpdateActionInstall = nameof(ToastStoreUpdateActionInstall);
|
public const string ToastStoreUpdateActionInstall = nameof(ToastStoreUpdateActionInstall);
|
||||||
public const string ClientLogFile = "Client_.log";
|
public const string ClientLogFile = "Client_.log";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class AccountCalendar : IAccountCalendar
|
|||||||
public string SynchronizationDeltaToken { get; set; }
|
public string SynchronizationDeltaToken { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public bool IsPrimary { get; set; }
|
public bool IsPrimary { get; set; }
|
||||||
|
public bool IsReadOnly { get; set; }
|
||||||
public bool IsSynchronizationEnabled { get; set; } = true;
|
public bool IsSynchronizationEnabled { get; set; } = true;
|
||||||
public bool IsExtended { get; set; } = true;
|
public bool IsExtended { get; set; } = true;
|
||||||
public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy;
|
public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using SQLite;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Entities.Mail;
|
||||||
|
|
||||||
|
public class MailCategory
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid MailAccountId { get; set; }
|
||||||
|
|
||||||
|
public string RemoteId { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
public bool IsFavorite { get; set; }
|
||||||
|
|
||||||
|
public string BackgroundColorHex { get; set; }
|
||||||
|
|
||||||
|
public string TextColorHex { get; set; }
|
||||||
|
|
||||||
|
public MailCategorySource Source { get; set; } = MailCategorySource.Local;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Entities.Mail;
|
||||||
|
|
||||||
|
public class MailCategoryAssignment
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid MailCategoryId { get; set; }
|
||||||
|
|
||||||
|
public Guid MailCopyUniqueId { get; set; }
|
||||||
|
}
|
||||||
@@ -132,5 +132,10 @@ public class MailAccount
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
|
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the account can perform category definition sync type.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook;
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
public enum MailCategorySource
|
||||||
|
{
|
||||||
|
Local,
|
||||||
|
Outlook
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public enum MailSynchronizerOperation
|
|||||||
AlwaysMoveTo,
|
AlwaysMoveTo,
|
||||||
MoveToFocused,
|
MoveToFocused,
|
||||||
Archive,
|
Archive,
|
||||||
|
UpdateCategories,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum FolderSynchronizerOperation
|
public enum FolderSynchronizerOperation
|
||||||
@@ -35,6 +36,13 @@ public enum CalendarSynchronizerOperation
|
|||||||
TentativeEvent,
|
TentativeEvent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum CategorySynchronizerOperation
|
||||||
|
{
|
||||||
|
CreateCategory,
|
||||||
|
UpdateCategory,
|
||||||
|
DeleteCategory,
|
||||||
|
}
|
||||||
|
|
||||||
// UI requests
|
// UI requests
|
||||||
public enum MailOperation
|
public enum MailOperation
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
public enum MailSynchronizationType
|
public enum MailSynchronizationType
|
||||||
{
|
{
|
||||||
UpdateProfile, // Only update profile information
|
UpdateProfile, // Only update profile information
|
||||||
|
Categories, // Only update mail categories
|
||||||
ExecuteRequests, // Run the queued requests, and then synchronize if needed.
|
ExecuteRequests, // Run the queued requests, and then synchronize if needed.
|
||||||
FoldersOnly, // Only synchronize folder metadata.
|
FoldersOnly, // Only synchronize folder metadata.
|
||||||
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
|
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ public enum WinoPage
|
|||||||
AboutPage,
|
AboutPage,
|
||||||
PersonalizationPage,
|
PersonalizationPage,
|
||||||
MessageListPage,
|
MessageListPage,
|
||||||
|
MailNotificationSettingsPage,
|
||||||
MailListPage,
|
MailListPage,
|
||||||
ReadComposePanePage,
|
ReadComposePanePage,
|
||||||
AppPreferencesPage,
|
AppPreferencesPage,
|
||||||
SettingOptionsPage,
|
SettingOptionsPage,
|
||||||
AliasManagementPage,
|
AliasManagementPage,
|
||||||
|
MailCategoryManagementPage,
|
||||||
ImapCalDavSettingsPage,
|
ImapCalDavSettingsPage,
|
||||||
KeyboardShortcutsPage,
|
KeyboardShortcutsPage,
|
||||||
CalendarPage,
|
CalendarPage,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public interface IAccountCalendar
|
|||||||
string TextColorHex { get; set; }
|
string TextColorHex { get; set; }
|
||||||
string BackgroundColorHex { get; set; }
|
string BackgroundColorHex { get; set; }
|
||||||
bool IsPrimary { get; set; }
|
bool IsPrimary { get; set; }
|
||||||
|
bool IsReadOnly { get; set; }
|
||||||
bool IsSynchronizationEnabled { get; set; }
|
bool IsSynchronizationEnabled { get; set; }
|
||||||
Guid AccountId { get; set; }
|
Guid AccountId { get; set; }
|
||||||
string RemoteCalendarId { get; set; }
|
string RemoteCalendarId { get; set; }
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ public interface IFolderMenuItem : IBaseFolderMenuItem
|
|||||||
|
|
||||||
public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { }
|
public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { }
|
||||||
|
|
||||||
|
public interface IMailCategoryMenuItem : IBaseFolderMenuItem
|
||||||
|
{
|
||||||
|
Entities.Mail.MailCategory MailCategory { get; }
|
||||||
|
string TextColorHex { get; }
|
||||||
|
string BackgroundColorHex { get; }
|
||||||
|
bool HasTextColor { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IMergedMailCategoryMenuItem : IBaseFolderMenuItem
|
||||||
|
{
|
||||||
|
IReadOnlyList<Entities.Mail.MailCategory> Categories { get; }
|
||||||
|
string TextColorHex { get; }
|
||||||
|
string BackgroundColorHex { get; }
|
||||||
|
bool HasTextColor { get; }
|
||||||
|
}
|
||||||
|
|
||||||
public interface IBaseFolderMenuItem : IMenuItem
|
public interface IBaseFolderMenuItem : IMenuItem
|
||||||
{
|
{
|
||||||
string FolderName { get; }
|
string FolderName { get; }
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IMailCategoryService
|
||||||
|
{
|
||||||
|
Task<List<MailCategory>> GetCategoriesAsync(Guid accountId);
|
||||||
|
Task<List<MailCategory>> GetFavoriteCategoriesAsync(Guid accountId);
|
||||||
|
Task<MailCategory> GetCategoryAsync(Guid categoryId);
|
||||||
|
Task<bool> CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null);
|
||||||
|
Task<MailCategory> CreateCategoryAsync(MailCategory category);
|
||||||
|
Task UpdateCategoryAsync(MailCategory category);
|
||||||
|
Task DeleteCategoryAsync(Guid categoryId);
|
||||||
|
Task DeleteCategoriesAsync(Guid accountId);
|
||||||
|
Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite);
|
||||||
|
Task UpdateRemoteIdAsync(Guid categoryId, string remoteId);
|
||||||
|
Task ReplaceCategoriesAsync(Guid accountId, IEnumerable<MailCategory> categories);
|
||||||
|
Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable<string> categoryNames);
|
||||||
|
Task AssignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
|
Task UnassignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
|
Task<List<MailCategory>> GetCategoriesForMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
|
Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
|
Task<List<string>> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId);
|
||||||
|
Task<List<MailCopy>> GetMailCopiesForCategoryAsync(Guid categoryId);
|
||||||
|
Task<List<UnreadCategoryCountResult>> GetUnreadCategoryCountResultsAsync(IEnumerable<Guid> accountIds);
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ using Wino.Core.Domain.Models;
|
|||||||
using Wino.Core.Domain.Models.Accounts;
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
using Wino.Core.Domain.Models.Folders;
|
using Wino.Core.Domain.Models.Folders;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Interfaces;
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
public interface IMailDialogService : IDialogServiceBase
|
public interface IMailDialogService : IDialogServiceBase
|
||||||
{
|
{
|
||||||
|
void ShowReadOnlyCalendarMessage();
|
||||||
Task<bool> ShowHardDeleteConfirmationAsync();
|
Task<bool> ShowHardDeleteConfirmationAsync();
|
||||||
Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService);
|
Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService);
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ public interface IMailDialogService : IDialogServiceBase
|
|||||||
/// <returns>Created alias model if not canceled.</returns>
|
/// <returns>Created alias model if not canceled.</returns>
|
||||||
Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync();
|
Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Presents a dialog to the user for mail category creation/modification.
|
||||||
|
/// </summary>
|
||||||
|
#pragma warning disable CS8625
|
||||||
|
Task<MailCategoryDialogResult> ShowEditMailCategoryDialogAsync(MailCategory category = null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Presents a dialog to the user to show email source.
|
/// Presents a dialog to the user to show email source.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -192,6 +192,16 @@ public interface IPreferencesService : INotifyPropertyChanged
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Guid? StartupEntityId { get; set; }
|
Guid? StartupEntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setting: First action button displayed on mail toast notifications.
|
||||||
|
/// </summary>
|
||||||
|
MailOperation FirstMailNotificationAction { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setting: Second action button displayed on mail toast notifications.
|
||||||
|
/// </summary>
|
||||||
|
MailOperation SecondMailNotificationAction { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -72,3 +72,9 @@ public interface ICalendarActionRequest : IRequestBase
|
|||||||
Guid? LocalCalendarItemId { get; }
|
Guid? LocalCalendarItemId { get; }
|
||||||
CalendarSynchronizerOperation Operation { get; }
|
CalendarSynchronizerOperation Operation { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface ICategoryActionRequest : IRequestBase
|
||||||
|
{
|
||||||
|
Guid AccountId { get; }
|
||||||
|
CategorySynchronizerOperation Operation { get; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ public interface ISynchronizationManager
|
|||||||
Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
|
Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles category synchronization for the given account.
|
||||||
|
/// </summary>
|
||||||
|
Task<MailSynchronizationResult> SynchronizeCategoriesAsync(Guid accountId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles profile synchronization for the given account.
|
/// Handles profile synchronization for the given account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Threading.Tasks;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
using Wino.Core.Domain.Models.Folders;
|
using Wino.Core.Domain.Models.Folders;
|
||||||
using Wino.Core.Domain.Models.MailItem;
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
@@ -36,4 +38,9 @@ public interface IWinoRequestDelegator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="calendarOperationPreparationRequest">Calendar preparation request.</param>
|
/// <param name="calendarOperationPreparationRequest">Calendar preparation request.</param>
|
||||||
Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest);
|
Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues pre-built requests for a single account and triggers synchronization.
|
||||||
|
/// </summary>
|
||||||
|
Task ExecuteAsync(Guid accountId, IEnumerable<IRequestBase> requests);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Folders;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.MenuItems;
|
||||||
|
|
||||||
|
public partial class MailCategoryMenuItem : MenuItemBase<MailCategory, IMenuItem>, IFolderMenuItem, IMailCategoryMenuItem
|
||||||
|
{
|
||||||
|
private IReadOnlyList<IMailItemFolder> _handlingFolders;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int unreadItemCount;
|
||||||
|
|
||||||
|
public MailCategoryMenuItem(MailCategory category, MailAccount parentAccount, IEnumerable<IMailItemFolder> handlingFolders, IMenuItem parentMenuItem)
|
||||||
|
: base(category, category.Id, parentMenuItem)
|
||||||
|
{
|
||||||
|
ParentAccount = parentAccount;
|
||||||
|
_handlingFolders = handlingFolders?.ToList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FolderName => Parameter.Name;
|
||||||
|
public bool IsSynchronizationEnabled => false;
|
||||||
|
public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
|
||||||
|
public IEnumerable<IMailItemFolder> HandlingFolders => _handlingFolders;
|
||||||
|
public new ObservableCollection<IMenuItem> SubMenuItems { get; } = [];
|
||||||
|
public bool IsMoveTarget => true;
|
||||||
|
public bool IsSticky => false;
|
||||||
|
public bool IsSystemFolder => false;
|
||||||
|
public bool ShowUnreadCount => true;
|
||||||
|
public string AssignedAccountName => ParentAccount?.Name;
|
||||||
|
public MailAccount ParentAccount { get; private set; }
|
||||||
|
public string TextColorHex => Parameter.TextColorHex;
|
||||||
|
public string BackgroundColorHex => Parameter.BackgroundColorHex;
|
||||||
|
public bool HasTextColor => !string.IsNullOrWhiteSpace(Parameter.TextColorHex);
|
||||||
|
public MailCategory MailCategory => Parameter;
|
||||||
|
|
||||||
|
public void UpdateFolder(IMailItemFolder folder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateParentAccounnt(MailAccount account) => ParentAccount = account;
|
||||||
|
}
|
||||||
@@ -22,11 +22,13 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
public IEnumerable<IAccountMenuItem> GetAllAccountMenuItems()
|
public IEnumerable<IAccountMenuItem> GetAllAccountMenuItems()
|
||||||
{
|
{
|
||||||
foreach (var item in this)
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
foreach (var item in rootItems)
|
||||||
{
|
{
|
||||||
if (item is MergedAccountMenuItem mergedAccountMenuItem)
|
if (item is MergedAccountMenuItem mergedAccountMenuItem)
|
||||||
{
|
{
|
||||||
foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType<IAccountMenuItem>())
|
foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType<IAccountMenuItem>().ToList())
|
||||||
{
|
{
|
||||||
yield return singleItem;
|
yield return singleItem;
|
||||||
}
|
}
|
||||||
@@ -40,9 +42,11 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
public IEnumerable<IBaseFolderMenuItem> GetAllFolderMenuItems(Guid folderId)
|
public IEnumerable<IBaseFolderMenuItem> GetAllFolderMenuItems(Guid folderId)
|
||||||
{
|
{
|
||||||
foreach (var item in this)
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
foreach (var item in rootItems)
|
||||||
{
|
{
|
||||||
if (item is IBaseFolderMenuItem folderMenuItem)
|
if (item is IBaseFolderMenuItem folderMenuItem && item is not IMailCategoryMenuItem && item is not IMergedMailCategoryMenuItem)
|
||||||
{
|
{
|
||||||
if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId))
|
if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId))
|
||||||
{
|
{
|
||||||
@@ -50,7 +54,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
}
|
}
|
||||||
else if (folderMenuItem.SubMenuItems.Any())
|
else if (folderMenuItem.SubMenuItems.Any())
|
||||||
{
|
{
|
||||||
foreach (var subItem in folderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>())
|
foreach (var subItem in folderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>().ToList())
|
||||||
{
|
{
|
||||||
if (subItem.HandlingFolders.Any(a => a.Id == folderId))
|
if (subItem.HandlingFolders.Any(a => a.Id == folderId))
|
||||||
{
|
{
|
||||||
@@ -65,8 +69,10 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
|
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
|
||||||
{
|
{
|
||||||
value = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId);
|
var rootItems = this.ToList();
|
||||||
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Where(b => b.AccountId == accountId) != null);
|
|
||||||
|
value = rootItems.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId);
|
||||||
|
value ??= rootItems.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Any(b => b.AccountId == accountId));
|
||||||
|
|
||||||
return value != null;
|
return value != null;
|
||||||
}
|
}
|
||||||
@@ -74,7 +80,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
|
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
|
||||||
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
||||||
{
|
{
|
||||||
value = this.OfType<IBaseFolderMenuItem>()
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
value = rootItems.OfType<IBaseFolderMenuItem>()
|
||||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
|
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
|
||||||
|
|
||||||
return value != null;
|
return value != null;
|
||||||
@@ -84,7 +92,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
// This will not look for the folders inside individual account menu items inside merged account menu item.
|
// This will not look for the folders inside individual account menu items inside merged account menu item.
|
||||||
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
|
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
|
||||||
{
|
{
|
||||||
value = this.OfType<MergedAccountFolderMenuItem>()
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
value = rootItems.OfType<MergedAccountFolderMenuItem>()
|
||||||
.Where(a => a.MergedInbox.Id == mergedInboxId)
|
.Where(a => a.MergedInbox.Id == mergedInboxId)
|
||||||
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
|
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
|
||||||
|
|
||||||
@@ -93,11 +103,14 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
|
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
|
||||||
{
|
{
|
||||||
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
// Root folders
|
// Root folders
|
||||||
value = this.OfType<IBaseFolderMenuItem>()
|
value = rootItems.OfType<IBaseFolderMenuItem>()
|
||||||
|
.Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
|
||||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
||||||
|
|
||||||
value ??= this.OfType<FolderMenuItem>()
|
value ??= rootItems.OfType<FolderMenuItem>()
|
||||||
.SelectMany(a => a.SubMenuItems)
|
.SelectMany(a => a.SubMenuItems)
|
||||||
.OfType<IBaseFolderMenuItem>()
|
.OfType<IBaseFolderMenuItem>()
|
||||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
||||||
@@ -105,10 +118,23 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
return value != null;
|
return value != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryGetCategoryMenuItem(Guid categoryId, out IBaseFolderMenuItem value)
|
||||||
|
{
|
||||||
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
value = rootItems.OfType<IMailCategoryMenuItem>()
|
||||||
|
.FirstOrDefault(a => a.MailCategory.Id == categoryId);
|
||||||
|
|
||||||
|
value ??= rootItems.OfType<IMergedMailCategoryMenuItem>()
|
||||||
|
.FirstOrDefault(a => a.Categories.Any(b => b.Id == categoryId)) as IBaseFolderMenuItem;
|
||||||
|
|
||||||
|
return value != null;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateUnreadItemCountsToZero()
|
public void UpdateUnreadItemCountsToZero()
|
||||||
{
|
{
|
||||||
// Handle the root folders.
|
// Handle the root folders.
|
||||||
foreach (var item in this.OfType<IBaseFolderMenuItem>())
|
foreach (var item in this.OfType<IBaseFolderMenuItem>().ToList())
|
||||||
{
|
{
|
||||||
RecursivelyResetUnreadItemCount(item);
|
RecursivelyResetUnreadItemCount(item);
|
||||||
}
|
}
|
||||||
@@ -120,7 +146,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
if (baseFolderMenuItem.SubMenuItems == null) return;
|
if (baseFolderMenuItem.SubMenuItems == null) return;
|
||||||
|
|
||||||
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>())
|
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>().ToList())
|
||||||
{
|
{
|
||||||
RecursivelyResetUnreadItemCount(subMenuItem);
|
RecursivelyResetUnreadItemCount(subMenuItem);
|
||||||
}
|
}
|
||||||
@@ -128,7 +154,9 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
|
|
||||||
public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
||||||
{
|
{
|
||||||
value = this.OfType<IBaseFolderMenuItem>()
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
|
value = rootItems.OfType<IBaseFolderMenuItem>()
|
||||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
|
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
|
||||||
|
|
||||||
return value != null;
|
return value != null;
|
||||||
@@ -142,11 +170,12 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
|
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
|
||||||
{
|
{
|
||||||
AccountMenuItem accountMenuItem = null;
|
AccountMenuItem accountMenuItem = null;
|
||||||
|
var rootItems = this.ToList();
|
||||||
|
|
||||||
accountMenuItem = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
|
accountMenuItem = rootItems.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
|
||||||
|
|
||||||
// Look for the items inside the merged accounts if regular menu item is not found.
|
// Look for the items inside the merged accounts if regular menu item is not found.
|
||||||
accountMenuItem ??= this.OfType<MergedAccountMenuItem>()
|
accountMenuItem ??= rootItems.OfType<MergedAccountMenuItem>()
|
||||||
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
|
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
|
||||||
.OfType<AccountMenuItem>()
|
.OfType<AccountMenuItem>()
|
||||||
.FirstOrDefault(a => a.AccountId == accountId);
|
.FirstOrDefault(a => a.AccountId == accountId);
|
||||||
@@ -167,7 +196,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
/// <param name="isEnabled">Whether menu items should be enabled or disabled.</param>
|
/// <param name="isEnabled">Whether menu items should be enabled or disabled.</param>
|
||||||
public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled)
|
public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled)
|
||||||
{
|
{
|
||||||
var accountItems = this.Where(a => a is IAccountMenuItem).Cast<IAccountMenuItem>();
|
var accountItems = this.Where(a => a is IAccountMenuItem).Cast<IAccountMenuItem>().ToList();
|
||||||
|
|
||||||
await _dispatcher.ExecuteOnUIThread(() =>
|
await _dispatcher.ExecuteOnUIThread(() =>
|
||||||
{
|
{
|
||||||
@@ -192,6 +221,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
{
|
{
|
||||||
// Check root-level items.
|
// Check root-level items.
|
||||||
var rootItem = this.OfType<IBaseFolderMenuItem>()
|
var rootItem = this.OfType<IBaseFolderMenuItem>()
|
||||||
|
.Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
|
||||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
|
||||||
|
|
||||||
if (rootItem != null)
|
if (rootItem != null)
|
||||||
@@ -201,7 +231,7 @@ public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check sub-items of root folders.
|
// Check sub-items of root folders.
|
||||||
foreach (var rootFolder in this.OfType<IBaseFolderMenuItem>())
|
foreach (var rootFolder in this.OfType<IBaseFolderMenuItem>().ToList())
|
||||||
{
|
{
|
||||||
var subItem = rootFolder.SubMenuItems
|
var subItem = rootFolder.SubMenuItems
|
||||||
.OfType<IBaseFolderMenuItem>()
|
.OfType<IBaseFolderMenuItem>()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Folders;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.MenuItems;
|
||||||
|
|
||||||
|
public partial class MergedMailCategoryMenuItem : MenuItemBase<List<MailCategory>, IMenuItem>, IMergedAccountFolderMenuItem, IMergedMailCategoryMenuItem
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IMailItemFolder> _handlingFolders;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int unreadItemCount;
|
||||||
|
|
||||||
|
public MergedMailCategoryMenuItem(List<MailCategory> categories, IEnumerable<IMailItemFolder> handlingFolders, MergedInbox mergedInbox)
|
||||||
|
: base(categories, null, null)
|
||||||
|
{
|
||||||
|
_handlingFolders = handlingFolders?.ToList() ?? [];
|
||||||
|
MergedInbox = mergedInbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FolderName => Parameter.FirstOrDefault()?.Name ?? string.Empty;
|
||||||
|
public bool IsSynchronizationEnabled => false;
|
||||||
|
public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
|
||||||
|
public IEnumerable<IMailItemFolder> HandlingFolders => _handlingFolders;
|
||||||
|
public bool IsMoveTarget => true;
|
||||||
|
public bool IsSticky => false;
|
||||||
|
public bool IsSystemFolder => false;
|
||||||
|
public bool ShowUnreadCount => true;
|
||||||
|
public string AssignedAccountName => MergedInbox?.Name;
|
||||||
|
public MergedInbox MergedInbox { get; }
|
||||||
|
public string TextColorHex => Parameter.FirstOrDefault()?.TextColorHex;
|
||||||
|
public string BackgroundColorHex => Parameter.FirstOrDefault()?.BackgroundColorHex;
|
||||||
|
public bool HasTextColor => !string.IsNullOrWhiteSpace(TextColorHex);
|
||||||
|
public IReadOnlyList<MailCategory> Categories => Parameter;
|
||||||
|
|
||||||
|
public void UpdateFolder(IMailItemFolder folder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
public class UnreadCategoryCountResult
|
||||||
|
{
|
||||||
|
public Guid CategoryId { get; set; }
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public int UnreadItemCount { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Models.Calendar;
|
namespace Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
@@ -18,4 +19,9 @@ public class CalendarPageNavigationArgs
|
|||||||
/// Force reloading the calendar data even when the target range does not change.
|
/// Force reloading the calendar data even when the target range does not change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ForceReload { get; set; }
|
public bool ForceReload { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional event target to navigate to after the calendar page loads the requested range.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarItemTarget? PendingTarget { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
|
public sealed record MailCategoryColorOption(string BackgroundColorHex, string TextColorHex);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
|
public sealed record MailCategoryDialogResult(string Name, string BackgroundColorHex, string TextColorHex);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
|
public static class MailCategoryPalette
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<MailCategoryColorOption> DefaultOptions { get; } =
|
||||||
|
[
|
||||||
|
new("#FEE2E2", "#991B1B"),
|
||||||
|
new("#FECACA", "#7F1D1D"),
|
||||||
|
new("#FFEDD5", "#9A3412"),
|
||||||
|
new("#FED7AA", "#7C2D12"),
|
||||||
|
new("#FEF3C7", "#92400E"),
|
||||||
|
new("#FDE68A", "#78350F"),
|
||||||
|
new("#ECFCCB", "#3F6212"),
|
||||||
|
new("#D9F99D", "#365314"),
|
||||||
|
new("#DCFCE7", "#166534"),
|
||||||
|
new("#BBF7D0", "#14532D"),
|
||||||
|
new("#CCFBF1", "#115E59"),
|
||||||
|
new("#99F6E4", "#134E4A"),
|
||||||
|
new("#CFFAFE", "#155E75"),
|
||||||
|
new("#A5F3FC", "#164E63"),
|
||||||
|
new("#DBEAFE", "#1D4ED8"),
|
||||||
|
new("#BFDBFE", "#1E3A8A"),
|
||||||
|
new("#E0E7FF", "#4338CA"),
|
||||||
|
new("#DDD6FE", "#5B21B6"),
|
||||||
|
new("#F3E8FF", "#7E22CE"),
|
||||||
|
new("#FCE7F3", "#9D174D")
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ public record NewMailItemPackage(
|
|||||||
MailCopy Copy,
|
MailCopy Copy,
|
||||||
MimeMessage Mime,
|
MimeMessage Mime,
|
||||||
string AssignedRemoteFolderId,
|
string AssignedRemoteFolderId,
|
||||||
IReadOnlyList<AccountContact> ExtractedContacts = null);
|
IReadOnlyList<AccountContact> ExtractedContacts = null,
|
||||||
|
IReadOnlyList<string> CategoryNames = null);
|
||||||
|
|||||||
@@ -17,4 +17,8 @@ public record MailListInitializationOptions(IEnumerable<IMailItemFolder> Folders
|
|||||||
List<MailCopy> PreFetchMailCopies = null,
|
List<MailCopy> PreFetchMailCopies = null,
|
||||||
bool DeduplicateByServerId = false,
|
bool DeduplicateByServerId = false,
|
||||||
int Skip = 0,
|
int Skip = 0,
|
||||||
int Take = 0);
|
int Take = 0)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<Guid> CategoryIds { get; init; }
|
||||||
|
public bool IsCategoryView => CategoryIds?.Count > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase<Cale
|
|||||||
public virtual Guid? LocalCalendarItemId => Item?.Id;
|
public virtual Guid? LocalCalendarItemId => Item?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public abstract record CategoryRequestBase(Guid AccountId) : RequestBase<CategorySynchronizerOperation>, ICategoryActionRequest
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest
|
public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest
|
||||||
{
|
{
|
||||||
public BatchCollection(IEnumerable<TRequestType> collection) : base(collection)
|
public BatchCollection(IEnumerable<TRequestType> collection) : base(collection)
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ public static class SettingsNavigationInfoProvider
|
|||||||
Translator.SettingsMessageList_Description,
|
Translator.SettingsMessageList_Description,
|
||||||
"\uE8C4",
|
"\uE8C4",
|
||||||
searchKeywords: Translator.SettingsSearch_MessageList_Keywords),
|
searchKeywords: Translator.SettingsSearch_MessageList_Keywords),
|
||||||
|
new(WinoPage.MailNotificationSettingsPage,
|
||||||
|
Translator.SettingsMailNotifications_Title,
|
||||||
|
Translator.SettingsMailNotifications_Description,
|
||||||
|
"\uE7F4",
|
||||||
|
searchKeywords: Translator.SettingsSearch_MailNotifications_Keywords),
|
||||||
new(WinoPage.ReadComposePanePage,
|
new(WinoPage.ReadComposePanePage,
|
||||||
Translator.SettingsReadComposePane_Title,
|
Translator.SettingsReadComposePane_Title,
|
||||||
Translator.SettingsReadComposePane_Description,
|
Translator.SettingsReadComposePane_Description,
|
||||||
@@ -149,6 +154,7 @@ public static class SettingsNavigationInfoProvider
|
|||||||
WinoPage.PersonalizationPage => Translator.SettingsPersonalization_Title,
|
WinoPage.PersonalizationPage => Translator.SettingsPersonalization_Title,
|
||||||
WinoPage.AboutPage => Translator.SettingsAbout_Title,
|
WinoPage.AboutPage => Translator.SettingsAbout_Title,
|
||||||
WinoPage.MessageListPage => Translator.SettingsMessageList_Title,
|
WinoPage.MessageListPage => Translator.SettingsMessageList_Title,
|
||||||
|
WinoPage.MailNotificationSettingsPage => Translator.SettingsMailNotifications_Title,
|
||||||
WinoPage.ReadComposePanePage => Translator.SettingsReadComposePane_Title,
|
WinoPage.ReadComposePanePage => Translator.SettingsReadComposePane_Title,
|
||||||
WinoPage.AppPreferencesPage => Translator.SettingsAppPreferences_Title,
|
WinoPage.AppPreferencesPage => Translator.SettingsAppPreferences_Title,
|
||||||
WinoPage.CalendarSettingsPage => Translator.CalendarSettings_Preferences_Title,
|
WinoPage.CalendarSettingsPage => Translator.CalendarSettings_Preferences_Title,
|
||||||
@@ -170,6 +176,7 @@ public static class SettingsNavigationInfoProvider
|
|||||||
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
|
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
|
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
|
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
|
||||||
|
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
|
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
|
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage,
|
WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"BasicIMAPSetupDialog_Password": "Password",
|
"BasicIMAPSetupDialog_Password": "Password",
|
||||||
"BasicIMAPSetupDialog_Title": "IMAP Account",
|
"BasicIMAPSetupDialog_Title": "IMAP Account",
|
||||||
"Busy": "Busy",
|
"Busy": "Busy",
|
||||||
|
"Buttons_Add": "Add",
|
||||||
"Buttons_AddAccount": "Add Account",
|
"Buttons_AddAccount": "Add Account",
|
||||||
"Buttons_FixAccount": "Fix Account",
|
"Buttons_FixAccount": "Fix Account",
|
||||||
"Buttons_AddNewAlias": "Add New Alias",
|
"Buttons_AddNewAlias": "Add New Alias",
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"Buttons_Delete": "Delete",
|
"Buttons_Delete": "Delete",
|
||||||
"Buttons_Deny": "Deny",
|
"Buttons_Deny": "Deny",
|
||||||
"Buttons_Discard": "Discard",
|
"Buttons_Discard": "Discard",
|
||||||
|
"Buttons_Dismiss": "Dismiss",
|
||||||
"Buttons_Edit": "Edit",
|
"Buttons_Edit": "Edit",
|
||||||
"Buttons_EnableImageRendering": "Enable",
|
"Buttons_EnableImageRendering": "Enable",
|
||||||
"Buttons_Multiselect": "Select Multiple",
|
"Buttons_Multiselect": "Select Multiple",
|
||||||
@@ -213,6 +215,8 @@
|
|||||||
"CalendarEventDetails_Organizer": "Organizer",
|
"CalendarEventDetails_Organizer": "Organizer",
|
||||||
"CalendarEventDetails_People": "People",
|
"CalendarEventDetails_People": "People",
|
||||||
"CalendarEventDetails_ReadOnlyEvent": "Read-only event",
|
"CalendarEventDetails_ReadOnlyEvent": "Read-only event",
|
||||||
|
"CalendarReadOnly_Title": "Read-only calendar",
|
||||||
|
"CalendarReadOnly_Message": "You can't update this calendar or its events. This calendar is read-only.",
|
||||||
"CalendarContextMenu_Respond": "Respond",
|
"CalendarContextMenu_Respond": "Respond",
|
||||||
"CalendarEventDetails_Reminder": "Reminder",
|
"CalendarEventDetails_Reminder": "Reminder",
|
||||||
"CalendarReminder_StartedHoursAgo": "Started {0} hours ago",
|
"CalendarReminder_StartedHoursAgo": "Started {0} hours ago",
|
||||||
@@ -875,10 +879,28 @@
|
|||||||
"SettingsManageAccountSettings_Title": "Manage Accounts",
|
"SettingsManageAccountSettings_Title": "Manage Accounts",
|
||||||
"SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.",
|
"SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.",
|
||||||
"SettingsManageAliases_Title": "Aliases",
|
"SettingsManageAliases_Title": "Aliases",
|
||||||
|
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
|
||||||
|
"SettingsMailCategories_Title": "Categories",
|
||||||
"SettingsEditAccountDetails_Title": "Edit Account Details",
|
"SettingsEditAccountDetails_Title": "Edit Account Details",
|
||||||
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
|
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
|
||||||
"EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved",
|
"EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved",
|
||||||
"EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.",
|
"EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.",
|
||||||
|
"MailCategoryManagementPage_Title": "Categories",
|
||||||
|
"MailCategoryManagementPage_Description": "Create, edit, delete, and favorite categories for this account.",
|
||||||
|
"MailCategoryManagementPage_Empty": "No categories yet.",
|
||||||
|
"MailCategoryManagementPage_DeleteConfirmationTitle": "Delete Category",
|
||||||
|
"MailCategoryManagementPage_DeleteConfirmationMessage": "Delete category \"{0}\"?",
|
||||||
|
"MailCategoryManagementPage_RefreshConfirmationMessage": "This will delete all your local categories, and re-synchronize everything from the server. Do you want to continue?",
|
||||||
|
"MailCategoryMenuItem": "Category",
|
||||||
|
"MailCategoryDialog_CreateTitle": "Create category",
|
||||||
|
"MailCategoryDialog_EditTitle": "Edit category",
|
||||||
|
"MailCategoryDialog_Name": "Name",
|
||||||
|
"MailCategoryDialog_NamePlaceholder": "Category name",
|
||||||
|
"MailCategoryDialog_Color": "Color",
|
||||||
|
"MailCategoryDialog_InvalidNameTitle": "Category name required",
|
||||||
|
"MailCategoryDialog_InvalidNameMessage": "Enter a category name to continue.",
|
||||||
|
"MailCategoryDialog_DuplicateTitle": "Category already exists",
|
||||||
|
"MailCategoryDialog_DuplicateMessage": "A category with the same name already exists for this account.",
|
||||||
"SettingsManageLink_Description": "Move items to add new link or remove existing link.",
|
"SettingsManageLink_Description": "Move items to add new link or remove existing link.",
|
||||||
"SettingsManageLink_Title": "Manage Link",
|
"SettingsManageLink_Title": "Manage Link",
|
||||||
"SettingsMarkAsRead_Description": "Change what should happen to the selected item.",
|
"SettingsMarkAsRead_Description": "Change what should happen to the selected item.",
|
||||||
@@ -889,6 +911,14 @@
|
|||||||
"SettingsMarkAsRead_WhenSelected": "When selected",
|
"SettingsMarkAsRead_WhenSelected": "When selected",
|
||||||
"SettingsMessageList_Description": "Change how your messages should be organized in mail list.",
|
"SettingsMessageList_Description": "Change how your messages should be organized in mail list.",
|
||||||
"SettingsMessageList_Title": "Message List",
|
"SettingsMessageList_Title": "Message List",
|
||||||
|
"SettingsMailNotifications_Title": "Notifications",
|
||||||
|
"SettingsMailNotifications_Description": "Notification settings and preferences for mails.",
|
||||||
|
"SettingsMailNotifications_Actions_Title": "App notification actions.",
|
||||||
|
"SettingsMailNotifications_Actions_Description": "Customize the button behaviors on the notifications as you like.",
|
||||||
|
"SettingsMailNotifications_FirstAction_Title": "First notification action",
|
||||||
|
"SettingsMailNotifications_FirstAction_Description": "Choose the first button shown on mail notifications.",
|
||||||
|
"SettingsMailNotifications_SecondAction_Title": "Second notification action",
|
||||||
|
"SettingsMailNotifications_SecondAction_Description": "Choose the second button shown on mail notifications.",
|
||||||
"SettingsNoAccountSetupMessage": "You didn't setup any accounts yet.",
|
"SettingsNoAccountSetupMessage": "You didn't setup any accounts yet.",
|
||||||
"SettingsNotifications_Description": "Turn on or off notifications for this account.",
|
"SettingsNotifications_Description": "Turn on or off notifications for this account.",
|
||||||
"SettingsNotifications_Title": "Notifications",
|
"SettingsNotifications_Title": "Notifications",
|
||||||
@@ -925,6 +955,7 @@
|
|||||||
"SettingsSearch_About_Keywords": "about;version;website;privacy;github;donate;store;support",
|
"SettingsSearch_About_Keywords": "about;version;website;privacy;github;donate;store;support",
|
||||||
"SettingsSearch_KeyboardShortcuts_Keywords": "shortcut;shortcuts;hotkey;hotkeys;keyboard;keys",
|
"SettingsSearch_KeyboardShortcuts_Keywords": "shortcut;shortcuts;hotkey;hotkeys;keyboard;keys",
|
||||||
"SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
|
"SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
|
||||||
|
"SettingsSearch_MailNotifications_Keywords": "mail;notification;notifications;toast;action;actions;reply;reply all;forward;archive;delete;junk;read",
|
||||||
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
|
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
|
||||||
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
|
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
|
||||||
"SettingsSearch_Storage_Keywords": "storage;cache;caching;mime;disk;space;cleanup;clean up;local data",
|
"SettingsSearch_Storage_Keywords": "storage;cache;caching;mime;disk;space;cleanup;clean up;local data",
|
||||||
@@ -1491,11 +1522,13 @@
|
|||||||
"AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication",
|
"AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication",
|
||||||
"AccountSetup_Step_SavingAccount": "Saving account information",
|
"AccountSetup_Step_SavingAccount": "Saving account information",
|
||||||
"AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata",
|
"AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata",
|
||||||
|
"AccountSetup_Step_SyncingCategories": "Synchronizing categories",
|
||||||
"AccountSetup_Step_SyncingAliases": "Synchronizing aliases",
|
"AccountSetup_Step_SyncingAliases": "Synchronizing aliases",
|
||||||
"AccountSetup_Step_Finalizing": "Finalizing setup",
|
"AccountSetup_Step_Finalizing": "Finalizing setup",
|
||||||
"AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.",
|
"AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.",
|
||||||
"AccountSetup_SuccessMessage": "Your account has been set up successfully!",
|
"AccountSetup_SuccessMessage": "Your account has been set up successfully!",
|
||||||
"AccountSetup_GoBackButton": "Go Back",
|
"AccountSetup_GoBackButton": "Go Back",
|
||||||
"AccountSetup_TryAgainButton": "Try Again",
|
"AccountSetup_TryAgainButton": "Try Again",
|
||||||
|
"Exception_FailedToSynchronizeCategories": "Failed to synchronize categories",
|
||||||
"ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab."
|
"ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,7 +510,8 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
preferencesService.Object,
|
preferencesService.Object,
|
||||||
contactPictureFileService.Object);
|
contactPictureFileService.Object);
|
||||||
|
|
||||||
var folderService = new FolderService(db, accountService);
|
var mailCategoryService = new MailCategoryService(db);
|
||||||
|
var folderService = new FolderService(db, accountService, mailCategoryService);
|
||||||
var contactService = new ContactService(db);
|
var contactService = new ContactService(db);
|
||||||
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
||||||
|
|
||||||
@@ -522,6 +523,7 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
signatureService.Object,
|
signatureService.Object,
|
||||||
mimeFileService.Object,
|
mimeFileService.Object,
|
||||||
preferencesService.Object,
|
preferencesService.Object,
|
||||||
sentMailReceiptService);
|
sentMailReceiptService,
|
||||||
|
mailCategoryService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ public class MailThreadingTests : IAsyncLifetime
|
|||||||
preferencesService.Object,
|
preferencesService.Object,
|
||||||
contactPictureFileService.Object);
|
contactPictureFileService.Object);
|
||||||
|
|
||||||
var folderService = new FolderService(db, accountService);
|
var mailCategoryService = new MailCategoryService(db);
|
||||||
|
var folderService = new FolderService(db, accountService, mailCategoryService);
|
||||||
var contactService = new ContactService(db);
|
var contactService = new ContactService(db);
|
||||||
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
|
||||||
|
|
||||||
@@ -281,6 +282,7 @@ public class MailThreadingTests : IAsyncLifetime
|
|||||||
signatureService.Object,
|
signatureService.Object,
|
||||||
mimeFileService.Object,
|
mimeFileService.Object,
|
||||||
preferencesService.Object,
|
preferencesService.Object,
|
||||||
sentMailReceiptService);
|
sentMailReceiptService,
|
||||||
|
mailCategoryService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ public sealed class OutlookSynchronizerRequestSuccessTests
|
|||||||
|
|
||||||
var authenticator = new Mock<IAuthenticator>(MockBehavior.Loose);
|
var authenticator = new Mock<IAuthenticator>(MockBehavior.Loose);
|
||||||
var errorFactory = new Mock<IOutlookSynchronizerErrorHandlerFactory>(MockBehavior.Loose);
|
var errorFactory = new Mock<IOutlookSynchronizerErrorHandlerFactory>(MockBehavior.Loose);
|
||||||
|
var mailCategoryService = new Mock<IMailCategoryService>(MockBehavior.Loose);
|
||||||
|
|
||||||
return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object);
|
return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object, mailCategoryService.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MailCopy CreateMailCopy() =>
|
private static MailCopy CreateMailCopy() =>
|
||||||
|
|||||||
@@ -14,27 +14,32 @@ namespace Wino.Core.Extensions;
|
|||||||
|
|
||||||
public static class GoogleIntegratorExtensions
|
public static class GoogleIntegratorExtensions
|
||||||
{
|
{
|
||||||
private static string GetNormalizedLabelName(string labelName)
|
private static bool TryGetKnownFolderLabelName(string labelName, out string normalizedLabelName)
|
||||||
{
|
{
|
||||||
// 1. Remove CATEGORY_ prefix.
|
normalizedLabelName = string.Empty;
|
||||||
var normalizedLabelName = labelName.Replace(ServiceConstants.CATEGORY_PREFIX, string.Empty);
|
|
||||||
|
|
||||||
// 2. Normalize label name by capitalizing first letter.
|
if (string.IsNullOrEmpty(labelName))
|
||||||
normalizedLabelName = char.ToUpper(normalizedLabelName[0]) + normalizedLabelName.Substring(1).ToLower();
|
return false;
|
||||||
|
|
||||||
return normalizedLabelName;
|
var knownFolderKey = labelName.Replace(ServiceConstants.CATEGORY_PREFIX, string.Empty);
|
||||||
|
|
||||||
|
if (!ServiceConstants.KnownFolderDictionary.ContainsKey(knownFolderKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
normalizedLabelName = char.ToUpper(knownFolderKey[0]) + knownFolderKey.Substring(1).ToLower();
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MailItemFolder GetLocalFolder(this Label label, ListLabelsResponse labelsResponse, Guid accountId)
|
public static MailItemFolder GetLocalFolder(this Label label, ListLabelsResponse labelsResponse, Guid accountId)
|
||||||
{
|
{
|
||||||
var normalizedLabelName = GetFolderName(label.Name);
|
var folderName = GetFolderName(label.Name);
|
||||||
|
|
||||||
// Even though we normalize the label name, check is done by capitalizing the label name.
|
var lookupLabelName = GetLookupLabelName(label.Name);
|
||||||
var capitalNormalizedLabelName = normalizedLabelName.ToUpper();
|
|
||||||
|
|
||||||
bool isSpecialFolder = ServiceConstants.KnownFolderDictionary.ContainsKey(capitalNormalizedLabelName);
|
bool isSpecialFolder = ServiceConstants.KnownFolderDictionary.ContainsKey(lookupLabelName);
|
||||||
|
|
||||||
var specialFolderType = isSpecialFolder ? ServiceConstants.KnownFolderDictionary[capitalNormalizedLabelName] : SpecialFolderType.Other;
|
var specialFolderType = isSpecialFolder ? ServiceConstants.KnownFolderDictionary[lookupLabelName] : SpecialFolderType.Other;
|
||||||
|
|
||||||
// We used to support FOLDER_HIDE_IDENTIFIER to hide invisible folders.
|
// We used to support FOLDER_HIDE_IDENTIFIER to hide invisible folders.
|
||||||
// However, a lot of people complained that they don't see their folders after the initial sync
|
// However, a lot of people complained that they don't see their folders after the initial sync
|
||||||
@@ -59,7 +64,7 @@ public static class GoogleIntegratorExtensions
|
|||||||
{
|
{
|
||||||
TextColorHex = label.Color?.TextColor,
|
TextColorHex = label.Color?.TextColor,
|
||||||
BackgroundColorHex = label.Color?.BackgroundColor,
|
BackgroundColorHex = label.Color?.BackgroundColor,
|
||||||
FolderName = normalizedLabelName,
|
FolderName = folderName,
|
||||||
RemoteFolderId = label.Id,
|
RemoteFolderId = label.Id,
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
MailAccountId = accountId,
|
MailAccountId = accountId,
|
||||||
@@ -104,7 +109,29 @@ public static class GoogleIntegratorExtensions
|
|||||||
return labelsResponse.Labels.FirstOrDefault(a => a.Name == parentLabelName)?.Id ?? string.Empty;
|
return labelsResponse.Labels.FirstOrDefault(a => a.Name == parentLabelName)?.Id ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string GetLookupLabelName(string fullFolderName)
|
||||||
|
{
|
||||||
|
var folderName = GetLastFolderName(fullFolderName);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(folderName))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return folderName.Replace(ServiceConstants.CATEGORY_PREFIX, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
public static string GetFolderName(string fullFolderName)
|
public static string GetFolderName(string fullFolderName)
|
||||||
|
{
|
||||||
|
var lastPart = GetLastFolderName(fullFolderName);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(lastPart))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return TryGetKnownFolderLabelName(lastPart, out var normalizedLabelName)
|
||||||
|
? normalizedLabelName
|
||||||
|
: lastPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLastFolderName(string fullFolderName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(fullFolderName)) return string.Empty;
|
if (string.IsNullOrEmpty(fullFolderName)) return string.Empty;
|
||||||
|
|
||||||
@@ -113,9 +140,7 @@ public static class GoogleIntegratorExtensions
|
|||||||
|
|
||||||
string[] parts = fullFolderName.Split(ServiceConstants.FOLDER_SEPERATOR_CHAR);
|
string[] parts = fullFolderName.Split(ServiceConstants.FOLDER_SEPERATOR_CHAR);
|
||||||
|
|
||||||
var lastPart = parts[parts.Length - 1];
|
return parts[parts.Length - 1];
|
||||||
|
|
||||||
return GetNormalizedLabelName(lastPart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<RemoteAccountAlias> GetRemoteAliases(this ListSendAsResponse response)
|
public static List<RemoteAccountAlias> GetRemoteAliases(this ListSendAsResponse response)
|
||||||
@@ -145,6 +170,8 @@ public static class GoogleIntegratorExtensions
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TimeZone = calendarListEntry.TimeZone,
|
TimeZone = calendarListEntry.TimeZone,
|
||||||
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
|
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
|
||||||
|
IsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase),
|
||||||
IsSynchronizationEnabled = true,
|
IsSynchronizationEnabled = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ public static class OutlookIntegratorExtensions
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
RemoteCalendarId = outlookCalendar.Id,
|
RemoteCalendarId = outlookCalendar.Id,
|
||||||
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
|
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
|
||||||
|
IsReadOnly = !outlookCalendar.CanEdit.GetValueOrDefault(true),
|
||||||
Name = outlookCalendar.Name,
|
Name = outlookCalendar.Name,
|
||||||
IsSynchronizationEnabled = true,
|
IsSynchronizationEnabled = true,
|
||||||
IsExtended = true,
|
IsExtended = true,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Requests;
|
||||||
|
|
||||||
|
namespace Wino.Core.Requests.Category;
|
||||||
|
|
||||||
|
public record MailCategoryCreateRequest(MailCategory Category) : CategoryRequestBase(Category.MailAccountId)
|
||||||
|
{
|
||||||
|
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.CreateCategory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Requests;
|
||||||
|
|
||||||
|
namespace Wino.Core.Requests.Category;
|
||||||
|
|
||||||
|
public record MailCategoryDeleteRequest(
|
||||||
|
MailCategory Category,
|
||||||
|
string PreviousRemoteId,
|
||||||
|
IReadOnlyList<MailCategoryMessageUpdateTarget> AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
|
||||||
|
{
|
||||||
|
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.DeleteCategory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Wino.Core.Requests.Category;
|
||||||
|
|
||||||
|
public sealed record MailCategoryMessageUpdateTarget(string MessageId, IReadOnlyList<string> CategoryNames);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Requests;
|
||||||
|
|
||||||
|
namespace Wino.Core.Requests.Category;
|
||||||
|
|
||||||
|
public record MailCategoryUpdateRequest(
|
||||||
|
MailCategory Category,
|
||||||
|
string PreviousName,
|
||||||
|
string PreviousRemoteId,
|
||||||
|
IReadOnlyList<MailCategoryMessageUpdateTarget> AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
|
||||||
|
{
|
||||||
|
public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.UpdateCategory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Requests;
|
||||||
|
|
||||||
|
namespace Wino.Core.Requests.Mail;
|
||||||
|
|
||||||
|
public record MailCategoryAssignmentRequest(
|
||||||
|
MailCopy Item,
|
||||||
|
Guid MailCategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
IReadOnlyList<string> CategoryNames,
|
||||||
|
bool IsAssigned) : MailRequestBase(Item), ICustomFolderSynchronizationRequest
|
||||||
|
{
|
||||||
|
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.UpdateCategories;
|
||||||
|
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||||
|
public bool ExcludeMustHaveFolders => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BatchMailCategoryAssignmentRequest : BatchCollection<MailCategoryAssignmentRequest>
|
||||||
|
{
|
||||||
|
public BatchMailCategoryAssignmentRequest(IEnumerable<MailCategoryAssignmentRequest> collection) : base(collection)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -370,6 +370,26 @@ public class SynchronizationManager : ISynchronizationManager, IRecipient<Accoun
|
|||||||
return await SynchronizeMailAsync(options, cancellationToken);
|
return await SynchronizeMailAsync(options, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles category synchronization for the given account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">Account ID to synchronize categories for</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Synchronization result</returns>
|
||||||
|
public async Task<MailSynchronizationResult> SynchronizeCategoriesAsync(Guid accountId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
|
||||||
|
var options = new MailSynchronizationOptions
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
Type = MailSynchronizationType.Categories
|
||||||
|
};
|
||||||
|
|
||||||
|
return await SynchronizeMailAsync(options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles profile synchronization for the given account.
|
/// Handles profile synchronization for the given account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
private readonly ICalDavClient _calDavClient;
|
private readonly ICalDavClient _calDavClient;
|
||||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||||
private readonly ICalendarService _calendarService;
|
private readonly ICalendarService _calendarService;
|
||||||
|
private readonly IMailCategoryService _mailCategoryService;
|
||||||
|
|
||||||
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
UnifiedImapSynchronizer unifiedImapSynchronizer,
|
UnifiedImapSynchronizer unifiedImapSynchronizer,
|
||||||
ICalDavClient calDavClient,
|
ICalDavClient calDavClient,
|
||||||
IAutoDiscoveryService autoDiscoveryService,
|
IAutoDiscoveryService autoDiscoveryService,
|
||||||
ICalendarService calendarService)
|
ICalendarService calendarService,
|
||||||
|
IMailCategoryService mailCategoryService)
|
||||||
{
|
{
|
||||||
_outlookChangeProcessor = outlookChangeProcessor;
|
_outlookChangeProcessor = outlookChangeProcessor;
|
||||||
_gmailChangeProcessor = gmailChangeProcessor;
|
_gmailChangeProcessor = gmailChangeProcessor;
|
||||||
@@ -56,6 +58,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
_calDavClient = calDavClient;
|
_calDavClient = calDavClient;
|
||||||
_autoDiscoveryService = autoDiscoveryService;
|
_autoDiscoveryService = autoDiscoveryService;
|
||||||
_calendarService = calendarService;
|
_calendarService = calendarService;
|
||||||
|
_mailCategoryService = mailCategoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
||||||
@@ -86,7 +89,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
{
|
{
|
||||||
case Domain.Enums.MailProviderType.Outlook:
|
case Domain.Enums.MailProviderType.Outlook:
|
||||||
var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator;
|
var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator;
|
||||||
return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory);
|
return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory, _mailCategoryService);
|
||||||
case Domain.Enums.MailProviderType.Gmail:
|
case Domain.Enums.MailProviderType.Gmail:
|
||||||
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
|||||||
if (calendarPreparationRequest == null)
|
if (calendarPreparationRequest == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var resolvedCalendar = await ResolveCalendarAsync(calendarPreparationRequest).ConfigureAwait(false);
|
||||||
|
if (resolvedCalendar?.IsReadOnly == true)
|
||||||
|
{
|
||||||
|
_dialogService.ShowReadOnlyCalendarMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
IRequestBase request = calendarPreparationRequest.Operation switch
|
IRequestBase request = calendarPreparationRequest.Operation switch
|
||||||
{
|
{
|
||||||
CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
|
CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
|
||||||
@@ -200,6 +207,21 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
|||||||
await QueueCalendarSynchronizationAsync(accountId);
|
await QueueCalendarSynchronizationAsync(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(Guid accountId, IEnumerable<IRequestBase> requests)
|
||||||
|
{
|
||||||
|
var requestList = requests?.Where(a => a != null).ToList() ?? [];
|
||||||
|
if (requestList.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var request in requestList)
|
||||||
|
{
|
||||||
|
await QueueRequestAsync(request, accountId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendSyncActionsAddedAsync(requestList, accountId).ConfigureAwait(false);
|
||||||
|
await QueueSynchronizationAsync(accountId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
||||||
{
|
{
|
||||||
var composeResult = calendarPreparationRequest.ComposeResult
|
var composeResult = calendarPreparationRequest.ComposeResult
|
||||||
@@ -212,6 +234,25 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
|||||||
return new CreateCalendarEventRequest(composeResult, assignedCalendar);
|
return new CreateCalendarEventRequest(composeResult, assignedCalendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<AccountCalendar> ResolveCalendarAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
||||||
|
{
|
||||||
|
if (calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent)
|
||||||
|
{
|
||||||
|
var calendarId = calendarPreparationRequest.ComposeResult?.CalendarId ?? Guid.Empty;
|
||||||
|
return calendarId == Guid.Empty
|
||||||
|
? null
|
||||||
|
: await _calendarService.GetAccountCalendarAsync(calendarId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendarPreparationRequest.CalendarItem?.AssignedCalendar is AccountCalendar assignedCalendar)
|
||||||
|
return assignedCalendar;
|
||||||
|
|
||||||
|
var fallbackCalendarId = calendarPreparationRequest.CalendarItem?.CalendarId ?? Guid.Empty;
|
||||||
|
return fallbackCalendarId == Guid.Empty
|
||||||
|
? null
|
||||||
|
: await _calendarService.GetAccountCalendarAsync(fallbackCalendarId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
|
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
|
||||||
{
|
{
|
||||||
// For Outlook accounts, declined events are deleted by the server after synchronization.
|
// For Outlook accounts, declined events are deleted by the server after synchronization.
|
||||||
|
|||||||
@@ -759,6 +759,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||||
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
existingLocalCalendar.IsReadOnly = !string.Equals(calendar.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(calendar.AccessRole, "writer", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
updatedCalendars.Add(existingLocalCalendar);
|
updatedCalendars.Add(existingLocalCalendar);
|
||||||
}
|
}
|
||||||
@@ -902,7 +904,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
|
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
|
||||||
{
|
{
|
||||||
existingLocalFolder.FolderName = remoteFolder.Name;
|
existingLocalFolder.FolderName = GoogleIntegratorExtensions.GetFolderName(remoteFolder.Name);
|
||||||
existingLocalFolder.TextColorHex = remoteFolder.Color?.TextColor;
|
existingLocalFolder.TextColorHex = remoteFolder.Color?.TextColor;
|
||||||
existingLocalFolder.BackgroundColorHex = remoteFolder.Color?.BackgroundColor;
|
existingLocalFolder.BackgroundColorHex = remoteFolder.Color?.BackgroundColor;
|
||||||
|
|
||||||
@@ -940,14 +942,17 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteGmailCalendarBackgroundColor(calendarListEntry), accountCalendar);
|
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteGmailCalendarBackgroundColor(calendarListEntry), accountCalendar);
|
||||||
var remoteTextColor = ColorHelpers.GetReadableTextColorHex(remoteBackgroundColor);
|
var remoteTextColor = ColorHelpers.GetReadableTextColorHex(remoteBackgroundColor);
|
||||||
var remoteIsPrimary = string.Equals(calendarListEntry.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
var remoteIsPrimary = string.Equals(calendarListEntry.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var remoteIsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
|
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase);
|
bool isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase);
|
bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
|
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
|
||||||
|
bool isReadOnlyChanged = accountCalendar.IsReadOnly != remoteIsReadOnly;
|
||||||
|
|
||||||
return isNameChanged || isTimeZoneChanged || isBackgroundColorChanged || isTextColorChanged || isPrimaryChanged;
|
return isNameChanged || isTimeZoneChanged || isBackgroundColorChanged || isTextColorChanged || isPrimaryChanged || isReadOnlyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetRemoteGmailCalendarBackgroundColor(CalendarListEntry calendarListEntry)
|
private static string GetRemoteGmailCalendarBackgroundColor(CalendarListEntry calendarListEntry)
|
||||||
@@ -993,9 +998,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)
|
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)
|
||||||
{
|
{
|
||||||
var remoteFolderName = GoogleIntegratorExtensions.GetFolderName(remoteFolder.Name);
|
var remoteFolderName = GoogleIntegratorExtensions.GetFolderName(remoteFolder.Name);
|
||||||
var localFolderName = GoogleIntegratorExtensions.GetFolderName(existingLocalFolder.FolderName);
|
var localFolderName = existingLocalFolder.FolderName ?? string.Empty;
|
||||||
|
|
||||||
bool isNameChanged = !localFolderName.Equals(remoteFolderName, StringComparison.OrdinalIgnoreCase);
|
bool isNameChanged = !localFolderName.Equals(remoteFolderName, StringComparison.Ordinal);
|
||||||
bool isColorChanged = existingLocalFolder.BackgroundColorHex != remoteFolder.Color?.BackgroundColor ||
|
bool isColorChanged = existingLocalFolder.BackgroundColorHex != remoteFolder.Color?.BackgroundColor ||
|
||||||
existingLocalFolder.TextColorHex != remoteFolder.Color?.TextColor;
|
existingLocalFolder.TextColorHex != remoteFolder.Color?.TextColor;
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ using Wino.Core.Integration.Processors;
|
|||||||
using Wino.Core.Misc;
|
using Wino.Core.Misc;
|
||||||
using Wino.Core.Requests.Bundles;
|
using Wino.Core.Requests.Bundles;
|
||||||
using Wino.Core.Requests.Calendar;
|
using Wino.Core.Requests.Calendar;
|
||||||
|
using Wino.Core.Requests.Category;
|
||||||
using Wino.Core.Requests.Folder;
|
using Wino.Core.Requests.Folder;
|
||||||
using Wino.Core.Requests.Mail;
|
using Wino.Core.Requests.Mail;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
@@ -107,6 +108,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
"ParentFolderId",
|
"ParentFolderId",
|
||||||
"InternetMessageId",
|
"InternetMessageId",
|
||||||
"InternetMessageHeaders",
|
"InternetMessageHeaders",
|
||||||
|
"Categories",
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
|
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
|
||||||
@@ -116,6 +118,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
||||||
private readonly GraphServiceClient _graphClient;
|
private readonly GraphServiceClient _graphClient;
|
||||||
private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory;
|
private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory;
|
||||||
|
private readonly IMailCategoryService _mailCategoryService;
|
||||||
private bool _isFolderStructureChanged;
|
private bool _isFolderStructureChanged;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _concurrentDownloadSemaphore = new(10); // Limit to 10 concurrent downloads
|
private readonly SemaphoreSlim _concurrentDownloadSemaphore = new(10); // Limit to 10 concurrent downloads
|
||||||
@@ -123,7 +126,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
public OutlookSynchronizer(MailAccount account,
|
public OutlookSynchronizer(MailAccount account,
|
||||||
IAuthenticator authenticator,
|
IAuthenticator authenticator,
|
||||||
IOutlookChangeProcessor outlookChangeProcessor,
|
IOutlookChangeProcessor outlookChangeProcessor,
|
||||||
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account, WeakReferenceMessenger.Default)
|
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory,
|
||||||
|
IMailCategoryService mailCategoryService) : base(account, WeakReferenceMessenger.Default)
|
||||||
{
|
{
|
||||||
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
|
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
|
||||||
|
|
||||||
@@ -138,6 +142,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
_outlookChangeProcessor = outlookChangeProcessor;
|
_outlookChangeProcessor = outlookChangeProcessor;
|
||||||
_errorHandlingFactory = errorHandlingFactory;
|
_errorHandlingFactory = errorHandlingFactory;
|
||||||
|
_mailCategoryService = mailCategoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region MS Graph Handlers
|
#region MS Graph Handlers
|
||||||
@@ -1152,6 +1157,11 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
_logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged);
|
_logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged);
|
||||||
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
|
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.Categories != null)
|
||||||
|
{
|
||||||
|
await ReplaceMailAssignmentsAsync(item.Id, item.Categories).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1208,6 +1218,43 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _graphClient.Me.Outlook.MasterCategories
|
||||||
|
.GetAsync(cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var categories = response?.Value?
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a?.DisplayName))
|
||||||
|
.Select(a =>
|
||||||
|
{
|
||||||
|
var colorOption = GetMailCategoryColorOption(a.Color);
|
||||||
|
|
||||||
|
return new MailCategory
|
||||||
|
{
|
||||||
|
MailAccountId = Account.Id,
|
||||||
|
RemoteId = a.Id,
|
||||||
|
Name = a.DisplayName,
|
||||||
|
BackgroundColorHex = colorOption.BackgroundColorHex,
|
||||||
|
TextColorHex = colorOption.TextColorHex,
|
||||||
|
Source = MailCategorySource.Outlook
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList() ?? [];
|
||||||
|
|
||||||
|
await _mailCategoryService.ReplaceCategoriesAsync(Account.Id, categories).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReplaceMailAssignmentsAsync(string messageId, IEnumerable<string> categoryNames)
|
||||||
|
{
|
||||||
|
var localMailCopies = await _outlookChangeProcessor.GetMailCopiesAsync([messageId]).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var localMailCopy in localMailCopies)
|
||||||
|
{
|
||||||
|
await _mailCategoryService.ReplaceMailAssignmentsAsync(Account.Id, localMailCopy.UniqueId, categoryNames ?? []).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<OutlookSpecialFolderIdInformation> GetSpecialFolderIdsAsync(CancellationToken cancellationToken)
|
private async Task<OutlookSpecialFolderIdInformation> GetSpecialFolderIdsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
||||||
@@ -1767,6 +1814,87 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
return Move(batchMoveRequest);
|
return Move(batchMoveRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override List<IRequestBundle<RequestInformation>> UpdateCategories(BatchMailCategoryAssignmentRequest request)
|
||||||
|
=> ForEachRequest(request, item => CreateMessageCategoryPatchRequest(item.Item.Id, item.CategoryNames));
|
||||||
|
|
||||||
|
public override List<IRequestBundle<RequestInformation>> CreateCategory(MailCategoryCreateRequest request)
|
||||||
|
{
|
||||||
|
var outlookCategory = new OutlookCategory
|
||||||
|
{
|
||||||
|
DisplayName = request.Category.Name,
|
||||||
|
Color = GetOutlookCategoryColor(request.Category)
|
||||||
|
};
|
||||||
|
|
||||||
|
var requestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(outlookCategory);
|
||||||
|
return [new HttpRequestBundle<RequestInformation>(requestInfo, request)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override List<IRequestBundle<RequestInformation>> UpdateCategory(MailCategoryUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
|
||||||
|
return CreateCategory(new MailCategoryCreateRequest(request.Category));
|
||||||
|
|
||||||
|
var hasNameChanged = !string.Equals(request.PreviousName, request.Category.Name, StringComparison.Ordinal);
|
||||||
|
if (!hasNameChanged)
|
||||||
|
{
|
||||||
|
var requestInfo = _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToPatchRequestInformation(new OutlookCategory
|
||||||
|
{
|
||||||
|
Color = GetOutlookCategoryColor(request.Category)
|
||||||
|
});
|
||||||
|
|
||||||
|
return [new HttpRequestBundle<RequestInformation>(requestInfo, request)];
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundles = new List<IRequestBundle<RequestInformation>>();
|
||||||
|
var createRequestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(new OutlookCategory
|
||||||
|
{
|
||||||
|
DisplayName = request.Category.Name,
|
||||||
|
Color = GetOutlookCategoryColor(request.Category)
|
||||||
|
});
|
||||||
|
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(createRequestInfo, request));
|
||||||
|
|
||||||
|
foreach (var target in request.AffectedMessages ?? [])
|
||||||
|
{
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(
|
||||||
|
CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
|
||||||
|
request));
|
||||||
|
}
|
||||||
|
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(
|
||||||
|
_graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
|
||||||
|
request));
|
||||||
|
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override List<IRequestBundle<RequestInformation>> DeleteCategory(MailCategoryDeleteRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var bundles = new List<IRequestBundle<RequestInformation>>();
|
||||||
|
|
||||||
|
foreach (var target in request.AffectedMessages ?? [])
|
||||||
|
{
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(
|
||||||
|
CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
|
||||||
|
request));
|
||||||
|
}
|
||||||
|
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(
|
||||||
|
_graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
|
||||||
|
request));
|
||||||
|
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestInformation CreateMessageCategoryPatchRequest(string messageId, IReadOnlyList<string> categoryNames)
|
||||||
|
=> _graphClient.Me.Messages[messageId].ToPatchRequestInformation(new Message
|
||||||
|
{
|
||||||
|
Categories = categoryNames?.ToList() ?? []
|
||||||
|
});
|
||||||
|
|
||||||
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
|
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
|
||||||
MailKit.ITransferProgress transferProgress = null,
|
MailKit.ITransferProgress transferProgress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -1962,7 +2090,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
for (int i = 0; i < itemCount; i++)
|
for (int i = 0; i < itemCount; i++)
|
||||||
{
|
{
|
||||||
var bundle = batch.ElementAt(i);
|
var bundle = batch.ElementAt(i);
|
||||||
requiresSerial |= bundle.UIChangeRequest is SendDraftRequest;
|
requiresSerial |= bundle.UIChangeRequest is SendDraftRequest
|
||||||
|
or MailCategoryUpdateRequest
|
||||||
|
or MailCategoryDeleteRequest;
|
||||||
|
|
||||||
// UI changes are already applied in ExecuteNativeRequestsAsync before batching.
|
// UI changes are already applied in ExecuteNativeRequestsAsync before batching.
|
||||||
var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest);
|
var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest);
|
||||||
@@ -2110,7 +2240,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|| request is ChangeFlagRequest
|
|| request is ChangeFlagRequest
|
||||||
|| request is MarkReadRequest
|
|| request is MarkReadRequest
|
||||||
|| request is ArchiveRequest
|
|| request is ArchiveRequest
|
||||||
|
|| request is MailCategoryAssignmentRequest
|
||||||
|| request is RenameFolderRequest
|
|| request is RenameFolderRequest
|
||||||
|
|| request is MailCategoryUpdateRequest
|
||||||
|
|| request is MailCategoryDeleteRequest
|
||||||
|| request is DeleteFolderRequest
|
|| request is DeleteFolderRequest
|
||||||
|| request is AcceptEventRequest
|
|| request is AcceptEventRequest
|
||||||
|| request is DeclineEventRequest
|
|| request is DeclineEventRequest
|
||||||
@@ -2165,6 +2298,26 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false);
|
await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle?.UIChangeRequest is MailCategoryCreateRequest createCategoryRequest)
|
||||||
|
{
|
||||||
|
var createdCategoryId = json?["id"]?.GetValue<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(createdCategoryId))
|
||||||
|
{
|
||||||
|
await _mailCategoryService.UpdateRemoteIdAsync(createCategoryRequest.Category.Id, createdCategoryId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle?.UIChangeRequest is MailCategoryUpdateRequest updateCategoryRequest)
|
||||||
|
{
|
||||||
|
var updatedCategoryId = json?["id"]?.GetValue<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(updatedCategoryId))
|
||||||
|
{
|
||||||
|
await _mailCategoryService.UpdateRemoteIdAsync(updateCategoryRequest.Category.Id, updatedCategoryId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2367,11 +2520,68 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
// Outlook messages can only be assigned to 1 folder at a time.
|
// Outlook messages can only be assigned to 1 folder at a time.
|
||||||
// Therefore we don't need to create multiple copies of the same message for different folders.
|
// Therefore we don't need to create multiple copies of the same message for different folders.
|
||||||
var contacts = ExtractContactsFromOutlookMessage(message);
|
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||||
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts);
|
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts, message.Categories);
|
||||||
|
|
||||||
return [package];
|
return [package];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MailCategoryColorOption GetMailCategoryColorOption(CategoryColor? color)
|
||||||
|
=> color switch
|
||||||
|
{
|
||||||
|
CategoryColor.Preset0 => new("#FEE2E2", "#991B1B"),
|
||||||
|
CategoryColor.Preset1 => new("#FFEDD5", "#9A3412"),
|
||||||
|
CategoryColor.Preset2 => new("#FEF3C7", "#92400E"),
|
||||||
|
CategoryColor.Preset3 => new("#ECFCCB", "#3F6212"),
|
||||||
|
CategoryColor.Preset4 => new("#DCFCE7", "#166534"),
|
||||||
|
CategoryColor.Preset5 => new("#CCFBF1", "#115E59"),
|
||||||
|
CategoryColor.Preset6 => new("#CFFAFE", "#155E75"),
|
||||||
|
CategoryColor.Preset7 => new("#DBEAFE", "#1D4ED8"),
|
||||||
|
CategoryColor.Preset8 => new("#E0E7FF", "#4338CA"),
|
||||||
|
CategoryColor.Preset9 => new("#F3E8FF", "#7E22CE"),
|
||||||
|
CategoryColor.Preset10 => new("#FCE7F3", "#9D174D"),
|
||||||
|
CategoryColor.Preset11 => new("#FECACA", "#7F1D1D"),
|
||||||
|
CategoryColor.Preset12 => new("#FED7AA", "#7C2D12"),
|
||||||
|
CategoryColor.Preset13 => new("#FDE68A", "#78350F"),
|
||||||
|
CategoryColor.Preset14 => new("#D9F99D", "#365314"),
|
||||||
|
CategoryColor.Preset15 => new("#BBF7D0", "#14532D"),
|
||||||
|
CategoryColor.Preset16 => new("#99F6E4", "#134E4A"),
|
||||||
|
CategoryColor.Preset17 => new("#A5F3FC", "#164E63"),
|
||||||
|
CategoryColor.Preset18 => new("#BFDBFE", "#1E3A8A"),
|
||||||
|
CategoryColor.Preset19 => new("#DDD6FE", "#5B21B6"),
|
||||||
|
CategoryColor.Preset20 => new("#E5E7EB", "#374151"),
|
||||||
|
CategoryColor.Preset21 => new("#D1D5DB", "#1F2937"),
|
||||||
|
CategoryColor.Preset22 => new("#F3F4F6", "#111827"),
|
||||||
|
CategoryColor.Preset23 => new("#E2E8F0", "#334155"),
|
||||||
|
CategoryColor.Preset24 => new("#F8FAFC", "#475569"),
|
||||||
|
_ => new("#E5E7EB", "#374151")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CategoryColor GetOutlookCategoryColor(MailCategory category)
|
||||||
|
=> (category.BackgroundColorHex?.ToUpperInvariant(), category.TextColorHex?.ToUpperInvariant()) switch
|
||||||
|
{
|
||||||
|
("#FEE2E2", "#991B1B") => CategoryColor.Preset0,
|
||||||
|
("#FFEDD5", "#9A3412") => CategoryColor.Preset1,
|
||||||
|
("#FEF3C7", "#92400E") => CategoryColor.Preset2,
|
||||||
|
("#ECFCCB", "#3F6212") => CategoryColor.Preset3,
|
||||||
|
("#DCFCE7", "#166534") => CategoryColor.Preset4,
|
||||||
|
("#CCFBF1", "#115E59") => CategoryColor.Preset5,
|
||||||
|
("#CFFAFE", "#155E75") => CategoryColor.Preset6,
|
||||||
|
("#DBEAFE", "#1D4ED8") => CategoryColor.Preset7,
|
||||||
|
("#E0E7FF", "#4338CA") => CategoryColor.Preset8,
|
||||||
|
("#F3E8FF", "#7E22CE") => CategoryColor.Preset9,
|
||||||
|
("#FCE7F3", "#9D174D") => CategoryColor.Preset10,
|
||||||
|
("#FECACA", "#7F1D1D") => CategoryColor.Preset11,
|
||||||
|
("#FED7AA", "#7C2D12") => CategoryColor.Preset12,
|
||||||
|
("#FDE68A", "#78350F") => CategoryColor.Preset13,
|
||||||
|
("#D9F99D", "#365314") => CategoryColor.Preset14,
|
||||||
|
("#BBF7D0", "#14532D") => CategoryColor.Preset15,
|
||||||
|
("#99F6E4", "#134E4A") => CategoryColor.Preset16,
|
||||||
|
("#A5F3FC", "#164E63") => CategoryColor.Preset17,
|
||||||
|
("#BFDBFE", "#1E3A8A") => CategoryColor.Preset18,
|
||||||
|
("#DDD6FE", "#5B21B6") => CategoryColor.Preset19,
|
||||||
|
_ => CategoryColor.Preset0
|
||||||
|
};
|
||||||
|
|
||||||
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||||
@@ -2674,6 +2884,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
{
|
{
|
||||||
existingLocalCalendar.Name = calendar.Name;
|
existingLocalCalendar.Name = calendar.Name;
|
||||||
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
existingLocalCalendar.IsReadOnly = !calendar.CanEdit.GetValueOrDefault(true);
|
||||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||||
|
|
||||||
@@ -2712,12 +2923,14 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
var remoteCalendarName = calendar.Name;
|
var remoteCalendarName = calendar.Name;
|
||||||
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteOutlookCalendarBackgroundColor(calendar), accountCalendar);
|
var remoteBackgroundColor = ResolveSynchronizedCalendarBackgroundColor(GetRemoteOutlookCalendarBackgroundColor(calendar), accountCalendar);
|
||||||
var remoteIsPrimary = string.Equals(calendar.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
var remoteIsPrimary = string.Equals(calendar.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var remoteIsReadOnly = !calendar.CanEdit.GetValueOrDefault(true);
|
||||||
|
|
||||||
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
|
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
|
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
|
||||||
|
bool isReadOnlyChanged = accountCalendar.IsReadOnly != remoteIsReadOnly;
|
||||||
|
|
||||||
return isNameChanged || isBackgroundColorChanged || isPrimaryChanged;
|
return isNameChanged || isBackgroundColorChanged || isPrimaryChanged || isReadOnlyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetRemoteOutlookCalendarBackgroundColor(Calendar calendar)
|
private static string GetRemoteOutlookCalendarBackgroundColor(Calendar calendar)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using Wino.Core.Domain.Models.MailItem;
|
|||||||
using Wino.Core.Domain.Models.Synchronization;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
using Wino.Core.Requests.Bundles;
|
using Wino.Core.Requests.Bundles;
|
||||||
using Wino.Core.Requests.Calendar;
|
using Wino.Core.Requests.Calendar;
|
||||||
|
using Wino.Core.Requests.Category;
|
||||||
using Wino.Core.Requests.Folder;
|
using Wino.Core.Requests.Folder;
|
||||||
using Wino.Core.Requests.Mail;
|
using Wino.Core.Requests.Mail;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
@@ -63,6 +64,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
/// Only available for Gmail right now.
|
/// Only available for Gmail right now.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
|
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
|
||||||
|
protected virtual Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Queues all mail ids for initial synchronization for a specific folder.
|
/// Queues all mail ids for initial synchronization for a specific folder.
|
||||||
@@ -194,6 +196,9 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
case MailSynchronizerOperation.Archive:
|
case MailSynchronizerOperation.Archive:
|
||||||
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
|
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
|
||||||
break;
|
break;
|
||||||
|
case MailSynchronizerOperation.UpdateCategories:
|
||||||
|
nativeRequests.AddRange(UpdateCategories(new BatchMailCategoryAssignmentRequest(group.Cast<MailCategoryAssignmentRequest>())));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -221,6 +226,23 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (key is CategorySynchronizerOperation categorySynchronizerOperation)
|
||||||
|
{
|
||||||
|
switch (categorySynchronizerOperation)
|
||||||
|
{
|
||||||
|
case CategorySynchronizerOperation.CreateCategory:
|
||||||
|
nativeRequests.AddRange(CreateCategory(group.ElementAt(0) as MailCategoryCreateRequest));
|
||||||
|
break;
|
||||||
|
case CategorySynchronizerOperation.UpdateCategory:
|
||||||
|
nativeRequests.AddRange(UpdateCategory(group.ElementAt(0) as MailCategoryUpdateRequest));
|
||||||
|
break;
|
||||||
|
case CategorySynchronizerOperation.DeleteCategory:
|
||||||
|
nativeRequests.AddRange(DeleteCategory(group.ElementAt(0) as MailCategoryDeleteRequest));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeRequestQueue.Clear();
|
changeRequestQueue.Clear();
|
||||||
@@ -322,6 +344,30 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category definition sync.
|
||||||
|
if (options.Type == MailSynchronizationType.Categories)
|
||||||
|
{
|
||||||
|
if (!Account.IsCategorySyncSupported) return MailSynchronizationResult.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SynchronizeCategoriesAsync(activeSynchronizationCancellationToken);
|
||||||
|
|
||||||
|
return FinalizeMailResult(MailSynchronizationResult.Empty);
|
||||||
|
}
|
||||||
|
catch (AuthenticationAttentionException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to update categories for {Name}", Account.Name);
|
||||||
|
|
||||||
|
CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "CategorySync"));
|
||||||
|
return FinalizeMailResult(MailSynchronizationResult.Failed(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldDelayExecution)
|
if (shouldDelayExecution)
|
||||||
{
|
{
|
||||||
await Task.Delay(maxExecutionDelay);
|
await Task.Delay(maxExecutionDelay);
|
||||||
@@ -526,6 +572,16 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
/// <returns>New synchronization options with minimal HTTP effort.</returns>
|
/// <returns>New synchronization options with minimal HTTP effort.</returns>
|
||||||
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId)
|
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId)
|
||||||
{
|
{
|
||||||
|
if (requests.All(a => a is ICategoryActionRequest or MailCategoryAssignmentRequest))
|
||||||
|
{
|
||||||
|
return new MailSynchronizationOptions
|
||||||
|
{
|
||||||
|
AccountId = Account.Id,
|
||||||
|
Id = existingSynchronizationId,
|
||||||
|
Type = MailSynchronizationType.FoldersOnly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
List<Guid> synchronizationFolderIds = requests
|
List<Guid> synchronizationFolderIds = requests
|
||||||
.Where(a => a is ICustomFolderSynchronizationRequest)
|
.Where(a => a is ICustomFolderSynchronizationRequest)
|
||||||
.Cast<ICustomFolderSynchronizationRequest>()
|
.Cast<ICustomFolderSynchronizationRequest>()
|
||||||
@@ -602,6 +658,10 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
|
public virtual List<IRequestBundle<TBaseRequest>> UpdateCategories(BatchMailCategoryAssignmentRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
|
public virtual List<IRequestBundle<TBaseRequest>> CreateCategory(MailCategoryCreateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
|
public virtual List<IRequestBundle<TBaseRequest>> UpdateCategory(MailCategoryUpdateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
|
public virtual List<IRequestBundle<TBaseRequest>> DeleteCategory(MailCategoryDeleteRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
|
|||||||
private void EditAliases()
|
private void EditAliases()
|
||||||
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id));
|
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id));
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void EditCategories()
|
||||||
|
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id));
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void EditImapCalDavSettings()
|
private void EditImapCalDavSettings()
|
||||||
=> Messenger.Send(new BreadcrumbNavigationRequested(
|
=> Messenger.Send(new BreadcrumbNavigationRequested(
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
|
|||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile });
|
||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount });
|
||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders });
|
||||||
|
if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook)
|
||||||
|
{
|
||||||
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories });
|
||||||
|
}
|
||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
|
||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases });
|
||||||
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
|
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
|
||||||
@@ -229,6 +233,16 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
|
|||||||
throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
|
throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
|
||||||
SetCurrentStepSucceeded();
|
SetCurrentStepSucceeded();
|
||||||
|
|
||||||
|
// Step: Categories
|
||||||
|
if (_createdAccount.IsCategorySyncSupported)
|
||||||
|
{
|
||||||
|
SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories);
|
||||||
|
var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id);
|
||||||
|
if (categoryResult.CompletedState != SynchronizationCompletedState.Success)
|
||||||
|
throw new Exception(Translator.Exception_FailedToSynchronizeCategories);
|
||||||
|
SetCurrentStepSucceeded();
|
||||||
|
}
|
||||||
|
|
||||||
// Step: Calendar metadata
|
// Step: Calendar metadata
|
||||||
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
|
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
|
||||||
if (_createdAccount.IsCalendarAccessGranted)
|
if (_createdAccount.IsCalendarAccessGranted)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
|
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
|
||||||
|
|
||||||
private readonly IFolderService _folderService;
|
private readonly IFolderService _folderService;
|
||||||
|
private readonly IMailCategoryService _mailCategoryService;
|
||||||
private readonly IConfigurationService _configurationService;
|
private readonly IConfigurationService _configurationService;
|
||||||
private readonly IStartupBehaviorService _startupBehaviorService;
|
private readonly IStartupBehaviorService _startupBehaviorService;
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
@@ -99,6 +100,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
IMimeFileService mimeFileService,
|
IMimeFileService mimeFileService,
|
||||||
INativeAppService nativeAppService,
|
INativeAppService nativeAppService,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
|
IMailCategoryService mailCategoryService,
|
||||||
IAccountService accountService,
|
IAccountService accountService,
|
||||||
IContextMenuItemService contextMenuItemService,
|
IContextMenuItemService contextMenuItemService,
|
||||||
IStoreRatingService storeRatingService,
|
IStoreRatingService storeRatingService,
|
||||||
@@ -125,6 +127,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
_mimeFileService = mimeFileService;
|
_mimeFileService = mimeFileService;
|
||||||
_nativeAppService = nativeAppService;
|
_nativeAppService = nativeAppService;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
|
_mailCategoryService = mailCategoryService;
|
||||||
_folderService = folderService;
|
_folderService = folderService;
|
||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
_contextMenuItemService = contextMenuItemService;
|
_contextMenuItemService = contextMenuItemService;
|
||||||
@@ -721,7 +724,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
{
|
{
|
||||||
await HandleCreateNewMailAsync();
|
await HandleCreateNewMailAsync();
|
||||||
}
|
}
|
||||||
else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem && baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget))
|
else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem &&
|
||||||
|
(clickedMenuItem is IMailCategoryMenuItem or IMergedMailCategoryMenuItem || baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget)))
|
||||||
{
|
{
|
||||||
// Don't navigate to base folders that contain non-move target folders.
|
// Don't navigate to base folders that contain non-move target folders.
|
||||||
// Theory: This is a special folder like Categories or More. Don't navigate to it.
|
// Theory: This is a special folder like Categories or More. Don't navigate to it.
|
||||||
@@ -793,11 +797,20 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
{
|
{
|
||||||
// Get visible account menu items, ordered by merged accounts at the last.
|
// Get visible account menu items, ordered by merged accounts at the last.
|
||||||
// We will update the unread counts for all single accounts and trigger UI refresh for merged menu items.
|
// We will update the unread counts for all single accounts and trigger UI refresh for merged menu items.
|
||||||
var accountMenuItems = MenuItems.GetAllAccountMenuItems().OrderBy(a => a.HoldingAccounts.Count());
|
List<IAccountMenuItem> accountMenuItems = null;
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
accountMenuItems = MenuItems
|
||||||
|
.GetAllAccountMenuItems()
|
||||||
|
.OrderBy(a => a.HoldingAccounts.Count())
|
||||||
|
.ToList();
|
||||||
|
});
|
||||||
|
|
||||||
// Individually get all single accounts' unread counts.
|
// Individually get all single accounts' unread counts.
|
||||||
var accountIds = accountMenuItems.OfType<AccountMenuItem>().Select(a => a.AccountId);
|
var accountIds = accountMenuItems.OfType<AccountMenuItem>().Select(a => a.AccountId).ToList();
|
||||||
var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false);
|
var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false);
|
||||||
|
var unreadCategoryCountResult = await _mailCategoryService.GetUnreadCategoryCountResultsAsync(accountIds).ConfigureAwait(false);
|
||||||
|
|
||||||
// Recursively update all folders' unread counts to 0.
|
// Recursively update all folders' unread counts to 0.
|
||||||
// Query above only returns unread counts that exists. We need to reset the rest to 0 first.
|
// Query above only returns unread counts that exists. We need to reset the rest to 0 first.
|
||||||
@@ -849,6 +862,29 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var unreadCategoryCount in unreadCategoryCountResult)
|
||||||
|
{
|
||||||
|
if (MenuItems.TryGetCategoryMenuItem(unreadCategoryCount.CategoryId, out var categoryMenuItem))
|
||||||
|
{
|
||||||
|
if (categoryMenuItem is IMergedMailCategoryMenuItem mergedCategoryMenuItem)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
categoryMenuItem.UnreadItemCount = unreadCategoryCountResult
|
||||||
|
.Where(a => mergedCategoryMenuItem.Categories.Any(b => b.Id == a.CategoryId))
|
||||||
|
.Sum(a => a.UnreadItemCount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
categoryMenuItem.UnreadItemCount = unreadCategoryCount.UnreadItemCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update unread badge after all unread counts are updated.
|
// Update unread badge after all unread counts are updated.
|
||||||
await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
|
await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
|
using Wino.Core.Requests.Category;
|
||||||
|
using Wino.Core.Services;
|
||||||
|
|
||||||
|
namespace Wino.Mail.ViewModels;
|
||||||
|
|
||||||
|
public partial class MailCategoryManagementPageViewModel : MailBaseViewModel
|
||||||
|
{
|
||||||
|
private readonly IMailCategoryService _mailCategoryService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly IMailDialogService _dialogService;
|
||||||
|
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanRefresh))]
|
||||||
|
public partial MailAccount Account { get; set; }
|
||||||
|
|
||||||
|
public ObservableCollection<MailCategory> Categories { get; } = [];
|
||||||
|
|
||||||
|
public bool CanRefresh => Account?.ProviderType == MailProviderType.Outlook;
|
||||||
|
public bool HasCategories => Categories.Count > 0;
|
||||||
|
|
||||||
|
public MailCategoryManagementPageViewModel(
|
||||||
|
IMailCategoryService mailCategoryService,
|
||||||
|
IAccountService accountService,
|
||||||
|
IMailDialogService dialogService,
|
||||||
|
IWinoRequestDelegator winoRequestDelegator)
|
||||||
|
{
|
||||||
|
_mailCategoryService = mailCategoryService;
|
||||||
|
_accountService = accountService;
|
||||||
|
_dialogService = dialogService;
|
||||||
|
_winoRequestDelegator = winoRequestDelegator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(mode, parameters);
|
||||||
|
|
||||||
|
if (parameters is not Guid accountId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (Account != null)
|
||||||
|
{
|
||||||
|
await LoadCategoriesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task AddCategoryAsync()
|
||||||
|
=> CreateOrUpdateCategoryAsync();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshCategoriesAsync()
|
||||||
|
{
|
||||||
|
if (!CanRefresh)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var shouldContinue = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
|
Translator.MailCategoryManagementPage_RefreshConfirmationMessage,
|
||||||
|
Translator.Buttons_Refresh,
|
||||||
|
Translator.Buttons_Refresh).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!shouldContinue)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _mailCategoryService.DeleteCategoriesAsync(Account.Id).ConfigureAwait(false);
|
||||||
|
await SynchronizationManager.Instance.SynchronizeCategoriesAsync(Account.Id).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await LoadCategoriesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task EditCategoryAsync(MailCategory category)
|
||||||
|
=> CreateOrUpdateCategoryAsync(category);
|
||||||
|
|
||||||
|
public async Task DeleteCategoryAsync(MailCategory category)
|
||||||
|
{
|
||||||
|
if (category == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
|
string.Format(Translator.MailCategoryManagementPage_DeleteConfirmationMessage, category.Name),
|
||||||
|
Translator.MailCategoryManagementPage_DeleteConfirmationTitle,
|
||||||
|
Translator.Buttons_Delete).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!shouldDelete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var deleteRequest = await BuildDeleteCategoryRequestAsync(category).ConfigureAwait(false);
|
||||||
|
await _mailCategoryService.DeleteCategoryAsync(category.Id).ConfigureAwait(false);
|
||||||
|
await QueueOutlookCategoryRequestsAsync(deleteRequest).ConfigureAwait(false);
|
||||||
|
await LoadCategoriesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetFavoriteAsync(MailCategory category, bool isFavorite)
|
||||||
|
{
|
||||||
|
if (category == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _mailCategoryService.ToggleFavoriteAsync(category.Id, isFavorite).ConfigureAwait(false);
|
||||||
|
await LoadCategoriesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateOrUpdateCategoryAsync(MailCategory existingCategory = null)
|
||||||
|
{
|
||||||
|
var dialogResult = await _dialogService.ShowEditMailCategoryDialogAsync(existingCategory).ConfigureAwait(false);
|
||||||
|
if (dialogResult == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(dialogResult.Name))
|
||||||
|
{
|
||||||
|
await _dialogService.ShowMessageAsync(
|
||||||
|
Translator.MailCategoryDialog_InvalidNameMessage,
|
||||||
|
Translator.MailCategoryDialog_InvalidNameTitle,
|
||||||
|
WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedName = dialogResult.Name.Trim();
|
||||||
|
var categoryIdToExclude = existingCategory?.Id;
|
||||||
|
var alreadyExists = await _mailCategoryService.CategoryNameExistsAsync(Account.Id, normalizedName, categoryIdToExclude).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (alreadyExists)
|
||||||
|
{
|
||||||
|
await _dialogService.ShowMessageAsync(
|
||||||
|
Translator.MailCategoryDialog_DuplicateMessage,
|
||||||
|
Translator.MailCategoryDialog_DuplicateTitle,
|
||||||
|
WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCategory == null)
|
||||||
|
{
|
||||||
|
var newCategory = new MailCategory
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MailAccountId = Account.Id,
|
||||||
|
Name = normalizedName,
|
||||||
|
BackgroundColorHex = dialogResult.BackgroundColorHex,
|
||||||
|
TextColorHex = dialogResult.TextColorHex,
|
||||||
|
Source = Account.ProviderType == MailProviderType.Outlook ? MailCategorySource.Outlook : MailCategorySource.Local
|
||||||
|
};
|
||||||
|
|
||||||
|
await _mailCategoryService.CreateCategoryAsync(newCategory).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (Account.ProviderType == MailProviderType.Outlook)
|
||||||
|
{
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(newCategory)]).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var previousName = existingCategory.Name;
|
||||||
|
var previousRemoteId = existingCategory.RemoteId;
|
||||||
|
|
||||||
|
existingCategory.Name = normalizedName;
|
||||||
|
existingCategory.BackgroundColorHex = dialogResult.BackgroundColorHex;
|
||||||
|
existingCategory.TextColorHex = dialogResult.TextColorHex;
|
||||||
|
|
||||||
|
await _mailCategoryService.UpdateCategoryAsync(existingCategory).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (Account.ProviderType == MailProviderType.Outlook)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(previousRemoteId))
|
||||||
|
{
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(existingCategory)]).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var affectedMessages = await BuildAffectedMessageTargetsAsync(existingCategory.Id).ConfigureAwait(false);
|
||||||
|
var updateRequest = new MailCategoryUpdateRequest(existingCategory, previousName, previousRemoteId, affectedMessages);
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(Account.Id, [updateRequest]).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadCategoriesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MailCategoryDeleteRequest> BuildDeleteCategoryRequestAsync(MailCategory category)
|
||||||
|
{
|
||||||
|
if (category == null || Account?.ProviderType != MailProviderType.Outlook)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(category.Id).ConfigureAwait(false);
|
||||||
|
var affectedMessages = new List<MailCategoryMessageUpdateTarget>();
|
||||||
|
|
||||||
|
foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
|
||||||
|
{
|
||||||
|
var remainingNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
|
||||||
|
var categoryNames = remainingNames
|
||||||
|
.Where(a => !string.Equals(a, category.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MailCategoryDeleteRequest(category, category.RemoteId, affectedMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<MailCategoryMessageUpdateTarget>> BuildAffectedMessageTargetsAsync(Guid categoryId)
|
||||||
|
{
|
||||||
|
var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(categoryId).ConfigureAwait(false);
|
||||||
|
var affectedMessages = new List<MailCategoryMessageUpdateTarget>();
|
||||||
|
|
||||||
|
foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
|
||||||
|
{
|
||||||
|
var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
|
||||||
|
affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
return affectedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task QueueOutlookCategoryRequestsAsync(params IRequestBase[] requests)
|
||||||
|
=> Account?.ProviderType == MailProviderType.Outlook && requests.Any(a => a != null)
|
||||||
|
? _winoRequestDelegator.ExecuteAsync(Account.Id, requests.Where(a => a != null))
|
||||||
|
: Task.CompletedTask;
|
||||||
|
|
||||||
|
private async Task LoadCategoriesAsync()
|
||||||
|
{
|
||||||
|
var categories = await _mailCategoryService.GetCategoriesAsync(Account.Id).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
Categories.Clear();
|
||||||
|
|
||||||
|
foreach (var category in categories)
|
||||||
|
{
|
||||||
|
Categories.Add(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasCategories));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ using Wino.Core.Domain.Models.MailItem;
|
|||||||
using Wino.Core.Domain.Models.Menus;
|
using Wino.Core.Domain.Models.Menus;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
using Wino.Core.Domain.Models.Reader;
|
using Wino.Core.Domain.Models.Reader;
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
using Wino.Core.Requests.Mail;
|
||||||
using Wino.Core.Services;
|
using Wino.Core.Services;
|
||||||
using Wino.Mail.ViewModels.Collections;
|
using Wino.Mail.ViewModels.Collections;
|
||||||
using Wino.Mail.ViewModels.Data;
|
using Wino.Mail.ViewModels.Data;
|
||||||
@@ -77,6 +77,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
private readonly INotificationBuilder _notificationBuilder;
|
private readonly INotificationBuilder _notificationBuilder;
|
||||||
private readonly IFolderService _folderService;
|
private readonly IFolderService _folderService;
|
||||||
private readonly IContextMenuItemService _contextMenuItemService;
|
private readonly IContextMenuItemService _contextMenuItemService;
|
||||||
|
private readonly IMailCategoryService _mailCategoryService;
|
||||||
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
private readonly IWinoRequestDelegator _winoRequestDelegator;
|
||||||
private readonly IKeyPressService _keyPressService;
|
private readonly IKeyPressService _keyPressService;
|
||||||
private readonly IWinoLogger _winoLogger;
|
private readonly IWinoLogger _winoLogger;
|
||||||
@@ -156,6 +157,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
|
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCategoryView))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsSyncButtonVisible))]
|
||||||
public partial IBaseFolderMenuItem ActiveFolder { get; set; }
|
public partial IBaseFolderMenuItem ActiveFolder { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -172,6 +175,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
INotificationBuilder notificationBuilder,
|
INotificationBuilder notificationBuilder,
|
||||||
IFolderService folderService,
|
IFolderService folderService,
|
||||||
IContextMenuItemService contextMenuItemService,
|
IContextMenuItemService contextMenuItemService,
|
||||||
|
IMailCategoryService mailCategoryService,
|
||||||
IWinoRequestDelegator winoRequestDelegator,
|
IWinoRequestDelegator winoRequestDelegator,
|
||||||
IKeyPressService keyPressService,
|
IKeyPressService keyPressService,
|
||||||
IPreferencesService preferencesService,
|
IPreferencesService preferencesService,
|
||||||
@@ -185,6 +189,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
_mimeFileService = mimeFileService;
|
_mimeFileService = mimeFileService;
|
||||||
_folderService = folderService;
|
_folderService = folderService;
|
||||||
_contextMenuItemService = contextMenuItemService;
|
_contextMenuItemService = contextMenuItemService;
|
||||||
|
_mailCategoryService = mailCategoryService;
|
||||||
_winoRequestDelegator = winoRequestDelegator;
|
_winoRequestDelegator = winoRequestDelegator;
|
||||||
_keyPressService = keyPressService;
|
_keyPressService = keyPressService;
|
||||||
|
|
||||||
@@ -277,9 +282,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanSynchronize => !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
|
public bool CanSynchronize => !IsCategoryView && !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
|
||||||
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
|
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
|
||||||
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
|
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
|
||||||
|
public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
|
||||||
|
public bool IsSyncButtonVisible => !IsCategoryView;
|
||||||
|
|
||||||
public string SelectedMessageText => IsDragInProgress
|
public string SelectedMessageText => IsDragInProgress
|
||||||
? string.Format(Translator.MailsDragging, DraggingItemsCount)
|
? string.Format(Translator.MailsDragging, DraggingItemsCount)
|
||||||
@@ -396,9 +403,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (IsCategoryView)
|
||||||
|
{
|
||||||
|
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
|
||||||
|
}
|
||||||
// Merged folders don't support focused feature.
|
// Merged folders don't support focused feature.
|
||||||
|
else if (ActiveFolder is IMergedAccountFolderMenuItem)
|
||||||
if (ActiveFolder is IMergedAccountFolderMenuItem)
|
|
||||||
{
|
{
|
||||||
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
|
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
|
||||||
}
|
}
|
||||||
@@ -475,26 +485,29 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
{
|
{
|
||||||
if (!CanSynchronize) return;
|
if (!CanSynchronize) return;
|
||||||
|
|
||||||
|
_notificationBuilder.CreateNotificationsAsync(MailCollection.SelectedItems.Select(a => a.MailCopy));
|
||||||
|
return;
|
||||||
|
|
||||||
// Only synchronize listed folders.
|
// Only synchronize listed folders.
|
||||||
|
|
||||||
// When doing linked inbox sync, we need to save the sync id to report progress back only once.
|
// When doing linked inbox sync, we need to save the sync id to report progress back only once.
|
||||||
// Otherwise, we will report progress for each folder and that's what we don't want.
|
// Otherwise, we will report progress for each folder and that's what we don't want.
|
||||||
|
|
||||||
trackingSynchronizationId = Guid.NewGuid();
|
//trackingSynchronizationId = Guid.NewGuid();
|
||||||
completedTrackingSynchronizationCount = 0;
|
//completedTrackingSynchronizationCount = 0;
|
||||||
|
|
||||||
foreach (var folder in ActiveFolder.HandlingFolders)
|
//foreach (var folder in ActiveFolder.HandlingFolders)
|
||||||
{
|
//{
|
||||||
var options = new MailSynchronizationOptions()
|
// var options = new MailSynchronizationOptions()
|
||||||
{
|
// {
|
||||||
AccountId = folder.MailAccountId,
|
// AccountId = folder.MailAccountId,
|
||||||
Type = MailSynchronizationType.CustomFolders,
|
// Type = MailSynchronizationType.CustomFolders,
|
||||||
SynchronizationFolderIds = [folder.Id],
|
// SynchronizationFolderIds = [folder.Id],
|
||||||
GroupedSynchronizationTrackingId = trackingSynchronizationId
|
// GroupedSynchronizationTrackingId = trackingSynchronizationId
|
||||||
};
|
// };
|
||||||
|
|
||||||
Messenger.Send(new NewMailSynchronizationRequested(options));
|
// Messenger.Send(new NewMailSynchronizationRequested(options));
|
||||||
}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -545,7 +558,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task EnableFolderSynchronizationAsync()
|
private async Task EnableFolderSynchronizationAsync()
|
||||||
{
|
{
|
||||||
if (ActiveFolder == null) return;
|
if (ActiveFolder == null || IsCategoryView) return;
|
||||||
|
|
||||||
foreach (var folder in ActiveFolder.HandlingFolders)
|
foreach (var folder in ActiveFolder.HandlingFolders)
|
||||||
{
|
{
|
||||||
@@ -561,13 +574,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
Debug.WriteLine("Loading more...");
|
Debug.WriteLine("Loading more...");
|
||||||
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
||||||
|
|
||||||
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
|
var initializationOptions = CreateInitializationOptions(
|
||||||
SelectedFilterOption.Type,
|
IsInSearchMode ? SearchQuery : string.Empty,
|
||||||
SelectedSortingOption.Type,
|
MailCollection.MailCopyIdHashSet);
|
||||||
PreferencesService.IsThreadingEnabled,
|
|
||||||
SelectedFolderPivot.IsFocused,
|
|
||||||
IsInSearchMode ? SearchQuery : string.Empty,
|
|
||||||
MailCollection.MailCopyIdHashSet);
|
|
||||||
|
|
||||||
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
|
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -674,6 +683,60 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<MailItemViewModel> contextMailItems)
|
public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<MailItemViewModel> contextMailItems)
|
||||||
=> _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy));
|
=> _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy));
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<MailCategory> Categories, IReadOnlyCollection<Guid> AssignedCategoryIds)> GetAvailableCategoriesAsync(IEnumerable<MailItemViewModel> targetItems)
|
||||||
|
{
|
||||||
|
var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
|
||||||
|
if (targetList.Count == 0)
|
||||||
|
return ([], []);
|
||||||
|
|
||||||
|
var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
|
||||||
|
if (accountIds.Count != 1)
|
||||||
|
return ([], []);
|
||||||
|
|
||||||
|
var accountId = accountIds[0];
|
||||||
|
var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
|
||||||
|
|
||||||
|
var categories = await _mailCategoryService.GetCategoriesAsync(accountId).ConfigureAwait(false);
|
||||||
|
var assignedCategoryIds = await _mailCategoryService.GetAssignedCategoryIdsForAllAsync(uniqueIds).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return (categories, assignedCategoryIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ToggleCategoryAssignmentAsync(MailCategory category, IEnumerable<MailItemViewModel> targetItems, bool isAssignedToAll)
|
||||||
|
{
|
||||||
|
var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
|
||||||
|
if (category == null || targetList.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
|
||||||
|
if (accountIds.Count != 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var accountId = accountIds[0];
|
||||||
|
var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
|
||||||
|
|
||||||
|
if (isAssignedToAll)
|
||||||
|
{
|
||||||
|
await _mailCategoryService.UnassignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _mailCategoryService.AssignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetList.First().MailCopy.AssignedAccount.ProviderType != MailProviderType.Outlook)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var requests = new List<IRequestBase>();
|
||||||
|
foreach (var mailItem in targetList.Select(a => a.MailCopy).DistinctBy(a => a.UniqueId))
|
||||||
|
{
|
||||||
|
var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailItem.UniqueId).ConfigureAwait(false);
|
||||||
|
requests.Add(new MailCategoryAssignmentRequest(mailItem, category.Id, category.Name, categoryNames, !isAssignedToAll));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private bool ShouldPreventItemAdd(MailCopy mailItem)
|
private bool ShouldPreventItemAdd(MailCopy mailItem)
|
||||||
{
|
{
|
||||||
bool condition = mailItem.IsRead
|
bool condition = mailItem.IsRead
|
||||||
@@ -691,7 +754,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
|
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
|
||||||
|
|
||||||
private bool BelongsToActiveFolder(MailCopy mailItem)
|
private bool BelongsToActiveFolder(MailCopy mailItem)
|
||||||
=> mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
|
=> !IsCategoryView && mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
|
||||||
|
|
||||||
private bool ShouldIncludeByThread(MailCopy mailItem)
|
private bool ShouldIncludeByThread(MailCopy mailItem)
|
||||||
=> PreferencesService.IsThreadingEnabled
|
=> PreferencesService.IsThreadingEnabled
|
||||||
@@ -1069,6 +1132,38 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MailListInitializationOptions CreateInitializationOptions(
|
||||||
|
string searchQuery,
|
||||||
|
System.Collections.Concurrent.ConcurrentDictionary<Guid, bool> existingUniqueIds,
|
||||||
|
List<MailCopy> preFetchedMailCopies = null,
|
||||||
|
bool deduplicateByServerId = false)
|
||||||
|
{
|
||||||
|
var options = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
|
||||||
|
SelectedFilterOption.Type,
|
||||||
|
SelectedSortingOption.Type,
|
||||||
|
PreferencesService.IsThreadingEnabled,
|
||||||
|
SelectedFolderPivot.IsFocused,
|
||||||
|
searchQuery,
|
||||||
|
existingUniqueIds,
|
||||||
|
preFetchedMailCopies,
|
||||||
|
DeduplicateByServerId: deduplicateByServerId);
|
||||||
|
|
||||||
|
if (!IsCategoryView)
|
||||||
|
return options;
|
||||||
|
|
||||||
|
var categoryIds = ActiveFolder switch
|
||||||
|
{
|
||||||
|
IMailCategoryMenuItem singleCategoryMenuItem => new List<Guid> { singleCategoryMenuItem.MailCategory.Id },
|
||||||
|
IMergedMailCategoryMenuItem mergedCategoryMenuItem => mergedCategoryMenuItem.Categories.Select(a => a.Id).ToList(),
|
||||||
|
_ => []
|
||||||
|
};
|
||||||
|
|
||||||
|
return options with
|
||||||
|
{
|
||||||
|
CategoryIds = categoryIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task PerformOnlineSearchAsync()
|
private async Task PerformOnlineSearchAsync()
|
||||||
{
|
{
|
||||||
@@ -1218,15 +1313,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
|
var initializationOptions = CreateInitializationOptions(
|
||||||
SelectedFilterOption.Type,
|
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
||||||
SelectedSortingOption.Type,
|
MailCollection.MailCopyIdHashSet,
|
||||||
PreferencesService.IsThreadingEnabled,
|
onlineSearchItems,
|
||||||
SelectedFolderPivot.IsFocused,
|
isDoingOnlineSearch);
|
||||||
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
|
||||||
MailCollection.MailCopyIdHashSet,
|
|
||||||
onlineSearchItems,
|
|
||||||
DeduplicateByServerId: isDoingOnlineSearch);
|
|
||||||
|
|
||||||
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
|
||||||
|
namespace Wino.Mail.ViewModels;
|
||||||
|
|
||||||
|
public partial class MailNotificationSettingsPageViewModel : MailBaseViewModel
|
||||||
|
{
|
||||||
|
private static readonly MailOperation[] SupportedMailNotificationActions =
|
||||||
|
[
|
||||||
|
MailOperation.MarkAsRead,
|
||||||
|
MailOperation.SoftDelete,
|
||||||
|
MailOperation.MoveToJunk,
|
||||||
|
MailOperation.Archive,
|
||||||
|
MailOperation.Reply,
|
||||||
|
MailOperation.ReplyAll,
|
||||||
|
MailOperation.Forward
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly IPreferencesService _preferencesService;
|
||||||
|
private bool _isUpdatingSelection;
|
||||||
|
private bool _isLoaded;
|
||||||
|
|
||||||
|
public ObservableCollection<MailNotificationActionOption> AvailableNotificationActions { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial MailNotificationActionOption SelectedFirstAction { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial MailNotificationActionOption SelectedSecondAction { get; set; }
|
||||||
|
|
||||||
|
public MailNotificationSettingsPageViewModel(IPreferencesService preferencesService)
|
||||||
|
{
|
||||||
|
_preferencesService = preferencesService;
|
||||||
|
|
||||||
|
foreach (var action in SupportedMailNotificationActions)
|
||||||
|
{
|
||||||
|
AvailableNotificationActions.Add(new MailNotificationActionOption(action, GetOperationDisplayText(action)));
|
||||||
|
}
|
||||||
|
|
||||||
|
InitializeSelections();
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedFirstActionChanged(MailNotificationActionOption value)
|
||||||
|
{
|
||||||
|
if (!_isLoaded || _isUpdatingSelection || value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EnsureDistinctSelections(changedSelection: value, isFirstSelection: true);
|
||||||
|
_preferencesService.FirstMailNotificationAction = value.Operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedSecondActionChanged(MailNotificationActionOption value)
|
||||||
|
{
|
||||||
|
if (!_isLoaded || _isUpdatingSelection || value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EnsureDistinctSelections(changedSelection: value, isFirstSelection: false);
|
||||||
|
_preferencesService.SecondMailNotificationAction = value.Operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeSelections()
|
||||||
|
{
|
||||||
|
var firstAction = ResolveSupportedAction(_preferencesService.FirstMailNotificationAction, MailOperation.MarkAsRead);
|
||||||
|
var secondAction = ResolveSupportedAction(_preferencesService.SecondMailNotificationAction, MailOperation.SoftDelete);
|
||||||
|
|
||||||
|
if (secondAction == firstAction)
|
||||||
|
{
|
||||||
|
secondAction = GetFallbackDistinctAction(firstAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedFirstAction = GetOption(firstAction);
|
||||||
|
SelectedSecondAction = GetOption(secondAction);
|
||||||
|
|
||||||
|
_preferencesService.FirstMailNotificationAction = firstAction;
|
||||||
|
_preferencesService.SecondMailNotificationAction = secondAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDistinctSelections(MailNotificationActionOption changedSelection, bool isFirstSelection)
|
||||||
|
{
|
||||||
|
var otherSelection = isFirstSelection ? SelectedSecondAction : SelectedFirstAction;
|
||||||
|
if (otherSelection?.Operation != changedSelection.Operation)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isUpdatingSelection = true;
|
||||||
|
|
||||||
|
var fallbackAction = GetFallbackDistinctAction(changedSelection.Operation);
|
||||||
|
var fallbackOption = GetOption(fallbackAction);
|
||||||
|
|
||||||
|
if (isFirstSelection)
|
||||||
|
{
|
||||||
|
SelectedSecondAction = fallbackOption;
|
||||||
|
_preferencesService.SecondMailNotificationAction = fallbackAction;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedFirstAction = fallbackOption;
|
||||||
|
_preferencesService.FirstMailNotificationAction = fallbackAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isUpdatingSelection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MailNotificationActionOption GetOption(MailOperation action)
|
||||||
|
=> AvailableNotificationActions.First(option => option.Operation == action);
|
||||||
|
|
||||||
|
private static MailOperation ResolveSupportedAction(MailOperation action, MailOperation fallbackAction)
|
||||||
|
=> SupportedMailNotificationActions.Contains(action) ? action : fallbackAction;
|
||||||
|
|
||||||
|
private static MailOperation GetFallbackDistinctAction(MailOperation excludedAction)
|
||||||
|
=> SupportedMailNotificationActions.First(action => action != excludedAction);
|
||||||
|
|
||||||
|
private static string GetOperationDisplayText(MailOperation action)
|
||||||
|
=> action switch
|
||||||
|
{
|
||||||
|
MailOperation.MarkAsRead => Translator.MailOperation_MarkAsRead,
|
||||||
|
MailOperation.SoftDelete => Translator.MailOperation_Delete,
|
||||||
|
MailOperation.MoveToJunk => Translator.MailOperation_MarkAsJunk,
|
||||||
|
MailOperation.Archive => Translator.MailOperation_Archive,
|
||||||
|
MailOperation.Reply => Translator.MailOperation_Reply,
|
||||||
|
MailOperation.ReplyAll => Translator.MailOperation_ReplyAll,
|
||||||
|
MailOperation.Forward => Translator.MailOperation_Forward,
|
||||||
|
_ => action.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MailNotificationActionOption(MailOperation operation, string displayText)
|
||||||
|
{
|
||||||
|
public MailOperation Operation { get; } = operation;
|
||||||
|
public string DisplayText { get; } = displayText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Windows.AppNotifications;
|
||||||
|
|
||||||
|
namespace Wino.Mail.WinUI.Activation;
|
||||||
|
|
||||||
|
internal sealed record BufferedAppNotificationActivation(string Argument, IReadOnlyDictionary<string, string>? UserInput);
|
||||||
|
|
||||||
|
internal sealed class AppNotificationActivationBuffer
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<BufferedAppNotificationActivation> _pendingActivations = new();
|
||||||
|
private readonly SemaphoreSlim _pendingSignal = new(0);
|
||||||
|
|
||||||
|
public void Enqueue(AppNotificationActivatedEventArgs args)
|
||||||
|
{
|
||||||
|
var copiedUserInput = args.UserInput == null
|
||||||
|
? null
|
||||||
|
: new Dictionary<string, string>(args.UserInput, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
_pendingActivations.Enqueue(new BufferedAppNotificationActivation(args.Argument, copiedUserInput));
|
||||||
|
_pendingSignal.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDequeue(out BufferedAppNotificationActivation activation)
|
||||||
|
=> _pendingActivations.TryDequeue(out activation!);
|
||||||
|
|
||||||
|
public async Task<BufferedAppNotificationActivation?> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_pendingActivations.IsEmpty && TryDequeue(out var queuedActivation))
|
||||||
|
return queuedActivation;
|
||||||
|
|
||||||
|
if (!await _pendingSignal.WaitAsync(timeout, cancellationToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return TryDequeue(out var activation) ? activation : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Windows.AppLifecycle;
|
||||||
|
using Windows.ApplicationModel;
|
||||||
|
using Windows.ApplicationModel.Activation;
|
||||||
|
using Windows.Storage;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Wino.Mail.WinUI.Activation;
|
||||||
|
|
||||||
|
internal enum PendingBootstrapActivationKind
|
||||||
|
{
|
||||||
|
Launch,
|
||||||
|
Protocol,
|
||||||
|
File
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PendingBootstrapActivation
|
||||||
|
{
|
||||||
|
public PendingBootstrapActivationKind Kind { get; init; }
|
||||||
|
public WinoApplicationMode Mode { get; init; } = WinoApplicationMode.Mail;
|
||||||
|
public string? LaunchArguments { get; init; }
|
||||||
|
public string? TileId { get; init; }
|
||||||
|
public string? ProtocolUri { get; init; }
|
||||||
|
public string[] FilePaths { get; init; } = [];
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class CalendarEntryBootstrapActivation
|
||||||
|
{
|
||||||
|
private const string PendingActivationKey = "PendingCalendarEntryBootstrapActivation";
|
||||||
|
private const string KindKey = "Kind";
|
||||||
|
private const string ModeKey = "Mode";
|
||||||
|
private const string LaunchArgumentsKey = "LaunchArguments";
|
||||||
|
private const string TileIdKey = "TileId";
|
||||||
|
private const string ProtocolUriKey = "ProtocolUri";
|
||||||
|
private const string FilePathsKey = "FilePaths";
|
||||||
|
private const string CreatedAtUtcKey = "CreatedAtUtc";
|
||||||
|
private static readonly TimeSpan PendingActivationLifetime = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
public static bool ShouldBootstrapToMailHost(AppActivationArguments activationArgs)
|
||||||
|
=> TryCreatePendingActivation(activationArgs, out _);
|
||||||
|
|
||||||
|
public static bool QueuePendingActivation(AppActivationArguments activationArgs)
|
||||||
|
{
|
||||||
|
if (!TryCreatePendingActivation(activationArgs, out var pendingActivation))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
ApplicationData.Current.LocalSettings.Values[PendingActivationKey] = CreateCompositeValue(pendingActivation!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearPendingActivation()
|
||||||
|
=> ApplicationData.Current.LocalSettings.Values.Remove(PendingActivationKey);
|
||||||
|
|
||||||
|
public static PendingBootstrapActivation? ConsumePendingActivation()
|
||||||
|
{
|
||||||
|
if (!ApplicationData.Current.LocalSettings.Values.TryGetValue(PendingActivationKey, out var pendingActivationValue) ||
|
||||||
|
pendingActivationValue is not ApplicationDataCompositeValue compositeValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearPendingActivation();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pendingActivation = ParseCompositeValue(compositeValue);
|
||||||
|
if (pendingActivation == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (DateTimeOffset.UtcNow - pendingActivation.CreatedAtUtc > PendingActivationLifetime)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return pendingActivation;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDataCompositeValue CreateCompositeValue(PendingBootstrapActivation pendingActivation)
|
||||||
|
{
|
||||||
|
var compositeValue = new ApplicationDataCompositeValue
|
||||||
|
{
|
||||||
|
[KindKey] = pendingActivation.Kind.ToString(),
|
||||||
|
[ModeKey] = pendingActivation.Mode.ToString(),
|
||||||
|
[LaunchArgumentsKey] = pendingActivation.LaunchArguments ?? string.Empty,
|
||||||
|
[TileIdKey] = pendingActivation.TileId ?? string.Empty,
|
||||||
|
[ProtocolUriKey] = pendingActivation.ProtocolUri ?? string.Empty,
|
||||||
|
[FilePathsKey] = string.Join("\n", pendingActivation.FilePaths),
|
||||||
|
[CreatedAtUtcKey] = pendingActivation.CreatedAtUtc.ToString("o")
|
||||||
|
};
|
||||||
|
|
||||||
|
return compositeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingBootstrapActivation? ParseCompositeValue(ApplicationDataCompositeValue compositeValue)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse(compositeValue[KindKey]?.ToString(), ignoreCase: true, out PendingBootstrapActivationKind kind) ||
|
||||||
|
!Enum.TryParse(compositeValue[ModeKey]?.ToString(), ignoreCase: true, out WinoApplicationMode mode) ||
|
||||||
|
!DateTimeOffset.TryParse(compositeValue[CreatedAtUtcKey]?.ToString(), out var createdAtUtc))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PendingBootstrapActivation
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Mode = mode,
|
||||||
|
LaunchArguments = GetOptionalCompositeString(compositeValue, LaunchArgumentsKey),
|
||||||
|
TileId = GetOptionalCompositeString(compositeValue, TileIdKey),
|
||||||
|
ProtocolUri = GetOptionalCompositeString(compositeValue, ProtocolUriKey),
|
||||||
|
FilePaths = GetOptionalCompositeString(compositeValue, FilePathsKey)?
|
||||||
|
.Split(['\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray() ?? [],
|
||||||
|
CreatedAtUtc = createdAtUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetOptionalCompositeString(ApplicationDataCompositeValue compositeValue, string key)
|
||||||
|
{
|
||||||
|
if (!compositeValue.TryGetValue(key, out var value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var stringValue = value?.ToString();
|
||||||
|
return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool LaunchMailHost()
|
||||||
|
{
|
||||||
|
var mailAppUserModelId = AppEntryConstants.GetAppUserModelId(WinoApplicationMode.Mail);
|
||||||
|
var appEntries = Package.Current.GetAppListEntriesAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
var mailEntry = appEntries.FirstOrDefault(entry =>
|
||||||
|
string.Equals(entry.AppUserModelId, mailAppUserModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return mailEntry != null && mailEntry.LaunchAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreatePendingActivation(AppActivationArguments activationArgs, out PendingBootstrapActivation? pendingActivation)
|
||||||
|
{
|
||||||
|
pendingActivation = null;
|
||||||
|
|
||||||
|
if (activationArgs.Kind == ExtendedActivationKind.Launch &&
|
||||||
|
activationArgs.Data is ILaunchActivatedEventArgs launchArgs)
|
||||||
|
{
|
||||||
|
var resolvedMode = AppModeActivationResolver.Resolve(launchArgs.Arguments, launchArgs.TileId, Environment.CommandLine);
|
||||||
|
if (resolvedMode != WinoApplicationMode.Calendar)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
pendingActivation = new PendingBootstrapActivation
|
||||||
|
{
|
||||||
|
Kind = PendingBootstrapActivationKind.Launch,
|
||||||
|
Mode = resolvedMode,
|
||||||
|
LaunchArguments = launchArgs.Arguments,
|
||||||
|
TileId = launchArgs.TileId
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activationArgs.Kind == ExtendedActivationKind.Protocol &&
|
||||||
|
activationArgs.Data is IProtocolActivatedEventArgs protocolArgs &&
|
||||||
|
protocolArgs.Uri != null &&
|
||||||
|
(string.Equals(protocolArgs.Uri.Scheme, "webcal", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(protocolArgs.Uri.Scheme, "webcals", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
pendingActivation = new PendingBootstrapActivation
|
||||||
|
{
|
||||||
|
Kind = PendingBootstrapActivationKind.Protocol,
|
||||||
|
Mode = WinoApplicationMode.Calendar,
|
||||||
|
ProtocolUri = protocolArgs.Uri.AbsoluteUri
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activationArgs.Kind == ExtendedActivationKind.File &&
|
||||||
|
activationArgs.Data is IFileActivatedEventArgs fileArgs)
|
||||||
|
{
|
||||||
|
var filePaths = fileArgs.Files?
|
||||||
|
.OfType<IStorageItem>()
|
||||||
|
.Where(item => string.Equals(Path.GetExtension(item.Path), ".ics", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(item => item.Path)
|
||||||
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (filePaths == null || filePaths.Length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
pendingActivation = new PendingBootstrapActivation
|
||||||
|
{
|
||||||
|
Kind = PendingBootstrapActivationKind.File,
|
||||||
|
Mode = WinoApplicationMode.Calendar,
|
||||||
|
FilePaths = filePaths
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using Microsoft.Windows.AppNotifications;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
@@ -41,39 +41,22 @@ internal static class ToastActivationResolver
|
|||||||
return calendarAction == Constants.ToastCalendarNavigateAction;
|
return calendarAction == Constants.ToastCalendarNavigateAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toastArguments.TryGetValue(Constants.ToastDismissActionKey, out string _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation mailAction))
|
if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation mailAction))
|
||||||
{
|
{
|
||||||
return mailAction == MailOperation.Navigate;
|
return mailAction is MailOperation.Navigate or MailOperation.Reply or MailOperation.ReplyAll or MailOperation.Forward;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool TryResolveMode(NotificationArguments toastArguments, out WinoApplicationMode mode)
|
|
||||||
{
|
|
||||||
mode = WinoApplicationMode.Mail;
|
|
||||||
|
|
||||||
if (!toastArguments.TryGetValue(Constants.ToastModeKey, out string toastMode))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (string.Equals(toastMode, Constants.ToastModeCalendar, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
mode = WinoApplicationMode.Calendar;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(toastMode, Constants.ToastModeMail, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
mode = WinoApplicationMode.Mail;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ContainsKnownToastKey(NotificationArguments toastArguments)
|
private static bool ContainsKnownToastKey(NotificationArguments toastArguments)
|
||||||
=> toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string _) ||
|
=> toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string _) ||
|
||||||
toastArguments.TryGetValue(Constants.ToastModeKey, out string _) ||
|
|
||||||
toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string _) ||
|
toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string _) ||
|
||||||
|
toastArguments.TryGetValue(Constants.ToastDismissActionKey, out string _) ||
|
||||||
toastArguments.TryGetValue(Constants.ToastActionKey, out string _);
|
toastArguments.TryGetValue(Constants.ToastActionKey, out string _);
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 420 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 506 B |
|
After Width: | Height: | Size: 518 B |
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 399 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 365 B |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 481 B |
|
After Width: | Height: | Size: 597 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 386 B |
|
After Width: | Height: | Size: 271 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 481 B |
|
After Width: | Height: | Size: 318 B |
|
After Width: | Height: | Size: 363 B |
|
After Width: | Height: | Size: 558 B |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 691 B |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 452 B |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 1.4 KiB |