diff --git a/Wino.Authentication/GmailAuthenticator.cs b/Wino.Authentication/GmailAuthenticator.cs index 506d0448..e5eda86b 100644 --- a/Wino.Authentication/GmailAuthenticator.cs +++ b/Wino.Authentication/GmailAuthenticator.cs @@ -45,6 +45,6 @@ public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator return GoogleWebAuthorizationBroker.AuthorizeAsync(new ClientSecrets() { ClientId = ClientId - }, AuthenticatorConfig.GmailScope, account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier)); + }, AuthenticatorConfig.GetGmailScope(account?.IsMailAccessGranted != false, account?.IsCalendarAccessGranted == true), account.Id.ToString(), CancellationToken.None, new FileDataStore(AuthenticatorConfig.GmailTokenStoreIdentifier)); } } diff --git a/Wino.Authentication/OutlookAuthenticator.cs b/Wino.Authentication/OutlookAuthenticator.cs index 156cc321..76429526 100644 --- a/Wino.Authentication/OutlookAuthenticator.cs +++ b/Wino.Authentication/OutlookAuthenticator.cs @@ -65,7 +65,10 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator _publicClientApplication = outlookAppBuilder.Build(); } - public string[] Scope => AuthenticatorConfig.OutlookScope; + private string[] GetScope(MailAccount account) + => AuthenticatorConfig.GetOutlookScope( + account?.IsMailAccessGranted != false, + account?.IsCalendarAccessGranted == true); private async Task EnsureTokenCacheAttachedAsync() { @@ -91,7 +94,7 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator try { - var authResult = await _publicClientApplication.AcquireTokenSilent(Scope, storedAccount).ExecuteAsync(); + var authResult = await _publicClientApplication.AcquireTokenSilent(GetScope(account), storedAccount).ExecuteAsync(); return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username); } @@ -122,7 +125,7 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator if (_nativeAppService.GetCoreWindowHwnd == null) throw new AuthenticationAttentionException(account); AuthenticationResult authResult = await _publicClientApplication - .AcquireTokenInteractive(Scope) + .AcquireTokenInteractive(GetScope(account)) .ExecuteAsync(); // If the account is null, it means it's the initial creation of it. diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index 15c6931d..cf9287da 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -299,6 +299,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, foreach (var account in accounts) { + if (!GroupedAccountCalendarViewModel.SupportsCalendar(account)) + continue; + var accountCalendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false); var calendarViewModels = accountCalendars.Select(calendar => new AccountCalendarViewModel(account, calendar)).ToList(); var groupedAccountCalendarViewModel = new GroupedAccountCalendarViewModel(account, calendarViewModels); diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs index 951493be..d058bb7a 100644 --- a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs @@ -408,6 +408,9 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel foreach (var account in accounts) { + if (!GroupedAccountCalendarViewModel.SupportsCalendar(account)) + continue; + var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false); var viewModels = calendars .Select(calendar => new AccountCalendarViewModel(account, calendar)) diff --git a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs index 95b8cb9e..bb9a1050 100644 --- a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs @@ -17,6 +17,9 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject public MailAccount Account { get; } public ObservableCollection AccountCalendars { get; } + public static bool SupportsCalendar(MailAccount account) + => account?.IsCalendarAccessGranted == true; + public GroupedAccountCalendarViewModel(MailAccount account, IEnumerable calendarViewModels) { Account = account; diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index 045206ef..55273bc2 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -78,6 +78,13 @@ public class MailAccount /// public SpecialImapProvider SpecialImapProvider { get; set; } + /// + /// Gets or sets whether mail access is granted for this account. + /// When false, mail folders, aliases, compose flows, and mail synchronization are unavailable. + /// Default is true for legacy accounts to preserve existing behavior. + /// + public bool IsMailAccessGranted { get; set; } = true; + /// /// Gets or sets whether calendar access is granted for this account. /// When false, synchronizers will not process EventMessages or calendar invitations. diff --git a/Wino.Core.Domain/Interfaces/IAuthenticatorConfig.cs b/Wino.Core.Domain/Interfaces/IAuthenticatorConfig.cs index a867fee7..1bcd5327 100644 --- a/Wino.Core.Domain/Interfaces/IAuthenticatorConfig.cs +++ b/Wino.Core.Domain/Interfaces/IAuthenticatorConfig.cs @@ -3,8 +3,8 @@ public interface IAuthenticatorConfig { string OutlookAuthenticatorClientId { get; } - string[] OutlookScope { get; } + string[] GetOutlookScope(bool isMailAccessGranted, bool isCalendarAccessGranted); string GmailAuthenticatorClientId { get; } - string[] GmailScope { get; } + string[] GetGmailScope(bool isMailAccessGranted, bool isCalendarAccessGranted); string GmailTokenStoreIdentifier { get; } } diff --git a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs index 3a3ca1e2..64cdc9d7 100644 --- a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs +++ b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs @@ -7,4 +7,6 @@ public record AccountCreationDialogResult( string AccountName, SpecialImapProviderDetails SpecialImapProviderDetails, string AccountColorHex, - InitialSynchronizationRange InitialSynchronizationRange); + InitialSynchronizationRange InitialSynchronizationRange, + bool IsMailAccessGranted, + bool IsCalendarAccessGranted); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 4f0e032c..bc824f55 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -47,6 +47,11 @@ "AccountDetailsPage_CalendarListDescription": "Select a calendar to configure its settings", "AccountDetailsPage_InitialSynchronization_Title": "Initial synchronization", "AccountDetailsPage_InitialSynchronization_Description": "Wino synchronized your mails until {0} going back.", + "AccountDetailsPage_CapabilityTitle": "Connected features", + "AccountDetailsPage_CapabilityDescription": "Choose whether this account is used for mail, calendar, or both. Enabling a new feature may ask you to sign in again.", + "AccountCapability_MailOnly": "Mail only", + "AccountCapability_CalendarOnly": "Calendar only", + "AccountCapability_MailAndCalendar": "Mail + Calendar", "AddHyperlink": "Add", "AppCloseBackgroundSynchronizationWarningTitle": "Background Synchronization", "AppCloseStartupLaunchDisabledWarningMessageFirstLine": "Application has not been set to launch on Windows startup.", @@ -76,6 +81,7 @@ "Buttons_ApplyTheme": "Apply Theme", "Buttons_PopOut": "Pop out", "Buttons_Browse": "Browse", + "Buttons_Back": "Back", "Buttons_Cancel": "Cancel", "Buttons_Close": "Close", "Buttons_Copy": "Copy", @@ -681,6 +687,10 @@ "NoMailSelected": "No message selected", "NoMessageCrieteria": "No messages match your search criteria", "NoMessageEmptyFolder": "This folder is empty", + "MailEmptyState_Title": "No mail-enabled accounts", + "MailEmptyState_Message": "You have accounts connected for calendar, but none of them are enabled for mail. Add a mail account or update an existing account to use mail.", + "MailEmptyState_AddAccount": "Add account", + "MailEmptyState_ManageAccounts": "Manage accounts", "Notifications_MultipleNotificationsMessage": "You have {0} new messages.", "Notifications_MultipleNotificationsTitle": "New Mail", "Notifications_WinoUpdatedMessage": "Checkout new version {0}", @@ -702,8 +712,8 @@ "ProviderDetail_Gmail_Description": "Google Account", "ProviderDetail_iCloud_Description": "Apple iCloud Account", "ProviderDetail_iCloud_Title": "iCloud", - "ProviderDetail_IMAP_Description": "Custom IMAP/SMTP server", - "ProviderDetail_IMAP_Title": "IMAP Server", + "ProviderDetail_IMAP_Description": "IMAP/SMTP mail with CalDAV or local calendar", + "ProviderDetail_IMAP_Title": "Custom server", "ProviderDetail_Yahoo_Description": "Yahoo Account", "ProviderDetail_Yahoo_Title": "Yahoo Mail", "QuickEventDialog_EventName": "Event name", @@ -1427,8 +1437,8 @@ "WinoAccount_Management_ExportDialog_InProgress": "Exporting your selected Wino data...", "WinoAccount_Management_LocalDataSectionTitle": "Transfer with a JSON file", "WinoAccount_Management_LocalDataSectionDescription": "Import from or export to a local JSON file. Passwords, tokens, and other sensitive information are not included.", - "WinoAccount_Management_LocalDataImportAction": "Import JSON", - "WinoAccount_Management_LocalDataExportAction": "Export JSON", + "WinoAccount_Management_LocalDataImportAction": "Import", + "WinoAccount_Management_LocalDataExportAction": "Export", "WinoAccount_Management_LocalDataSaved": "Saved your exported Wino data to {0}.", "WinoAccount_Management_LocalDataInvalidFile": "The selected JSON file doesn't contain a valid Wino export.", "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", @@ -1527,8 +1537,22 @@ "WelcomeWizard_Step3Title": "Finish Setup", "ProviderSelection_Title": "Choose your email provider", "ProviderSelection_Subtitle": "Select a provider below to add your email account to Wino Mail.", + "ProviderSelection_StepProgress": "Step {0} of 3", + "ProviderSelection_IdentityTitle": "Account identity", + "ProviderSelection_IdentityDescription": "Choose how this account appears inside Wino.", + "ProviderSelection_ProviderSectionTitle": "Provider", + "ProviderSelection_ProviderSectionDescription": "Select the service you want to connect.", + "ProviderSelection_CapabilitySectionTitle": "Use this account for", + "ProviderSelection_CapabilitySectionDescription": "Choose whether you want mail, calendar, or both.", + "ProviderSelection_CapabilityProviderDescription_OAuth": "On the next step, secure sign-in will connect your account. If you enable calendar, Wino will also connect Outlook Calendar or Google Calendar automatically.", + "ProviderSelection_CapabilityProviderDescription_SpecialImap": "On the next step, you'll enter your provider credentials. Mail uses IMAP/SMTP, and calendar can use CalDAV or stay local on this device.", + "ProviderSelection_CapabilityProviderDescription_CustomServer": "On the next step, you'll enter your server details. Mail uses IMAP/SMTP, and calendar can use CalDAV or stay local on this device.", "ProviderSelection_AccountNameHeader": "Account Name", "ProviderSelection_AccountNamePlaceholder": "e.g. Personal, Work", + "ProviderSelection_UseForMail": "Mail", + "ProviderSelection_UseForCalendar": "Calendar", + "ProviderSelection_CapabilityValidationMessage": "Choose at least one capability before continuing.", + "ProviderSelection_CalendarOnlyServerHint": "If you continue with calendar only, the next page will not require an email address.", "ProviderSelection_DisplayNameHeader": "Display Name", "ProviderSelection_DisplayNamePlaceholder": "e.g. John Doe", "ProviderSelection_EmailHeader": "E-mail Address", diff --git a/Wino.Core.Tests/AssemblyInfo.cs b/Wino.Core.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/Wino.Core.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Wino.Core.Tests/Services/AccountServiceTests.cs b/Wino.Core.Tests/Services/AccountServiceTests.cs index 69c69dfd..5bfdafa3 100644 --- a/Wino.Core.Tests/Services/AccountServiceTests.cs +++ b/Wino.Core.Tests/Services/AccountServiceTests.cs @@ -83,7 +83,7 @@ public class AccountServiceTests : IAsyncLifetime var secondAccountId = Guid.NewGuid(); await _accountService.CreateAccountAsync( - CreateImapAccount(firstAccountId), + CreateImapAccount(firstAccountId, "IMAP Test Account 1", "imap1@test.local"), new CustomServerInformation { Id = Guid.NewGuid(), @@ -92,7 +92,7 @@ public class AccountServiceTests : IAsyncLifetime }); await _accountService.CreateAccountAsync( - CreateImapAccount(secondAccountId), + CreateImapAccount(secondAccountId, "IMAP Test Account 2", "imap2@test.local"), new CustomServerInformation { Id = Guid.NewGuid(), @@ -119,13 +119,13 @@ public class AccountServiceTests : IAsyncLifetime .BeGreaterThanOrEqualTo(50); } - private static MailAccount CreateImapAccount(Guid accountId) + private static MailAccount CreateImapAccount(Guid accountId, string name = "IMAP Test Account", string address = "imap@test.local") { return new MailAccount { Id = accountId, - Name = "IMAP Test Account", - Address = "imap@test.local", + Name = name, + Address = address, SenderName = "IMAP Test", ProviderType = MailProviderType.IMAP4 }; diff --git a/Wino.Core.Tests/Services/MailRequestStateTests.cs b/Wino.Core.Tests/Services/MailRequestStateTests.cs index 82d9a35c..f07e9a2b 100644 --- a/Wino.Core.Tests/Services/MailRequestStateTests.cs +++ b/Wino.Core.Tests/Services/MailRequestStateTests.cs @@ -13,6 +13,8 @@ public sealed class MailRequestStateTests [Fact] public void MarkReadRequest_RevertUiChanges_RestoresOriginalReadState() { + WeakReferenceMessenger.Default.Reset(); + var mailCopy = CreateMailCopy(isRead: false, isFlagged: false); var request = new MarkReadRequest(mailCopy, IsRead: true); var recipient = new MailRequestRecipient(); @@ -35,12 +37,15 @@ public sealed class MailRequestStateTests finally { WeakReferenceMessenger.Default.UnregisterAll(recipient); + WeakReferenceMessenger.Default.Reset(); } } [Fact] public void ChangeFlagRequest_RevertUiChanges_RestoresOriginalFlagState() { + WeakReferenceMessenger.Default.Reset(); + var mailCopy = CreateMailCopy(isRead: true, isFlagged: false); var request = new ChangeFlagRequest(mailCopy, IsFlagged: true); var recipient = new MailRequestRecipient(); @@ -63,6 +68,7 @@ public sealed class MailRequestStateTests finally { WeakReferenceMessenger.Default.UnregisterAll(recipient); + WeakReferenceMessenger.Default.Reset(); } } diff --git a/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs b/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs index 51dd96f9..1be4eebe 100644 --- a/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs +++ b/Wino.Core.Tests/Synchronizers/BaseSynchronizerUiChangeTests.cs @@ -21,6 +21,8 @@ public sealed class BaseSynchronizerUiChangeTests [Fact] public void ApplyOptimisticUiChanges_UsesBundleUiChangeRequest_ForBatchBundle() { + WeakReferenceMessenger.Default.Reset(); + var folderId = Guid.NewGuid(); var account = new MailAccount { Id = Guid.NewGuid(), Name = "Test account" }; var synchronizer = new TestSynchronizer(account); @@ -48,6 +50,7 @@ public sealed class BaseSynchronizerUiChangeTests { WeakReferenceMessenger.Default.Unregister(recipient); WeakReferenceMessenger.Default.Unregister(recipient); + WeakReferenceMessenger.Default.Reset(); } } diff --git a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs index c1c9e7b5..631deeb7 100644 --- a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs +++ b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; @@ -9,6 +10,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.ViewModels.Data; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Navigation; @@ -17,6 +19,7 @@ namespace Wino.Core.ViewModels; public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewModel { public ObservableCollection Accounts { get; set; } = []; + public IEnumerable StartupAccounts => Accounts.Where(IsStartupEligible); public bool IsPurchasePanelVisible => !HasUnlimitedAccountProduct; public bool IsAccountCreationAlmostOnLimit => Accounts != null && Accounts.Count == FREE_ACCOUNT_COUNT - 1; @@ -130,10 +133,21 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM private void AccountsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { OnPropertyChanged(nameof(HasAccountsDefined)); + OnPropertyChanged(nameof(StartupAccounts)); } private static string GetAccountDetailsTitle(MailAccount account) => !string.IsNullOrWhiteSpace(account?.Address) ? string.Format(Translator.SettingsAccountDetails_NavigationTitle, account.Address) : account?.Name ?? Translator.AccountDetailsPage_Title; + + private static bool IsStartupEligible(IAccountProviderDetailViewModel account) + { + return account switch + { + AccountProviderDetailViewModel accountViewModel => accountViewModel.Account.IsMailAccessGranted, + MergedAccountProviderDetailViewModel mergedAccountViewModel => mergedAccountViewModel.HoldingAccounts.Any(a => a.Account.IsMailAccessGranted), + _ => true + }; + } } diff --git a/Wino.Core.ViewModels/Data/AccountProviderDetailViewModel.cs b/Wino.Core.ViewModels/Data/AccountProviderDetailViewModel.cs index 70ff1e6c..06132cd9 100644 --- a/Wino.Core.ViewModels/Data/AccountProviderDetailViewModel.cs +++ b/Wino.Core.ViewModels/Data/AccountProviderDetailViewModel.cs @@ -1,5 +1,6 @@ using System; using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; @@ -9,6 +10,8 @@ public partial class AccountProviderDetailViewModel : ObservableObject, IAccount { [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CapabilitySummary))] + [NotifyPropertyChangedFor(nameof(DescriptionText))] private MailAccount account; public IProviderDetail ProviderDetail { get; set; } @@ -20,6 +23,10 @@ public partial class AccountProviderDetailViewModel : ObservableObject, IAccount public int Order => Account.Order; public string StartupEntityAddresses => Account.Address; + public string CapabilitySummary => BuildCapabilitySummary(Account); + public string DescriptionText => string.IsNullOrWhiteSpace(Account.Address) + ? CapabilitySummary + : $"{CapabilitySummary} | {Account.Address}"; public int HoldingAccountCount => 1; @@ -30,4 +37,15 @@ public partial class AccountProviderDetailViewModel : ObservableObject, IAccount ProviderDetail = providerDetail; Account = account; } + + private static string BuildCapabilitySummary(MailAccount account) + { + if (account?.IsMailAccessGranted == true && account.IsCalendarAccessGranted) + return Translator.AccountCapability_MailAndCalendar; + + if (account?.IsMailAccessGranted == true) + return Translator.AccountCapability_MailOnly; + + return Translator.AccountCapability_CalendarOnly; + } } diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index 2d8bfac5..783d7449 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -17,6 +17,7 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Misc; using Wino.Core.Services; using Wino.Core.ViewModels.Data; @@ -96,8 +97,14 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel [ObservableProperty] private bool isTaskbarBadgeEnabled; + [ObservableProperty] + public partial AccountCapabilityOption SelectedCapabilityOption { get; set; } + public bool IsFocusedInboxSupportedForAccount => Account != null && Account.Preferences.IsFocusedInboxEnabled != null; public bool IsImapServer => ServerInformation != null; + public bool HasMailAccess => Account?.IsMailAccessGranted == true; + public bool HasCalendarAccess => Account?.IsCalendarAccessGranted == true; + public bool IsOAuthCapabilityEditable => Account?.ProviderType is MailProviderType.Outlook or MailProviderType.Gmail; public string ProviderIconPath => Account?.SpecialImapProvider != SpecialImapProvider.None ? $"ms-appx:///Assets/Providers/{Account.SpecialImapProvider}.png" : $"ms-appx:///Assets/Providers/{Account?.ProviderType}.png"; @@ -130,6 +137,13 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.None, Translator.ImapConnectionSecurity_None) ]; + public List CapabilityOptions { get; } = + [ + new(true, false, Translator.AccountCapability_MailOnly), + new(false, true, Translator.AccountCapability_CalendarOnly), + new(true, true, Translator.AccountCapability_MailAndCalendar) + ]; + public AccountDetailsPageViewModel(IMailDialogService dialogService, IAccountService accountService, @@ -262,6 +276,7 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel AccountName = Account.Name; SenderName = Account.SenderName; ServerInformation = Account.ServerInformation; + SelectedCapabilityOption = ResolveCapabilityOption(Account.IsMailAccessGranted, Account.IsCalendarAccessGranted); IsFocusedInboxEnabled = Account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault(); AreNotificationsEnabled = Account.Preferences.IsNotificationsEnabled; @@ -288,7 +303,11 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel SelectedOutgoingServerConnectionSecurityIndex = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == ServerInformation.OutgoingServerSocketOption); } - SelectedTabIndex = _statePersistanceService.ApplicationMode == WinoApplicationMode.Calendar ? 2 : 1; + SelectedTabIndex = _statePersistanceService.ApplicationMode == WinoApplicationMode.Calendar && HasCalendarAccess + ? 2 + : HasMailAccess + ? 1 + : 0; var folderStructures = (await _folderService.GetFolderStructureForAccountAsync(Account.Id, true)).Folders; @@ -382,11 +401,15 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel partial void OnAccountChanged(MailAccount value) { + SelectedCapabilityOption = ResolveCapabilityOption(value?.IsMailAccessGranted == true, value?.IsCalendarAccessGranted == true); OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount)); OnPropertyChanged(nameof(ProviderIconPath)); OnPropertyChanged(nameof(Address)); OnPropertyChanged(nameof(IsInitialSynchronizationSummaryVisible)); OnPropertyChanged(nameof(InitialSynchronizationSummary)); + OnPropertyChanged(nameof(HasMailAccess)); + OnPropertyChanged(nameof(HasCalendarAccess)); + OnPropertyChanged(nameof(IsOAuthCapabilityEditable)); } protected override async void OnPropertyChanged(PropertyChangedEventArgs e) @@ -417,6 +440,21 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel Account.Preferences.IsTaskbarBadgeEnabled = IsTaskbarBadgeEnabled; await _accountService.UpdateAccountAsync(Account); break; + case nameof(SelectedCapabilityOption) when IsOAuthCapabilityEditable && SelectedCapabilityOption != null: + if (Account.IsMailAccessGranted == SelectedCapabilityOption.IsMailAccessGranted && + Account.IsCalendarAccessGranted == SelectedCapabilityOption.IsCalendarAccessGranted) + break; + + try + { + await UpdateOAuthCapabilityAsync(SelectedCapabilityOption); + } + catch (Exception ex) + { + await ExecuteUIThread(() => SelectedCapabilityOption = ResolveCapabilityOption(Account.IsMailAccessGranted, Account.IsCalendarAccessGranted)); + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error); + } + break; case nameof(SelectedPrimaryCalendar) when SelectedPrimaryCalendar != null: foreach (var calendar in AccountCalendars) { @@ -427,6 +465,111 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel break; } } + + private AccountCapabilityOption ResolveCapabilityOption(bool isMailAccessGranted, bool isCalendarAccessGranted) + => CapabilityOptions.First(option => + option.IsMailAccessGranted == isMailAccessGranted && + option.IsCalendarAccessGranted == isCalendarAccessGranted); + + private async Task UpdateOAuthCapabilityAsync(AccountCapabilityOption selectedOption) + { + var previousMailAccess = Account.IsMailAccessGranted; + var previousCalendarAccess = Account.IsCalendarAccessGranted; + var requiresReauthorization = (selectedOption.IsMailAccessGranted && !previousMailAccess) || + (selectedOption.IsCalendarAccessGranted && !previousCalendarAccess); + + try + { + if (requiresReauthorization) + { + Account.IsMailAccessGranted = selectedOption.IsMailAccessGranted; + Account.IsCalendarAccessGranted = selectedOption.IsCalendarAccessGranted; + + await SynchronizationManager.Instance.HandleAuthorizationAsync( + Account.ProviderType, + Account, + Account.ProviderType == MailProviderType.Gmail); + } + } + catch + { + Account.IsMailAccessGranted = previousMailAccess; + Account.IsCalendarAccessGranted = previousCalendarAccess; + throw; + } + + Account.IsMailAccessGranted = selectedOption.IsMailAccessGranted; + Account.IsCalendarAccessGranted = selectedOption.IsCalendarAccessGranted; + + await _accountService.UpdateAccountAsync(Account); + + if (selectedOption.IsMailAccessGranted && !previousMailAccess) + { + await SynchronizationManager.Instance.SynchronizeProfileAsync(Account.Id); + await SynchronizationManager.Instance.SynchronizeMailAsync(new MailSynchronizationOptions + { + AccountId = Account.Id, + Type = MailSynchronizationType.FullFolders + }); + + if (Account.ProviderType == MailProviderType.Outlook) + { + await SynchronizationManager.Instance.SynchronizeMailAsync(new MailSynchronizationOptions + { + AccountId = Account.Id, + Type = MailSynchronizationType.Categories + }); + } + + if (!string.IsNullOrWhiteSpace(Account.Address)) + { + var aliases = await _accountService.GetAccountAliasesAsync(Account.Id); + var hasRootAlias = aliases.Any(alias => alias.IsRootAlias); + + if (!hasRootAlias) + { + await _accountService.CreateRootAliasAsync(Account.Id, Account.Address); + } + } + + await SynchronizationManager.Instance.SynchronizeMailAsync(new MailSynchronizationOptions + { + AccountId = Account.Id, + Type = MailSynchronizationType.Alias + }); + } + + if (selectedOption.IsCalendarAccessGranted && !previousCalendarAccess) + { + await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions + { + AccountId = Account.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }); + } + + var refreshedAccount = await _accountService.GetAccountAsync(Account.Id); + + await ExecuteUIThread(() => + { + Account = refreshedAccount; + AccountName = refreshedAccount.Name; + SenderName = refreshedAccount.SenderName; + EnsureSelectedTabForCapabilities(); + }); + } + + private void EnsureSelectedTabForCapabilities() + { + if (SelectedTabIndex == 1 && !HasMailAccess) + { + SelectedTabIndex = HasCalendarAccess ? 2 : 0; + } + else if (SelectedTabIndex == 2 && !HasCalendarAccess) + { + SelectedTabIndex = HasMailAccess ? 1 : 0; + } + } } public sealed class AccountCalendarShowAsOption @@ -441,6 +584,20 @@ public sealed class AccountCalendarShowAsOption } } +public sealed class AccountCapabilityOption +{ + public bool IsMailAccessGranted { get; } + public bool IsCalendarAccessGranted { get; } + public string DisplayText { get; } + + public AccountCapabilityOption(bool isMailAccessGranted, bool isCalendarAccessGranted, string displayText) + { + IsMailAccessGranted = isMailAccessGranted; + IsCalendarAccessGranted = isCalendarAccessGranted; + DisplayText = displayText; + } +} + public partial class AccountCalendarSettingsItemViewModel : ObservableObject { public AccountCalendar Calendar { get; } diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs index 6af8b2ca..3139266f 100644 --- a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs @@ -71,6 +71,8 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel private void BuildSteps() { Steps.Clear(); + var shouldSetupMail = WizardContext.IsMailAccessEnabled; + var shouldSetupCalendar = WizardContext.IsCalendarAccessEnabled; if (WizardContext.IsOAuthProvider) { @@ -78,31 +80,47 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel { Title = string.Format(Translator.AccountSetup_Step_Authenticating, WizardContext.SelectedProvider.Name) }); - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); - if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook) + if (shouldSetupMail) { - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + + if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories }); + } + + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases }); } - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases }); + + if (shouldSetupCalendar) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); + } + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); } else if (WizardContext.IsSpecialImapProvider) { - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingMailAuth }); + if (shouldSetupMail) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingMailAuth }); + } - if (WizardContext.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + if (shouldSetupCalendar && WizardContext.CalendarSupportMode == ImapCalendarSupportMode.CalDav) { Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_DiscoveringCalDav }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingCalendarAuth }); } Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + if (shouldSetupMail) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + } - if (WizardContext.CalendarSupportMode != ImapCalendarSupportMode.Disabled) + if (shouldSetupCalendar && WizardContext.CalendarSupportMode != ImapCalendarSupportMode.Disabled) { Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); } @@ -112,7 +130,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel else // Generic IMAP { Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); - Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + if (shouldSetupMail) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + } var setupResult = WizardContext.ImapCalDavSetupResult; if (setupResult?.IsCalendarAccessGranted == true && @@ -186,7 +207,8 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel AccountColorHex = WizardContext.AccountColorHex, CreatedAt = accountCreatedAt, InitialSynchronizationRange = WizardContext.SelectedInitialSynchronizationRange, - IsCalendarAccessGranted = true + IsMailAccessGranted = WizardContext.IsMailAccessEnabled, + IsCalendarAccessGranted = WizardContext.IsCalendarAccessEnabled }; if (WizardContext.IsOAuthProvider) @@ -208,50 +230,53 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _dbWritten = true; SetCurrentStepSucceeded(); - // Step: Profile - SetStepInProgress(Translator.AccountSetup_Step_FetchingProfile); - var profileResult = await SynchronizationManager.Instance.SynchronizeProfileAsync(_createdAccount.Id); - if (profileResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); - - if (profileResult.ProfileInformation != null) + if (_createdAccount.IsMailAccessGranted) { - _createdAccount.SenderName = profileResult.ProfileInformation.SenderName; - _createdAccount.Base64ProfilePictureData = profileResult.ProfileInformation.Base64ProfilePictureData; + // Step: Profile + SetStepInProgress(Translator.AccountSetup_Step_FetchingProfile); + var profileResult = await SynchronizationManager.Instance.SynchronizeProfileAsync(_createdAccount.Id); + if (profileResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); - if (!string.IsNullOrEmpty(profileResult.ProfileInformation.AccountAddress)) + if (profileResult.ProfileInformation != null) { - if (await _accountService.AccountAddressExistsAsync(profileResult.ProfileInformation.AccountAddress, _createdAccount.Id)) - throw new InvalidOperationException(Translator.DialogMessage_AccountAddressExistsMessage); + _createdAccount.SenderName = profileResult.ProfileInformation.SenderName; + _createdAccount.Base64ProfilePictureData = profileResult.ProfileInformation.Base64ProfilePictureData; - _createdAccount.Address = profileResult.ProfileInformation.AccountAddress; + if (!string.IsNullOrEmpty(profileResult.ProfileInformation.AccountAddress)) + { + if (await _accountService.AccountAddressExistsAsync(profileResult.ProfileInformation.AccountAddress, _createdAccount.Id)) + throw new InvalidOperationException(Translator.DialogMessage_AccountAddressExistsMessage); + + _createdAccount.Address = profileResult.ProfileInformation.AccountAddress; + } + + await _accountService.UpdateProfileInformationAsync(_createdAccount.Id, profileResult.ProfileInformation); } - - await _accountService.UpdateProfileInformationAsync(_createdAccount.Id, profileResult.ProfileInformation); - } - SetCurrentStepSucceeded(); - - // Step: Folders - SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); - var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); - if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - SetCurrentStepSucceeded(); - - // Step: Categories - if (_createdAccount.IsCategorySyncSupported) - { - SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories); - var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id); - if (categoryResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeCategories); SetCurrentStepSucceeded(); + + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + + // Step: Categories + if (_createdAccount.IsCategorySyncSupported) + { + SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories); + var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id); + if (categoryResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeCategories); + SetCurrentStepSucceeded(); + } } // Step: Calendar metadata - SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); if (_createdAccount.IsCalendarAccessGranted) { + SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions { AccountId = _createdAccount.Id, @@ -259,22 +284,25 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel }); if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success) throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata); + SetCurrentStepSucceeded(); } - SetCurrentStepSucceeded(); // Step: Aliases - SetStepInProgress(Translator.AccountSetup_Step_SyncingAliases); - if (_createdAccount.IsAliasSyncSupported) + if (_createdAccount.IsMailAccessGranted) { - var aliasResult = await SynchronizationManager.Instance.SynchronizeAliasesAsync(_createdAccount.Id); - if (aliasResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeAliases); + SetStepInProgress(Translator.AccountSetup_Step_SyncingAliases); + if (_createdAccount.IsAliasSyncSupported) + { + var aliasResult = await SynchronizationManager.Instance.SynchronizeAliasesAsync(_createdAccount.Id); + if (aliasResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeAliases); + } + else + { + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } + SetCurrentStepSucceeded(); } - else - { - await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); - } - SetCurrentStepSucceeded(); } else if (WizardContext.IsSpecialImapProvider) { @@ -288,13 +316,17 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _createdAccount.Address = WizardContext.EmailAddress; _createdAccount.SenderName = WizardContext.DisplayName; + _createdAccount.IsMailAccessGranted = dialogResult.IsMailAccessGranted; _createdAccount.IsCalendarAccessGranted = customServerInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled; _createdAccount.ServerInformation = customServerInformation; - // Step: Test IMAP - SetStepInProgress(Translator.AccountSetup_Step_TestingMailAuth); - await ValidateImapConnectivityAsync(customServerInformation); - SetCurrentStepSucceeded(); + if (_createdAccount.IsMailAccessGranted) + { + // Step: Test IMAP + SetStepInProgress(Translator.AccountSetup_Step_TestingMailAuth); + await ValidateImapConnectivityAsync(customServerInformation); + SetCurrentStepSucceeded(); + } // Step: CalDAV discovery and testing (if applicable) if (customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) @@ -313,12 +345,15 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _dbWritten = true; SetCurrentStepSucceeded(); - // Step: Folders - SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); - var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); - if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - SetCurrentStepSucceeded(); + if (_createdAccount.IsMailAccessGranted) + { + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + } // Step: Calendar metadata (if not disabled) if (_createdAccount.IsCalendarAccessGranted) @@ -334,8 +369,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel SetCurrentStepSucceeded(); } - // Aliases for IMAP - await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + if (_createdAccount.IsMailAccessGranted) + { + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } } else // Generic IMAP { @@ -350,6 +387,7 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _createdAccount.Address = setupResult.EmailAddress; _createdAccount.SenderName = setupResult.DisplayName; + _createdAccount.IsMailAccessGranted = setupResult.IsMailAccessGranted; _createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; _createdAccount.ServerInformation = customServerInformation; @@ -359,12 +397,15 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _dbWritten = true; SetCurrentStepSucceeded(); - // Step: Folders - SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); - var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); - if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - SetCurrentStepSucceeded(); + if (_createdAccount.IsMailAccessGranted) + { + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + } // Step: CalDAV (if applicable) if (setupResult.IsCalendarAccessGranted && @@ -392,8 +433,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel SetCurrentStepSucceeded(); } - // Aliases for IMAP - await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + if (_createdAccount.IsMailAccessGranted) + { + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } } // Step: Finalizing diff --git a/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs index 904573c1..3d987fef 100644 --- a/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs +++ b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs @@ -61,6 +61,7 @@ public sealed class ImapCalDavSetupResult { public string DisplayName { get; init; } public string EmailAddress { get; init; } + public bool IsMailAccessGranted { get; init; } public bool IsCalendarAccessGranted { get; init; } public CustomServerInformation ServerInformation { get; init; } } diff --git a/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs index 61e2f2a9..90410004 100644 --- a/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs +++ b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs @@ -21,6 +21,12 @@ public partial class WelcomeWizardContext : ObservableObject [ObservableProperty] public partial InitialSynchronizationRange SelectedInitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths; + [ObservableProperty] + public partial bool IsMailAccessEnabled { get; set; } = true; + + [ObservableProperty] + public partial bool IsCalendarAccessEnabled { get; set; } = true; + // Special IMAP fields (iCloud/Yahoo) [ObservableProperty] public partial string DisplayName { get; set; } @@ -66,7 +72,9 @@ public partial class WelcomeWizardContext : ObservableObject AccountName, BuildSpecialImapProviderDetails(), AccountColorHex, - SelectedInitialSynchronizationRange); + SelectedInitialSynchronizationRange, + IsMailAccessEnabled, + IsCalendarAccessEnabled); } public void Reset() @@ -75,6 +83,8 @@ public partial class WelcomeWizardContext : ObservableObject AccountName = null; AccountColorHex = null; SelectedInitialSynchronizationRange = InitialSynchronizationRange.SixMonths; + IsMailAccessEnabled = true; + IsCalendarAccessEnabled = true; DisplayName = null; EmailAddress = null; AppSpecificPassword = null; diff --git a/Wino.Mail.ViewModels/IdlePageViewModel.cs b/Wino.Mail.ViewModels/IdlePageViewModel.cs index 134472cb..6f97d7d2 100644 --- a/Wino.Mail.ViewModels/IdlePageViewModel.cs +++ b/Wino.Mail.ViewModels/IdlePageViewModel.cs @@ -1,9 +1,50 @@ -using Wino.Core.Domain.Interfaces; +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels; namespace Wino.Mail.ViewModels; public partial class IdlePageViewModel : CoreBaseViewModel { - public IdlePageViewModel(IMailDialogService dialogService) { } + public const string MailEmptyStateParameter = "mail-empty-state"; + + private readonly INavigationService _navigationService; + + [ObservableProperty] + public partial bool IsMailEmptyStateVisible { get; set; } + + public string MailEmptyStateTitle => Translator.MailEmptyState_Title; + public string MailEmptyStateMessage => Translator.MailEmptyState_Message; + public string AddAccountText => Translator.MailEmptyState_AddAccount; + public string ManageAccountsText => Translator.MailEmptyState_ManageAccounts; + + public IdlePageViewModel(IMailDialogService dialogService, INavigationService navigationService) + { + _navigationService = navigationService; + } + + public override void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + IsMailEmptyStateVisible = string.Equals(parameters as string, MailEmptyStateParameter, StringComparison.Ordinal); + } + + [RelayCommand] + private void AddAccount() + => _navigationService.Navigate( + WinoPage.SettingsPage, + ProviderSelectionNavigationContext.CreateForSettingsAddAccount(), + NavigationReferenceFrame.ShellFrame); + + [RelayCommand] + private void ManageAccounts() + => _navigationService.Navigate( + WinoPage.SettingsPage, + WinoPage.ManageAccountsPage, + NavigationReferenceFrame.ShellFrame); } diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs index fe73cd8b..30cb94fb 100644 --- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -57,6 +57,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel [ObservableProperty] private string password = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsMailSettingsVisible))] + [NotifyPropertyChangedFor(nameof(IsMailPasswordInputVisible))] + [NotifyPropertyChangedFor(nameof(IsMailActionsVisible))] + private bool isMailSupportEnabled = true; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCalendarModeSelectionVisible))] [NotifyPropertyChangedFor(nameof(IsCalDavSettingsVisible))] @@ -141,6 +147,9 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel public bool HasProviderHint => !string.IsNullOrWhiteSpace(ProviderHint); public bool IsBasicSetupSelected => SelectedSetupTabIndex == 0; public bool IsAdvancedSetupSelected => SelectedSetupTabIndex == 1; + public bool IsMailSettingsVisible => IsMailSupportEnabled; + public bool IsMailPasswordInputVisible => IsMailSupportEnabled; + public bool IsMailActionsVisible => IsMailSupportEnabled; public bool IsCalendarModeSelectionVisible => IsCalendarSupportEnabled; public bool IsCalDavSettingsVisible => IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav; public bool IsLocalCalendarModeSelected => IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.LocalOnly; @@ -152,6 +161,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel public string EmailAddressHeaderText => Translator.IMAPSetupDialog_MailAddress; public string EmailAddressPlaceholderText => Translator.IMAPSetupDialog_MailAddressPlaceholder; public string PasswordHeaderText => Translator.IMAPSetupDialog_Password; + public string EnableMailSupportText => Translator.ProviderSelection_UseForMail; public string EnableCalendarSupportText => Translator.ImapCalDavSettingsPage_EnableCalendarSupport; public string AutoDiscoverButtonText => Translator.ImapCalDavSettingsPage_AutoDiscoverButton; public string BasicTabText => Translator.ImapCalDavSettingsPage_BasicTab; @@ -342,6 +352,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel { try { + ValidateCapabilitySelection(); await EnsureImapSettingsPreparedAsync().ConfigureAwait(false); var serverInformation = BuildServerInformation(); @@ -401,6 +412,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel { try { + ValidateCapabilitySelection(); await EnsureImapSettingsPreparedAsync(); var serverInformation = BuildServerInformation(); @@ -416,8 +428,15 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel if (!await ValidateAccountUniquenessAsync(excludedAccountId)) return; - await ValidateImapConnectivityAsync(serverInformation); - IsImapValidationSucceeded = true; + if (IsMailSupportEnabled) + { + await ValidateImapConnectivityAsync(serverInformation); + IsImapValidationSucceeded = true; + } + else + { + IsImapValidationSucceeded = false; + } if (serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) { @@ -485,6 +504,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel { DisplayName = DisplayName.Trim(), EmailAddress = EmailAddress.Trim(), + IsMailAccessGranted = IsMailSupportEnabled, IsCalendarAccessGranted = serverInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled, ServerInformation = serverInformation }; @@ -511,6 +531,14 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel } } + partial void OnIsMailSupportEnabledChanged(bool value) + { + if (!value) + { + IsImapValidationSucceeded = false; + } + } + partial void OnSelectedCalendarSupportModeChanged(ImapCalendarSupportMode value) { if (value == ImapCalendarSupportMode.LocalOnly && !_localOnlyInfoShown) @@ -562,6 +590,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel ApplyProviderHint(_editingSpecialImapProvider); ApplyServerInformation(account.ServerInformation); + IsMailSupportEnabled = account.IsMailAccessGranted; if (account.ServerInformation != null) { @@ -588,8 +617,10 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName)) DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName; - IsCalendarSupportEnabled = true; - SelectedCalendarSupportMode = ImapCalendarSupportMode.CalDav; + IsMailSupportEnabled = accountCreationDialogResult?.IsMailAccessGranted != false; + IsCalendarSupportEnabled = accountCreationDialogResult?.IsCalendarAccessGranted == true; + SelectedCalendarSupportMode = accountCreationDialogResult?.SpecialImapProviderDetails?.CalendarSupportMode + ?? (IsCalendarSupportEnabled ? ImapCalendarSupportMode.CalDav : ImapCalendarSupportMode.Disabled); var specialProvider = accountCreationDialogResult?.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None; _editingSpecialImapProvider = specialProvider; @@ -737,6 +768,9 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private async Task EnsureImapSettingsPreparedAsync() { + if (!IsMailSupportEnabled) + return; + if (HasCompleteImapSettings()) return; @@ -847,6 +881,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel { DisplayName = DisplayName.Trim(), EmailAddress = EmailAddress.Trim(), + IsMailAccessGranted = IsMailSupportEnabled, IsCalendarAccessGranted = serverInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled, ServerInformation = serverInformation }); @@ -863,7 +898,9 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private async Task ValidateAccountUniquenessAsync(Guid? excludedAccountId) { - var accountName = (_pageMode == ImapCalDavSettingsPageMode.Create || _pageMode == ImapCalDavSettingsPageMode.Wizard) + var accountName = (_pageMode == ImapCalDavSettingsPageMode.Create + || _pageMode == ImapCalDavSettingsPageMode.Wizard + || _pageMode == ImapCalDavSettingsPageMode.AddAccount) ? _accountCreationContext?.AccountName : null; @@ -889,6 +926,15 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel return true; } + private static void ValidateCapabilitySelection(bool isMailEnabled, bool isCalendarEnabled) + { + if (!isMailEnabled && !isCalendarEnabled) + throw new InvalidOperationException(Translator.ProviderSelection_CapabilityValidationMessage); + } + + private void ValidateCapabilitySelection() + => ValidateCapabilitySelection(IsMailSupportEnabled, IsCalendarSupportEnabled); + private async Task SaveEditFlowAsync(CustomServerInformation serverInformation) { var account = await _accountService.GetAccountAsync(_editingAccountId); @@ -897,6 +943,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel account.SenderName = DisplayName.Trim(); account.Address = EmailAddress.Trim(); + account.IsMailAccessGranted = IsMailSupportEnabled; account.IsCalendarAccessGranted = serverInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled; serverInformation.Id = account.ServerInformation?.Id ?? Guid.NewGuid(); @@ -908,11 +955,14 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel await _accountService.UpdateAccountCustomServerInformationAsync(serverInformation); await _accountService.UpdateAccountAsync(account); - Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions + if (account.IsMailAccessGranted) { - AccountId = account.Id, - Type = MailSynchronizationType.FullFolders - })); + Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions + { + AccountId = account.Id, + Type = MailSynchronizationType.FullFolders + })); + } if (account.IsCalendarAccessGranted) { @@ -1007,6 +1057,9 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private void ValidateImapSettings(CustomServerInformation serverInformation) { + if (!IsMailSupportEnabled) + return; + ValidateIdentitySettings(); if (string.IsNullOrWhiteSpace(serverInformation.IncomingServer)) @@ -1075,7 +1128,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private bool TryApplyKnownProviderSettingsIfNeeded(bool requireCompleteImapSettings, bool requireCompleteCalDavSettings) { - var needsImapSettings = requireCompleteImapSettings && !HasCompleteImapSettings(); + var needsImapSettings = IsMailSupportEnabled && requireCompleteImapSettings && !HasCompleteImapSettings(); var needsCalDavSettings = requireCompleteCalDavSettings && IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav @@ -1114,6 +1167,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel SenderName = DisplayName.Trim(), ProviderType = MailProviderType.IMAP4, SpecialImapProvider = _editingSpecialImapProvider, + IsMailAccessGranted = IsMailSupportEnabled, IsCalendarAccessGranted = mode != ImapCalendarSupportMode.Disabled }, new AccountCreationDialogResult( @@ -1121,7 +1175,9 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel DisplayName.Trim(), providerDetails, string.Empty, - _wizardContext.SelectedInitialSynchronizationRange)); + _wizardContext.SelectedInitialSynchronizationRange, + IsMailSupportEnabled, + mode != ImapCalendarSupportMode.Disabled)); if (serverInformation == null) return false; @@ -1158,7 +1214,8 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel && !string.IsNullOrWhiteSpace(CalDavPassword)); private bool HasCompleteImapSettings() - => !string.IsNullOrWhiteSpace(IncomingServer) + => !IsMailSupportEnabled + || (!string.IsNullOrWhiteSpace(IncomingServer) && !string.IsNullOrWhiteSpace(IncomingServerPort) && !string.IsNullOrWhiteSpace(IncomingServerUsername) && !string.IsNullOrWhiteSpace(IncomingServerPassword) @@ -1167,7 +1224,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel && !string.IsNullOrWhiteSpace(OutgoingServerUsername) && !string.IsNullOrWhiteSpace(OutgoingServerPassword) && IsValidPort(IncomingServerPort) - && IsValidPort(OutgoingServerPort); + && IsValidPort(OutgoingServerPort)); private int FindAuthenticationMethodIndex(ImapAuthenticationMethod method) { diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 686a2195..ab227fec 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -46,6 +46,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IRecipient, IRecipient { + private const string MailEmptyStateNavigationParameter = IdlePageViewModel.MailEmptyStateParameter; + #region Menu Items [ObservableProperty] @@ -188,7 +190,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, // First clear all account menu items. MenuItems.RemoveRange(MenuItems.Where(a => a is IAccountMenuItem)); - var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var accounts = await GetMailEnabledAccountsAsync().ConfigureAwait(false); List initializedAccountIds = new(); @@ -373,7 +375,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private async Task ForceAllAccountSynchronizationsAsync() { // Run Inbox synchronization for all accounts on startup. - var accounts = await _accountService.GetAccountsAsync(); + var accounts = await GetMailEnabledAccountsAsync().ConfigureAwait(false); foreach (var account in accounts) { @@ -442,6 +444,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private async Task ProcessLaunchDefaultAsync() { + if (await NavigateToMailEmptyStateIfNeededAsync().ConfigureAwait(false)) + return; + if (PreferencesService.StartupEntityId == null) { NavigateToWelcomeWizard(); @@ -468,8 +473,16 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } else { - // Fallback to the welcome wizard if startup entity is not found. - NavigateToWelcomeWizard(); + var firstMailAccountMenuItem = MenuItems.FirstOrDefault(a => a is IAccountMenuItem) as IAccountMenuItem; + if (firstMailAccountMenuItem != null) + { + firstMailAccountMenuItem.Expand(); + await ChangeLoadedAccountAsync(firstMailAccountMenuItem); + } + else + { + NavigateToWelcomeWizard(); + } } } } @@ -947,6 +960,11 @@ public partial class MailAppShellViewModel : MailBaseViewModel, operationAccount = selectedFolderMenuItem.ParentAccount; } + if (operationAccount?.IsMailAccessGranted == false) + { + operationAccount = null; + } + // We couldn't find any account so far. // If there is only 1 account to use, use it. If not, // send a message for flyout so user can pick from it. @@ -956,7 +974,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, // No selected account. // List all accounts and let user pick one. - var accounts = await _accountService.GetAccountsAsync(); + var accounts = await GetMailEnabledAccountsAsync().ConfigureAwait(false); if (!accounts.Any()) { @@ -1081,7 +1099,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, public async void Receive(MailtoProtocolMessageRequested message) { - var accounts = await _accountService.GetAccountsAsync(); + var accounts = await GetMailEnabledAccountsAsync().ConfigureAwait(false); MailAccount targetAccount = null; @@ -1114,7 +1132,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, if (shareRequest?.Files == null || shareRequest.Files.Count == 0) return; - var accounts = await _accountService.GetAccountsAsync(); + var accounts = await GetMailEnabledAccountsAsync().ConfigureAwait(false); if (!accounts.Any()) return; @@ -1188,7 +1206,10 @@ public partial class MailAppShellViewModel : MailBaseViewModel, else { await ExecuteUIThread(() => SelectedMenuItem = null); - NavigateToWelcomeWizard(); + if (!await NavigateToMailEmptyStateIfNeededAsync().ConfigureAwait(false)) + { + NavigateToWelcomeWizard(); + } } } @@ -1199,6 +1220,37 @@ public partial class MailAppShellViewModel : MailBaseViewModel, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + private Task> GetMailEnabledAccountsAsync() + => GetAccountsByCapabilityAsync(requireMail: true); + + private async Task> GetAccountsByCapabilityAsync(bool requireMail = false, bool requireCalendar = false) + { + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + + return accounts + .Where(account => (!requireMail || account.IsMailAccessGranted) && + (!requireCalendar || account.IsCalendarAccessGranted)) + .ToList(); + } + + private async Task NavigateToMailEmptyStateIfNeededAsync() + { + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + if (!accounts.Any() || accounts.Any(account => account.IsMailAccessGranted)) + return false; + + latestSelectedAccountMenuItem = null; + await ExecuteUIThread(() => SelectedMenuItem = null); + + NavigationService.Navigate( + WinoPage.IdlePage, + MailEmptyStateNavigationParameter, + NavigationReferenceFrame.InnerShellFrame, + NavigationTransitionType.None); + + return true; + } + private bool IsAccountCurrentlyLoaded(Guid accountId) { return latestSelectedAccountMenuItem?.HoldingAccounts?.Any(a => a.Id == accountId) == true; @@ -1385,7 +1437,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel, public async void Receive(AccountRemovedMessage message) { var remainingAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); - if (!remainingAccounts.Any()) + var remainingMailAccounts = remainingAccounts.Where(account => account.IsMailAccessGranted).ToList(); + + if (!remainingMailAccounts.Any()) { latestSelectedAccountMenuItem = null; await ExecuteUIThread(() => @@ -1394,6 +1448,12 @@ public partial class MailAppShellViewModel : MailBaseViewModel, MenuItems?.Clear(); MenuItems?.Add(CreateMailMenuItem); }); + + if (remainingAccounts.Any()) + { + await NavigateToMailEmptyStateIfNeededAsync().ConfigureAwait(false); + } + return; } @@ -1413,22 +1473,34 @@ public partial class MailAppShellViewModel : MailBaseViewModel, await RecreateMenuItemsAsync(); + if (!createdAccount.IsMailAccessGranted) + { + await NavigateToMailEmptyStateIfNeededAsync().ConfigureAwait(false); + } + if (!MenuItems.TryGetAccountMenuItem(createdAccount.Id, out IAccountMenuItem createdMenuItem)) { Log.Warning("Created account {AccountId} could not be found in menu items after refresh.", createdAccount.Id); + + if (!createdAccount.IsMailAccessGranted) + return; + return; } await ChangeLoadedAccountAsync(createdMenuItem); - // Each created account should start a new synchronization automatically. - var options = new MailSynchronizationOptions() + if (createdAccount.IsMailAccessGranted) { - AccountId = createdAccount.Id, - Type = MailSynchronizationType.FullFolders, - }; + // Each created account should start a new synchronization automatically. + var options = new MailSynchronizationOptions() + { + AccountId = createdAccount.Id, + Type = MailSynchronizationType.FullFolders, + }; - Messenger.Send(new NewMailSynchronizationRequested(options)); + Messenger.Send(new NewMailSynchronizationRequested(options)); + } if (createdAccount.IsCalendarAccessGranted) { @@ -1455,6 +1527,13 @@ public partial class MailAppShellViewModel : MailBaseViewModel, { var updatedAccount = message.Account; + if (!updatedAccount.IsMailAccessGranted || !MenuItems.TryGetAccountMenuItem(updatedAccount.Id, out _)) + { + await RecreateMenuItemsAsync(); + await RestoreSelectedAccountAfterMenuRefreshAsync(false); + return; + } + await ExecuteUIThread(() => { if (MenuItems.TryGetAccountMenuItem(updatedAccount.Id, out IAccountMenuItem foundAccountMenuItem)) diff --git a/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs index fb431fbf..292c4d64 100644 --- a/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs @@ -10,9 +10,7 @@ using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; -using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Requests.Category; using Wino.Core.Services; @@ -53,11 +51,11 @@ public partial class MailCategoryManagementPageViewModel : MailBaseViewModel if (parameters is not Guid accountId) return; - Account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + Account = await _accountService.GetAccountAsync(accountId); if (Account != null) { - await LoadCategoriesAsync().ConfigureAwait(false); + await LoadCategoriesAsync(); } } diff --git a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs index 35b0e75c..85d3d1a3 100644 --- a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs +++ b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs @@ -15,6 +15,13 @@ using Wino.Messaging.Client.Navigation; namespace Wino.Mail.ViewModels; +public enum ProviderSelectionWizardStep +{ + Provider = 0, + Identity = 1, + Capabilities = 2 +} + public partial class ProviderSelectionPageViewModel : MailBaseViewModel { private readonly IAccountService _accountService; @@ -45,16 +52,50 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsInitialSynchronizationWarningVisible))] + [NotifyPropertyChangedFor(nameof(IsMailSynchronizationRangeVisible))] public partial InitialSynchronizationRangeOption SelectedInitialSynchronizationRange { get; set; } [ObservableProperty] public partial string AccountName { get; set; } [ObservableProperty] - public partial bool CanProceed { get; set; } + [NotifyPropertyChangedFor(nameof(IsMailSynchronizationRangeVisible))] + public partial bool IsMailAccessEnabled { get; set; } = true; + + [ObservableProperty] + public partial bool IsCalendarAccessEnabled { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CurrentStepNumber))] + [NotifyPropertyChangedFor(nameof(StepProgressValue))] + [NotifyPropertyChangedFor(nameof(StepProgressText))] + [NotifyPropertyChangedFor(nameof(IsProviderStepVisible))] + [NotifyPropertyChangedFor(nameof(IsIdentityStepVisible))] + [NotifyPropertyChangedFor(nameof(IsCapabilityStepVisible))] + [NotifyPropertyChangedFor(nameof(CanGoBack))] + [NotifyCanExecuteChangedFor(nameof(ContinueCommand))] + [NotifyCanExecuteChangedFor(nameof(GoBackCommand))] + public partial ProviderSelectionWizardStep CurrentStep { get; set; } = ProviderSelectionWizardStep.Provider; public bool IsColorSelected => SelectedColor != null; - public bool IsInitialSynchronizationWarningVisible => SelectedInitialSynchronizationRange?.IsEverything == true; + public bool IsInitialSynchronizationWarningVisible => IsMailSynchronizationRangeVisible && SelectedInitialSynchronizationRange?.IsEverything == true; + public bool IsMailSynchronizationRangeVisible => IsMailAccessEnabled; + public int CurrentStepNumber => (int)CurrentStep + 1; + public double StepProgressValue => CurrentStepNumber; + public string StepProgressText => string.Format(Translator.ProviderSelection_StepProgress, CurrentStepNumber); + public bool IsProviderStepVisible => CurrentStep == ProviderSelectionWizardStep.Provider; + public bool IsIdentityStepVisible => CurrentStep == ProviderSelectionWizardStep.Identity; + public bool IsCapabilityStepVisible => CurrentStep == ProviderSelectionWizardStep.Capabilities; + public bool CanGoBack => CurrentStep != ProviderSelectionWizardStep.Provider; + public string SelectedProviderName => SelectedProvider?.Name ?? string.Empty; + public string SelectedProviderDescription => SelectedProvider?.Description ?? string.Empty; + public string SelectedProviderImage => SelectedProvider?.ProviderImage ?? string.Empty; + public string SelectedProviderCapabilityDescription => GetSelectedProviderCapabilityDescription(); + public bool IsCapabilitySelectionMissing => !IsMailAccessEnabled && !IsCalendarAccessEnabled; + public bool IsCalendarOnlyServerHintVisible => + SelectedProvider?.Type == MailProviderType.IMAP4 && + !IsMailAccessEnabled && + IsCalendarAccessEnabled; public ProviderSelectionPageViewModel( IAccountService accountService, @@ -101,48 +142,110 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel p.Type == WizardContext.SelectedProvider.Type && p.SpecialImapProvider == WizardContext.SelectedProvider.SpecialImapProvider); AccountName = WizardContext.AccountName; + IsMailAccessEnabled = WizardContext.IsMailAccessEnabled; + IsCalendarAccessEnabled = WizardContext.IsCalendarAccessEnabled; if (WizardContext.AccountColorHex != null) SelectedColor = AvailableColors.FirstOrDefault(c => c.Hex == WizardContext.AccountColorHex); } + else + { + IsMailAccessEnabled = true; + IsCalendarAccessEnabled = true; + } - Validate(); + CurrentStep = mode == NavigationMode.Back && SelectedProvider != null + ? ProviderSelectionWizardStep.Capabilities + : ProviderSelectionWizardStep.Provider; } partial void OnSelectedProviderChanged(IProviderDetail value) { - Validate(); + OnPropertyChanged(nameof(SelectedProviderName)); + OnPropertyChanged(nameof(SelectedProviderDescription)); + OnPropertyChanged(nameof(SelectedProviderImage)); + OnPropertyChanged(nameof(SelectedProviderCapabilityDescription)); + OnPropertyChanged(nameof(IsCapabilitySelectionMissing)); + OnPropertyChanged(nameof(IsCalendarOnlyServerHintVisible)); + ContinueCommand.NotifyCanExecuteChanged(); } - partial void OnAccountNameChanged(string value) => Validate(); + partial void OnAccountNameChanged(string value) => ContinueCommand.NotifyCanExecuteChanged(); + + partial void OnIsMailAccessEnabledChanged(bool value) + { + OnPropertyChanged(nameof(IsCapabilitySelectionMissing)); + OnPropertyChanged(nameof(IsCalendarOnlyServerHintVisible)); + ContinueCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsCalendarAccessEnabledChanged(bool value) + { + OnPropertyChanged(nameof(IsCapabilitySelectionMissing)); + OnPropertyChanged(nameof(IsCalendarOnlyServerHintVisible)); + ContinueCommand.NotifyCanExecuteChanged(); + } [RelayCommand] private void ClearColor() => SelectedColor = null; - private void Validate() + private bool CanContinue() { - CanProceed = SelectedProvider != null && !string.IsNullOrWhiteSpace(AccountName); + return CurrentStep switch + { + ProviderSelectionWizardStep.Provider => SelectedProvider != null, + ProviderSelectionWizardStep.Identity => !string.IsNullOrWhiteSpace(AccountName), + ProviderSelectionWizardStep.Capabilities => IsMailAccessEnabled || IsCalendarAccessEnabled, + _ => false + }; } - [RelayCommand] - private async Task ProceedAsync() + [RelayCommand(CanExecute = nameof(CanGoBack))] + private void GoBack() { - if (!CanProceed) return; - - if (await _accountService.AccountNameExistsAsync(AccountName)) - { - await _dialogService.ShowMessageAsync( - Translator.DialogMessage_AccountNameExistsMessage, - Translator.DialogMessage_AccountExistsTitle, - WinoCustomMessageDialogIcon.Warning); + if (!CanGoBack) + return; + + CurrentStep--; + } + + [RelayCommand(CanExecute = nameof(CanContinue))] + private async Task ContinueAsync() + { + switch (CurrentStep) + { + case ProviderSelectionWizardStep.Provider: + CurrentStep = ProviderSelectionWizardStep.Identity; + return; + case ProviderSelectionWizardStep.Identity: + if (await _accountService.AccountNameExistsAsync(AccountName?.Trim())) + { + await _dialogService.ShowMessageAsync( + Translator.DialogMessage_AccountNameExistsMessage, + Translator.DialogMessage_AccountExistsTitle, + WinoCustomMessageDialogIcon.Warning); + return; + } + + CurrentStep = ProviderSelectionWizardStep.Capabilities; + return; + case ProviderSelectionWizardStep.Capabilities: + await CompleteWizardAsync(); + return; + } + } + + private async Task CompleteWizardAsync() + { + if (!CanContinue()) return; - } - // Persist to wizard context WizardContext.SelectedProvider = SelectedProvider; WizardContext.AccountName = AccountName?.Trim(); WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty; WizardContext.SelectedInitialSynchronizationRange = SelectedInitialSynchronizationRange?.Range ?? InitialSynchronizationRange.SixMonths; + WizardContext.IsMailAccessEnabled = IsMailAccessEnabled; + WizardContext.IsCalendarAccessEnabled = IsCalendarAccessEnabled; if (WizardContext.IsGenericImap) { @@ -172,4 +275,23 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel WinoPage.AccountSetupProgressPage)); } } + + partial void OnSelectedProviderChanging(IProviderDetail value) + { + + } + + private string GetSelectedProviderCapabilityDescription() + { + if (SelectedProvider == null) + return string.Empty; + + if (SelectedProvider.Type is MailProviderType.Outlook or MailProviderType.Gmail) + return Translator.ProviderSelection_CapabilityProviderDescription_OAuth; + + if (SelectedProvider.SpecialImapProvider is SpecialImapProvider.iCloud or SpecialImapProvider.Yahoo) + return Translator.ProviderSelection_CapabilityProviderDescription_SpecialImap; + + return Translator.ProviderSelection_CapabilityProviderDescription_CustomServer; + } } diff --git a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs index 380bf0df..76f1c641 100644 --- a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs +++ b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs @@ -38,11 +38,15 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel public partial string AppSpecificPassword { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RequiresAppSpecificPassword))] public partial int SelectedCalendarModeIndex { get; set; } [ObservableProperty] public partial bool CanProceed { get; set; } + public bool IsCalendarModeSelectionVisible => WizardContext.IsCalendarAccessEnabled; + public bool RequiresAppSpecificPassword => WizardContext.IsMailAccessEnabled || SelectedCalendarModeIndex == 1; + public string AppPasswordHelpUrl { get @@ -86,8 +90,15 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel _ => 0 }; + if (!WizardContext.IsCalendarAccessEnabled) + { + SelectedCalendarModeIndex = 0; + } + OnPropertyChanged(nameof(AppPasswordHelpUrl)); OnPropertyChanged(nameof(CalendarModeCalDavDescription)); + OnPropertyChanged(nameof(IsCalendarModeSelectionVisible)); + OnPropertyChanged(nameof(RequiresAppSpecificPassword)); Validate(); } @@ -95,13 +106,18 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel partial void OnDisplayNameChanged(string value) => Validate(); partial void OnEmailAddressChanged(string value) => Validate(); partial void OnAppSpecificPasswordChanged(string value) => Validate(); + partial void OnSelectedCalendarModeIndexChanged(int value) + { + OnPropertyChanged(nameof(RequiresAppSpecificPassword)); + Validate(); + } private void Validate() { CanProceed = !string.IsNullOrWhiteSpace(DisplayName) && !string.IsNullOrWhiteSpace(EmailAddress) && MailAccountAddressValidator.IsValid(EmailAddress) - && !string.IsNullOrWhiteSpace(AppSpecificPassword); + && (!RequiresAppSpecificPassword || !string.IsNullOrWhiteSpace(AppSpecificPassword)); } [RelayCommand] @@ -121,12 +137,14 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel WizardContext.DisplayName = DisplayName?.Trim(); WizardContext.EmailAddress = EmailAddress?.Trim(); WizardContext.AppSpecificPassword = AppSpecificPassword?.Trim(); - WizardContext.CalendarSupportMode = SelectedCalendarModeIndex switch - { - 1 => ImapCalendarSupportMode.CalDav, - 2 => ImapCalendarSupportMode.LocalOnly, - _ => ImapCalendarSupportMode.Disabled - }; + WizardContext.CalendarSupportMode = WizardContext.IsCalendarAccessEnabled + ? SelectedCalendarModeIndex switch + { + 1 => ImapCalendarSupportMode.CalDav, + 2 => ImapCalendarSupportMode.LocalOnly, + _ => ImapCalendarSupportMode.Disabled + } + : ImapCalendarSupportMode.Disabled; Messenger.Send(new BreadcrumbNavigationRequested( Translator.WelcomeWizard_Step3Title, diff --git a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs index 2b6f2ead..0fcd88b3 100644 --- a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs @@ -158,7 +158,9 @@ public sealed partial class NewAccountDialog : ContentDialog AccountNameTextbox.Text.Trim(), details, SelectedColor?.Hex ?? string.Empty, - initialSynchronizationRange); + initialSynchronizationRange, + true, + calendarSupportMode != ImapCalendarSupportMode.Disabled); Hide(); return; @@ -185,7 +187,9 @@ public sealed partial class NewAccountDialog : ContentDialog AccountNameTextbox.Text.Trim(), null, SelectedColor?.Hex ?? string.Empty, - initialSynchronizationRange); + initialSynchronizationRange, + true, + true); Hide(); } } diff --git a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs index 88cc1c9e..bd15c89c 100644 --- a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs +++ b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs @@ -72,6 +72,26 @@ public static class XamlHelpers return null; } } + + public static Microsoft.UI.Xaml.Media.Imaging.BitmapImage? StringToBitmapImage(string? imagePath) + { + if (string.IsNullOrWhiteSpace(imagePath)) + return null; + + try + { + var uri = imagePath.StartsWith("/") + ? new Uri($"ms-appx://{imagePath}") + : new Uri(imagePath, UriKind.Absolute); + + return new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri); + } + catch + { + return null; + } + } + public static InfoBarSeverity InfoBarSeverityConverter(InfoBarMessageType messageType) { return messageType switch diff --git a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs index 4450931f..375de843 100644 --- a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs +++ b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs @@ -105,6 +105,9 @@ public partial class AccountCalendarStateService : ObservableRecipient, { lock (_calendarStateLock) { + if (!GroupedAccountCalendarViewModel.SupportsCalendar(groupedAccountCalendar.Account)) + return; + groupedAccountCalendar.CalendarSelectionStateChanged += SingleCalendarSelectionStateChanged; groupedAccountCalendar.CollectiveSelectionStateChanged += SingleGroupCalendarCollectiveStateChanged; try @@ -180,6 +183,9 @@ public partial class AccountCalendarStateService : ObservableRecipient, { lock (_calendarStateLock) { + if (!GroupedAccountCalendarViewModel.SupportsCalendar(accountCalendar.Account)) + return; + // Find the group that this calendar belongs to. var group = _internalGroupedAccountCalendars.FirstOrDefault(g => g.Account.Id == accountCalendar.Account.Id); @@ -396,6 +402,16 @@ public partial class AccountCalendarStateService : ObservableRecipient, lock (_calendarStateLock) { groupedAccount = _internalGroupedAccountCalendars.FirstOrDefault(a => a.Account.Id == updatedAccount.Id); + + if (!GroupedAccountCalendarViewModel.SupportsCalendar(updatedAccount)) + { + if (groupedAccount != null) + { + RemoveGroupedAccountCalendar(groupedAccount); + } + + return; + } } groupedAccount?.UpdateAccount(updatedAccount); diff --git a/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs b/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs index 4d82b854..54bb1b6a 100644 --- a/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs +++ b/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Wino.Core.Domain.Interfaces; namespace Wino.Services; @@ -6,35 +7,73 @@ public class MailAuthenticatorConfiguration : IAuthenticatorConfig { public string OutlookAuthenticatorClientId => "b19c2035-d740-49ff-b297-de6ec561b208"; - public string[] OutlookScope => - [ - "email", - "mail.readwrite", - "offline_access", - "mail.send", - "Mail.Send.Shared", - "Mail.ReadWrite.Shared", - "User.Read", - "Calendars.ReadBasic", - "Calendars.ReadWrite", - "Calendars.ReadWrite.Shared", - "Calendars.Read", - "Calendars.Read.Shared", - ]; - public string GmailAuthenticatorClientId => "973025879644-s7b4ur9p3rlgop6a22u7iuptdc0brnrn.apps.googleusercontent.com"; - public string[] GmailScope => - [ - "https://mail.google.com/", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/gmail.labels", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/calendar.events", - "https://www.googleapis.com/auth/calendar.settings.readonly", - "https://www.googleapis.com/auth/drive.file", - ]; - public string GmailTokenStoreIdentifier => "WinoMailGmailTokenStore"; + + public string[] GetOutlookScope(bool isMailAccessGranted, bool isCalendarAccessGranted) + { + var scopes = new List + { + "email", + "offline_access", + "User.Read" + }; + + if (isMailAccessGranted) + { + scopes.AddRange( + [ + "mail.readwrite", + "mail.send", + "Mail.Send.Shared", + "Mail.ReadWrite.Shared" + ]); + } + + if (isCalendarAccessGranted) + { + scopes.AddRange( + [ + "Calendars.ReadBasic", + "Calendars.ReadWrite", + "Calendars.ReadWrite.Shared", + "Calendars.Read", + "Calendars.Read.Shared" + ]); + } + + return [.. scopes]; + } + + public string[] GetGmailScope(bool isMailAccessGranted, bool isCalendarAccessGranted) + { + var scopes = new List + { + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email" + }; + + if (isMailAccessGranted) + { + scopes.AddRange( + [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.labels" + ]); + } + + if (isCalendarAccessGranted) + { + scopes.AddRange( + [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar.settings.readonly", + "https://www.googleapis.com/auth/drive.file" + ]); + } + + return [.. scopes]; + } } diff --git a/Wino.Mail.WinUI/Views/Abstract/ProviderSelectionPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/ProviderSelectionPageAbstract.cs index 8c65495a..be5932d8 100644 --- a/Wino.Mail.WinUI/Views/Abstract/ProviderSelectionPageAbstract.cs +++ b/Wino.Mail.WinUI/Views/Abstract/ProviderSelectionPageAbstract.cs @@ -1,5 +1,4 @@ using Wino.Mail.ViewModels; -using Wino.Mail.WinUI; namespace Wino.Mail.WinUI.Views.Abstract; diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index 74a66ff2..d2736360 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -90,12 +90,12 @@ - + - + @@ -117,7 +117,10 @@ Text="{x:Bind ViewModel.AccountName, Mode=TwoWay}" /> - + @@ -127,7 +130,9 @@ Text="{x:Bind ViewModel.SenderName, Mode=TwoWay}" /> - + @@ -137,6 +142,25 @@ Text="{x:Bind ViewModel.Address, Mode=OneWay}" /> + + + + + + + + + + + + + @@ -403,6 +428,7 @@ diff --git a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml index e06f212e..847db777 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml @@ -25,7 +25,7 @@ Margin="0,2,0,0" Click="RootAccountTemplate_Click" CommandParameter="{x:Bind}" - Description="{x:Bind Account.Address}" + Description="{x:Bind DescriptionText}" Header="{x:Bind Account.Name}" IsClickEnabled="True"> @@ -199,6 +199,7 @@ x:Name="AccountsListView" Grid.Row="1" x:Load="{x:Bind ViewModel.HasAccountsDefined, Mode=OneWay}" + CanReorderItems="True" ItemContainerStyle="{StaticResource StretchedItemContainerStyle}" ItemTemplateSelector="{StaticResource AccountProviderViewModelTemplateSelector}" ItemsSource="{x:Bind ViewModel.Accounts, Mode=OneWay}" @@ -208,7 +209,7 @@ @@ -220,16 +221,10 @@ - + - - - - - - - - - + CornerRadius="8" + Visibility="{x:Bind helpers:XamlHelpers.BoolToVisibilityConverter(ViewModel.IsProviderStepVisible), Mode=OneWay}"> + - + - - - - - - - - - + + + + + + + + + - - + + + + + + + + + + + + - - + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - + TextWrapping="Wrap"> + + - - - - + - -