Import functionality for wino accounts, calendar sync UI, bunch of shell improvements

This commit is contained in:
Burak Kaan Köse
2026-04-04 20:23:20 +02:00
parent 1667aa34db
commit 1d0fcfb5b0
68 changed files with 2792 additions and 519 deletions
+10 -9
View File
@@ -4,9 +4,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="ColorHashSharp" Version="1.1.0" /> <PackageVersion Include="ColorHashSharp" Version="1.1.0" />
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" /> <PackageVersion Include="CommunityToolkit.Common" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.0" /> <PackageVersion Include="CommunityToolkit.Diagnostics" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1-build.4" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
@@ -15,6 +15,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" /> <PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" /> <PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250926-build.2293" />
<PackageVersion Include="CommunityToolkit.WinUI.Lottie" Version="8.2.250604" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" /> <PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" /> <PackageVersion Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" Version="0.1.250926-build.2293" />
<PackageVersion Include="EmailValidation" Version="1.3.0" /> <PackageVersion Include="EmailValidation" Version="1.3.0" />
@@ -24,11 +25,11 @@
<PackageVersion Include="IsExternalInit" Version="1.0.3" /> <PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageVersion Include="Microsoft.Graph" Version="5.103.0" /> <PackageVersion Include="Microsoft.Graph" Version="5.103.0" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" /> <PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.82.1" /> <PackageVersion Include="Microsoft.Identity.Client" Version="4.83.3" />
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.82.1" /> <PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.82.1" /> <PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.82.1" />
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" /> <PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
@@ -39,7 +40,7 @@
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" /> <PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
<PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" /> <PackageVersion Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
<PackageVersion Include="NodaTime" Version="3.3.1" /> <PackageVersion Include="NodaTime" Version="3.3.1" />
<PackageVersion Include="Sentry.Serilog" Version="6.1.0" /> <PackageVersion Include="Sentry.Serilog" Version="6.3.0" />
<PackageVersion Include="Serilog" Version="4.3.1" /> <PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" /> <PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
@@ -48,7 +49,7 @@
<PackageVersion Include="SkiaSharp" Version="3.119.2" /> <PackageVersion Include="SkiaSharp" Version="3.119.2" />
<PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" /> <PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" />
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" /> <PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.3" /> <PackageVersion Include="System.Drawing.Common" Version="10.0.5" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" /> <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.3" /> <PackageVersion Include="System.Text.Json" Version="10.0.3" />
@@ -66,7 +67,7 @@
<PackageVersion Include="System.Reactive" Version="6.1.0" /> <PackageVersion Include="System.Reactive" Version="6.1.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" /> <PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.3" /> <PackageVersion Include="System.Text.Encodings.Web" Version="10.0.3" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" /> <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" /> <PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
<PackageVersion Include="WinUIEx" Version="2.9.0" /> <PackageVersion Include="WinUIEx" Version="2.9.0" />
<!-- Testing packages --> <!-- Testing packages -->
@@ -52,6 +52,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand; System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand;
System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand; System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand;
System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand; System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand;
System.Windows.Input.ICommand ICalendarShellClient.SyncCommand => SyncCommand;
public bool CanSynchronizeCalendars => !AccountCalendarStateService.IsAnySynchronizationInProgress;
public MenuItemCollection MenuItems { get; private set; } public MenuItemCollection MenuItems { get; private set; }
public MenuItemCollection FooterItems { get; private set; } public MenuItemCollection FooterItems { get; private set; }
@@ -75,7 +78,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private readonly CalendarPageViewModel _calendarPageViewModel; private readonly CalendarPageViewModel _calendarPageViewModel;
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IUpdateManager _updateManager;
private readonly IStoreUpdateService _storeUpdateService; private readonly IStoreUpdateService _storeUpdateService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService; private readonly ICalendarService _calendarService;
@@ -93,7 +95,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
INavigationService navigationService, INavigationService navigationService,
CalendarPageViewModel calendarPageViewModel, CalendarPageViewModel calendarPageViewModel,
IMailDialogService dialogService, IMailDialogService dialogService,
IUpdateManager updateManager,
IStoreUpdateService storeUpdateService, IStoreUpdateService storeUpdateService,
IDateContextProvider dateContextProvider) IDateContextProvider dateContextProvider)
{ {
@@ -105,11 +106,11 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_calendarService = calendarService; _calendarService = calendarService;
_calendarPageViewModel = calendarPageViewModel; _calendarPageViewModel = calendarPageViewModel;
_dialogService = dialogService; _dialogService = dialogService;
_updateManager = updateManager;
_storeUpdateService = storeUpdateService; _storeUpdateService = storeUpdateService;
_dateContextProvider = dateContextProvider; _dateContextProvider = dateContextProvider;
_calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged; _calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged;
AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged;
} }
protected override void OnDispatcherAssigned() protected override void OnDispatcherAssigned()
@@ -177,11 +178,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
await InitializeAccountCalendarsAsync(); await InitializeAccountCalendarsAsync();
ValidateConfiguredNewEventCalendar(); ValidateConfiguredNewEventCalendar();
if (shouldRunStartupFlows)
{
await ShowWhatIsNewIfNeededAsync();
}
TodayClicked(); TodayClicked();
} }
@@ -218,6 +214,15 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_calendarPageViewModel.CleanupForShellDeactivation(); _calendarPageViewModel.CleanupForShellDeactivation();
} }
private void AccountCalendarStateServicePropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(IAccountCalendarStateService.IsAnySynchronizationInProgress))
return;
OnPropertyChanged(nameof(CanSynchronizeCalendars));
SyncCommand.NotifyCanExecuteChanged();
}
private void AttachRuntimeSubscriptions() private void AttachRuntimeSubscriptions()
{ {
if (_runtimeSubscriptionsAttached) if (_runtimeSubscriptionsAttached)
@@ -240,18 +245,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_runtimeSubscriptionsAttached = false; _runtimeSubscriptionsAttached = false;
} }
private async Task ShowWhatIsNewIfNeededAsync()
{
if (!_updateManager.ShouldShowUpdateNotes())
return;
var notes = await _updateManager.GetLatestUpdateNotesAsync();
if (notes.Sections.Count == 0)
return;
await _dialogService.ShowWhatIsNewDialogAsync(notes);
}
private async Task RefreshFooterItemsAsync(bool showNotification) private async Task RefreshFooterItemsAsync(bool showNotification)
{ {
await ExecuteUIThread(() => await ExecuteUIThread(() =>
@@ -326,7 +319,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
_navigationDate = null; _navigationDate = null;
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanSynchronizeCalendars))]
private async Task Sync() private async Task Sync()
{ {
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
@@ -335,7 +328,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
{ {
AccountId = account.Id, AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarEvents Type = CalendarSynchronizationType.Strict
})); }));
} }
} }
@@ -20,6 +20,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
{ {
Account = account; Account = account;
AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels); AccountCalendars = new ObservableCollection<AccountCalendarViewModel>(calendarViewModels);
AccountColorHex = account.AccountColorHex;
ManageIsCheckedState(); ManageIsCheckedState();
@@ -74,6 +75,18 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial bool? IsCheckedState { get; set; } = true; public partial bool? IsCheckedState { get; set; } = true;
[ObservableProperty]
public partial string AccountColorHex { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsSynchronizationInProgress { get; set; }
[ObservableProperty]
public partial string SynchronizationStatus { get; set; } = string.Empty;
public bool CanSynchronize => !IsSynchronizationInProgress;
public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress;
private bool _isExternalPropChangeBlocked = false; private bool _isExternalPropChangeBlocked = false;
private void ManageIsCheckedState() private void ManageIsCheckedState()
@@ -142,4 +155,24 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject
CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel); CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel);
} }
partial void OnIsSynchronizationInProgressChanged(bool value)
{
OnPropertyChanged(nameof(CanSynchronize));
OnPropertyChanged(nameof(IsSynchronizationProgressVisible));
}
public void UpdateAccount(MailAccount updatedAccount)
{
if (updatedAccount == null || updatedAccount.Id != Account.Id)
return;
Account.Name = updatedAccount.Name;
Account.Address = updatedAccount.Address;
Account.AccountColorHex = updatedAccount.AccountColorHex;
Account.AttentionReason = updatedAccount.AttentionReason;
Account.MergedInboxId = updatedAccount.MergedInboxId;
AccountColorHex = updatedAccount.AccountColorHex;
OnPropertyChanged(nameof(Account));
}
} }
@@ -29,5 +29,6 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged
/// </summary> /// </summary>
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; } IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
IEnumerable<AccountCalendarViewModel> AllCalendars { get; } IEnumerable<AccountCalendarViewModel> AllCalendars { get; }
bool IsAnySynchronizationInProgress { get; }
ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; } ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
} }
@@ -5,6 +5,7 @@ public enum CalendarSynchronizationType
ExecuteRequests, // Execute all requests in the queue. ExecuteRequests, // Execute all requests in the queue.
CalendarMetadata, // Sync calendar metadata. CalendarMetadata, // Sync calendar metadata.
CalendarEvents, // Sync all events for all calendars. CalendarEvents, // Sync all events for all calendars.
Strict, // Run metadata and event synchronization in sequence.
SingleCalendar, // Sync events for only specified calendars. SingleCalendar, // Sync events for only specified calendars.
UpdateProfile // Update profile information only. UpdateProfile // Update profile information only.
} }
@@ -5,7 +5,6 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Common; using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Printing; using Wino.Core.Domain.Models.Printing;
using Wino.Core.Domain.Models.Updates;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
@@ -32,10 +31,4 @@ public interface IDialogServiceBase
Task<List<PickedFileMetadata>> PickFilesMetadataAsync(params object[] typeFilters); Task<List<PickedFileMetadata>> PickFilesMetadataAsync(params object[] typeFilters);
Task<string> PickFilePathAsync(string saveFileName); Task<string> PickFilePathAsync(string saveFileName);
Task<WebView2PrintSettingsModel> ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null); Task<WebView2PrintSettingsModel> ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null);
/// <summary>
/// Presents the "What's New" dialog for the current version.
/// This dialog is undismissable and runs any pending migrations when the user clicks "Get Started".
/// </summary>
Task ShowWhatIsNewDialogAsync(UpdateNotes notes);
} }
@@ -8,6 +8,7 @@ using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models; using Wino.Core.Domain.Models;
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;
@@ -74,4 +75,6 @@ public interface IMailDialogService : IDialogServiceBase
Task<WinoAccount?> ShowWinoAccountRegistrationDialogAsync(); Task<WinoAccount?> ShowWinoAccountRegistrationDialogAsync();
Task<WinoAccount?> ShowWinoAccountLoginDialogAsync(); Task<WinoAccount?> ShowWinoAccountLoginDialogAsync();
Task<WinoAccountSyncExportResult?> ShowWinoAccountExportDialogAsync();
} }
@@ -51,6 +51,8 @@ public interface ICalendarShellClient : IShellClient
int SelectedDateNavigationHeaderIndex { get; } int SelectedDateNavigationHeaderIndex { get; }
VisibleDateRange? CurrentVisibleRange { get; } VisibleDateRange? CurrentVisibleRange { get; }
string VisibleDateRangeText { get; } string VisibleDateRangeText { get; }
bool CanSynchronizeCalendars { get; }
ICommand SyncCommand { get; }
ICommand TodayClickedCommand { get; } ICommand TodayClickedCommand { get; }
ICommand DateClickedCommand { get; } ICommand DateClickedCommand { get; }
ICommand PreviousDateRangeCommand { get; } ICommand PreviousDateRangeCommand { get; }
@@ -6,6 +6,7 @@ using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.Api.Contracts.Users;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
@@ -26,5 +27,7 @@ public interface IWinoAccountApiClient
Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default); Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default); Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
Task<UserMailboxSyncListDto> GetMailboxesAsync(CancellationToken cancellationToken = default);
Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountDataSyncService
{
Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default);
Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default);
}
@@ -8,6 +8,7 @@ using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.Api.Contracts.Users;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
@@ -28,6 +29,10 @@ public interface IWinoAccountProfileService
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
Task<UserMailboxSyncListDto> GetMailboxesAsync(CancellationToken cancellationToken = default);
Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default);
} }
@@ -13,6 +13,7 @@ public interface INavigationService
Type GetPageType(WinoPage winoPage); Type GetPageType(WinoPage winoPage);
bool ChangeApplicationMode(WinoApplicationMode mode); bool ChangeApplicationMode(WinoApplicationMode mode);
bool ChangeApplicationMode(WinoApplicationMode mode, ShellModeActivationContext activationContext);
bool CanGoBack(); bool CanGoBack();
void GoBack(NavigationTransitionEffect slideEffect = NavigationTransitionEffect.FromRight); void GoBack(NavigationTransitionEffect slideEffect = NavigationTransitionEffect.FromRight);
} }
@@ -111,6 +111,7 @@ public partial class AccountMenuItem : MenuItemBase<MailAccount, MenuItemBase<IM
public void UpdateAccount(MailAccount account) public void UpdateAccount(MailAccount account)
{ {
Parameter = account; Parameter = account;
AttentionReason = account.AttentionReason;
OnPropertyChanged(nameof(AccountName)); OnPropertyChanged(nameof(AccountName));
OnPropertyChanged(nameof(Base64ProfilePicture)); OnPropertyChanged(nameof(Base64ProfilePicture));
+10 -6
View File
@@ -8,12 +8,16 @@ public static class CalendarColorPalette
{ {
private static readonly string[] FlatUiColorPalette = private static readonly string[] FlatUiColorPalette =
[ [
"#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", "#00897B", "#43A047", "#E53935", "#D81B60", "#C2185B", "#AD1457", "#8E24AA", "#7B1FA2", "#6A1B9A", "#5E35B1", "#512DA8", "#4527A0",
"#7CB342", "#C0CA33", "#FDD835", "#FFB300", "#FB8C00", "#F4511E", "#6D4C41", "#757575", "#546E7A", "#C62828", "#3949AB", "#303F9F", "#283593", "#1E88E5", "#1976D2", "#1565C0", "#039BE5", "#0288D1", "#0277BD", "#00ACC1",
"#AD1457", "#6A1B9A", "#4527A0", "#283593", "#1565C0", "#0277BD", "#00838F", "#00695C", "#2E7D32", "#558B2F", "#0097A7", "#00838F", "#00897B", "#00796B", "#00695C", "#43A047", "#388E3C", "#2E7D32", "#7CB342", "#689F38",
"#9E9D24", "#F9A825", "#FF8F00", "#EF6C00", "#D84315", "#4E342E", "#616161", "#455A64", "#EF5350", "#EC407A", "#558B2F", "#9CCC65", "#8BC34A", "#AED581", "#C0CA33", "#AFB42B", "#9E9D24", "#D4E157", "#CDDC39", "#FDD835",
"#AB47BC", "#7E57C2", "#5C6BC0", "#42A5F5", "#29B6F6", "#26C6DA", "#26A69A", "#66BB6A", "#9CCC65", "#D4E157", "#FBC02D", "#F9A825", "#FFB300", "#FFA000", "#FF8F00", "#FB8C00", "#F57C00", "#EF6C00", "#F4511E", "#E64A19",
"#FFEE58", "#FFCA28", "#FFA726", "#FF7043", "#8D6E63", "#BDBDBD", "#78909C", "#F06292", "#BA68C8", "#9575CD" "#D84315", "#FF7043", "#FF8A65", "#FFAB91", "#6D4C41", "#5D4037", "#4E342E", "#8D6E63", "#795548", "#A1887F",
"#546E7A", "#455A64", "#37474F", "#607D8B", "#78909C", "#90A4AE", "#757575", "#616161", "#424242", "#9E9E9E",
"#BDBDBD", "#EC407A", "#F06292", "#F48FB1", "#BA68C8", "#CE93D8", "#9575CD", "#B39DDB", "#7986CB", "#9FA8DA",
"#64B5F6", "#90CAF9", "#4FC3F7", "#81D4FA", "#4DD0E1", "#80DEEA", "#4DB6AC", "#80CBC4", "#81C784", "#A5D6A7",
"#C5E1A5", "#E6EE9C", "#FFF176", "#FFD54F", "#FFCC80", "#FFB74D", "#FFAB40", "#FF9E80", "#BCAAA4", "#A1887F"
]; ];
public static IReadOnlyList<string> GetColors() => FlatUiColorPalette; public static IReadOnlyList<string> GetColors() => FlatUiColorPalette;
@@ -0,0 +1,8 @@
namespace Wino.Core.Domain.Models.Accounts;
public sealed class WinoAccountSyncExportResult
{
public bool IncludedPreferences { get; init; }
public bool IncludedAccounts { get; init; }
public int ExportedMailboxCount { get; init; }
}
@@ -0,0 +1,15 @@
namespace Wino.Core.Domain.Models.Accounts;
public sealed class WinoAccountSyncImportResult
{
public bool IncludedPreferences { get; init; }
public bool IncludedAccounts { get; init; }
public bool HadRemotePreferences { get; init; }
public int AppliedPreferenceCount { get; init; }
public int FailedPreferenceCount { get; init; }
public int ImportedMailboxCount { get; init; }
public int SkippedDuplicateMailboxCount { get; init; }
public int RemoteMailboxCount { get; init; }
public bool HasAnyRemoteData => HadRemotePreferences || RemoteMailboxCount > 0;
}
@@ -0,0 +1,5 @@
namespace Wino.Core.Domain.Models.Accounts;
public sealed record WinoAccountSyncSelection(
bool IncludePreferences = true,
bool IncludeAccounts = true);
@@ -1,7 +1,10 @@
#nullable enable
namespace Wino.Core.Domain.Models.Navigation; namespace Wino.Core.Domain.Models.Navigation;
public sealed class ShellModeActivationContext public sealed class ShellModeActivationContext
{ {
public bool IsInitialActivation { get; init; } public bool IsInitialActivation { get; init; }
public object Parameter { get; init; } public bool SuppressStartupFlows { get; init; }
public object? Parameter { get; init; }
} }
@@ -104,6 +104,9 @@
"SyncAction_SettingFlag": "Flagging {0} mail(s)", "SyncAction_SettingFlag": "Flagging {0} mail(s)",
"SyncAction_SynchronizingAccount": "Synchronizing {0}", "SyncAction_SynchronizingAccount": "Synchronizing {0}",
"SyncAction_SynchronizingAccounts": "Synchronizing {0} account(s)", "SyncAction_SynchronizingAccounts": "Synchronizing {0} account(s)",
"SyncAction_SynchronizingCalendarData": "Synchronizing calendar data",
"SyncAction_SynchronizingCalendarEvents": "Synchronizing calendar events",
"SyncAction_SynchronizingCalendarMetadata": "Synchronizing calendar metadata",
"SyncAction_Unarchiving": "Unarchiving {0} mail(s)", "SyncAction_Unarchiving": "Unarchiving {0} mail(s)",
"CalendarAllDayEventSummary": "all-day events", "CalendarAllDayEventSummary": "all-day events",
"CalendarDisplayOptions_Color": "Color", "CalendarDisplayOptions_Color": "Color",
@@ -313,6 +316,8 @@
"Exception_CustomThemeMissingName": "You must provide a name.", "Exception_CustomThemeMissingName": "You must provide a name.",
"Exception_CustomThemeMissingWallpaper": "You must provide a custom background image.", "Exception_CustomThemeMissingWallpaper": "You must provide a custom background image.",
"Exception_FailedToSynchronizeAliases": "Failed to synchronize aliases", "Exception_FailedToSynchronizeAliases": "Failed to synchronize aliases",
"Exception_FailedToSynchronizeCalendarData": "Failed to synchronize calendar data",
"Exception_FailedToSynchronizeCalendarEvents": "Failed to synchronize calendar events",
"Exception_FailedToSynchronizeCalendarMetadata": "Failed to synchronize calendar details", "Exception_FailedToSynchronizeCalendarMetadata": "Failed to synchronize calendar details",
"Exception_FailedToSynchronizeFolders": "Failed to synchronize folders", "Exception_FailedToSynchronizeFolders": "Failed to synchronize folders",
"Exception_FailedToSynchronizeProfileInformation": "Failed to synchronize profile information", "Exception_FailedToSynchronizeProfileInformation": "Failed to synchronize profile information",
@@ -505,6 +510,11 @@
"Info_AccountDeletedMessage": "{0} is successfuly deleted.", "Info_AccountDeletedMessage": "{0} is successfuly deleted.",
"Info_AccountDeletedTitle": "Account Deleted", "Info_AccountDeletedTitle": "Account Deleted",
"Info_AccountIssueFixFailedTitle": "Failed", "Info_AccountIssueFixFailedTitle": "Failed",
"Info_AccountIssueFixImapMessage": "Open the IMAP and calendar settings page to enter your server credentials again.",
"Info_AccountAttentionRequiredMessage": "This account needs your attention.",
"Info_AccountAttentionRequiredClickableMessage": "Click to fix this account and resynchronize it.",
"Info_AccountAttentionRequiredAction": "Fix",
"Info_AccountAttentionRequiredActionHint": "Click Fix to resolve this account issue.",
"Info_AccountIssueFixSuccessMessage": "Fixed all account issues.", "Info_AccountIssueFixSuccessMessage": "Fixed all account issues.",
"Info_AccountIssueFixSuccessTitle": "Success", "Info_AccountIssueFixSuccessTitle": "Success",
"Info_AttachmentOpenFailedMessage": "Can't open this attachment.", "Info_AttachmentOpenFailedMessage": "Can't open this attachment.",
@@ -678,6 +688,9 @@
"SettingConfigureSpecialFolders_Button": "Configure", "SettingConfigureSpecialFolders_Button": "Configure",
"SettingsEditAccountDetails_IMAPConfiguration_Title": "IMAP/SMTP Configuration", "SettingsEditAccountDetails_IMAPConfiguration_Title": "IMAP/SMTP Configuration",
"SettingsEditAccountDetails_IMAPConfiguration_Description": "Change your incoming/outgoing server settings.", "SettingsEditAccountDetails_IMAPConfiguration_Description": "Change your incoming/outgoing server settings.",
"SettingsEditAccountDetails_ImapCalDavSettings_Title": "IMAP and calendar settings",
"SettingsEditAccountDetails_ImapCalDavSettings_Description": "Open the dedicated IMAP, SMTP, and CalDAV settings page for this account.",
"SettingsEditAccountDetails_ImapCalDavSettings_Action": "Open settings",
"SettingsAbout_Description": "Learn more about Wino.", "SettingsAbout_Description": "Learn more about Wino.",
"SettingsAbout_Title": "About", "SettingsAbout_Title": "About",
"SettingsAboutGithub_Description": "Go to issue tracker GitHub repository.", "SettingsAboutGithub_Description": "Go to issue tracker GitHub repository.",
@@ -1222,6 +1235,10 @@
"WelcomeWindow_FeaturesTab": "Features", "WelcomeWindow_FeaturesTab": "Features",
"WelcomeWindow_GetStartedButton": "Get started by adding an account", "WelcomeWindow_GetStartedButton": "Get started by adding an account",
"WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.", "WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.",
"WelcomeWindow_ImportFromWinoAccount": "Import from your Wino Account",
"WelcomeWindow_ImportInProgress": "Importing your synchronized preferences and accounts...",
"WelcomeWindow_ImportNoAccountsFound": "No synced accounts were found in your Wino Account. If preferences were available, they were restored. Use Get started to add an account manually.",
"WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} synced accounts are already available on this device. Use Get started to add another account manually if needed.",
"WelcomeWindow_SetupTitle": "Set up your account", "WelcomeWindow_SetupTitle": "Set up your account",
"WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started",
"WelcomeWindow_AddAccountButton": "Add account", "WelcomeWindow_AddAccountButton": "Add account",
@@ -1233,7 +1250,7 @@
"WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.", "WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.",
"WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons", "WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons",
"WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.", "WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.",
"WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized settings.", "WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized preferences and account details.",
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail", "WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail",
"WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.", "WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.",
"WinoAccount_Management_ProfileSectionHeader": "Profile", "WinoAccount_Management_ProfileSectionHeader": "Profile",
@@ -1273,18 +1290,31 @@
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize", "WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
"WinoAccount_Management_AddOnLoadFailed": "We had issues loading this add-on.", "WinoAccount_Management_AddOnLoadFailed": "We had issues loading this add-on.",
"WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences", "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences and Accounts",
"WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.", "WinoAccount_Management_SyncPreferencesDescription": "Import or export your Wino preferences and mailbox details across devices. Passwords, tokens, and other sensitive information are never synced.",
"WinoAccount_Management_SignOutTitle": "Sign out", "WinoAccount_Management_SignOutTitle": "Sign out",
"WinoAccount_Management_SignOutDescription": "Sign out of your account on this device", "WinoAccount_Management_SignOutDescription": "Sign out of your account on this device",
"WinoAccount_Management_StatusLabel": "Status: {0}", "WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.", "WinoAccount_Management_NoRemoteSettings": "There is no synchronized data stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.", "WinoAccount_Management_ExportSucceeded": "Your selected Wino data was exported successfully.",
"WinoAccount_Management_ImportSucceeded": "Imported {0} settings from your Wino Account.", "WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported to your Wino Account.",
"WinoAccount_Management_ImportPartial": "Imported {0} settings. {1} settings could not be restored.", "WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details to your Wino Account.",
"WinoAccount_Management_ImportSucceeded": "Imported synchronized data from your Wino Account.",
"WinoAccount_Management_ImportPreferencesSucceeded": "Applied {0} synchronized preferences.",
"WinoAccount_Management_ImportAccountsSucceeded": "Imported {0} accounts.",
"WinoAccount_Management_ImportDuplicateAccountsSkipped": "Skipped {0} accounts that already exist on this device.",
"WinoAccount_Management_ImportPartial": "Applied {0} synchronized preferences. {1} preferences could not be restored.",
"WinoAccount_Management_ImportReloginReminder": "Passwords, tokens, and other sensitive information were not imported. Sign in again for each account on this device before using it.",
"WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.", "WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.",
"WinoAccount_Management_EmptyExport": "There are no preference values to export.", "WinoAccount_Management_EmptyExport": "There are no preference values to export.",
"WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.", "WinoAccount_Management_ImportEmpty": "The synchronized data payload does not contain anything new to restore.",
"WinoAccount_Management_ExportDialog_Title": "Export to your Wino Account",
"WinoAccount_Management_ExportDialog_Description": "Choose what you want to sync to your Wino Account.",
"WinoAccount_Management_ExportDialog_IncludePreferences": "Preferences",
"WinoAccount_Management_ExportDialog_IncludeAccounts": "Accounts",
"WinoAccount_Management_ExportDialog_AccountsDisclaimer": "Passwords, tokens, and other sensitive information are not synced.",
"WinoAccount_Management_ExportDialog_AccountsRelogin": "Imported accounts on another PC will still need you to sign in again before they can be used.",
"WinoAccount_Management_ExportDialog_InProgress": "Exporting your selected Wino data...",
"WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
"WinoAccount_SettingsSection_Title": "Wino Account", "WinoAccount_SettingsSection_Title": "Wino Account",
@@ -0,0 +1,268 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
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.Accounts;
using Wino.Core.Tests.Helpers;
using Wino.Mail.Api.Contracts.Users;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Services;
public sealed class WinoAccountDataSyncServiceTests : IAsyncLifetime
{
private InMemoryDatabaseService _databaseService = null!;
private Mock<IWinoAccountProfileService> _profileService = null!;
private Mock<IPreferencesService> _preferencesService = null!;
private AccountService _accountService = null!;
private WinoAccountDataSyncService _service = null!;
public async Task InitializeAsync()
{
_databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync();
_profileService = new Mock<IWinoAccountProfileService>(MockBehavior.Strict);
_preferencesService = new Mock<IPreferencesService>();
_preferencesService.SetupProperty(a => a.StartupEntityId);
_accountService = CreateAccountService(_databaseService, _preferencesService.Object);
_service = new WinoAccountDataSyncService(_profileService.Object, _preferencesService.Object, _accountService);
}
public async Task DisposeAsync()
{
await _databaseService.DisposeAsync();
}
[Fact]
public async Task ExportAsync_ImapMailbox_MapsSanitizedPayload()
{
var accountId = Guid.NewGuid();
await _accountService.CreateAccountAsync(
new MailAccount
{
Id = accountId,
Name = "Custom IMAP",
SenderName = "Custom IMAP Sender",
Address = "imap@example.com",
ProviderType = MailProviderType.IMAP4,
SpecialImapProvider = SpecialImapProvider.iCloud,
AccountColorHex = "#123456",
IsCalendarAccessGranted = true,
SynchronizationDeltaIdentifier = "delta-token",
CalendarSynchronizationDeltaIdentifier = "calendar-delta",
Base64ProfilePictureData = "profile"
},
new CustomServerInformation
{
Id = Guid.NewGuid(),
AccountId = accountId,
Address = "imap@example.com",
IncomingServer = "imap.example.com",
IncomingServerPort = "993",
IncomingServerUsername = "imap-user",
IncomingServerPassword = "secret-incoming",
IncomingServerSocketOption = ImapConnectionSecurity.Auto,
IncomingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
OutgoingServer = "smtp.example.com",
OutgoingServerPort = "465",
OutgoingServerUsername = "smtp-user",
OutgoingServerPassword = "secret-outgoing",
OutgoingServerSocketOption = ImapConnectionSecurity.Auto,
OutgoingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
CalendarSupportMode = ImapCalendarSupportMode.CalDav,
CalDavServiceUrl = "https://dav.example.com",
CalDavUsername = "dav-user",
CalDavPassword = "secret-caldav",
ProxyServer = "proxy.example.com",
ProxyServerPort = "8080",
MaxConcurrentClients = 7
});
ReplaceUserMailboxesRequestDto? capturedRequest = null;
_profileService
.Setup(a => a.ReplaceMailboxesAsync(It.IsAny<ReplaceUserMailboxesRequestDto>(), It.IsAny<CancellationToken>()))
.Callback<ReplaceUserMailboxesRequestDto, CancellationToken>((request, _) => capturedRequest = request)
.Returns(Task.CompletedTask);
var result = await _service.ExportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true));
result.ExportedMailboxCount.Should().Be(1);
capturedRequest.Should().NotBeNull();
capturedRequest!.Mailboxes.Should().ContainSingle();
var exportedMailbox = capturedRequest.Mailboxes[0];
exportedMailbox.Address.Should().Be("imap@example.com");
exportedMailbox.ProviderType.Should().Be((int)MailProviderType.IMAP4);
exportedMailbox.SpecialImapProvider.Should().Be((int)SpecialImapProvider.iCloud);
exportedMailbox.AccountName.Should().Be("Custom IMAP");
exportedMailbox.SenderName.Should().Be("Custom IMAP Sender");
exportedMailbox.AccountColorHex.Should().Be("#123456");
exportedMailbox.IsCalendarAccessGranted.Should().BeTrue();
exportedMailbox.IncomingServer.Should().Be("imap.example.com");
exportedMailbox.IncomingServerUsername.Should().Be("imap-user");
exportedMailbox.OutgoingServer.Should().Be("smtp.example.com");
exportedMailbox.OutgoingServerUsername.Should().Be("smtp-user");
exportedMailbox.CalDavServiceUrl.Should().Be("https://dav.example.com");
exportedMailbox.CalDavUsername.Should().Be("dav-user");
exportedMailbox.ProxyServer.Should().Be("proxy.example.com");
exportedMailbox.ProxyServerPort.Should().Be("8080");
exportedMailbox.MaxConcurrentClients.Should().Be(7);
_profileService.Verify(a => a.SaveSettingsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ExportAsync_GmailMailbox_DoesNotIncludeCustomServerSettings()
{
await _accountService.CreateAccountAsync(
new MailAccount
{
Id = Guid.NewGuid(),
Name = "Gmail",
SenderName = "Gmail Sender",
Address = "gmail@example.com",
ProviderType = MailProviderType.Gmail
},
null!);
ReplaceUserMailboxesRequestDto? capturedRequest = null;
_profileService
.Setup(a => a.ReplaceMailboxesAsync(It.IsAny<ReplaceUserMailboxesRequestDto>(), It.IsAny<CancellationToken>()))
.Callback<ReplaceUserMailboxesRequestDto, CancellationToken>((request, _) => capturedRequest = request)
.Returns(Task.CompletedTask);
await _service.ExportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true));
var exportedMailbox = capturedRequest!.Mailboxes.Single();
exportedMailbox.IncomingServer.Should().BeNull();
exportedMailbox.OutgoingServer.Should().BeNull();
exportedMailbox.CalDavServiceUrl.Should().BeNull();
exportedMailbox.MaxConcurrentClients.Should().BeNull();
}
[Fact]
public async Task ImportAsync_SkipsDuplicateMailbox_ByAddressAndProviderCaseInsensitive()
{
await _accountService.CreateAccountAsync(
new MailAccount
{
Id = Guid.NewGuid(),
Name = "Existing Gmail",
SenderName = "Existing Gmail",
Address = "User@Example.com",
ProviderType = MailProviderType.Gmail
},
null!);
_profileService
.Setup(a => a.GetMailboxesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new UserMailboxSyncListDto(
[
new UserMailboxSyncItemDto
{
Address = "user@example.com",
ProviderType = (int)MailProviderType.Gmail,
AccountName = "Duplicate Gmail"
},
new UserMailboxSyncItemDto
{
Address = "second@example.com",
ProviderType = (int)MailProviderType.Outlook,
AccountName = "New Outlook"
}
]));
var result = await _service.ImportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true));
result.ImportedMailboxCount.Should().Be(1);
result.SkippedDuplicateMailboxCount.Should().Be(1);
var accounts = await _accountService.GetAccountsAsync();
accounts.Should().HaveCount(2);
accounts.Should().Contain(a => a.Address == "second@example.com" && a.ProviderType == MailProviderType.Outlook);
}
[Fact]
public async Task ImportAsync_ImapMailbox_CreatesRootAliasAndInvalidCredentialsAttentionWithoutPasswords()
{
_profileService
.Setup(a => a.GetMailboxesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new UserMailboxSyncListDto(
[
new UserMailboxSyncItemDto
{
Address = "imap@example.com",
ProviderType = (int)MailProviderType.IMAP4,
SpecialImapProvider = (int)SpecialImapProvider.Yahoo,
AccountName = "Imported IMAP",
SenderName = "Imported Sender",
CalendarSupportMode = (int)ImapCalendarSupportMode.CalDav,
IncomingServer = "imap.example.com",
IncomingServerPort = "993",
IncomingServerUsername = "imap-user",
IncomingServerSocketOption = (int)ImapConnectionSecurity.Auto,
IncomingAuthenticationMethod = (int)ImapAuthenticationMethod.NormalPassword,
OutgoingServer = "smtp.example.com",
OutgoingServerPort = "465",
OutgoingServerUsername = "smtp-user",
OutgoingServerSocketOption = (int)ImapConnectionSecurity.Auto,
OutgoingAuthenticationMethod = (int)ImapAuthenticationMethod.NormalPassword,
CalDavServiceUrl = "https://dav.example.com",
CalDavUsername = "dav-user",
MaxConcurrentClients = 9
}
]));
var result = await _service.ImportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true));
result.ImportedMailboxCount.Should().Be(1);
var importedAccount = (await _accountService.GetAccountsAsync()).Single();
importedAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
importedAccount.SynchronizationDeltaIdentifier.Should().BeEmpty();
importedAccount.CalendarSynchronizationDeltaIdentifier.Should().BeEmpty();
var importedAliases = await _accountService.GetAccountAliasesAsync(importedAccount.Id);
importedAliases.Should().ContainSingle(a => a.IsRootAlias && a.IsPrimary && a.AliasAddress == "imap@example.com");
var serverInformation = await _accountService.GetAccountCustomServerInformationAsync(importedAccount.Id);
serverInformation.Should().NotBeNull();
serverInformation.IncomingServerPassword.Should().BeEmpty();
serverInformation.OutgoingServerPassword.Should().BeEmpty();
serverInformation.CalDavPassword.Should().BeEmpty();
serverInformation.MaxConcurrentClients.Should().Be(9);
serverInformation.CalDavServiceUrl.Should().Be("https://dav.example.com");
}
private static AccountService CreateAccountService(InMemoryDatabaseService databaseService, IPreferencesService preferencesService)
{
var signatureService = new Mock<ISignatureService>();
signatureService
.Setup(a => a.CreateDefaultSignatureAsync(It.IsAny<Guid>()))
.ReturnsAsync((Guid accountId) => new AccountSignature
{
Id = Guid.NewGuid(),
MailAccountId = accountId,
Name = "Default",
HtmlBody = string.Empty
});
return new AccountService(
databaseService,
signatureService.Object,
Mock.Of<IAuthenticationProvider>(),
Mock.Of<IMimeFileService>(),
preferencesService,
Mock.Of<IContactPictureFileService>());
}
}
@@ -232,11 +232,10 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult)); .ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
_apiClient _apiClient
.Setup(x => x.SummarizeAsync("<p>Hello</p>", default)) .Setup(x => x.SummarizeAsync("<p>Hello</p>", "en", default))
.ReturnsAsync(ApiEnvelope<AiTextResultDto>.Success( .ReturnsAsync(ApiEnvelope<AiTextResultDto>.Success(
new AiTextResultDto("<p>Summary</p>"), new AiTextResultDto("<p>Summary</p>"),
new QuotaInfoDto( new QuotaInfoDto(
true,
"Active", "Active",
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(29), DateTimeOffset.UtcNow.AddDays(29),
@@ -247,7 +246,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
await _service.LoginAsync("first@example.com", "pw"); await _service.LoginAsync("first@example.com", "pw");
var response = await _service.SummarizeAsync("<p>Hello</p>"); var response = await _service.SummarizeAsync("<p>Hello</p>", "en");
response.IsSuccess.Should().BeTrue(); response.IsSuccess.Should().BeTrue();
response.Result?.Html.Should().Be("<p>Summary</p>"); response.Result?.Html.Should().Be("<p>Summary</p>");
@@ -163,6 +163,11 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage)); Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage));
} }
public void NavigateToManageAccounts()
{
Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAccountSettings_Title, WinoPage.ManageAccountsPage));
}
private async Task LoadDashboardAsync() private async Task LoadDashboardAsync()
{ {
var accounts = (await _accountService.GetAccountsAsync().ConfigureAwait(false) ?? []).ToList(); var accounts = (await _accountService.GetAccountsAsync().ConfigureAwait(false) ?? []).ToList();
@@ -1,14 +1,15 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Wino.Core.Domain.Entities.Shared;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.ViewModels.Data; using Wino.Core.ViewModels.Data;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
@@ -22,6 +23,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
IRecipient<WinoAccountAddOnPurchasedMessage> IRecipient<WinoAccountAddOnPurchasedMessage>
{ {
private readonly IWinoAccountProfileService _profileService; private readonly IWinoAccountProfileService _profileService;
private readonly IWinoAccountDataSyncService _syncService;
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IStoreManagementService _storeManagementService; private readonly IStoreManagementService _storeManagementService;
private readonly WinoAddOnItemViewModel _aiPackAddOn; private readonly WinoAddOnItemViewModel _aiPackAddOn;
@@ -49,10 +51,12 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
public bool IsSignedOut => !IsSignedIn; public bool IsSignedOut => !IsSignedIn;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
IWinoAccountDataSyncService syncService,
IMailDialogService dialogService, IMailDialogService dialogService,
IStoreManagementService storeManagementService) IStoreManagementService storeManagementService)
{ {
_profileService = profileService; _profileService = profileService;
_syncService = syncService;
_dialogService = dialogService; _dialogService = dialogService;
_storeManagementService = storeManagementService; _storeManagementService = storeManagementService;
@@ -220,10 +224,69 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
=> addOn != null && !addOn.IsPurchased && !addOn.IsLoading && !IsCheckoutInProgress; => addOn != null && !addOn.IsPurchased && !addOn.IsLoading && !IsCheckoutInProgress;
[RelayCommand] [RelayCommand]
private Task ExportSettingsAsync() => Task.CompletedTask; private async Task ExportSettingsAsync()
{
try
{
var result = await _dialogService.ShowWinoAccountExportDialogAsync().ConfigureAwait(false);
if (result == null)
{
return;
}
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Info,
BuildExportSuccessMessage(result),
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Error,
ex.Message,
InfoBarMessageType.Error);
}
}
[RelayCommand] [RelayCommand]
private Task ImportSettingsAsync() => Task.CompletedTask; private async Task ImportSettingsAsync()
{
await ExecuteUIThread(() => IsBusy = true);
try
{
var result = await _syncService.ImportAsync(new WinoAccountSyncSelection());
if (!result.HasAnyRemoteData)
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Info,
Translator.WinoAccount_Management_NoRemoteSettings,
InfoBarMessageType.Information);
return;
}
var messageType = result.FailedPreferenceCount > 0
? InfoBarMessageType.Warning
: InfoBarMessageType.Success;
_dialogService.InfoBarMessage(
result.FailedPreferenceCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info,
BuildImportMessage(result),
messageType);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Error,
ex.Message,
InfoBarMessageType.Error);
}
finally
{
await ExecuteUIThread(() => IsBusy = false);
}
}
protected override void RegisterRecipients() protected override void RegisterRecipients()
{ {
@@ -392,6 +455,62 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
_ => Translator.WinoAccount_Management_StoreSyncFailed _ => Translator.WinoAccount_Management_StoreSyncFailed
}; };
private static string BuildExportSuccessMessage(WinoAccountSyncExportResult result)
{
var parts = new Collection<string>();
if (result.IncludedPreferences)
{
parts.Add(Translator.WinoAccount_Management_ExportPreferencesSucceeded);
}
if (result.IncludedAccounts)
{
parts.Add(string.Format(Translator.WinoAccount_Management_ExportAccountsSucceeded, result.ExportedMailboxCount));
}
if (parts.Count == 0)
{
parts.Add(Translator.WinoAccount_Management_ExportSucceeded);
}
return string.Join(" ", parts);
}
private static string BuildImportMessage(WinoAccountSyncImportResult result)
{
var parts = new Collection<string>();
if (result.HadRemotePreferences)
{
parts.Add(result.FailedPreferenceCount > 0
? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount)
: string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount));
}
if (result.ImportedMailboxCount > 0)
{
parts.Add(string.Format(Translator.WinoAccount_Management_ImportAccountsSucceeded, result.ImportedMailboxCount));
}
if (result.SkippedDuplicateMailboxCount > 0)
{
parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount));
}
if (parts.Count == 0)
{
parts.Add(Translator.WinoAccount_Management_ImportEmpty);
}
if (result.ImportedMailboxCount > 0)
{
parts.Add(Translator.WinoAccount_Management_ImportReloginReminder);
}
return string.Join(" ", parts);
}
private static bool IsAccessTokenExpired(WinoAccount account) private static bool IsAccessTokenExpired(WinoAccount account)
=> string.IsNullOrWhiteSpace(account.AccessToken) || account.AccessTokenExpiresAtUtc <= DateTime.UtcNow; => string.IsNullOrWhiteSpace(account.AccessToken) || account.AccessTokenExpiresAtUtc <= DateTime.UtcNow;
+4
View File
@@ -44,6 +44,7 @@ public static class CoreContainerSetup
services.AddTransient<OutlookRateLimitHandler>(); services.AddTransient<OutlookRateLimitHandler>();
// Register Gmail error handlers // Register Gmail error handlers
services.AddTransient<GmailAuthenticationFailedHandler>();
services.AddTransient<GmailQuotaExceededHandler>(); services.AddTransient<GmailQuotaExceededHandler>();
services.AddTransient<GmailRateLimitHandler>(); services.AddTransient<GmailRateLimitHandler>();
services.AddTransient<GmailHistoryExpiredHandler>(); services.AddTransient<GmailHistoryExpiredHandler>();
@@ -56,6 +57,9 @@ public static class CoreContainerSetup
services.AddTransient<ImapFolderNotFoundHandler>(); services.AddTransient<ImapFolderNotFoundHandler>();
services.AddTransient<ImapProtocolErrorHandler>(); services.AddTransient<ImapProtocolErrorHandler>();
// Register Outlook auth handlers
services.AddTransient<OutlookAuthenticationFailedHandler>();
// Register error handler factories // Register error handler factories
services.AddTransient<IOutlookSynchronizerErrorHandlerFactory, OutlookSynchronizerErrorHandlingFactory>(); services.AddTransient<IOutlookSynchronizerErrorHandlerFactory, OutlookSynchronizerErrorHandlingFactory>();
services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>(); services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>();
@@ -147,10 +147,8 @@ public static class GoogleIntegratorExtensions
// Bg color must present. Generate one if doesnt exists. // Bg color must present. Generate one if doesnt exists.
// Text color is optional. It'll be overriden by UI for readibility. // Text color is optional. It'll be overriden by UI for readibility.
calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
: calendarListEntry.BackgroundColor;
calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor;
return calendar; return calendar;
} }
@@ -191,10 +191,8 @@ public static class OutlookIntegratorExtensions
// Bg must be present. Generate flat one if doesn't exists. // Bg must be present. Generate flat one if doesn't exists.
// Text doesnt exists for Outlook. // Text doesnt exists for Outlook.
calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor) calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
: outlookCalendar.HexColor;
calendar.TextColorHex = "#000000";
return calendar; return calendar;
} }
+57 -1
View File
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Globalization;
using System.Linq; using System.Linq;
using Wino.Core.Domain.Misc; using Wino.Core.Domain.Misc;
@@ -12,9 +13,21 @@ public static class ColorHelpers
public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty<string>()); public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty<string>());
public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors) public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors, string preferredColor = null)
{ {
var palette = CalendarColorPalette.GetColors(); var palette = CalendarColorPalette.GetColors();
var normalizedUsedColors = usedColors?
.Select(NormalizeHexColor)
.Where(color => !string.IsNullOrWhiteSpace(color))
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (TryNormalizeHexColor(preferredColor, out var normalizedPreferred) &&
palette.Contains(normalizedPreferred, StringComparer.OrdinalIgnoreCase) &&
!normalizedUsedColors.Contains(normalizedPreferred))
{
return normalizedPreferred;
}
var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors); var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors);
if (palette.Contains(distinctColor)) if (palette.Contains(distinctColor))
{ {
@@ -26,6 +39,18 @@ public static class ColorHelpers
return candidate; return candidate;
} }
public static string GetReadableTextColorHex(string backgroundColor)
{
if (!TryNormalizeHexColor(backgroundColor, out var normalizedColor))
{
return "#FFFFFF";
}
var color = ColorTranslator.FromHtml(normalizedColor);
var luminance = ((0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B)) / 255d;
return luminance > 0.6 ? "#111111" : "#FFFFFF";
}
public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}"; public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})"; public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})";
@@ -41,4 +66,35 @@ public static class ColorHelpers
return adjusted.ToHexString(); return adjusted.ToHexString();
} }
private static bool TryNormalizeHexColor(string value, out string normalized)
{
normalized = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var color = value.Trim();
if (color.StartsWith('#'))
{
color = color[1..];
}
if (color.Length != 6)
{
return false;
}
if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
{
return false;
}
normalized = $"#{color.ToUpperInvariant()}";
return true;
}
private static string NormalizeHexColor(string value)
=> TryNormalizeHexColor(value, out var normalized) ? normalized : string.Empty;
} }
@@ -11,12 +11,14 @@ namespace Wino.Core.Services;
public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory
{ {
public GmailSynchronizerErrorHandlingFactory( public GmailSynchronizerErrorHandlingFactory(
GmailAuthenticationFailedHandler authenticationFailedHandler,
GmailQuotaExceededHandler quotaExceededHandler, GmailQuotaExceededHandler quotaExceededHandler,
GmailRateLimitHandler rateLimitHandler, GmailRateLimitHandler rateLimitHandler,
GmailHistoryExpiredHandler historyExpiredHandler, GmailHistoryExpiredHandler historyExpiredHandler,
EntityNotFoundHandler entityNotFoundHandler) EntityNotFoundHandler entityNotFoundHandler)
{ {
// Order matters - more specific handlers should be registered first // Order matters - more specific handlers should be registered first
RegisterHandler(authenticationFailedHandler);
RegisterHandler(quotaExceededHandler); RegisterHandler(quotaExceededHandler);
RegisterHandler(historyExpiredHandler); RegisterHandler(historyExpiredHandler);
RegisterHandler(entityNotFoundHandler); RegisterHandler(entityNotFoundHandler);
@@ -6,11 +6,13 @@ namespace Wino.Core.Services;
public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IOutlookSynchronizerErrorHandlerFactory public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IOutlookSynchronizerErrorHandlerFactory
{ {
public OutlookSynchronizerErrorHandlingFactory(ObjectCannotBeDeletedHandler objectCannotBeDeleted, public OutlookSynchronizerErrorHandlingFactory(OutlookAuthenticationFailedHandler authenticationFailedHandler,
ObjectCannotBeDeletedHandler objectCannotBeDeleted,
EntityNotFoundHandler entityNotFoundHandler, EntityNotFoundHandler entityNotFoundHandler,
DeltaTokenExpiredHandler deltaTokenExpiredHandler, DeltaTokenExpiredHandler deltaTokenExpiredHandler,
OutlookRateLimitHandler outlookRateLimitHandler) OutlookRateLimitHandler outlookRateLimitHandler)
{ {
RegisterHandler(authenticationFailedHandler);
RegisterHandler(outlookRateLimitHandler); RegisterHandler(outlookRateLimitHandler);
RegisterHandler(objectCannotBeDeleted); RegisterHandler(objectCannotBeDeleted);
RegisterHandler(entityNotFoundHandler); RegisterHandler(entityNotFoundHandler);
@@ -4,7 +4,9 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Serilog; using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
@@ -13,6 +15,7 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication; using Wino.Core.Domain.Models.Authentication;
using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Connectivity;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.UI;
namespace Wino.Core.Services; namespace Wino.Core.Services;
@@ -27,6 +30,7 @@ public class SynchronizationManager : ISynchronizationManager
private readonly ConcurrentDictionary<Guid, IWinoSynchronizerBase> _synchronizerCache = new(); private readonly ConcurrentDictionary<Guid, IWinoSynchronizerBase> _synchronizerCache = new();
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _accountSynchronizationCancellationSources = new(); private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _accountSynchronizationCancellationSources = new();
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _calendarSynchronizationLocks = new();
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private readonly ILogger _logger = Log.ForContext<SynchronizationManager>(); private readonly ILogger _logger = Log.ForContext<SynchronizationManager>();
@@ -131,6 +135,12 @@ public class SynchronizationManager : ISynchronizationManager
{ {
EnsureInitialized(); EnsureInitialized();
if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false))
{
_logger.Information("Skipping mail synchronization for account {AccountId} because it requires credential attention.", options.AccountId);
return MailSynchronizationResult.Canceled;
}
var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId);
if (synchronizer == null) if (synchronizer == null)
{ {
@@ -170,6 +180,7 @@ public class SynchronizationManager : ISynchronizationManager
catch (AuthenticationAttentionException authEx) catch (AuthenticationAttentionException authEx)
{ {
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false);
// Create app notification for authentication attention // Create app notification for authentication attention
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
@@ -348,9 +359,75 @@ public class SynchronizationManager : ISynchronizationManager
/// <returns>Synchronization result</returns> /// <returns>Synchronization result</returns>
public async Task<CalendarSynchronizationResult> SynchronizeCalendarAsync(CalendarSynchronizationOptions options, public async Task<CalendarSynchronizationResult> SynchronizeCalendarAsync(CalendarSynchronizationOptions options,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> options.Type == CalendarSynchronizationType.Strict
? await SynchronizeCalendarStrictAsync(options, cancellationToken).ConfigureAwait(false)
: await RunCalendarSynchronizationWithLockAsync(
options.AccountId,
cancellationToken,
() => SynchronizeCalendarCoreAsync(options, cancellationToken, reportState: true)).ConfigureAwait(false);
private async Task<CalendarSynchronizationResult> SynchronizeCalendarStrictAsync(
CalendarSynchronizationOptions options,
CancellationToken cancellationToken)
{
var metadataOptions = new CalendarSynchronizationOptions
{
AccountId = options.AccountId,
Type = CalendarSynchronizationType.CalendarMetadata,
SynchronizationCalendarIds = options.SynchronizationCalendarIds
};
var eventOptions = new CalendarSynchronizationOptions
{
AccountId = options.AccountId,
Type = CalendarSynchronizationType.CalendarEvents,
SynchronizationCalendarIds = options.SynchronizationCalendarIds
};
return await RunCalendarSynchronizationWithLockAsync(options.AccountId, cancellationToken, async () =>
{
try
{
PublishCalendarSynchronizationState(
options.AccountId,
CalendarSynchronizationType.Strict,
isSynchronizationInProgress: true,
Translator.SyncAction_SynchronizingCalendarMetadata);
var metadataResult = await SynchronizeCalendarCoreAsync(metadataOptions, cancellationToken, reportState: false).ConfigureAwait(false);
if (metadataResult.CompletedState is SynchronizationCompletedState.Failed or SynchronizationCompletedState.Canceled)
{
return metadataResult;
}
PublishCalendarSynchronizationState(
options.AccountId,
CalendarSynchronizationType.Strict,
isSynchronizationInProgress: true,
Translator.SyncAction_SynchronizingCalendarEvents);
return await SynchronizeCalendarCoreAsync(eventOptions, cancellationToken, reportState: false).ConfigureAwait(false);
}
finally
{
PublishCalendarSynchronizationState(options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: false);
}
}).ConfigureAwait(false);
}
private async Task<CalendarSynchronizationResult> SynchronizeCalendarCoreAsync(
CalendarSynchronizationOptions options,
CancellationToken cancellationToken,
bool reportState)
{ {
EnsureInitialized(); EnsureInitialized();
if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false))
{
_logger.Information("Skipping calendar synchronization for account {AccountId} because it requires credential attention.", options.AccountId);
return CalendarSynchronizationResult.Canceled;
}
var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId);
if (synchronizer == null) if (synchronizer == null)
{ {
@@ -361,6 +438,15 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type); options.AccountId, options.Type);
if (reportState)
{
PublishCalendarSynchronizationState(
options.AccountId,
options.Type,
isSynchronizationInProgress: true,
GetCalendarSynchronizationStatus(options.Type));
}
var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, cancellationToken,
@@ -387,6 +473,7 @@ public class SynchronizationManager : ISynchronizationManager
catch (AuthenticationAttentionException authEx) catch (AuthenticationAttentionException authEx)
{ {
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false);
// Create app notification for authentication attention // Create app notification for authentication attention
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
@@ -398,6 +485,13 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId);
return CalendarSynchronizationResult.Failed; return CalendarSynchronizationResult.Failed;
} }
finally
{
if (reportState)
{
PublishCalendarSynchronizationState(options.AccountId, options.Type, isSynchronizationInProgress: false);
}
}
} }
/// <summary> /// <summary>
@@ -667,4 +761,69 @@ public class SynchronizationManager : ISynchronizationManager
throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first."); throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first.");
} }
} }
private async Task SetInvalidCredentialAttentionAsync(MailAccount account)
{
if (account == null || _accountService == null)
return;
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null)
return;
if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
private async Task<bool> IsSynchronizationBlockedByAttentionAsync(Guid accountId)
{
if (_accountService == null)
return false;
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
return account?.AttentionReason == AccountAttentionReason.InvalidCredentials;
}
private void PublishCalendarSynchronizationState(
Guid accountId,
CalendarSynchronizationType synchronizationType,
bool isSynchronizationInProgress,
string synchronizationStatus = "")
{
WeakReferenceMessenger.Default.Send(new AccountCalendarSynchronizationStateChanged(
accountId,
synchronizationType,
isSynchronizationInProgress,
synchronizationStatus));
}
private static string GetCalendarSynchronizationStatus(CalendarSynchronizationType synchronizationType)
=> synchronizationType switch
{
CalendarSynchronizationType.CalendarMetadata => Translator.SyncAction_SynchronizingCalendarMetadata,
CalendarSynchronizationType.Strict => Translator.SyncAction_SynchronizingCalendarData,
_ => Translator.SyncAction_SynchronizingCalendarEvents
};
private async Task<CalendarSynchronizationResult> RunCalendarSynchronizationWithLockAsync(
Guid accountId,
CancellationToken cancellationToken,
Func<Task<CalendarSynchronizationResult>> synchronizationFactory)
{
var calendarSemaphore = _calendarSynchronizationLocks.GetOrAdd(accountId, _ => new SemaphoreSlim(1, 1));
await calendarSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await synchronizationFactory().ConfigureAwait(false);
}
finally
{
calendarSemaphore.Release();
}
}
} }
@@ -0,0 +1,68 @@
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Google;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Gmail;
public class GmailAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<GmailAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public GmailAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.Exception is not GoogleApiException googleEx)
return false;
var reason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
var message = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
return googleEx.HttpStatusCode == HttpStatusCode.Unauthorized ||
(googleEx.HttpStatusCode == HttpStatusCode.Forbidden &&
(reason.Contains("auth") ||
reason.Contains("credential") ||
message.Contains("invalid credentials") ||
message.Contains("insufficient authentication") ||
message.Contains("login required")));
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Gmail authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
error.RetryDelay = null;
return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
@@ -1,6 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using MailKit.Security; using MailKit.Security;
using Serilog; using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
@@ -14,6 +15,12 @@ namespace Wino.Core.Synchronizers.Errors.Imap;
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
{ {
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>(); private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public ImapAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error) public bool CanHandle(SynchronizerErrorContext error)
{ {
@@ -22,12 +29,17 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false); (error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
} }
public Task<bool> HandleAsync(SynchronizerErrorContext error) public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{ {
_logger.Warning(error.Exception, _logger.Warning(error.Exception,
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.", "IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
error.Account?.Name, error.Account?.Id); error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
// Mark as requiring authentication - this will stop sync and notify user // Mark as requiring authentication - this will stop sync and notify user
error.Severity = SynchronizerErrorSeverity.AuthRequired; error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication; error.Category = SynchronizerErrorCategory.Authentication;
@@ -35,6 +47,20 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
// No point in retrying auth failures - credentials need to be updated // No point in retrying auth failures - credentials need to be updated
error.RetryDelay = null; error.RetryDelay = null;
return Task.FromResult(true); return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null)
return;
if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
} }
} }
@@ -0,0 +1,83 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Graph.Models.ODataErrors;
using Microsoft.Kiota.Abstractions;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Outlook;
public class OutlookAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<OutlookAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public OutlookAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.Exception is ApiException apiException)
{
if (apiException.ResponseStatusCode == 401)
return true;
if (apiException.ResponseStatusCode == 403)
{
var message = apiException.Message?.ToLowerInvariant() ?? string.Empty;
return message.Contains("access denied") || message.Contains("authentication");
}
}
if (error.Exception is ODataError oDataError)
{
if (oDataError.ResponseStatusCode == 401)
return true;
var code = oDataError.Error?.Code?.ToLowerInvariant() ?? string.Empty;
var message = oDataError.Error?.Message?.ToLowerInvariant() ?? string.Empty;
return code.Contains("invalidauthenticationtoken") ||
code.Contains("invalidgrant") ||
code.Contains("token") ||
message.Contains("access token") ||
message.Contains("authentication");
}
return false;
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Outlook authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
error.RetryDelay = null;
return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
+11 -13
View File
@@ -603,11 +603,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
var remotePrimaryCalendarId = GetPrimaryCalendarId(calendarListResponse.Items); var remotePrimaryCalendarId = GetPrimaryCalendarId(calendarListResponse.Items);
var usedCalendarColors = new HashSet<string>( var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new(); List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new(); List<AccountCalendar> updatedCalendars = new();
@@ -637,25 +633,25 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (existingLocalCalendar == null) if (existingLocalCalendar == null)
{ {
// Insert new calendar. // Insert new calendar.
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors); var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.BackgroundColor);
var localCalendar = calendar.AsCalendar(Account.Id, fallbackColor); var localCalendar = calendar.AsCalendar(Account.Id, fallbackColor);
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex)) localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors); localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
usedCalendarColors.Add(localCalendar.BackgroundColorHex); usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar); insertedCalendars.Add(localCalendar);
} }
else else
{ {
// Update existing calendar. Right now we only update the name. // Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId)) var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
{ {
existingLocalCalendar.Name = calendar.Summary; existingLocalCalendar.Name = calendar.Summary;
existingLocalCalendar.TimeZone = calendar.TimeZone; existingLocalCalendar.TimeZone = calendar.TimeZone;
if (!string.IsNullOrEmpty(calendar.BackgroundColor)) existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.BackgroundColorHex = calendar.BackgroundColor; existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
if (!string.IsNullOrEmpty(calendar.ForegroundColor))
existingLocalCalendar.TextColorHex = calendar.ForegroundColor;
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
updatedCalendars.Add(existingLocalCalendar); updatedCalendars.Add(existingLocalCalendar);
@@ -665,6 +661,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Remove it from the local folder list to skip additional calendar updates. // Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar); localCalendars.Remove(existingLocalCalendar);
} }
usedCalendarColors.Add(resolvedColor);
} }
} }
+11 -7
View File
@@ -1455,11 +1455,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
var remoteCalendarsById = remoteCalendars var remoteCalendarsById = remoteCalendars
.GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase) .GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var usedCalendarColors = new HashSet<string>( var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId; var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
@@ -1493,25 +1489,33 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
IsPrimary = isPrimary, IsPrimary = isPrimary,
IsSynchronizationEnabled = true, IsSynchronizationEnabled = true,
IsExtended = true, IsExtended = true,
TextColorHex = "#000000",
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors), BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
TimeZone = "UTC", TimeZone = "UTC",
SynchronizationDeltaToken = string.Empty SynchronizationDeltaToken = string.Empty
}; };
newCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(newCalendar.BackgroundColorHex);
usedCalendarColors.Add(newCalendar.BackgroundColorHex); usedCalendarColors.Add(newCalendar.BackgroundColorHex);
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false); await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
continue; continue;
} }
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal) var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|| existingLocal.IsPrimary != isPrimary; || existingLocal.IsPrimary != isPrimary
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
if (!shouldUpdate) if (!shouldUpdate)
{
usedCalendarColors.Add(resolvedColor);
continue; continue;
}
existingLocal.Name = remoteCalendar.Name; existingLocal.Name = remoteCalendar.Name;
existingLocal.IsPrimary = isPrimary; existingLocal.IsPrimary = isPrimary;
existingLocal.BackgroundColorHex = resolvedColor;
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false); await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
} }
} }
+11 -11
View File
@@ -2333,11 +2333,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync(calendars.Value, cancellationToken).ConfigureAwait(false); var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync(calendars.Value, cancellationToken).ConfigureAwait(false);
var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
var usedCalendarColors = new HashSet<string>( var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new(); List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new(); List<AccountCalendar> updatedCalendars = new();
@@ -2367,23 +2363,25 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
if (existingLocalCalendar == null) if (existingLocalCalendar == null)
{ {
// Insert new calendar. // Insert new calendar.
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors); var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.HexColor);
var localCalendar = calendar.AsCalendar(Account, fallbackColor); var localCalendar = calendar.AsCalendar(Account, fallbackColor);
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex)) localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors); localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
usedCalendarColors.Add(localCalendar.BackgroundColorHex); usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar); insertedCalendars.Add(localCalendar);
} }
else else
{ {
// Update existing calendar. Right now we only update the name. // Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId)) var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
{ {
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);
if (!string.IsNullOrEmpty(calendar.HexColor)) existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.BackgroundColorHex = calendar.HexColor; existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
updatedCalendars.Add(existingLocalCalendar); updatedCalendars.Add(existingLocalCalendar);
} }
@@ -2392,6 +2390,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// Remove it from the local folder list to skip additional calendar updates. // Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar); localCalendars.Remove(existingLocalCalendar);
} }
usedCalendarColors.Add(resolvedColor);
} }
} }
@@ -160,6 +160,13 @@ 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 EditImapCalDavSettings()
=> Messenger.Send(new BreadcrumbNavigationRequested(
Translator.ImapCalDavSettingsPage_TitleEdit,
WinoPage.ImapCalDavSettingsPage,
ImapCalDavSettingsNavigationContext.CreateForEditMode(Account.Id)));
[RelayCommand] [RelayCommand]
private async Task SaveChangesAsync() private async Task SaveChangesAsync()
{ {
@@ -13,9 +13,12 @@ using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.AutoDiscovery; using Wino.Core.Domain.Models.AutoDiscovery;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
namespace Wino.Mail.ViewModels; namespace Wino.Mail.ViewModels;
@@ -289,7 +292,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
else else
{ {
PageTitle = Translator.ImapCalDavSettingsPage_TitleEdit; PageTitle = Translator.ImapCalDavSettingsPage_TitleEdit;
await InitializeEditModeAsync(context.AccountId).ConfigureAwait(false); await InitializeEditModeAsync(context.AccountId);
} }
} }
@@ -498,7 +501,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
private async Task InitializeEditModeAsync(Guid accountId) private async Task InitializeEditModeAsync(Guid accountId)
{ {
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); var account = await _accountService.GetAccountAsync(accountId);
if (account == null) if (account == null)
throw new InvalidOperationException(Translator.Exception_NullAssignedAccount); throw new InvalidOperationException(Translator.Exception_NullAssignedAccount);
@@ -768,10 +771,26 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
serverInformation.AccountId = account.Id; serverInformation.AccountId = account.Id;
account.ServerInformation = serverInformation; account.ServerInformation = serverInformation;
account.AttentionReason = AccountAttentionReason.None;
await _accountService.UpdateAccountCustomServerInformationAsync(serverInformation).ConfigureAwait(false); await _accountService.UpdateAccountCustomServerInformationAsync(serverInformation).ConfigureAwait(false);
await _accountService.UpdateAccountAsync(account).ConfigureAwait(false); await _accountService.UpdateAccountAsync(account).ConfigureAwait(false);
Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions
{
AccountId = account.Id,
Type = MailSynchronizationType.FullFolders
}));
if (account.IsCalendarAccessGranted)
{
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
{
AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarEvents
}));
}
_mailDialogService.InfoBarMessage( _mailDialogService.InfoBarMessage(
Translator.IMAPSetupDialog_ValidationSuccess_Title, Translator.IMAPSetupDialog_ValidationSuccess_Title,
Translator.ImapCalDavSettingsPage_SaveSuccessMessage, Translator.ImapCalDavSettingsPage_SaveSuccessMessage,
+65 -25
View File
@@ -20,6 +20,8 @@ using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Accounts; using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Client.Shell; using Wino.Messaging.Client.Shell;
@@ -81,7 +83,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IMimeFileService _mimeFileService; private readonly IMimeFileService _mimeFileService;
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
private readonly IUpdateManager _updateManager;
private readonly IStoreUpdateService _storeUpdateService; private readonly IStoreUpdateService _storeUpdateService;
private readonly INativeAppService _nativeAppService; private readonly INativeAppService _nativeAppService;
@@ -108,7 +109,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
IConfigurationService configurationService, IConfigurationService configurationService,
IStartupBehaviorService startupBehaviorService, IStartupBehaviorService startupBehaviorService,
IWebView2RuntimeValidatorService webView2RuntimeValidatorService, IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
IUpdateManager updateManager,
IStoreUpdateService storeUpdateService) IStoreUpdateService storeUpdateService)
{ {
StatePersistenceService = statePersistanceService; StatePersistenceService = statePersistanceService;
@@ -130,7 +130,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
_notificationBuilder = notificationBuilder; _notificationBuilder = notificationBuilder;
_winoRequestDelegator = winoRequestDelegator; _winoRequestDelegator = winoRequestDelegator;
_webView2RuntimeValidatorService = webView2RuntimeValidatorService; _webView2RuntimeValidatorService = webView2RuntimeValidatorService;
_updateManager = updateManager;
_storeUpdateService = storeUpdateService; _storeUpdateService = storeUpdateService;
} }
@@ -235,7 +234,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
} }
var activationContext = parameters as ShellModeActivationContext; var activationContext = parameters as ShellModeActivationContext;
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true; var shouldRunStartupFlows = (activationContext?.IsInitialActivation ?? true) &&
activationContext?.SuppressStartupFlows != true;
var hasExistingAccountMenuItems = MenuItems?.OfType<IAccountMenuItem>().Any() == true; var hasExistingAccountMenuItems = MenuItems?.OfType<IAccountMenuItem>().Any() == true;
PreferencesService.PreferenceChanged -= PreferencesServiceChanged; PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
@@ -258,7 +258,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
if (shouldRunStartupFlows) if (shouldRunStartupFlows)
{ {
await ShowWhatIsNewIfNeededAsync();
await MakeSureEnableStartupLaunchAsync(); await MakeSureEnableStartupLaunchAsync();
} }
} }
@@ -296,19 +295,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
FooterItems?.Clear(); FooterItems?.Clear();
} }
private async Task ShowWhatIsNewIfNeededAsync()
{
if (!_updateManager.ShouldShowUpdateNotes())
return;
var notes = await _updateManager.GetLatestUpdateNotesAsync();
if (notes.Sections.Count == 0)
return;
await _dialogService.ShowWhatIsNewDialogAsync(notes);
}
private async Task MakeSureEnableStartupLaunchAsync() private async Task MakeSureEnableStartupLaunchAsync()
{ {
if (!_configurationService.Get<bool>(IsActivateStartupLaunchAskedKey, false)) if (!_configurationService.Get<bool>(IsActivateStartupLaunchAskedKey, false))
@@ -605,21 +591,75 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
} }
} }
public Task HandleAccountAttentionAsync(MailAccount account)
=> FixAccountIssuesAsync(account);
private void TriggerFullSynchronization(MailAccount account)
{
Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions
{
AccountId = account.Id,
Type = MailSynchronizationType.FullFolders
}));
if (account.IsCalendarAccessGranted)
{
Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions
{
AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarEvents
}));
}
}
private async Task FixAccountIssuesAsync(MailAccount account) private async Task FixAccountIssuesAsync(MailAccount account)
{ {
// TODO: This area is very unclear. Needs to be rewritten with care.
// Fix account issues are expected to not work, but may work for some cases.
try try
{ {
if (account.AttentionReason == AccountAttentionReason.InvalidCredentials) if (account.AttentionReason == AccountAttentionReason.InvalidCredentials)
await _accountService.FixTokenIssuesAsync(account.Id); {
else if (account.AttentionReason == AccountAttentionReason.MissingSystemFolderConfiguration) if (account.ProviderType is MailProviderType.Gmail or MailProviderType.Outlook)
await _dialogService.HandleSystemFolderConfigurationDialogAsync(account.Id, _folderService); {
await SynchronizationManager.Instance.HandleAuthorizationAsync(
account.ProviderType,
account,
account.ProviderType == MailProviderType.Gmail);
await _accountService.ClearAccountAttentionAsync(account.Id); await _accountService.ClearAccountAttentionAsync(account.Id);
_dialogService.InfoBarMessage(Translator.Info_AccountIssueFixFailedTitle, Translator.Info_AccountIssueFixSuccessMessage, InfoBarMessageType.Success); _dialogService.InfoBarMessage(
Translator.Info_AccountIssueFixSuccessTitle,
Translator.Info_AccountIssueFixSuccessMessage,
InfoBarMessageType.Success);
TriggerFullSynchronization(account);
return;
}
NavigationService.Navigate(WinoPage.SettingsPage, WinoPage.ManageAccountsPage);
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.ImapCalDavSettingsPage_TitleEdit,
WinoPage.ImapCalDavSettingsPage,
ImapCalDavSettingsNavigationContext.CreateForEditMode(account.Id)));
_dialogService.InfoBarMessage(
Translator.Info_AccountIssueFixSuccessTitle,
Translator.Info_AccountIssueFixImapMessage,
InfoBarMessageType.Information);
return;
}
else if (account.AttentionReason == AccountAttentionReason.MissingSystemFolderConfiguration)
{
await _dialogService.HandleSystemFolderConfigurationDialogAsync(account.Id, _folderService);
await _accountService.ClearAccountAttentionAsync(account.Id);
_dialogService.InfoBarMessage(
Translator.Info_AccountIssueFixSuccessTitle,
Translator.Info_AccountIssueFixSuccessMessage,
InfoBarMessageType.Success);
TriggerFullSynchronization(account);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
+86 -2
View File
@@ -1,27 +1,47 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Updates; using Wino.Core.Domain.Models.Updates;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels; namespace Wino.Mail.ViewModels;
public partial class WelcomePageV2ViewModel : MailBaseViewModel public partial class WelcomePageV2ViewModel : MailBaseViewModel
{ {
private readonly IUpdateManager _updateManager; private readonly IUpdateManager _updateManager;
private readonly IMailDialogService _dialogService;
private readonly IWinoAccountDataSyncService _syncService;
[ObservableProperty] [ObservableProperty]
public partial List<UpdateNoteSection> UpdateSections { get; set; } = []; public partial List<UpdateNoteSection> UpdateSections { get; set; } = [];
public WelcomePageV2ViewModel(IUpdateManager updateManager) [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(GetStartedCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))]
public partial bool IsImportInProgress { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasImportStatus))]
public partial string ImportStatusMessage { get; set; } = string.Empty;
public bool HasImportStatus => !string.IsNullOrWhiteSpace(ImportStatusMessage);
public WelcomePageV2ViewModel(IUpdateManager updateManager,
IMailDialogService dialogService,
IWinoAccountDataSyncService syncService)
{ {
_updateManager = updateManager; _updateManager = updateManager;
_dialogService = dialogService;
_syncService = syncService;
} }
public override async void OnNavigatedTo(NavigationMode mode, object parameters) public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -39,11 +59,75 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
} }
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))]
private void GetStarted() private void GetStarted()
{ {
Messenger.Send(new BreadcrumbNavigationRequested( Messenger.Send(new BreadcrumbNavigationRequested(
Translator.WelcomeWizard_Step2Title, Translator.WelcomeWizard_Step2Title,
WinoPage.ProviderSelectionPage)); WinoPage.ProviderSelectionPage));
} }
[RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))]
private async Task ImportFromWinoAccountAsync()
{
await ExecuteUIThread(() => ImportStatusMessage = string.Empty);
try
{
var account = await _dialogService.ShowWinoAccountLoginDialogAsync().ConfigureAwait(false);
if (account == null)
{
return;
}
await ExecuteUIThread(() => IsImportInProgress = true);
var result = await _syncService.ImportAsync(new WinoAccountSyncSelection()).ConfigureAwait(false);
if (result.ImportedMailboxCount > 0)
{
ReportUIChange(new WelcomeImportCompletedMessage(result.ImportedMailboxCount));
return;
}
await ExecuteUIThread(() => ImportStatusMessage = BuildInlineImportMessage(result));
}
catch (Exception ex)
{
await _dialogService.ShowMessageAsync(ex.Message, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error);
}
finally
{
await ExecuteUIThread(() => IsImportInProgress = false);
}
}
private bool CanOpenWelcomeActions() => !IsImportInProgress;
private static string BuildInlineImportMessage(WinoAccountSyncImportResult result)
{
var preferencesMessage = result.FailedPreferenceCount > 0
? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount)
: result.HadRemotePreferences
? string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount)
: string.Empty;
if (result.RemoteMailboxCount == 0)
{
return string.IsNullOrWhiteSpace(preferencesMessage)
? Translator.WelcomeWindow_ImportNoAccountsFound
: $"{preferencesMessage} {Translator.WelcomeWindow_ImportNoAccountsFound}";
}
if (result.SkippedDuplicateMailboxCount > 0 && result.ImportedMailboxCount == 0)
{
var duplicateMessage = string.Format(Translator.WelcomeWindow_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount);
return string.IsNullOrWhiteSpace(preferencesMessage)
? duplicateMessage
: $"{preferencesMessage} {duplicateMessage}";
}
return string.IsNullOrWhiteSpace(preferencesMessage)
? Translator.WinoAccount_Management_ImportEmpty
: preferencesMessage;
}
} }
+556
View File
@@ -0,0 +1,556 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// LottieGen version:
// 8.2.250604.1+b02a3ee244
//
// Command:
// LottieGen -Language CSharp -Public -WinUIVersion 2.4 -InputFile sync.json
//
// Input file:
// sync.json (2404 bytes created 20:18+02:00 Apr 4 2026)
//
// LottieGen source:
// http://aka.ms/Lottie
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
// ___________________________________________________________
// | Object stats | UAP v15 count | UAP v7 count |
// |__________________________|_______________|______________|
// | All CompositionObjects | 26 | 26 |
// |--------------------------+---------------+--------------|
// | Expression animators | 1 | 1 |
// | KeyFrame animators | 1 | 1 |
// | Reference parameters | 1 | 1 |
// | Expression operations | 0 | 0 |
// |--------------------------+---------------+--------------|
// | Animated brushes | - | - |
// | Animated gradient stops | - | - |
// | ExpressionAnimations | 1 | 1 |
// | PathKeyFrameAnimations | - | - |
// |--------------------------+---------------+--------------|
// | ContainerVisuals | 1 | 1 |
// | ShapeVisuals | 1 | 1 |
// |--------------------------+---------------+--------------|
// | ContainerShapes | 1 | 1 |
// | CompositionSpriteShapes | 2 | 2 |
// |--------------------------+---------------+--------------|
// | Brushes | 1 | 1 |
// | Gradient stops | - | - |
// | CompositionVisualSurface | - | - |
// -----------------------------------------------------------
using Microsoft.Graphics.Canvas.Geometry;
using System;
using System.Collections.Generic;
using System.Numerics;
using Windows.Graphics;
using Windows.UI;
using Windows.UI.Composition;
namespace AnimatedVisuals
{
// Name: main_libary_shelf_icon_sync
// Frame rate: 60 fps
// Frame count: 61
// Duration: 1016.7 mS
sealed class Sync
: Microsoft.UI.Xaml.Controls.IAnimatedVisualSource
{
// Animation duration: 1.017 seconds.
internal const long c_durationTicks = 10166666;
public Microsoft.UI.Xaml.Controls.IAnimatedVisual TryCreateAnimatedVisual(Compositor compositor)
{
object ignored = null;
return TryCreateAnimatedVisual(compositor, out ignored);
}
public Microsoft.UI.Xaml.Controls.IAnimatedVisual TryCreateAnimatedVisual(Compositor compositor, out object diagnostics)
{
diagnostics = null;
if (Sync_AnimatedVisual_UAPv15.IsRuntimeCompatible())
{
var res =
new Sync_AnimatedVisual_UAPv15(
compositor
);
return res;
}
if (Sync_AnimatedVisual_UAPv7.IsRuntimeCompatible())
{
var res =
new Sync_AnimatedVisual_UAPv7(
compositor
);
return res;
}
return null;
}
/// <summary>
/// Gets the number of frames in the animation.
/// </summary>
public double FrameCount => 61d;
/// <summary>
/// Gets the frame rate of the animation.
/// </summary>
public double Framerate => 60d;
/// <summary>
/// Gets the duration of the animation.
/// </summary>
public TimeSpan Duration => TimeSpan.FromTicks(10166666);
/// <summary>
/// Converts a zero-based frame number to the corresponding progress value denoting the
/// start of the frame.
/// </summary>
public double FrameToProgress(double frameNumber)
{
return frameNumber / 61d;
}
/// <summary>
/// Returns a map from marker names to corresponding progress values.
/// </summary>
public IReadOnlyDictionary<string, double> Markers =>
new Dictionary<string, double>
{
};
/// <summary>
/// Sets the color property with the given name, or does nothing if no such property
/// exists.
/// </summary>
public void SetColorProperty(string propertyName, Color value)
{
}
/// <summary>
/// Sets the scalar property with the given name, or does nothing if no such property
/// exists.
/// </summary>
public void SetScalarProperty(string propertyName, double value)
{
}
sealed class Sync_AnimatedVisual_UAPv15
: Microsoft.UI.Xaml.Controls.IAnimatedVisual
{
const long c_durationTicks = 10166666;
readonly Compositor _c;
readonly ExpressionAnimation _reusableExpressionAnimation;
AnimationController _animationController_0;
CompositionColorBrush _colorBrush_AlmostDarkSlateGray_FF2D3846;
ContainerVisual _root;
void BindProperty(
CompositionObject target,
string animatedPropertyName,
string expression,
string referenceParameterName,
CompositionObject referencedObject)
{
_reusableExpressionAnimation.ClearAllParameters();
_reusableExpressionAnimation.Expression = expression;
_reusableExpressionAnimation.SetReferenceParameter(referenceParameterName, referencedObject);
target.StartAnimation(animatedPropertyName, _reusableExpressionAnimation);
}
ScalarKeyFrameAnimation CreateScalarKeyFrameAnimation(float initialProgress, float initialValue, CompositionEasingFunction initialEasingFunction)
{
var result = _c.CreateScalarKeyFrameAnimation();
result.Duration = TimeSpan.FromTicks(c_durationTicks);
result.InsertKeyFrame(initialProgress, initialValue, initialEasingFunction);
return result;
}
CompositionSpriteShape CreateSpriteShape(CompositionGeometry geometry, Matrix3x2 transformMatrix, CompositionBrush fillBrush)
{
var result = _c.CreateSpriteShape(geometry);
result.TransformMatrix = transformMatrix;
result.FillBrush = fillBrush;
return result;
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
AnimationController AnimationController_0()
{
var result = _animationController_0 = _c.CreateAnimationController();
result.Pause();
BindProperty(result, "Progress", "_.Progress", "_", _root);
return result;
}
// - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// - - ShapeGroup: Group 2 Offset:<28.255, 18.903>
CanvasGeometry Geometry_0()
{
CanvasGeometry result;
using (var builder = new CanvasPathBuilder(null))
{
builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding);
builder.BeginFigure(new Vector2(11.7449999F, 5.09700012F));
builder.AddCubicBezier(new Vector2(11.7449999F, -3.66000009F), new Vector2(4.56699991F, -10.9189997F), new Vector2(-4.25500011F, -10.9189997F));
builder.AddCubicBezier(new Vector2(-8.6239996F, -10.9189997F), new Vector2(-12.7040005F, -9.20300007F), new Vector2(-15.7449999F, -6.08900023F));
builder.AddLine(new Vector2(-12.8739996F, -3.32599998F));
builder.AddCubicBezier(new Vector2(-10.5930004F, -5.66200018F), new Vector2(-7.53200006F, -6.94799995F), new Vector2(-4.25500011F, -6.94799995F));
builder.AddCubicBezier(new Vector2(2.36199999F, -6.94799995F), new Vector2(7.74499989F, -1.47099996F), new Vector2(7.74499989F, 5.09700012F));
builder.AddLine(new Vector2(3.74499989F, 5.09700012F));
builder.AddLine(new Vector2(9.74499989F, 10.9189997F));
builder.AddLine(new Vector2(15.7449999F, 5.09700012F));
builder.AddLine(new Vector2(11.7449999F, 5.09700012F));
builder.EndFigure(CanvasFigureLoop.Closed);
result = CanvasGeometry.CreatePath(builder);
}
return result;
}
// - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// - - ShapeGroup: Group 1 Offset:<19.745, 29.096>
CanvasGeometry Geometry_1()
{
CanvasGeometry result;
using (var builder = new CanvasPathBuilder(null))
{
builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding);
builder.BeginFigure(new Vector2(4.25500011F, 6.94799995F));
builder.AddCubicBezier(new Vector2(-2.36199999F, 6.94799995F), new Vector2(-7.74499989F, 1.472F), new Vector2(-7.74499989F, -5.09499979F));
builder.AddLine(new Vector2(-3.74499989F, -5.09499979F));
builder.AddLine(new Vector2(-9.74499989F, -10.9189997F));
builder.AddLine(new Vector2(-15.7449999F, -5.09499979F));
builder.AddLine(new Vector2(-11.7449999F, -5.09499979F));
builder.AddCubicBezier(new Vector2(-11.7449999F, 3.66199994F), new Vector2(-4.56699991F, 10.9189997F), new Vector2(4.25500011F, 10.9189997F));
builder.AddCubicBezier(new Vector2(8.6260004F, 10.9189997F), new Vector2(12.7060003F, 9.20300007F), new Vector2(15.7449999F, 6.08900023F));
builder.AddLine(new Vector2(12.8739996F, 3.32500005F));
builder.AddCubicBezier(new Vector2(10.5930004F, 5.66099977F), new Vector2(7.53200006F, 6.94799995F), new Vector2(4.25500011F, 6.94799995F));
builder.EndFigure(CanvasFigureLoop.Closed);
result = CanvasGeometry.CreatePath(builder);
}
return result;
}
CompositionColorBrush ColorBrush_AlmostDarkSlateGray_FF2D3846()
{
return _colorBrush_AlmostDarkSlateGray_FF2D3846 = _c.CreateColorBrush(Color.FromArgb(0xFF, 0x2D, 0x38, 0x46));
}
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
CompositionContainerShape ContainerShape()
{
var result = _c.CreateContainerShape();
result.CenterPoint = new Vector2(24F, 24F);
var shapes = result.Shapes;
// ShapeGroup: Group 2 Offset:<28.255, 18.903>
shapes.Add(SpriteShape_0());
// ShapeGroup: Group 1 Offset:<19.745, 29.096>
shapes.Add(SpriteShape_1());
result.StartAnimation("RotationAngleInDegrees", RotationAngleInDegreesScalarAnimation_0_to_360(), AnimationController_0());
return result;
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// ShapeGroup: Group 2 Offset:<28.255, 18.903>
CompositionPathGeometry PathGeometry_0()
{
return _c.CreatePathGeometry(new CompositionPath(Geometry_0()));
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// ShapeGroup: Group 1 Offset:<19.745, 29.096>
CompositionPathGeometry PathGeometry_1()
{
return _c.CreatePathGeometry(new CompositionPath(Geometry_1()));
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Path 1
CompositionSpriteShape SpriteShape_0()
{
// Offset:<28.255, 18.903>
var geometry = PathGeometry_0();
var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 28.2549992F, 18.9029999F), ColorBrush_AlmostDarkSlateGray_FF2D3846());;
return result;
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Path 1
CompositionSpriteShape SpriteShape_1()
{
// Offset:<19.745, 29.096>
var geometry = PathGeometry_1();
var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 19.7450008F, 29.0960007F), _colorBrush_AlmostDarkSlateGray_FF2D3846);;
return result;
}
// The root of the composition.
ContainerVisual Root()
{
var result = _root = _c.CreateContainerVisual();
var propertySet = result.Properties;
propertySet.InsertScalar("Progress", 0F);
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
result.Children.InsertAtTop(ShapeVisual_0());
return result;
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Rotation
ScalarKeyFrameAnimation RotationAngleInDegreesScalarAnimation_0_to_360()
{
// Frame 0.
var result = CreateScalarKeyFrameAnimation(0F, 0F, HoldThenStepEasingFunction());
// Frame 61.
result.InsertKeyFrame(1F, 360F, _c.CreateCubicBezierEasingFunction(new Vector2(0.314999998F, 0F), new Vector2(0.465000004F, 0.861999989F)));
return result;
}
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
ShapeVisual ShapeVisual_0()
{
var result = _c.CreateShapeVisual();
result.Size = new Vector2(48F, 48F);
result.Shapes.Add(ContainerShape());
return result;
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// RotationAngleInDegrees
StepEasingFunction HoldThenStepEasingFunction()
{
var result = _c.CreateStepEasingFunction();
result.IsFinalStepSingleFrame = true;
return result;
}
internal Sync_AnimatedVisual_UAPv15(
Compositor compositor
)
{
_c = compositor;
_reusableExpressionAnimation = compositor.CreateExpressionAnimation();
Root();
}
public Visual RootVisual => _root;
public TimeSpan Duration => TimeSpan.FromTicks(c_durationTicks);
public Vector2 Size => new Vector2(48F, 48F);
void IDisposable.Dispose() => _root?.Dispose();
internal static bool IsRuntimeCompatible()
{
return Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 15);
}
}
sealed class Sync_AnimatedVisual_UAPv7
: Microsoft.UI.Xaml.Controls.IAnimatedVisual
{
const long c_durationTicks = 10166666;
readonly Compositor _c;
readonly ExpressionAnimation _reusableExpressionAnimation;
CompositionColorBrush _colorBrush_AlmostDarkSlateGray_FF2D3846;
ContainerVisual _root;
void BindProperty(
CompositionObject target,
string animatedPropertyName,
string expression,
string referenceParameterName,
CompositionObject referencedObject)
{
_reusableExpressionAnimation.ClearAllParameters();
_reusableExpressionAnimation.Expression = expression;
_reusableExpressionAnimation.SetReferenceParameter(referenceParameterName, referencedObject);
target.StartAnimation(animatedPropertyName, _reusableExpressionAnimation);
}
ScalarKeyFrameAnimation CreateScalarKeyFrameAnimation(float initialProgress, float initialValue, CompositionEasingFunction initialEasingFunction)
{
var result = _c.CreateScalarKeyFrameAnimation();
result.Duration = TimeSpan.FromTicks(c_durationTicks);
result.InsertKeyFrame(initialProgress, initialValue, initialEasingFunction);
return result;
}
CompositionSpriteShape CreateSpriteShape(CompositionGeometry geometry, Matrix3x2 transformMatrix, CompositionBrush fillBrush)
{
var result = _c.CreateSpriteShape(geometry);
result.TransformMatrix = transformMatrix;
result.FillBrush = fillBrush;
return result;
}
// - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// - - ShapeGroup: Group 2 Offset:<28.255, 18.903>
CanvasGeometry Geometry_0()
{
CanvasGeometry result;
using (var builder = new CanvasPathBuilder(null))
{
builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding);
builder.BeginFigure(new Vector2(11.7449999F, 5.09700012F));
builder.AddCubicBezier(new Vector2(11.7449999F, -3.66000009F), new Vector2(4.56699991F, -10.9189997F), new Vector2(-4.25500011F, -10.9189997F));
builder.AddCubicBezier(new Vector2(-8.6239996F, -10.9189997F), new Vector2(-12.7040005F, -9.20300007F), new Vector2(-15.7449999F, -6.08900023F));
builder.AddLine(new Vector2(-12.8739996F, -3.32599998F));
builder.AddCubicBezier(new Vector2(-10.5930004F, -5.66200018F), new Vector2(-7.53200006F, -6.94799995F), new Vector2(-4.25500011F, -6.94799995F));
builder.AddCubicBezier(new Vector2(2.36199999F, -6.94799995F), new Vector2(7.74499989F, -1.47099996F), new Vector2(7.74499989F, 5.09700012F));
builder.AddLine(new Vector2(3.74499989F, 5.09700012F));
builder.AddLine(new Vector2(9.74499989F, 10.9189997F));
builder.AddLine(new Vector2(15.7449999F, 5.09700012F));
builder.AddLine(new Vector2(11.7449999F, 5.09700012F));
builder.EndFigure(CanvasFigureLoop.Closed);
result = CanvasGeometry.CreatePath(builder);
}
return result;
}
// - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// - - ShapeGroup: Group 1 Offset:<19.745, 29.096>
CanvasGeometry Geometry_1()
{
CanvasGeometry result;
using (var builder = new CanvasPathBuilder(null))
{
builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding);
builder.BeginFigure(new Vector2(4.25500011F, 6.94799995F));
builder.AddCubicBezier(new Vector2(-2.36199999F, 6.94799995F), new Vector2(-7.74499989F, 1.472F), new Vector2(-7.74499989F, -5.09499979F));
builder.AddLine(new Vector2(-3.74499989F, -5.09499979F));
builder.AddLine(new Vector2(-9.74499989F, -10.9189997F));
builder.AddLine(new Vector2(-15.7449999F, -5.09499979F));
builder.AddLine(new Vector2(-11.7449999F, -5.09499979F));
builder.AddCubicBezier(new Vector2(-11.7449999F, 3.66199994F), new Vector2(-4.56699991F, 10.9189997F), new Vector2(4.25500011F, 10.9189997F));
builder.AddCubicBezier(new Vector2(8.6260004F, 10.9189997F), new Vector2(12.7060003F, 9.20300007F), new Vector2(15.7449999F, 6.08900023F));
builder.AddLine(new Vector2(12.8739996F, 3.32500005F));
builder.AddCubicBezier(new Vector2(10.5930004F, 5.66099977F), new Vector2(7.53200006F, 6.94799995F), new Vector2(4.25500011F, 6.94799995F));
builder.EndFigure(CanvasFigureLoop.Closed);
result = CanvasGeometry.CreatePath(builder);
}
return result;
}
CompositionColorBrush ColorBrush_AlmostDarkSlateGray_FF2D3846()
{
return _colorBrush_AlmostDarkSlateGray_FF2D3846 = _c.CreateColorBrush(Color.FromArgb(0xFF, 0x2D, 0x38, 0x46));
}
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
CompositionContainerShape ContainerShape()
{
var result = _c.CreateContainerShape();
result.CenterPoint = new Vector2(24F, 24F);
var shapes = result.Shapes;
// ShapeGroup: Group 2 Offset:<28.255, 18.903>
shapes.Add(SpriteShape_0());
// ShapeGroup: Group 1 Offset:<19.745, 29.096>
shapes.Add(SpriteShape_1());
result.StartAnimation("RotationAngleInDegrees", RotationAngleInDegreesScalarAnimation_0_to_360());
var controller = result.TryGetAnimationController("RotationAngleInDegrees");
controller.Pause();
BindProperty(controller, "Progress", "_.Progress", "_", _root);
return result;
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// ShapeGroup: Group 2 Offset:<28.255, 18.903>
CompositionPathGeometry PathGeometry_0()
{
return _c.CreatePathGeometry(new CompositionPath(Geometry_0()));
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// ShapeGroup: Group 1 Offset:<19.745, 29.096>
CompositionPathGeometry PathGeometry_1()
{
return _c.CreatePathGeometry(new CompositionPath(Geometry_1()));
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Path 1
CompositionSpriteShape SpriteShape_0()
{
// Offset:<28.255, 18.903>
var geometry = PathGeometry_0();
var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 28.2549992F, 18.9029999F), ColorBrush_AlmostDarkSlateGray_FF2D3846());;
return result;
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Path 1
CompositionSpriteShape SpriteShape_1()
{
// Offset:<19.745, 29.096>
var geometry = PathGeometry_1();
var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 19.7450008F, 29.0960007F), _colorBrush_AlmostDarkSlateGray_FF2D3846);;
return result;
}
// The root of the composition.
ContainerVisual Root()
{
var result = _root = _c.CreateContainerVisual();
var propertySet = result.Properties;
propertySet.InsertScalar("Progress", 0F);
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
result.Children.InsertAtTop(ShapeVisual_0());
return result;
}
// - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// Rotation
ScalarKeyFrameAnimation RotationAngleInDegreesScalarAnimation_0_to_360()
{
// Frame 0.
var result = CreateScalarKeyFrameAnimation(0F, 0F, HoldThenStepEasingFunction());
// Frame 61.
result.InsertKeyFrame(1F, 360F, _c.CreateCubicBezierEasingFunction(new Vector2(0.314999998F, 0F), new Vector2(0.465000004F, 0.861999989F)));
return result;
}
// Shape tree root for layer: main_library_shelf_icon_sync Outlines
ShapeVisual ShapeVisual_0()
{
var result = _c.CreateShapeVisual();
result.Size = new Vector2(48F, 48F);
result.Shapes.Add(ContainerShape());
return result;
}
// - - Shape tree root for layer: main_library_shelf_icon_sync Outlines
// RotationAngleInDegrees
StepEasingFunction HoldThenStepEasingFunction()
{
var result = _c.CreateStepEasingFunction();
result.IsFinalStepSingleFrame = true;
return result;
}
internal Sync_AnimatedVisual_UAPv7(
Compositor compositor
)
{
_c = compositor;
_reusableExpressionAnimation = compositor.CreateExpressionAnimation();
Root();
}
public Visual RootVisual => _root;
public TimeSpan Duration => TimeSpan.FromTicks(c_durationTicks);
public Vector2 Size => new Vector2(48F, 48F);
void IDisposable.Dispose() => _root?.Dispose();
internal static bool IsRuntimeCompatible()
{
return Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 7);
}
}
}
}
+121 -25
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System; using System;
using System.IO; using System.IO;
@@ -48,7 +49,8 @@ public partial class App : WinoApplication,
IRecipient<NewCalendarSynchronizationRequested>, IRecipient<NewCalendarSynchronizationRequested>,
IRecipient<AccountCreatedMessage>, IRecipient<AccountCreatedMessage>,
IRecipient<AccountRemovedMessage>, IRecipient<AccountRemovedMessage>,
IRecipient<GetStartedFromWelcomeRequested> IRecipient<GetStartedFromWelcomeRequested>,
IRecipient<WelcomeImportCompletedMessage>
{ {
private const int InboxSyncsPerFullSync = 20; private const int InboxSyncsPerFullSync = 20;
private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default"; private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default";
@@ -63,7 +65,7 @@ public partial class App : WinoApplication,
private bool _isExiting; private bool _isExiting;
private CancellationTokenSource? _autoSynchronizationLoopCts; private CancellationTokenSource? _autoSynchronizationLoopCts;
private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1); private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1);
private readonly Dictionary<Guid, int> _inboxSyncCounters = []; private readonly ConcurrentDictionary<Guid, int> _inboxSyncCounters = [];
private NativeTrayIcon? _trayIcon; private NativeTrayIcon? _trayIcon;
internal bool IsExiting => _isExiting; internal bool IsExiting => _isExiting;
@@ -756,7 +758,9 @@ public partial class App : WinoApplication,
/// Creates the main window without activating it. /// Creates the main window without activating it.
/// Used for both normal launch and startup task launch (tray only). /// Used for both normal launch and startup task launch (tray only).
/// </summary> /// </summary>
private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs? args, string? forcedLaunchArguments = null) private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs? args,
string? forcedLaunchArguments = null,
ShellModeActivationContext? activationContextOverride = null)
{ {
LogActivation("Creating main window."); LogActivation("Creating main window.");
@@ -769,14 +773,28 @@ public partial class App : WinoApplication,
windowManager.SetPrimaryNavigationFrame(WinoWindowKind.Shell, shellWindow.GetMainFrame()); windowManager.SetPrimaryNavigationFrame(WinoWindowKind.Shell, shellWindow.GetMainFrame());
var navigationService = Services.GetRequiredService<INavigationService>();
var defaultMode = _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail;
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activationContextOverride != null)
{
var targetMode = !string.IsNullOrWhiteSpace(forcedLaunchArguments)
? AppModeActivationResolver.Resolve(forcedLaunchArguments, null, null, defaultMode)
: TryResolveActivationMode(activationArgs, defaultMode, out var resolvedActivationMode)
? resolvedActivationMode
: AppModeActivationResolver.Resolve(args?.Arguments, GetCurrentLaunchTileId(), Environment.CommandLine, defaultMode);
navigationService.ChangeApplicationMode(targetMode, activationContextOverride);
return;
}
if (!string.IsNullOrWhiteSpace(forcedLaunchArguments)) if (!string.IsNullOrWhiteSpace(forcedLaunchArguments))
{ {
shellWindow.HandleAppActivation(forcedLaunchArguments); shellWindow.HandleAppActivation(forcedLaunchArguments);
return; return;
} }
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activationArgs.Kind == ExtendedActivationKind.Launch && if (activationArgs.Kind == ExtendedActivationKind.Launch &&
activationArgs.Data is ILaunchActivatedEventArgs launchArgs) activationArgs.Data is ILaunchActivatedEventArgs launchArgs)
{ {
@@ -791,7 +809,7 @@ public partial class App : WinoApplication,
return; return;
} }
if (TryResolveActivationMode(activationArgs, _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail, out var activationMode)) if (TryResolveActivationMode(activationArgs, defaultMode, out var activationMode))
{ {
shellWindow.HandleAppActivation(GetModeLaunchArgument(activationMode)); shellWindow.HandleAppActivation(GetModeLaunchArgument(activationMode));
return; return;
@@ -859,6 +877,7 @@ public partial class App : WinoApplication,
WeakReferenceMessenger.Default.Register<AccountCreatedMessage>(this); WeakReferenceMessenger.Default.Register<AccountCreatedMessage>(this);
WeakReferenceMessenger.Default.Register<AccountRemovedMessage>(this); WeakReferenceMessenger.Default.Register<AccountRemovedMessage>(this);
WeakReferenceMessenger.Default.Register<GetStartedFromWelcomeRequested>(this); WeakReferenceMessenger.Default.Register<GetStartedFromWelcomeRequested>(this);
WeakReferenceMessenger.Default.Register<WelcomeImportCompletedMessage>(this);
} }
public async void Receive(NewMailSynchronizationRequested message) public async void Receive(NewMailSynchronizationRequested message)
@@ -882,6 +901,11 @@ public partial class App : WinoApplication,
syncResult.CompletedState, syncResult.CompletedState,
message.Options.GroupedSynchronizationTrackingId)); message.Options.GroupedSynchronizationTrackingId));
if (syncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted)
{
await ClearInvalidCredentialAttentionIfNeededAsync(message.Options.AccountId).ConfigureAwait(false);
}
if (syncResult.CompletedState == SynchronizationCompletedState.Failed || if (syncResult.CompletedState == SynchronizationCompletedState.Failed ||
syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted) syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted)
{ {
@@ -906,7 +930,12 @@ public partial class App : WinoApplication,
var dialogService = Services.GetRequiredService<IMailDialogService>(); var dialogService = Services.GetRequiredService<IMailDialogService>();
dialogService.InfoBarMessage( dialogService.InfoBarMessage(
Translator.Info_SyncFailedTitle, Translator.Info_SyncFailedTitle,
Translator.Exception_FailedToSynchronizeFolders, message.Options.Type switch
{
CalendarSynchronizationType.CalendarMetadata => Translator.Exception_FailedToSynchronizeCalendarMetadata,
CalendarSynchronizationType.Strict => Translator.Exception_FailedToSynchronizeCalendarData,
_ => Translator.Exception_FailedToSynchronizeCalendarEvents
},
InfoBarMessageType.Error); InfoBarMessageType.Error);
} }
} }
@@ -942,6 +971,47 @@ public partial class App : WinoApplication,
}); });
} }
public void Receive(WelcomeImportCompletedMessage message)
{
_hasConfiguredAccounts = message.ImportedMailboxCount > 0;
var windowManager = Services.GetRequiredService<IWinoWindowManager>();
if (windowManager.GetWindow(WinoWindowKind.Welcome) == null)
return;
MainWindow?.DispatcherQueue?.TryEnqueue(async () =>
{
if (_preferencesService != null)
{
_preferencesService.PreferenceChanged -= PreferencesServiceChanged;
_preferencesService.PreferenceChanged += PreferencesServiceChanged;
}
CreateWindow(
null,
GetModeLaunchArgument(WinoApplicationMode.Mail),
new ShellModeActivationContext
{
SuppressStartupFlows = true
});
await LoadInitialWinoAccountAsync();
CloseWelcomeWindowIfPresent();
if (MainWindow != null)
{
await ActivateWindowAsync(MainWindow);
}
RestartAutoSynchronizationLoop();
Services.GetRequiredService<IMailDialogService>().InfoBarMessage(
Translator.GeneralTitle_Info,
Translator.WinoAccount_Management_ImportReloginReminder,
InfoBarMessageType.Information);
});
}
public void Receive(AccountRemovedMessage message) public void Receive(AccountRemovedMessage message)
{ {
var windowManager = Services.GetRequiredService<IWinoWindowManager>(); var windowManager = Services.GetRequiredService<IWinoWindowManager>();
@@ -1078,16 +1148,37 @@ public partial class App : WinoApplication,
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var currentAccountIds = accounts.Select(a => a.Id).ToHashSet(); var currentAccountIds = accounts.Select(a => a.Id).ToHashSet();
_inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList().ForEach(a => _inboxSyncCounters.Remove(a)); foreach (var staleAccountId in _inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList())
foreach (var account in accounts)
{ {
_inboxSyncCounters.TryRemove(staleAccountId, out _);
}
var synchronizationTasks = accounts
.Select(account => ExecuteAutoSynchronizationForAccountAsync(account, cancellationToken))
.ToList();
await Task.WhenAll(synchronizationTasks).ConfigureAwait(false);
}
finally
{
if (lockTaken)
{
_autoSynchronizationSemaphore.Release();
}
}
}
private async Task ExecuteAutoSynchronizationForAccountAsync(Wino.Core.Domain.Entities.Shared.MailAccount account, CancellationToken cancellationToken)
{
if (_synchronizationManager == null)
return;
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (_synchronizationManager.IsAccountSynchronizing(account.Id)) if (_synchronizationManager.IsAccountSynchronizing(account.Id))
continue; return;
var inboxSyncOptions = new MailSynchronizationOptions() var inboxSyncOptions = new MailSynchronizationOptions
{ {
AccountId = account.Id, AccountId = account.Id,
Type = MailSynchronizationType.InboxOnly Type = MailSynchronizationType.InboxOnly
@@ -1097,12 +1188,13 @@ public partial class App : WinoApplication,
if (inboxSyncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted) if (inboxSyncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted)
{ {
_inboxSyncCounters.TryAdd(account.Id, 0); await ClearInvalidCredentialAttentionIfNeededAsync(account.Id).ConfigureAwait(false);
_inboxSyncCounters[account.Id]++;
if (_inboxSyncCounters[account.Id] >= InboxSyncsPerFullSync) var inboxSyncCount = _inboxSyncCounters.AddOrUpdate(account.Id, 1, (_, currentCount) => currentCount + 1);
if (inboxSyncCount >= InboxSyncsPerFullSync)
{ {
var fullSyncOptions = new MailSynchronizationOptions() var fullSyncOptions = new MailSynchronizationOptions
{ {
AccountId = account.Id, AccountId = account.Id,
Type = MailSynchronizationType.FullFolders Type = MailSynchronizationType.FullFolders
@@ -1114,9 +1206,9 @@ public partial class App : WinoApplication,
} }
if (!account.IsCalendarAccessGranted) if (!account.IsCalendarAccessGranted)
continue; return;
var calendarOptions = new CalendarSynchronizationOptions() var calendarOptions = new CalendarSynchronizationOptions
{ {
AccountId = account.Id, AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarMetadata Type = CalendarSynchronizationType.CalendarMetadata
@@ -1124,14 +1216,18 @@ public partial class App : WinoApplication,
await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false); await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false);
} }
}
finally private async Task ClearInvalidCredentialAttentionIfNeededAsync(Guid accountId)
{ {
if (lockTaken) if (_accountService == null)
{ return;
_autoSynchronizationSemaphore.Release();
} var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
}
if (account?.AttentionReason != AccountAttentionReason.InvalidCredentials)
return;
await _accountService.ClearAccountAttentionAsync(accountId).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@@ -15,8 +15,9 @@
TargetType="Button"> TargetType="Button">
<Setter Property="Margin" Value="0" /> <Setter Property="Margin" Value="0" />
<Setter Property="Height" Value="32" /> <Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{ThemeResource SystemColorControlAccentBrush}" /> <!--<Setter Property="Foreground" Value="{ThemeResource SystemColorControlAccentBrush}" />-->
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
@@ -27,7 +28,7 @@
<Grid Margin="4,0,0,0" Background="Transparent"> <Grid Margin="4,0,0,0" Background="Transparent">
<Grid Background="Transparent" ColumnSpacing="16"> <Grid Background="Transparent" ColumnSpacing="16">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="64" /> <ColumnDefinition Width="128" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -0,0 +1,14 @@
<UserControl
x:Class="Wino.Mail.WinUI.Controls.SyncAnimationControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:animatedvisuals="using:Wino.Mail.WinUI.AnimatedVisuals">
<AnimatedVisualPlayer
x:Name="AnimationPlayer"
AutoPlay="True"
Stretch="Uniform">
<animatedvisuals:SyncRefreshAnimation />
</AnimatedVisualPlayer>
</UserControl>
@@ -0,0 +1,54 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Wino.Mail.WinUI.Controls;
public sealed partial class SyncAnimationControl : UserControl
{
public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register(
nameof(IsPlaying),
typeof(bool),
typeof(SyncAnimationControl),
new PropertyMetadata(true, OnIsPlayingChanged));
public bool IsPlaying
{
get => (bool)GetValue(IsPlayingProperty);
set => SetValue(IsPlayingProperty, value);
}
public SyncAnimationControl()
{
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (IsPlaying)
{
PlayAnimation();
}
}
private static void OnIsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (SyncAnimationControl)d;
if ((bool)e.NewValue)
{
control.PlayAnimation();
}
else
{
control.AnimationPlayer.Stop();
}
}
private void PlayAnimation()
{
#pragma warning disable CS4014 // Fire-and-forget is intentional for looped animation playback.
AnimationPlayer.PlayAsync(0, 1, looped: true);
#pragma warning restore CS4014
}
}
@@ -1,43 +0,0 @@
<ContentDialog
x:Class="Wino.Dialogs.WhatIsNewDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
Style="{StaticResource WinoDialogStyle}"
mc:Ignorable="d">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMinWidth">480</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">560</x:Double>
<x:Double x:Key="ContentDialogMinHeight">480</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">700</x:Double>
</ContentDialog.Resources>
<Grid RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<controls:UpdateNotesFlipViewControl x:Name="UpdateNotesControl" Sections="{x:Bind Sections, Mode=OneTime}" />
<StackPanel
Grid.Row="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="GetStartedButton"
Click="OnGetStartedClicked"
Content="{x:Bind domain:Translator.WhatIsNew_GetStartedButton}"
Style="{StaticResource AccentButtonStyle}"
Visibility="Collapsed" />
</StackPanel>
</Grid>
</ContentDialog>
@@ -1,54 +0,0 @@
using System.Collections.Generic;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Updates;
namespace Wino.Dialogs;
public sealed partial class WhatIsNewDialog : ContentDialog
{
private readonly IUpdateManager _updateManager;
public List<UpdateNoteSection> Sections { get; }
private bool _canClose = false;
public WhatIsNewDialog(UpdateNotes notes, IUpdateManager updateManager)
{
InitializeComponent();
_updateManager = updateManager;
Sections = notes.Sections;
// Show the Get Started button immediately when there is only one page.
UpdateNotesControl.SelectedIndexChanged += OnUpdateSectionChanged;
UpdateGetStartedButtonVisibility(UpdateNotesControl.SelectedIndex);
Closing += OnDialogClosing;
}
private void OnUpdateSectionChanged(object? sender, int selectedIndex)
=> UpdateGetStartedButtonVisibility(selectedIndex);
private void UpdateGetStartedButtonVisibility(int selectedIndex)
{
GetStartedButton.Visibility = selectedIndex == Sections.Count - 1
? Visibility.Visible
: Visibility.Collapsed;
}
private void OnDialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args)
{
// Only allow closing when Get Started button was clicked.
if (!_canClose)
args.Cancel = true;
}
private void OnGetStartedClicked(object sender, RoutedEventArgs e)
{
GetStartedButton.IsEnabled = false;
_updateManager.MarkUpdateNotesAsSeen();
_canClose = true;
Hide();
}
}
@@ -0,0 +1,76 @@
<ContentDialog
x:Class="Wino.Dialogs.WinoAccountSyncExportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DefaultButton="Primary"
PrimaryButtonClick="ExportClicked"
PrimaryButtonStyle="{ThemeResource AccentButtonStyle}"
PrimaryButtonText="{x:Bind domain:Translator.Buttons_Export, Mode=OneTime}"
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Close, Mode=OneTime}"
Style="{StaticResource WinoDialogStyle}"
Title="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_Title, Mode=OneTime}"
mc:Ignorable="d">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMinWidth">520</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">520</x:Double>
</ContentDialog.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16">
<TextBlock
Text="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_Description, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
<StackPanel Spacing="12">
<CheckBox
x:Name="PreferencesCheckBox"
Checked="SelectionChanged"
Content="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_IncludePreferences, Mode=OneTime}"
IsChecked="True"
Unchecked="SelectionChanged" />
<StackPanel Spacing="8">
<CheckBox
x:Name="AccountsCheckBox"
Checked="SelectionChanged"
Content="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_IncludeAccounts, Mode=OneTime}"
IsChecked="True"
Unchecked="SelectionChanged" />
<Border
Padding="12"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="10">
<StackPanel Spacing="6">
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_AccountsDisclaimer, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_AccountsRelogin, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
<StackPanel
x:Name="ProgressPanel"
Spacing="8"
Visibility="Collapsed">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ExportDialog_InProgress, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
<ProgressBar IsIndeterminate="True" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentDialog>
@@ -0,0 +1,73 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Dialogs;
public sealed partial class WinoAccountSyncExportDialog : ContentDialog
{
private readonly IWinoAccountDataSyncService _syncService;
private bool _isBusy;
public WinoAccountSyncExportDialog(IWinoAccountDataSyncService syncService)
{
_syncService = syncService;
InitializeComponent();
UpdateButtonState();
}
public WinoAccountSyncExportResult? Result { get; private set; }
public Exception? FailureException { get; private set; }
private async void ExportClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
args.Cancel = true;
if (!HasSelection())
{
return;
}
var deferral = args.GetDeferral();
try
{
SetBusyState(true);
FailureException = null;
Result = await _syncService.ExportAsync(new WinoAccountSyncSelection(
PreferencesCheckBox.IsChecked == true,
AccountsCheckBox.IsChecked == true));
Hide();
}
catch (Exception ex)
{
FailureException = ex;
Hide();
}
finally
{
SetBusyState(false);
deferral.Complete();
}
}
private void SelectionChanged(object sender, RoutedEventArgs e)
=> UpdateButtonState();
private void SetBusyState(bool isBusy)
{
_isBusy = isBusy;
ProgressPanel.Visibility = isBusy ? Visibility.Visible : Visibility.Collapsed;
IsSecondaryButtonEnabled = !isBusy;
UpdateButtonState();
}
private void UpdateButtonState()
=> IsPrimaryButtonEnabled = !_isBusy && HasSelection();
private bool HasSelection()
=> PreferencesCheckBox.IsChecked == true || AccountsCheckBox.IsChecked == true;
}
@@ -23,7 +23,9 @@ public partial class AccountCalendarStateService : ObservableRecipient,
IRecipient<CalendarListAdded>, IRecipient<CalendarListAdded>,
IRecipient<CalendarListUpdated>, IRecipient<CalendarListUpdated>,
IRecipient<CalendarListDeleted>, IRecipient<CalendarListDeleted>,
IRecipient<AccountRemovedMessage> IRecipient<AccountRemovedMessage>,
IRecipient<AccountUpdatedMessage>,
IRecipient<AccountCalendarSynchronizationStateChanged>
{ {
private readonly object _calendarStateLock = new(); private readonly object _calendarStateLock = new();
@@ -41,6 +43,9 @@ public partial class AccountCalendarStateService : ObservableRecipient,
[ObservableProperty] [ObservableProperty]
public partial ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; } public partial ReadOnlyObservableGroupedCollection<MailAccount, AccountCalendarViewModel> GroupedCalendars { get; set; }
[ObservableProperty]
public partial bool IsAnySynchronizationInProgress { get; set; }
public IEnumerable<AccountCalendarViewModel> ActiveCalendars public IEnumerable<AccountCalendarViewModel> ActiveCalendars
{ {
get get
@@ -84,6 +89,8 @@ public partial class AccountCalendarStateService : ObservableRecipient,
Messenger.Register<CalendarListUpdated>(this); Messenger.Register<CalendarListUpdated>(this);
Messenger.Register<CalendarListDeleted>(this); Messenger.Register<CalendarListDeleted>(this);
Messenger.Register<AccountRemovedMessage>(this); Messenger.Register<AccountRemovedMessage>(this);
Messenger.Register<AccountUpdatedMessage>(this);
Messenger.Register<AccountCalendarSynchronizationStateChanged>(this);
} }
private void SingleGroupCalendarCollectiveStateChanged(object? sender, EventArgs e) private void SingleGroupCalendarCollectiveStateChanged(object? sender, EventArgs e)
@@ -114,6 +121,8 @@ public partial class AccountCalendarStateService : ObservableRecipient,
group.Add(calendar); group.Add(calendar);
} }
} }
UpdateAggregateSynchronizationState();
} }
} }
@@ -140,6 +149,8 @@ public partial class AccountCalendarStateService : ObservableRecipient,
_internalGroupedCalendars.Remove(group); _internalGroupedCalendars.Remove(group);
} }
} }
UpdateAggregateSynchronizationState();
} }
} }
@@ -340,4 +351,60 @@ public partial class AccountCalendarStateService : ObservableRecipient,
} }
} }
} }
public async void Receive(AccountUpdatedMessage message)
{
if (Dispatcher != null)
{
await Dispatcher.ExecuteOnUIThread(() => UpdateGroupedAccount(message.Account));
}
else
{
UpdateGroupedAccount(message.Account);
}
}
public async void Receive(AccountCalendarSynchronizationStateChanged message)
{
if (Dispatcher != null)
{
await Dispatcher.ExecuteOnUIThread(() => UpdateCalendarSynchronizationState(message));
}
else
{
UpdateCalendarSynchronizationState(message);
}
}
private void UpdateGroupedAccount(MailAccount updatedAccount)
{
GroupedAccountCalendarViewModel? groupedAccount;
lock (_calendarStateLock)
{
groupedAccount = _internalGroupedAccountCalendars.FirstOrDefault(a => a.Account.Id == updatedAccount.Id);
}
groupedAccount?.UpdateAccount(updatedAccount);
}
private void UpdateCalendarSynchronizationState(AccountCalendarSynchronizationStateChanged message)
{
GroupedAccountCalendarViewModel? groupedAccount;
lock (_calendarStateLock)
{
groupedAccount = _internalGroupedAccountCalendars.FirstOrDefault(a => a.Account.Id == message.AccountId);
}
if (groupedAccount == null)
return;
groupedAccount.IsSynchronizationInProgress = message.IsSynchronizationInProgress;
groupedAccount.SynchronizationStatus = message.SynchronizationStatus;
UpdateAggregateSynchronizationState();
}
private void UpdateAggregateSynchronizationState()
{
IsAnySynchronizationInProgress = _internalGroupedAccountCalendars.Any(a => a.IsSynchronizationInProgress);
}
} }
+21 -2
View File
@@ -28,14 +28,16 @@ namespace Wino.Services;
public class DialogService : DialogServiceBase, IMailDialogService public class DialogService : DialogServiceBase, IMailDialogService
{ {
private readonly IWinoAccountProfileService _winoAccountProfileService; private readonly IWinoAccountProfileService _winoAccountProfileService;
private readonly IWinoAccountDataSyncService _winoAccountDataSyncService;
public DialogService(INewThemeService themeService, public DialogService(INewThemeService themeService,
IConfigurationService configurationService, IConfigurationService configurationService,
IApplicationResourceManager<ResourceDictionary> applicationResourceManager, IApplicationResourceManager<ResourceDictionary> applicationResourceManager,
IUpdateManager updateManager, IWinoAccountProfileService winoAccountProfileService,
IWinoAccountProfileService winoAccountProfileService) : base(themeService, configurationService, applicationResourceManager, updateManager) IWinoAccountDataSyncService winoAccountDataSyncService) : base(themeService, configurationService, applicationResourceManager)
{ {
_winoAccountProfileService = winoAccountProfileService; _winoAccountProfileService = winoAccountProfileService;
_winoAccountDataSyncService = winoAccountDataSyncService;
} }
public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync() public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync()
@@ -279,4 +281,21 @@ public class DialogService : DialogServiceBase, IMailDialogService
return dialog.Result; return dialog.Result;
} }
public async Task<WinoAccountSyncExportResult?> ShowWinoAccountExportDialogAsync()
{
var dialog = new WinoAccountSyncExportDialog(_winoAccountDataSyncService)
{
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
};
await HandleDialogPresentationAsync(dialog);
if (dialog.FailureException != null)
{
throw dialog.FailureException;
}
return dialog.Result;
}
} }
+1 -15
View File
@@ -16,7 +16,6 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Common; using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Printing; using Wino.Core.Domain.Models.Printing;
using Wino.Core.Domain.Models.Updates;
using Wino.Dialogs; using Wino.Dialogs;
using Wino.Mail.WinUI.Dialogs; using Wino.Mail.WinUI.Dialogs;
using Wino.Mail.WinUI.Extensions; using Wino.Mail.WinUI.Extensions;
@@ -31,16 +30,13 @@ public class DialogServiceBase : IDialogServiceBase
protected INewThemeService ThemeService { get; } protected INewThemeService ThemeService { get; }
protected IConfigurationService ConfigurationService { get; } protected IConfigurationService ConfigurationService { get; }
protected IUpdateManager UpdateManager { get; }
protected IApplicationResourceManager<ResourceDictionary> ApplicationResourceManager { get; } protected IApplicationResourceManager<ResourceDictionary> ApplicationResourceManager { get; }
public DialogServiceBase(INewThemeService themeService, IConfigurationService configurationService, IApplicationResourceManager<ResourceDictionary> applicationResourceManager, IUpdateManager updateManager) public DialogServiceBase(INewThemeService themeService, IConfigurationService configurationService, IApplicationResourceManager<ResourceDictionary> applicationResourceManager)
{ {
ThemeService = themeService; ThemeService = themeService;
ConfigurationService = configurationService; ConfigurationService = configurationService;
ApplicationResourceManager = applicationResourceManager; ApplicationResourceManager = applicationResourceManager;
UpdateManager = updateManager;
} }
protected XamlRoot? GetXamlRoot() protected XamlRoot? GetXamlRoot()
@@ -392,14 +388,4 @@ public class DialogServiceBase : IDialogServiceBase
return null!; return null!;
} }
} }
public async Task ShowWhatIsNewDialogAsync(UpdateNotes notes)
{
var dialog = new WhatIsNewDialog(notes, UpdateManager)
{
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
};
await HandleDialogPresentationAsync(dialog);
}
} }
+10 -3
View File
@@ -219,10 +219,13 @@ public class NavigationService : NavigationServiceBase, INavigationService
public bool ChangeApplicationMode(WinoApplicationMode mode) public bool ChangeApplicationMode(WinoApplicationMode mode)
=> ExecuteOnNavigationThread(() => ChangeApplicationModeInternal(mode)); => ExecuteOnNavigationThread(() => ChangeApplicationModeInternal(mode));
public bool ChangeApplicationMode(WinoApplicationMode mode, ShellModeActivationContext activationContext)
=> ExecuteOnNavigationThread(() => ChangeApplicationModeInternal(mode, activationContext));
public bool CanGoBack() public bool CanGoBack()
=> ExecuteOnNavigationThread(CanGoBackInternal); => ExecuteOnNavigationThread(CanGoBackInternal);
private bool ChangeApplicationModeInternal(WinoApplicationMode mode, object? activationParameter = null) private bool ChangeApplicationModeInternal(WinoApplicationMode mode, ShellModeActivationContext? activationContext = null)
{ {
var coreFrame = GetCoreFrameInternal(NavigationReferenceFrame.ShellFrame); var coreFrame = GetCoreFrameInternal(NavigationReferenceFrame.ShellFrame);
@@ -254,7 +257,8 @@ public class NavigationService : NavigationServiceBase, INavigationService
shell.ActivateMode(mode, new ShellModeActivationContext shell.ActivateMode(mode, new ShellModeActivationContext
{ {
IsInitialActivation = isInitialShellNavigation, IsInitialActivation = isInitialShellNavigation,
Parameter = activationParameter SuppressStartupFlows = activationContext?.SuppressStartupFlows ?? false,
Parameter = activationContext?.Parameter
}); });
ResetCurrentModeBackStackState(); ResetCurrentModeBackStackState();
@@ -280,7 +284,10 @@ public class NavigationService : NavigationServiceBase, INavigationService
{ {
if (_statePersistanceService.ApplicationMode != WinoApplicationMode.Settings) if (_statePersistanceService.ApplicationMode != WinoApplicationMode.Settings)
{ {
return ChangeApplicationModeInternal(WinoApplicationMode.Settings, settingsTarget); return ChangeApplicationModeInternal(WinoApplicationMode.Settings, new ShellModeActivationContext
{
Parameter = settingsTarget
});
} }
page = WinoPage.SettingsPage; page = WinoPage.SettingsPage;
File diff suppressed because one or more lines are too long
@@ -10,9 +10,9 @@
<ScrollViewer> <ScrollViewer>
<StackPanel <StackPanel
MaxWidth="860"
Padding="36,28,36,36" Padding="36,28,36,36"
HorizontalAlignment="Center" HorizontalAlignment="Stretch"
Spacing="24"> Spacing="24">
<!-- Page Header --> <!-- Page Header -->
@@ -219,6 +219,11 @@
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
Text="Manage account settings" /> Text="Manage account settings" />
<TextBlock Margin="20,0,0,12" Text="Add new account or manage individidual account settings." /> <TextBlock Margin="20,0,0,12" Text="Add new account or manage individidual account settings." />
<Button
Margin="20,0,0,12"
HorizontalAlignment="Left"
Click="ManageAccountsClicked"
Content="{x:Bind domain:Translator.MenuManageAccounts}" />
<ListView <ListView
x:Name="AccountsList" x:Name="AccountsList"
IsItemClickEnabled="False" IsItemClickEnabled="False"
@@ -51,6 +51,11 @@ public sealed partial class SettingOptionsPage : SettingOptionsPageAbstract
ViewModel.NavigateToAddAccount(); ViewModel.NavigateToAddAccount();
} }
private void ManageAccountsClicked(object sender, RoutedEventArgs e)
{
ViewModel.NavigateToManageAccounts();
}
private void SettingsSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) private void SettingsSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{ {
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput || string.IsNullOrWhiteSpace(sender.Text)) if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput || string.IsNullOrWhiteSpace(sender.Text))
+28
View File
@@ -130,6 +130,34 @@
MaxWidth="600" MaxWidth="600"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Spacing="8"> Spacing="8">
<HyperlinkButton
HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
Content="{x:Bind domain:Translator.WelcomeWindow_ImportFromWinoAccount}" />
<StackPanel
x:Name="ImportProgressPanel"
HorizontalAlignment="Stretch"
Spacing="8"
Visibility="{x:Bind ViewModel.IsImportInProgress, Mode=OneWay}">
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WelcomeWindow_ImportInProgress}" />
<ProgressBar IsIndeterminate="True" />
</StackPanel>
<TextBlock
x:Name="ImportStatusTextBlock"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.ImportStatusMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind ViewModel.HasImportStatus, Mode=OneWay}" />
<TextBlock <TextBlock
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+58 -13
View File
@@ -92,17 +92,22 @@
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" /> Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
</StackPanel> </StackPanel>
<PathIcon <Button
x:Name="AttentionIcon"
Grid.Column="1" Grid.Column="1"
Width="16"
Height="16"
Margin="8,0"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
x:Load="{x:Bind IsAttentionRequired, Mode=OneWay}" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
Data="F1 M 2.021484 18.769531 C 1.767578 18.769531 1.52832 18.720703 1.303711 18.623047 C 1.079102 18.525391 0.880534 18.391928 0.708008 18.222656 C 0.535482 18.053385 0.398763 17.856445 0.297852 17.631836 C 0.19694 17.407227 0.146484 17.167969 0.146484 16.914062 C 0.146484 16.614584 0.211589 16.328125 0.341797 16.054688 L 7.695312 1.347656 C 7.851562 1.035156 8.082682 0.784506 8.388672 0.595703 C 8.694661 0.406902 9.023438 0.3125 9.375 0.3125 C 9.726562 0.3125 10.055338 0.406902 10.361328 0.595703 C 10.667317 0.784506 10.898438 1.035156 11.054688 1.347656 L 18.408203 16.054688 C 18.53841 16.328125 18.603516 16.614584 18.603516 16.914062 C 18.603516 17.167969 18.553059 17.407227 18.452148 17.631836 C 18.351236 17.856445 18.216145 18.053385 18.046875 18.222656 C 17.877604 18.391928 17.679035 18.525391 17.451172 18.623047 C 17.223307 18.720703 16.982422 18.769531 16.728516 18.769531 Z M 16.728516 17.519531 C 16.884766 17.519531 17.027994 17.460938 17.158203 17.34375 C 17.28841 17.226562 17.353516 17.086588 17.353516 16.923828 C 17.353516 16.806641 17.330729 16.702475 17.285156 16.611328 L 9.931641 1.904297 C 9.879557 1.793621 9.80306 1.708984 9.702148 1.650391 C 9.601236 1.591797 9.492188 1.5625 9.375 1.5625 C 9.257812 1.5625 9.148763 1.593426 9.047852 1.655273 C 8.946939 1.717123 8.870442 1.800131 8.818359 1.904297 L 1.464844 16.611328 C 1.419271 16.702475 1.396484 16.803387 1.396484 16.914062 C 1.396484 17.083334 1.459961 17.226562 1.586914 17.34375 C 1.713867 17.460938 1.858724 17.519531 2.021484 17.519531 Z M 8.75 11.875 L 8.75 6.875 C 8.75 6.705729 8.811849 6.559245 8.935547 6.435547 C 9.059244 6.31185 9.205729 6.25 9.375 6.25 C 9.544271 6.25 9.690755 6.31185 9.814453 6.435547 C 9.93815 6.559245 10 6.705729 10 6.875 L 10 11.875 C 10 12.044271 9.93815 12.190756 9.814453 12.314453 C 9.690755 12.438151 9.544271 12.5 9.375 12.5 C 9.205729 12.5 9.059244 12.438151 8.935547 12.314453 C 8.811849 12.190756 8.75 12.044271 8.75 11.875 Z M 8.4375 14.375 C 8.4375 14.114584 8.528646 13.893229 8.710938 13.710938 C 8.893229 13.528646 9.114583 13.4375 9.375 13.4375 C 9.635416 13.4375 9.856771 13.528646 10.039062 13.710938 C 10.221354 13.893229 10.3125 14.114584 10.3125 14.375 C 10.3125 14.635417 10.221354 14.856771 10.039062 15.039062 C 9.856771 15.221354 9.635416 15.3125 9.375 15.3125 C 9.114583 15.3125 8.893229 15.221354 8.710938 15.039062 C 8.528646 14.856771 8.4375 14.635417 8.4375 14.375 Z " BorderBrush="{ThemeResource InfoBarWarningSeverityIconBackground}"
Foreground="{ThemeResource InfoBarWarningSeverityIconBackground}" /> BorderThickness="1"
Click="AttentionIconClicked"
Foreground="{ThemeResource InfoBarWarningSeverityIconBackground}"
ToolTipService.ToolTip="{x:Bind domain:Translator.Info_AccountAttentionRequiredClickableMessage, Mode=OneWay}"
Visibility="{x:Bind IsAttentionRequired, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon x:Name="AttentionIcon" Data="F1 M 2.021484 18.769531 C 1.767578 18.769531 1.52832 18.720703 1.303711 18.623047 C 1.079102 18.525391 0.880534 18.391928 0.708008 18.222656 C 0.535482 18.053385 0.398763 17.856445 0.297852 17.631836 C 0.19694 17.407227 0.146484 17.167969 0.146484 16.914062 C 0.146484 16.614584 0.211589 16.328125 0.341797 16.054688 L 7.695312 1.347656 C 7.851562 1.035156 8.082682 0.784506 8.388672 0.595703 C 8.694661 0.406902 9.023438 0.3125 9.375 0.3125 C 9.726562 0.3125 10.055338 0.406902 10.361328 0.595703 C 10.667317 0.784506 10.898438 1.035156 11.054688 1.347656 L 18.408203 16.054688 C 18.53841 16.328125 18.603516 16.614584 18.603516 16.914062 C 18.603516 17.167969 18.553059 17.407227 18.452148 17.631836 C 18.351236 17.856445 18.216145 18.053385 18.046875 18.222656 C 17.877604 18.391928 17.679035 18.525391 17.451172 18.623047 C 17.223307 18.720703 16.982422 18.769531 16.728516 18.769531 Z M 16.728516 17.519531 C 16.884766 17.519531 17.027994 17.460938 17.158203 17.34375 C 17.28841 17.226562 17.353516 17.086588 17.353516 16.923828 C 17.353516 16.806641 17.330729 16.702475 17.285156 16.611328 L 9.931641 1.904297 C 9.879557 1.793621 9.80306 1.708984 9.702148 1.650391 C 9.601236 1.591797 9.492188 1.5625 9.375 1.5625 C 9.257812 1.5625 9.148763 1.593426 9.047852 1.655273 C 8.946939 1.717123 8.870442 1.800131 8.818359 1.904297 L 1.464844 16.611328 C 1.419271 16.702475 1.396484 16.803387 1.396484 16.914062 C 1.396484 17.083334 1.459961 17.226562 1.586914 17.34375 C 1.713867 17.460938 1.858724 17.519531 2.021484 17.519531 Z M 8.75 11.875 L 8.75 6.875 C 8.75 6.705729 8.811849 6.559245 8.935547 6.435547 C 9.059244 6.31185 9.205729 6.25 9.375 6.25 C 9.544271 6.25 9.690755 6.31185 9.814453 6.435547 C 9.93815 6.559245 10 6.705729 10 6.875 L 10 11.875 C 10 12.044271 9.93815 12.190756 9.814453 12.314453 C 9.690755 12.438151 9.544271 12.5 9.375 12.5 C 9.205729 12.5 9.059244 12.438151 8.935547 12.314453 C 8.811849 12.190756 8.75 12.044271 8.75 11.875 Z M 8.4375 14.375 C 8.4375 14.114584 8.528646 13.893229 8.710938 13.710938 C 8.893229 13.528646 9.114583 13.4375 9.375 13.4375 C 9.635416 13.4375 9.856771 13.528646 10.039062 13.710938 C 10.221354 13.893229 10.3125 14.114584 10.3125 14.375 C 10.3125 14.635417 10.221354 14.856771 10.039062 15.039062 C 9.856771 15.221354 9.635416 15.3125 9.375 15.3125 C 9.114583 15.3125 8.893229 15.221354 8.710938 15.039062 C 8.528646 14.856771 8.4375 14.635417 8.4375 14.375 Z " />
<TextBlock Text="{x:Bind domain:Translator.Info_AccountAttentionRequiredAction, Mode=OneWay}" />
</StackPanel>
</Button>
<muxc:ProgressRing <muxc:ProgressRing
x:Name="SynchronizationProgressBar" x:Name="SynchronizationProgressBar"
@@ -455,6 +460,7 @@
Visibility="Collapsed"> Visibility="Collapsed">
<Grid x:Name="CalendarPaneContent" Visibility="Collapsed"> <Grid x:Name="CalendarPaneContent" Visibility="Collapsed">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
@@ -481,9 +487,31 @@
Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" /> Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" />
</coreControls:WinoNavigationViewItem> </coreControls:WinoNavigationViewItem>
<coreControls:WinoNavigationViewItem
x:Name="SynchronizeCalendarsNavigationItem"
Grid.Row="1"
Height="50"
Margin="0,0,0,12"
AutomationProperties.Name="{x:Bind domain:Translator.Buttons_Sync, Mode=OneTime}"
IsEnabled="{x:Bind ViewModel.CalendarClient.CanSynchronizeCalendars, Mode=OneWay}"
IsTabStop="True"
KeyDown="SynchronizeCalendarsNavigationItemKeyDown"
SelectsOnInvoked="False"
Tapped="SynchronizeCalendarsNavigationItemTapped">
<muxc:NavigationViewItem.Icon>
<coreControls:WinoFontIcon Icon="Sync" />
</muxc:NavigationViewItem.Icon>
<TextBlock
Margin="0,-2,0,0"
VerticalAlignment="Center"
FontSize="16"
Style="{StaticResource FlyoutPickerTitleTextBlockStyle}"
Text="{x:Bind domain:Translator.Buttons_Sync, Mode=OneTime}" />
</coreControls:WinoNavigationViewItem>
<muxc:CalendarView <muxc:CalendarView
x:Name="VisibleDateRangeCalendarView" x:Name="VisibleDateRangeCalendarView"
Grid.Row="1" Grid.Row="2"
Margin="12,0" Margin="12,0"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
SelectedDatesChanged="VisibleDateRangeCalendarViewSelectedDatesChanged" SelectedDatesChanged="VisibleDateRangeCalendarViewSelectedDatesChanged"
@@ -492,7 +520,7 @@
<ListView <ListView
x:Name="CalendarHostListView" x:Name="CalendarHostListView"
Grid.Row="2" Grid.Row="3"
SelectionMode="None"> SelectionMode="None">
<ListView.Header> <ListView.Header>
<TextBlock <TextBlock
@@ -509,6 +537,7 @@
<muxc:Expander.Header> <muxc:Expander.Header>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -519,13 +548,29 @@
IsChecked="{x:Bind IsCheckedState, Mode=TwoWay}" IsChecked="{x:Bind IsCheckedState, Mode=TwoWay}"
IsThreeState="True" /> IsThreeState="True" />
<TextBlock <Ellipse
Grid.Column="1" Grid.Column="1"
Width="12"
Height="12"
Margin="8,0,10,0"
VerticalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap"> Fill="{x:Bind AccountColorHex, Converter={StaticResource HexToColorBrushConverter}, Mode=OneWay}" />
<StackPanel
Grid.Column="2"
VerticalAlignment="Center"
Spacing="2">
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="{x:Bind Account.Name}" /> <Run FontWeight="SemiBold" Text="{x:Bind Account.Name}" />
<Run FontSize="12" Text="(" /><Run FontSize="12" Text="{x:Bind Account.Address}" /><Run FontSize="12" Text=")" /> <Run FontSize="12" Text=" (" /><Run FontSize="12" Text="{x:Bind Account.Address}" /><Run FontSize="12" Text=")" />
</TextBlock> </TextBlock>
<ProgressBar
Height="4"
IsIndeterminate="True"
ShowPaused="False"
ShowError="False"
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
</StackPanel>
</Grid> </Grid>
</muxc:Expander.Header> </muxc:Expander.Header>
<muxc:Expander.Content> <muxc:Expander.Content>
@@ -277,6 +277,17 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
await InvokeNewCalendarEventAsync(); await InvokeNewCalendarEventAsync();
} }
private async void AttentionIconClicked(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { DataContext: AccountMenuItem accountMenuItem })
return;
if (ViewModel.MailClient is MailAppShellViewModel mailClient)
{
await mailClient.HandleAccountAttentionAsync(accountMenuItem.Parameter);
}
}
private async void NewCalendarEventNavigationItemKeyDown(object sender, KeyRoutedEventArgs e) private async void NewCalendarEventNavigationItemKeyDown(object sender, KeyRoutedEventArgs e)
{ {
if (e.Key is not (VirtualKey.Enter or VirtualKey.Space)) if (e.Key is not (VirtualKey.Enter or VirtualKey.Space))
@@ -289,6 +300,31 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
private Task InvokeNewCalendarEventAsync() private Task InvokeNewCalendarEventAsync()
=> ViewModel.CalendarClient.HandleNavigationItemInvokedAsync(new NewCalendarEventMenuItem()); => ViewModel.CalendarClient.HandleNavigationItemInvokedAsync(new NewCalendarEventMenuItem());
private async void SynchronizeCalendarsNavigationItemTapped(object sender, TappedRoutedEventArgs e)
{
e.Handled = true;
await InvokeCalendarSynchronizationAsync();
}
private async void SynchronizeCalendarsNavigationItemKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key is not (VirtualKey.Enter or VirtualKey.Space))
return;
e.Handled = true;
await InvokeCalendarSynchronizationAsync();
}
private Task InvokeCalendarSynchronizationAsync()
{
if (ViewModel.CalendarClient.SyncCommand.CanExecute(null))
{
ViewModel.CalendarClient.SyncCommand.Execute(null);
}
return Task.CompletedTask;
}
public void Receive(CalendarDisplayTypeChangedMessage message) => NotifyTitleBarContentChanged(); public void Receive(CalendarDisplayTypeChangedMessage message) => NotifyTitleBarContentChanged();
public void Receive(AccountCreatedMessage message) public void Receive(AccountCreatedMessage message)
+1
View File
@@ -200,6 +200,7 @@
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" /> <PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" /> <PackageReference Include="CommunityToolkit.WinUI.Controls.TokenizingTextBox" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" /> <PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Lottie" />
<PackageReference Include="Microsoft.Graphics.Win2D" /> <PackageReference Include="Microsoft.Graphics.Win2D" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" /> <PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" />
@@ -0,0 +1,13 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI;
/// <summary>
/// Emitted when calendar synchronization state for an account changes.
/// </summary>
public record AccountCalendarSynchronizationStateChanged(
Guid AccountId,
CalendarSynchronizationType SynchronizationType,
bool IsSynchronizationInProgress,
string SynchronizationStatus = "") : UIMessageBase<AccountCalendarSynchronizationStateChanged>;
@@ -0,0 +1,3 @@
namespace Wino.Messaging.UI;
public record WelcomeImportCompletedMessage(int ImportedMailboxCount) : UIMessageBase<WelcomeImportCompletedMessage>;
+14 -2
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Diagnostics; using CommunityToolkit.Diagnostics;
@@ -642,10 +643,11 @@ public class AccountService : BaseDatabaseService, IAccountService
IsExtended = true, IsExtended = true,
RemoteCalendarId = string.Empty, RemoteCalendarId = string.Empty,
TimeZone = string.Empty, TimeZone = string.Empty,
BackgroundColorHex = await GetNextDistinctCalendarColorAsync().ConfigureAwait(false), BackgroundColorHex = await GetNextDistinctCalendarColorAsync().ConfigureAwait(false)
TextColorHex = "#FFFFFF"
}; };
localCalendar.TextColorHex = GetReadableTextColorHex(localCalendar.BackgroundColorHex);
await Connection.InsertAsync(localCalendar, typeof(AccountCalendar)).ConfigureAwait(false); await Connection.InsertAsync(localCalendar, typeof(AccountCalendar)).ConfigureAwait(false);
} }
@@ -658,6 +660,16 @@ public class AccountService : BaseDatabaseService, IAccountService
return CalendarColorPalette.GetDistinctColor(usedColors.Select(a => a.BackgroundColorHex)); return CalendarColorPalette.GetDistinctColor(usedColors.Select(a => a.BackgroundColorHex));
} }
private static string GetReadableTextColorHex(string backgroundColorHex)
{
if (string.IsNullOrWhiteSpace(backgroundColorHex))
return "#FFFFFF";
var color = ColorTranslator.FromHtml(backgroundColorHex);
var luminance = ((0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B)) / 255d;
return luminance > 0.6 ? "#111111" : "#FFFFFF";
}
public async Task UpdateAccountOrdersAsync(Dictionary<Guid, int> accountIdOrderPair) public async Task UpdateAccountOrdersAsync(Dictionary<Guid, int> accountIdOrderPair)
{ {
foreach (var pair in accountIdOrderPair) foreach (var pair in accountIdOrderPair)
+1
View File
@@ -29,6 +29,7 @@ public static class ServicesContainerSetup
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>(); services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>(); services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>(); services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
services.AddTransient<IWinoAccountDataSyncService, WinoAccountDataSyncService>();
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>(); services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
services.AddTransient<ICalDavClient, CalDavClient>(); services.AddTransient<ICalDavClient, CalDavClient>();
+69 -19
View File
@@ -16,6 +16,7 @@ using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.Api.Contracts.Users;
namespace Wino.Services; namespace Wino.Services;
@@ -159,33 +160,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
cancellationToken); cancellationToken);
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default) public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
{ {
using var response = await SendAuthorizedAsync( using var response = await SendAuthorizedAsync(
() => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"), () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (response == null) if (response == null)
return null; {
throw new InvalidOperationException("MissingAccessToken");
}
if (response.StatusCode == System.Net.HttpStatusCode.NoContent) if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return null; return null;
}
if (!response.IsSuccessStatusCode) await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false);
return null;
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
} }
catch
{
return null;
}
}
public async Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default)
{
try
{ {
using var response = await SendAuthorizedAsync( using var response = await SendAuthorizedAsync(
() => CreateAuthorizedRequestAsync( () => CreateAuthorizedRequestAsync(
@@ -195,14 +190,54 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (response == null) if (response == null)
return false;
return response.IsSuccessStatusCode;
}
catch
{ {
return false; throw new InvalidOperationException("MissingAccessToken");
} }
await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false);
}
public async Task<UserMailboxSyncListDto> GetMailboxesAsync(CancellationToken cancellationToken = default)
{
using var response = await SendAuthorizedAsync(
() => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/mailboxes"),
cancellationToken).ConfigureAwait(false);
if (response == null)
{
throw new InvalidOperationException("MissingAccessToken");
}
await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var envelope = string.IsNullOrWhiteSpace(payload)
? null
: JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeUserMailboxSyncListDto);
if (envelope?.IsSuccess == true && envelope.Result != null)
{
return envelope.Result;
}
throw new InvalidOperationException(ExtractErrorMessage(payload) ?? envelope?.ErrorCode ?? "Mailbox synchronization request failed.");
}
public async Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default)
{
using var response = await SendAuthorizedAsync(
() => CreateAuthorizedRequestAsync(
HttpMethod.Put,
"api/v1/users/me/mailboxes",
() => JsonContent.Create(request, WinoAccountApiJsonContext.Default.ReplaceUserMailboxesRequestDto)),
cancellationToken).ConfigureAwait(false);
if (response == null)
{
throw new InvalidOperationException("MissingAccessToken");
}
await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false);
} }
private async Task<WinoAccountApiResult<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken) private async Task<WinoAccountApiResult<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
@@ -321,6 +356,19 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
return !string.IsNullOrWhiteSpace(value); return !string.IsNullOrWhiteSpace(value);
} }
private static async Task EnsureSuccessResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(
ExtractErrorMessage(payload)
?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
}
private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken) private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken); => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
@@ -549,7 +597,9 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiTextResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AiTextResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<WinoStoreCollectionsIdTicketInfo>))] [JsonSerializable(typeof(ApiEnvelope<WinoStoreCollectionsIdTicketInfo>))]
[JsonSerializable(typeof(ApiEnvelope<UserMailboxSyncListDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))] [JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
[JsonSerializable(typeof(ReplaceUserMailboxesRequestDto))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext; internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey); internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey);
+289
View File
@@ -0,0 +1,289 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Users;
using Wino.Messaging.Client.Accounts;
namespace Wino.Services;
public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService
{
private const int DefaultMaxConcurrentClients = 5;
private readonly IWinoAccountProfileService _profileService;
private readonly IPreferencesService _preferencesService;
private readonly IAccountService _accountService;
public WinoAccountDataSyncService(
IWinoAccountProfileService profileService,
IPreferencesService preferencesService,
IAccountService accountService)
{
_profileService = profileService;
_preferencesService = preferencesService;
_accountService = accountService;
}
public async Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default)
{
var exportedMailboxCount = 0;
if (selection.IncludePreferences)
{
await _profileService.SaveSettingsAsync(_preferencesService.ExportPreferences(), cancellationToken).ConfigureAwait(false);
}
if (selection.IncludeAccounts)
{
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var request = new ReplaceUserMailboxesRequestDto
{
Mailboxes = accounts
.OrderBy(a => a.Order)
.Select(MapMailbox)
.ToList()
};
await _profileService.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false);
exportedMailboxCount = request.Mailboxes.Count;
}
return new WinoAccountSyncExportResult
{
IncludedPreferences = selection.IncludePreferences,
IncludedAccounts = selection.IncludeAccounts,
ExportedMailboxCount = exportedMailboxCount
};
}
public async Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default)
{
var result = new WinoAccountSyncImportResult
{
IncludedPreferences = selection.IncludePreferences,
IncludedAccounts = selection.IncludeAccounts
};
if (selection.IncludePreferences)
{
var settingsJson = await _profileService.GetSettingsAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(settingsJson))
{
var (appliedCount, failedCount) = _preferencesService.ImportPreferences(settingsJson);
result = new WinoAccountSyncImportResult
{
IncludedPreferences = result.IncludedPreferences,
IncludedAccounts = result.IncludedAccounts,
HadRemotePreferences = true,
AppliedPreferenceCount = appliedCount,
FailedPreferenceCount = failedCount,
ImportedMailboxCount = result.ImportedMailboxCount,
SkippedDuplicateMailboxCount = result.SkippedDuplicateMailboxCount,
RemoteMailboxCount = result.RemoteMailboxCount
};
}
}
if (selection.IncludeAccounts)
{
var mailboxes = await _profileService.GetMailboxesAsync(cancellationToken).ConfigureAwait(false);
var orderedMailboxes = mailboxes.Mailboxes
.OrderBy(a => a.SortOrder)
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.ToList();
var localAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var existingKeys = localAccounts
.Select(CreateMailboxKey)
.ToHashSet(StringComparer.Ordinal);
var importedMailboxCount = 0;
var skippedDuplicateMailboxCount = 0;
foreach (var mailbox in orderedMailboxes)
{
cancellationToken.ThrowIfCancellationRequested();
var mailboxKey = CreateMailboxKey(mailbox.Address, mailbox.ProviderType);
if (!existingKeys.Add(mailboxKey))
{
skippedDuplicateMailboxCount++;
continue;
}
var account = CreateImportedAccount(mailbox);
var serverInformation = CreateImportedServerInformation(mailbox, account.Id);
await _accountService.CreateAccountAsync(account, serverInformation).ConfigureAwait(false);
await _accountService.CreateRootAliasAsync(account.Id, account.Address).ConfigureAwait(false);
if (account.ProviderType == MailProviderType.IMAP4)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount != null && persistedAccount.AttentionReason != AccountAttentionReason.InvalidCredentials)
{
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
importedMailboxCount++;
}
if (importedMailboxCount > 0)
{
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false));
}
result = new WinoAccountSyncImportResult
{
IncludedPreferences = result.IncludedPreferences,
IncludedAccounts = result.IncludedAccounts,
HadRemotePreferences = result.HadRemotePreferences,
AppliedPreferenceCount = result.AppliedPreferenceCount,
FailedPreferenceCount = result.FailedPreferenceCount,
ImportedMailboxCount = importedMailboxCount,
SkippedDuplicateMailboxCount = skippedDuplicateMailboxCount,
RemoteMailboxCount = orderedMailboxes.Count
};
}
await RepairStartupEntityAsync().ConfigureAwait(false);
return result;
}
private static UserMailboxSyncItemDto MapMailbox(MailAccount account)
{
var serverInformation = account.ProviderType == MailProviderType.IMAP4
? account.ServerInformation
: null;
return new UserMailboxSyncItemDto
{
Address = account.Address ?? string.Empty,
ProviderType = (int)account.ProviderType,
SpecialImapProvider = (int)account.SpecialImapProvider,
AccountName = account.Name,
SenderName = account.SenderName,
AccountColorHex = account.AccountColorHex,
SortOrder = account.Order,
IsCalendarAccessGranted = account.IsCalendarAccessGranted,
CalendarSupportMode = serverInformation != null ? (int)serverInformation.CalendarSupportMode : 0,
IncomingServer = serverInformation?.IncomingServer,
IncomingServerPort = serverInformation?.IncomingServerPort,
IncomingServerUsername = serverInformation?.IncomingServerUsername,
IncomingServerSocketOption = serverInformation != null ? (int?)serverInformation.IncomingServerSocketOption : null,
IncomingAuthenticationMethod = serverInformation != null ? (int?)serverInformation.IncomingAuthenticationMethod : null,
OutgoingServer = serverInformation?.OutgoingServer,
OutgoingServerPort = serverInformation?.OutgoingServerPort,
OutgoingServerUsername = serverInformation?.OutgoingServerUsername,
OutgoingServerSocketOption = serverInformation != null ? (int?)serverInformation.OutgoingServerSocketOption : null,
OutgoingAuthenticationMethod = serverInformation != null ? (int?)serverInformation.OutgoingAuthenticationMethod : null,
CalDavServiceUrl = serverInformation?.CalDavServiceUrl,
CalDavUsername = serverInformation?.CalDavUsername,
ProxyServer = serverInformation?.ProxyServer,
ProxyServerPort = serverInformation?.ProxyServerPort,
MaxConcurrentClients = serverInformation?.MaxConcurrentClients
};
}
private static MailAccount CreateImportedAccount(UserMailboxSyncItemDto mailbox)
{
var providerType = (MailProviderType)mailbox.ProviderType;
return new MailAccount
{
Id = Guid.NewGuid(),
Address = mailbox.Address.Trim(),
Name = string.IsNullOrWhiteSpace(mailbox.AccountName) ? mailbox.Address.Trim() : mailbox.AccountName.Trim(),
SenderName = string.IsNullOrWhiteSpace(mailbox.SenderName) ? mailbox.Address.Trim() : mailbox.SenderName.Trim(),
ProviderType = providerType,
SpecialImapProvider = (SpecialImapProvider)mailbox.SpecialImapProvider,
AccountColorHex = mailbox.AccountColorHex?.Trim(),
Base64ProfilePictureData = string.Empty,
IsCalendarAccessGranted = mailbox.IsCalendarAccessGranted,
SynchronizationDeltaIdentifier = string.Empty,
CalendarSynchronizationDeltaIdentifier = string.Empty,
AttentionReason = AccountAttentionReason.InvalidCredentials
};
}
private static CustomServerInformation? CreateImportedServerInformation(UserMailboxSyncItemDto mailbox, Guid accountId)
{
var providerType = (MailProviderType)mailbox.ProviderType;
if (providerType != MailProviderType.IMAP4)
{
return null;
}
return new CustomServerInformation
{
Id = Guid.NewGuid(),
AccountId = accountId,
Address = mailbox.Address.Trim(),
IncomingServer = mailbox.IncomingServer?.Trim(),
IncomingServerPort = mailbox.IncomingServerPort?.Trim(),
IncomingServerUsername = mailbox.IncomingServerUsername?.Trim(),
IncomingServerPassword = string.Empty,
IncomingServerSocketOption = mailbox.IncomingServerSocketOption is int incomingSocketOption
? (ImapConnectionSecurity)incomingSocketOption
: ImapConnectionSecurity.Auto,
IncomingAuthenticationMethod = mailbox.IncomingAuthenticationMethod is int incomingAuthMethod
? (ImapAuthenticationMethod)incomingAuthMethod
: ImapAuthenticationMethod.Auto,
OutgoingServer = mailbox.OutgoingServer?.Trim(),
OutgoingServerPort = mailbox.OutgoingServerPort?.Trim(),
OutgoingServerUsername = mailbox.OutgoingServerUsername?.Trim(),
OutgoingServerPassword = string.Empty,
OutgoingServerSocketOption = mailbox.OutgoingServerSocketOption is int outgoingSocketOption
? (ImapConnectionSecurity)outgoingSocketOption
: ImapConnectionSecurity.Auto,
OutgoingAuthenticationMethod = mailbox.OutgoingAuthenticationMethod is int outgoingAuthMethod
? (ImapAuthenticationMethod)outgoingAuthMethod
: ImapAuthenticationMethod.Auto,
CalDavServiceUrl = mailbox.CalDavServiceUrl?.Trim(),
CalDavUsername = mailbox.CalDavUsername?.Trim(),
CalDavPassword = string.Empty,
CalendarSupportMode = (ImapCalendarSupportMode)mailbox.CalendarSupportMode,
ProxyServer = mailbox.ProxyServer?.Trim(),
ProxyServerPort = mailbox.ProxyServerPort?.Trim(),
MaxConcurrentClients = mailbox.MaxConcurrentClients.GetValueOrDefault(DefaultMaxConcurrentClients)
};
}
private async Task RepairStartupEntityAsync()
{
if (!_preferencesService.StartupEntityId.HasValue)
{
return;
}
var startupEntityId = _preferencesService.StartupEntityId.Value;
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var accountIds = accounts.Select(a => a.Id);
var mergedInboxIds = accounts.Where(a => a.MergedInboxId.HasValue).Select(a => a.MergedInboxId!.Value);
if (accountIds.Concat(mergedInboxIds).Contains(startupEntityId))
{
return;
}
_preferencesService.StartupEntityId = accounts.FirstOrDefault()?.Id;
}
private static string CreateMailboxKey(MailAccount account)
=> CreateMailboxKey(account.Address, (int)account.ProviderType);
private static string CreateMailboxKey(string? address, int providerType)
=> $"{address?.Trim().ToLowerInvariant()}|{providerType}";
}
@@ -11,6 +11,7 @@ using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.Api.Contracts.Users;
using Wino.Messaging.UI; using Wino.Messaging.UI;
namespace Wino.Services; namespace Wino.Services;
@@ -285,6 +286,38 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response; return response;
} }
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{
_ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("MissingAccessToken");
return await _apiClient.GetSettingsAsync(cancellationToken).ConfigureAwait(false);
}
public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default)
{
_ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("MissingAccessToken");
await _apiClient.SaveSettingsAsync(settingsJson, cancellationToken).ConfigureAwait(false);
}
public async Task<UserMailboxSyncListDto> GetMailboxesAsync(CancellationToken cancellationToken = default)
{
_ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("MissingAccessToken");
return await _apiClient.GetMailboxesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default)
{
_ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("MissingAccessToken");
await _apiClient.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default) public async Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default)
{ {
await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false);