Merged feature/vNext. Initial commit for Wino Mail 2.0

This commit is contained in:
Burak Kaan Köse
2026-04-05 16:30:26 +02:00
1513 changed files with 93788 additions and 26896 deletions
@@ -1,18 +1,26 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Misc;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
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.Services;
using Wino.Core.ViewModels.Data;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
namespace Wino.Mail.ViewModels;
@@ -20,13 +28,55 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
{
private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService;
private readonly IWinoServerConnectionManager _serverConnectionManager;
private readonly IFolderService _folderService;
private readonly ICalendarService _calendarService;
private readonly IStatePersistanceService _statePersistanceService;
private readonly INewThemeService _themeService;
private readonly IImapTestService _imapTestService;
private bool isLoaded = false;
public MailAccount Account { get; set; }
[ObservableProperty]
public partial MailAccount Account { get; set; }
public ObservableCollection<IMailItemFolder> CurrentFolders { get; set; } = [];
public ObservableCollection<AccountCalendar> AccountCalendars { get; set; } = [];
public ObservableCollection<AccountCalendarSettingsItemViewModel> AccountCalendarSettingsItems { get; } = [];
public ObservableCollection<AccountCalendarShowAsOption> ShowAsOptions { get; } = [];
[ObservableProperty]
public partial AccountCalendar SelectedPrimaryCalendar { get; set; }
[ObservableProperty]
public partial int SelectedTabIndex { get; set; } = 0; // Default to Mail tab
[ObservableProperty]
public partial string AccountName { get; set; }
[ObservableProperty]
public partial string SenderName { get; set; }
[ObservableProperty]
public partial AppColorViewModel SelectedColor { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsImapServer))]
public partial CustomServerInformation ServerInformation { get; set; }
[ObservableProperty]
public partial List<AppColorViewModel> AvailableColors { get; set; }
[ObservableProperty]
public partial int SelectedIncomingServerConnectionSecurityIndex { get; set; }
[ObservableProperty]
public partial int SelectedIncomingServerAuthenticationMethodIndex { get; set; }
[ObservableProperty]
public partial int SelectedOutgoingServerConnectionSecurityIndex { get; set; }
[ObservableProperty]
public partial int SelectedOutgoingServerAuthenticationMethodIndex { get; set; }
// Mail-related properties
[ObservableProperty]
private bool isFocusedInboxEnabled;
@@ -46,17 +96,56 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private bool isTaskbarBadgeEnabled;
public bool IsFocusedInboxSupportedForAccount => Account != null && Account.Preferences.IsFocusedInboxEnabled != null;
public bool IsImapServer => ServerInformation != null;
public string ProviderIconPath => Account?.SpecialImapProvider != SpecialImapProvider.None
? $"ms-appx:///Assets/Providers/{Account.SpecialImapProvider}.png"
: $"ms-appx:///Assets/Providers/{Account?.ProviderType}.png";
public string Address => Account?.Address ?? string.Empty;
public List<ImapAuthenticationMethodModel> AvailableAuthenticationMethods { get; } =
[
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Auto, Translator.ImapAuthenticationMethod_Auto),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.None, Translator.ImapAuthenticationMethod_None),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.NormalPassword, Translator.ImapAuthenticationMethod_Plain),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.EncryptedPassword, Translator.ImapAuthenticationMethod_EncryptedPassword),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Ntlm, Translator.ImapAuthenticationMethod_Ntlm),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.CramMd5, Translator.ImapAuthenticationMethod_CramMD5),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.DigestMd5, Translator.ImapAuthenticationMethod_DigestMD5)
];
public List<ImapConnectionSecurityModel> AvailableConnectionSecurities { get; set; } =
[
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.Auto, Translator.ImapConnectionSecurity_Auto),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.SslTls, Translator.ImapConnectionSecurity_SslTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.StartTls, Translator.ImapConnectionSecurity_StartTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.None, Translator.ImapConnectionSecurity_None)
];
public AccountDetailsPageViewModel(IMailDialogService dialogService,
IAccountService accountService,
IWinoServerConnectionManager serverConnectionManager,
IFolderService folderService)
IFolderService folderService,
ICalendarService calendarService,
IStatePersistanceService statePersistanceService,
INewThemeService themeService,
IImapTestService imapTestService)
{
_dialogService = dialogService;
_accountService = accountService;
_serverConnectionManager = serverConnectionManager;
_folderService = folderService;
_calendarService = calendarService;
_statePersistanceService = statePersistanceService;
_themeService = themeService;
_imapTestService = imapTestService;
var colorHexList = _themeService.GetAvailableAccountColors();
AvailableColors = colorHexList.Select(a => new AppColorViewModel(a)).ToList();
ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Free, Translator.CalendarShowAs_Free));
ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Tentative, Translator.CalendarShowAs_Tentative));
ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.Busy, Translator.CalendarShowAs_Busy));
ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.OutOfOffice, Translator.CalendarShowAs_OutOfOffice));
ShowAsOptions.Add(new AccountCalendarShowAsOption(CalendarItemShowAs.WorkingElsewhere, Translator.CalendarShowAs_WorkingElsewhere));
}
[RelayCommand]
@@ -71,44 +160,80 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private void EditAliases()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id));
public Task FolderSyncToggledAsync(IMailItemFolder folderStructure, bool isEnabled)
=> _folderService.ChangeFolderSynchronizationStateAsync(folderStructure.Id, isEnabled);
public Task FolderShowUnreadToggled(IMailItemFolder folderStructure, bool isEnabled)
=> _folderService.ChangeFolderShowUnreadCountStateAsync(folderStructure.Id, isEnabled);
[RelayCommand]
private void EditImapCalDavSettings()
=> Messenger.Send(new BreadcrumbNavigationRequested(
Translator.ImapCalDavSettingsPage_TitleEdit,
WinoPage.ImapCalDavSettingsPage,
ImapCalDavSettingsNavigationContext.CreateForEditMode(Account.Id)));
[RelayCommand]
private void EditAccountDetails()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsEditAccountDetails_Title, WinoPage.EditAccountDetailsPage, Account));
private async Task SaveChangesAsync()
{
await UpdateAccountAsync();
_dialogService.InfoBarMessage(Translator.EditAccountDetailsPage_SaveSuccess_Title, Translator.EditAccountDetailsPage_SaveSuccess_Message, InfoBarMessageType.Success);
}
[RelayCommand]
private async Task DeleteAccount()
private async Task DeleteAccountAsync()
{
if (Account == null)
return;
var confirmation = await _dialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_DeleteAccountConfirmationTitle,
string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, Account.Name),
Translator.Buttons_Delete);
string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, Account.Name),
Translator.Buttons_Delete);
if (!confirmation)
return;
await SynchronizationManager.Instance.DestroySynchronizerAsync(Account.Id);
await _accountService.DeleteAccountAsync(Account);
var isSynchronizerKilledResponse = await _serverConnectionManager.GetResponseAsync<bool, KillAccountSynchronizerRequested>(new KillAccountSynchronizerRequested(Account.Id));
_dialogService.InfoBarMessage(Translator.Info_AccountDeletedTitle, string.Format(Translator.Info_AccountDeletedMessage, Account.Name), InfoBarMessageType.Success);
if (isSynchronizerKilledResponse.IsSuccess)
Messenger.Send(new BackBreadcrumNavigationRequested());
}
[RelayCommand]
private async Task ValidateImapSettingsAsync()
{
try
{
await _accountService.DeleteAccountAsync(Account);
_dialogService.InfoBarMessage(Translator.Info_AccountDeletedTitle, string.Format(Translator.Info_AccountDeletedMessage, Account.Name), InfoBarMessageType.Success);
Messenger.Send(new BackBreadcrumNavigationRequested());
await _imapTestService.TestImapConnectionAsync(ServerInformation, true);
_dialogService.InfoBarMessage(Translator.IMAPSetupDialog_ValidationSuccess_Title, Translator.IMAPSetupDialog_ValidationSuccess_Message, InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(Translator.IMAPSetupDialog_ValidationFailed_Title, ex.Message, InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task UpdateCustomServerInformationAsync()
{
if (ServerInformation != null)
{
ServerInformation.IncomingAuthenticationMethod = AvailableAuthenticationMethods[SelectedIncomingServerAuthenticationMethodIndex].ImapAuthenticationMethod;
ServerInformation.IncomingServerSocketOption = AvailableConnectionSecurities[SelectedIncomingServerConnectionSecurityIndex].ImapConnectionSecurity;
ServerInformation.OutgoingAuthenticationMethod = AvailableAuthenticationMethods[SelectedOutgoingServerAuthenticationMethodIndex].ImapAuthenticationMethod;
ServerInformation.OutgoingServerSocketOption = AvailableConnectionSecurities[SelectedOutgoingServerConnectionSecurityIndex].ImapConnectionSecurity;
Account.ServerInformation = ServerInformation;
}
await _accountService.UpdateAccountCustomServerInformationAsync(Account.ServerInformation);
_dialogService.InfoBarMessage(Translator.IMAPSetupDialog_SaveImapSuccess_Title, Translator.IMAPSetupDialog_SaveImapSuccess_Message, InfoBarMessageType.Success);
}
public Task FolderSyncToggledAsync(IMailItemFolder folderStructure, bool isEnabled)
=> _folderService.ChangeFolderSynchronizationStateAsync(folderStructure.Id, isEnabled);
public Task FolderShowUnreadToggled(IMailItemFolder folderStructure, bool isEnabled)
=> _folderService.ChangeFolderShowUnreadCountStateAsync(folderStructure.Id, isEnabled);
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
@@ -117,6 +242,9 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
if (parameters is Guid accountId)
{
Account = await _accountService.GetAccountAsync(accountId);
AccountName = Account.Name;
SenderName = Account.SenderName;
ServerInformation = Account.ServerInformation;
IsFocusedInboxEnabled = Account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault();
AreNotificationsEnabled = Account.Preferences.IsNotificationsEnabled;
@@ -126,7 +254,24 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
IsAppendMessageSettinEnabled = Account.Preferences.ShouldAppendMessagesToSentFolder;
IsTaskbarBadgeEnabled = Account.Preferences.IsTaskbarBadgeEnabled;
OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount));
if (!string.IsNullOrEmpty(Account.AccountColorHex))
{
SelectedColor = AvailableColors.FirstOrDefault(a => a.Hex == Account.AccountColorHex);
}
else
{
SelectedColor = null;
}
if (ServerInformation != null)
{
SelectedIncomingServerAuthenticationMethodIndex = AvailableAuthenticationMethods.FindIndex(a => a.ImapAuthenticationMethod == ServerInformation.IncomingAuthenticationMethod);
SelectedIncomingServerConnectionSecurityIndex = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == ServerInformation.IncomingServerSocketOption);
SelectedOutgoingServerAuthenticationMethodIndex = AvailableAuthenticationMethods.FindIndex(a => a.ImapAuthenticationMethod == ServerInformation.OutgoingAuthenticationMethod);
SelectedOutgoingServerConnectionSecurityIndex = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == ServerInformation.OutgoingServerSocketOption);
}
SelectedTabIndex = _statePersistanceService.ApplicationMode == WinoApplicationMode.Calendar ? 2 : 1;
var folderStructures = (await _folderService.GetFolderStructureForAccountAsync(Account.Id, true)).Folders;
@@ -135,10 +280,90 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
CurrentFolders.Add(folder);
}
// Load calendar list
await LoadAccountCalendarsAsync();
isLoaded = true;
}
}
private Task UpdateAccountAsync()
{
Account.Name = AccountName;
Account.SenderName = SenderName;
Account.AccountColorHex = SelectedColor?.Hex ?? string.Empty;
return _accountService.UpdateAccountAsync(Account);
}
private async Task LoadAccountCalendarsAsync()
{
var calendars = await _calendarService.GetAccountCalendarsAsync(Account.Id);
await ExecuteUIThread(() =>
{
AccountCalendars.Clear();
AccountCalendarSettingsItems.Clear();
foreach (var calendar in calendars)
{
AccountCalendars.Add(calendar);
AccountCalendarSettingsItems.Add(new AccountCalendarSettingsItemViewModel(calendar, ShowAsOptions, AvailableColors));
}
});
SelectedPrimaryCalendar = AccountCalendars.FirstOrDefault(calendar => calendar.IsPrimary) ?? AccountCalendars.FirstOrDefault();
}
public AccountCalendarShowAsOption GetShowAsOption(CalendarItemShowAs showAs)
=> ShowAsOptions.FirstOrDefault(option => option.ShowAs == showAs) ?? ShowAsOptions.First();
public async Task UpdateCalendarSynchronizationAsync(AccountCalendar calendar, bool isEnabled)
{
if (calendar == null || calendar.IsSynchronizationEnabled == isEnabled)
return;
calendar.IsSynchronizationEnabled = isEnabled;
await _calendarService.UpdateAccountCalendarAsync(calendar);
}
public async Task UpdateCalendarDefaultShowAsAsync(AccountCalendar calendar, AccountCalendarShowAsOption option)
{
if (calendar == null || option == null || calendar.DefaultShowAs == option.ShowAs)
return;
calendar.DefaultShowAs = option.ShowAs;
await _calendarService.UpdateAccountCalendarAsync(calendar);
}
public async Task UpdateCalendarColorAsync(AccountCalendarSettingsItemViewModel calendarItem, AppColorViewModel color)
{
if (calendarItem?.Calendar == null || color == null || calendarItem.Calendar.BackgroundColorHex == color.Hex)
return;
calendarItem.SetBackgroundColor(color);
await _calendarService.UpdateAccountCalendarAsync(calendarItem.Calendar);
}
[RelayCommand]
private void ResetColor()
=> SelectedColor = null;
partial void OnSelectedColorChanged(AppColorViewModel oldValue, AppColorViewModel newValue)
{
if (Account != null)
{
_ = UpdateAccountAsync();
}
}
partial void OnAccountChanged(MailAccount value)
{
OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount));
OnPropertyChanged(nameof(ProviderIconPath));
OnPropertyChanged(nameof(Address));
}
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
@@ -167,6 +392,64 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
Account.Preferences.IsTaskbarBadgeEnabled = IsTaskbarBadgeEnabled;
await _accountService.UpdateAccountAsync(Account);
break;
case nameof(SelectedPrimaryCalendar) when SelectedPrimaryCalendar != null:
foreach (var calendar in AccountCalendars)
{
calendar.IsPrimary = calendar.Id == SelectedPrimaryCalendar.Id;
}
await _calendarService.SetPrimaryCalendarAsync(Account.Id, SelectedPrimaryCalendar.Id);
break;
}
}
}
public sealed class AccountCalendarShowAsOption
{
public CalendarItemShowAs ShowAs { get; }
public string DisplayText { get; }
public AccountCalendarShowAsOption(CalendarItemShowAs showAs, string displayText)
{
ShowAs = showAs;
DisplayText = displayText;
}
}
public partial class AccountCalendarSettingsItemViewModel : ObservableObject
{
public AccountCalendar Calendar { get; }
public ObservableCollection<AccountCalendarShowAsOption> ShowAsOptions { get; }
public List<AppColorViewModel> AvailableColors { get; }
public string Name => Calendar.Name;
public string TimeZone => Calendar.TimeZone;
public string BackgroundColorHex => Calendar.BackgroundColorHex;
[ObservableProperty]
public partial bool IsSynchronizationEnabled { get; set; }
[ObservableProperty]
public partial AccountCalendarShowAsOption SelectedShowAsOption { get; set; }
[ObservableProperty]
public partial AppColorViewModel SelectedColor { get; set; }
public AccountCalendarSettingsItemViewModel(AccountCalendar calendar, ObservableCollection<AccountCalendarShowAsOption> showAsOptions, List<AppColorViewModel> availableColors)
{
Calendar = calendar;
ShowAsOptions = showAsOptions;
AvailableColors = availableColors;
IsSynchronizationEnabled = calendar.IsSynchronizationEnabled;
SelectedShowAsOption = showAsOptions.FirstOrDefault(option => option.ShowAs == calendar.DefaultShowAs) ?? showAsOptions.FirstOrDefault();
SelectedColor = availableColors.FirstOrDefault(color => string.Equals(color.Hex, calendar.BackgroundColorHex, StringComparison.OrdinalIgnoreCase))
?? new AppColorViewModel(calendar.BackgroundColorHex ?? ColorHelpers.GenerateFlatColorHex());
}
public void SetBackgroundColor(AppColorViewModel color)
{
SelectedColor = color;
Calendar.BackgroundColorHex = color.Hex;
OnPropertyChanged(nameof(BackgroundColorHex));
}
}
+102 -235
View File
@@ -12,43 +12,42 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication;
using Wino.Core.Domain.Models.Connectivity;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services;
using Wino.Core.ViewModels;
using Wino.Core.ViewModels.Data;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels;
public partial class AccountManagementViewModel : AccountManagementPageViewModelBase
{
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly IImapTestService _imapTestService;
private readonly IWinoLogger _winoLogger;
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly ICalDavClient _calDavClient;
public IMailDialogService MailDialogService { get; }
public AccountManagementViewModel(IMailDialogService dialogService,
IWinoServerConnectionManager winoServerConnectionManager,
INavigationService navigationService,
IAccountService accountService,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
IProviderService providerService,
IImapTestService imapTestService,
IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService)
{
MailDialogService = dialogService;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_imapTestService = imapTestService;
_winoLogger = winoLogger;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_calDavClient = calDavClient;
}
[RelayCommand]
@@ -88,246 +87,114 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
return;
}
MailAccount createdAccount = null;
IAccountCreationDialog creationDialog = null;
Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage));
}
try
public Task StartAddNewAccountAsync() => AddNewAccountAsync();
private async Task ValidateSpecialImapConnectivityAsync(CustomServerInformation serverInformation)
{
var connectivityResult = await SynchronizationManager.Instance
.TestImapConnectivityAsync(serverInformation, allowSSLHandshake: false)
.ConfigureAwait(false);
if (connectivityResult.IsCertificateUIRequired)
{
var providers = ProviderService.GetAvailableProviders();
var certificateMessage =
$"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}\n\n" +
$"{Translator.IMAPSetupDialog_CertificateIssuer}: {connectivityResult.CertificateIssuer}\n" +
$"{Translator.IMAPSetupDialog_CertificateValidFrom}: {connectivityResult.CertificateValidFromDateString}\n" +
$"{Translator.IMAPSetupDialog_CertificateValidTo}: {connectivityResult.CertificateExpirationDateString}\n\n" +
$"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}";
// Select provider.
var accountCreationDialogResult = await MailDialogService.ShowAccountProviderSelectionDialogAsync(providers);
var allowCertificate = await ExecuteUIThreadTaskAsync(
() => MailDialogService.ShowConfirmationDialogAsync(certificateMessage, Translator.GeneralTitle_Warning, Translator.Buttons_Allow))
.ConfigureAwait(false);
var accountCreationCancellationTokenSource = new CancellationTokenSource();
if (!allowCertificate)
throw new InvalidOperationException(Translator.IMAPSetupDialog_CertificateDenied);
if (accountCreationDialogResult != null)
connectivityResult = await SynchronizationManager.Instance
.TestImapConnectivityAsync(serverInformation, allowSSLHandshake: true)
.ConfigureAwait(false);
}
if (!connectivityResult.IsSuccess)
throw new InvalidOperationException(connectivityResult.FailedReason ?? Translator.IMAPSetupDialog_ConnectionFailedMessage);
if (serverInformation.CalendarSupportMode != ImapCalendarSupportMode.CalDav)
return;
if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl))
throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired);
var settings = new CalDavConnectionSettings
{
ServiceUri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute),
Username = serverInformation.CalDavUsername,
Password = serverInformation.CalDavPassword
};
await _calDavClient.DiscoverCalendarsAsync(settings).ConfigureAwait(false);
}
private async Task ExecuteUIThreadTaskAsync(Func<Task> action)
{
if (Dispatcher == null)
{
await action().ConfigureAwait(false);
return;
}
var completionSource = new TaskCompletionSource<object>();
await ExecuteUIThread(() =>
{
_ = ExecuteAndCaptureAsync();
async Task ExecuteAndCaptureAsync()
{
creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult);
CustomServerInformation customServerInformation = null;
createdAccount = new MailAccount()
try
{
ProviderType = accountCreationDialogResult.ProviderType,
Name = accountCreationDialogResult.AccountName,
SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None,
Id = Guid.NewGuid(),
AccountColorHex = accountCreationDialogResult.AccountColorHex
};
await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource);
await Task.Delay(500);
creationDialog.State = AccountCreationDialogState.SigningIn;
string tokenInformation = string.Empty;
// Custom server implementation requires more async waiting.
if (creationDialog is IImapAccountCreationDialog customServerDialog)
{
// Pass along the account properties and perform initial navigation on the imap frame.
customServerDialog.StartImapConnectionSetup(createdAccount);
customServerInformation = await customServerDialog.GetCustomServerInformationAsync()
?? throw new AccountSetupCanceledException();
// At this point connection is successful.
// Save the server setup information and later on we'll fetch folders.
customServerInformation.AccountId = createdAccount.Id;
createdAccount.Address = customServerInformation.Address;
createdAccount.ServerInformation = customServerInformation;
createdAccount.SenderName = customServerInformation.DisplayName;
await action().ConfigureAwait(false);
completionSource.TrySetResult(null);
}
else
catch (Exception ex)
{
// Hanle special imap providers like iCloud and Yahoo.
if (accountCreationDialogResult.SpecialImapProviderDetails != null)
{
// Special imap provider testing dialog. This is only available for iCloud and Yahoo.
customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(createdAccount, accountCreationDialogResult);
customServerInformation.Id = Guid.NewGuid();
customServerInformation.AccountId = createdAccount.Id;
createdAccount.SenderName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
createdAccount.Address = customServerInformation.Address;
// Let server validate the imap/smtp connection.
var testResultResponse = await WinoServerConnectionManager.GetResponseAsync<ImapConnectivityTestResults, ImapConnectivityTestRequested>(new ImapConnectivityTestRequested(customServerInformation, true));
if (!testResultResponse.IsSuccess)
{
throw new Exception($"{Translator.IMAPSetupDialog_ConnectionFailedTitle}\n{testResultResponse.Message}");
}
else if (!testResultResponse.Data.IsSuccess)
{
// Server connectivity might succeed, but result might be failed.
throw new ImapClientPoolException(testResultResponse.Data.FailedReason, customServerInformation, testResultResponse.Data.FailureProtocolLog);
}
}
else
{
// OAuth authentication is handled here.
// Server authenticates, returns the token info here.
var tokenInformationResponse = await WinoServerConnectionManager
.GetResponseAsync<TokenInformationEx, AuthorizationRequested>(new AuthorizationRequested(accountCreationDialogResult.ProviderType,
createdAccount,
createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token);
if (creationDialog.State == AccountCreationDialogState.Canceled)
throw new AccountSetupCanceledException();
if (!tokenInformationResponse.IsSuccess)
throw new Exception(tokenInformationResponse.Message);
createdAccount.Address = tokenInformationResponse.Data.AccountAddress;
tokenInformationResponse.ThrowIfFailed();
}
completionSource.TrySetException(ex);
}
// Address is still doesn't have a value for API synchronizers.
// It'll be synchronized with profile information.
await AccountService.CreateAccountAsync(createdAccount, customServerInformation);
// Local account has been created.
// Sync profile information if supported.
if (createdAccount.IsProfileInfoSyncSupported)
{
// Start profile information synchronization.
// It's only available for Outlook and Gmail synchronizers.
var profileSyncOptions = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.UpdateProfile
};
var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client));
var profileSynchronizationResult = profileSynchronizationResponse.Data;
if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation);
createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName;
createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData;
if (!string.IsNullOrEmpty(profileSynchronizationResult.ProfileInformation.AccountAddress))
{
createdAccount.Address = profileSynchronizationResult.ProfileInformation.AccountAddress;
}
await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation);
}
if (creationDialog is IImapAccountCreationDialog customServerAccountCreationDialog)
customServerAccountCreationDialog.ShowPreparingFolders();
else
creationDialog.State = AccountCreationDialogState.PreparingFolders;
// Start synchronizing folders.
var folderSyncOptions = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.FoldersOnly
};
var folderSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(folderSyncOptions, SynchronizationSource.Client));
var folderSynchronizationResult = folderSynchronizationResponse.Data;
if (folderSynchronizationResult == null || folderSynchronizationResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception($"{Translator.Exception_FailedToSynchronizeFolders}\n{folderSynchronizationResponse.Message}");
// Sync aliases if supported.
if (createdAccount.IsAliasSyncSupported)
{
// Try to synchronize aliases for the account.
var aliasSyncOptions = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.Alias
};
var aliasSyncResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(aliasSyncOptions, SynchronizationSource.Client));
var aliasSynchronizationResult = folderSynchronizationResponse.Data;
if (aliasSynchronizationResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeAliases);
}
else
{
// Create root primary alias for the account.
// This is only available for accounts that do not support alias synchronization.
await AccountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address);
}
// Send changes to listeners.
ReportUIChange(new AccountCreatedMessage(createdAccount));
// Notify success.
DialogService.InfoBarMessage(Translator.Info_AccountCreatedTitle, string.Format(Translator.Info_AccountCreatedMessage, createdAccount.Address), InfoBarMessageType.Success);
}
}
catch (Exception ex) when (ex.Message.Contains(nameof(GmailServiceDisabledException)))
});
await completionSource.Task.ConfigureAwait(false);
}
private async Task<T> ExecuteUIThreadTaskAsync<T>(Func<Task<T>> action)
{
if (Dispatcher == null)
return await action().ConfigureAwait(false);
var completionSource = new TaskCompletionSource<T>();
await ExecuteUIThread(() =>
{
// For Google Workspace accounts, Gmail API might be disabled by the admin.
// Wino can't continue synchronization in this case.
// We must notify the user about this and prevent account creation.
_ = ExecuteAndCaptureAsync();
DialogService.InfoBarMessage(Translator.GmailServiceDisabled_Title, Translator.GmailServiceDisabled_Message, InfoBarMessageType.Error);
if (createdAccount != null)
async Task ExecuteAndCaptureAsync()
{
await AccountService.DeleteAccountAsync(createdAccount);
try
{
var result = await action().ConfigureAwait(false);
completionSource.TrySetResult(result);
}
catch (Exception ex)
{
completionSource.TrySetException(ex);
}
}
}
catch (AccountSetupCanceledException)
{
// Ignore
}
catch (Exception ex) when (ex.Message.Contains(nameof(AccountSetupCanceledException)))
{
// Ignore
}
catch (ImapClientPoolException testClientPoolException) when (testClientPoolException.CustomServerInformation != null)
{
var properties = testClientPoolException.CustomServerInformation.GetConnectionProperties();
});
properties.Add("ProtocolLog", testClientPoolException.ProtocolLog);
properties.Add("DiagnosticId", PreferencesService.DiagnosticId);
_winoLogger.TrackEvent("IMAP Test Failed", properties);
DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, testClientPoolException.Message, InfoBarMessageType.Error);
}
catch (ImapClientPoolException clientPoolException) when (clientPoolException.InnerException != null)
{
DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, clientPoolException.InnerException.Message, InfoBarMessageType.Error);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to create account.");
DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, ex.Message, InfoBarMessageType.Error);
// Delete account in case of failure.
if (createdAccount != null)
{
await AccountService.DeleteAccountAsync(createdAccount);
}
}
finally
{
creationDialog?.Complete(false);
}
return await completionSource.Task.ConfigureAwait(false);
}
[RelayCommand]
@@ -0,0 +1,477 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Core.Domain;
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.Domain.Models.Calendar;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels;
public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
{
private readonly IAccountService _accountService;
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly ICalDavClient _calDavClient;
private readonly IMailDialogService _dialogService;
public WelcomeWizardContext WizardContext { get; }
public ObservableCollection<AccountSetupStepModel> Steps { get; } = [];
[ObservableProperty]
public partial bool IsSetupComplete { get; set; }
[ObservableProperty]
public partial bool IsSetupFailed { get; set; }
[ObservableProperty]
public partial string FailureMessage { get; set; }
private MailAccount _createdAccount;
private bool _dbWritten;
public AccountSetupProgressPageViewModel(
IAccountService accountService,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient,
IMailDialogService dialogService,
WelcomeWizardContext wizardContext)
{
_accountService = accountService;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_calDavClient = calDavClient;
_dialogService = dialogService;
WizardContext = wizardContext;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
// Only run on fresh navigation, not on back-navigation
if (mode == NavigationMode.Back) return;
await RunSetupAsync();
}
private void BuildSteps()
{
Steps.Clear();
if (WizardContext.IsOAuthProvider)
{
Steps.Add(new AccountSetupStepModel
{
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 });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
}
else if (WizardContext.IsSpecialImapProvider)
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingMailAuth });
if (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 (WizardContext.CalendarSupportMode != ImapCalendarSupportMode.Disabled)
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
}
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
}
else // Generic IMAP
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders });
var setupResult = WizardContext.ImapCalDavSetupResult;
if (setupResult?.IsCalendarAccessGranted == true &&
setupResult.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav)
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_DiscoveringCalDav });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingCalendarAuth });
}
if (setupResult?.IsCalendarAccessGranted == true)
{
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
}
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
}
}
private int _currentStepIndex;
private void SetStepInProgress(string title)
{
for (int i = 0; i < Steps.Count; i++)
{
if (Steps[i].Title == title)
{
_currentStepIndex = i;
Steps[i].Status = AccountSetupStepStatus.InProgress;
return;
}
}
}
private void SetCurrentStepSucceeded()
{
if (_currentStepIndex < Steps.Count)
Steps[_currentStepIndex].Status = AccountSetupStepStatus.Succeeded;
}
private void SetCurrentStepFailed(string errorMessage)
{
if (_currentStepIndex < Steps.Count)
{
Steps[_currentStepIndex].Status = AccountSetupStepStatus.Failed;
Steps[_currentStepIndex].ErrorMessage = errorMessage;
}
}
private async Task RunSetupAsync()
{
IsSetupComplete = false;
IsSetupFailed = false;
FailureMessage = null;
_dbWritten = false;
_createdAccount = null;
BuildSteps();
try
{
CustomServerInformation customServerInformation = null;
// Build account in memory
_createdAccount = new MailAccount
{
Id = Guid.NewGuid(),
ProviderType = WizardContext.SelectedProvider.Type,
Name = WizardContext.AccountName,
SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider,
AccountColorHex = WizardContext.AccountColorHex,
IsCalendarAccessGranted = true
};
if (WizardContext.IsOAuthProvider)
{
// Step: Authenticating
SetStepInProgress(string.Format(Translator.AccountSetup_Step_Authenticating, WizardContext.SelectedProvider.Name));
var authTokenInfo = await SynchronizationManager.Instance.HandleAuthorizationAsync(
WizardContext.SelectedProvider.Type,
_createdAccount,
_createdAccount.ProviderType == MailProviderType.Gmail);
_createdAccount.Address = authTokenInfo.AccountAddress;
SetCurrentStepSucceeded();
// Step: Save to DB
SetStepInProgress(Translator.AccountSetup_Step_SavingAccount);
await _accountService.CreateAccountAsync(_createdAccount, null);
_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)
{
_createdAccount.SenderName = profileResult.ProfileInformation.SenderName;
_createdAccount.Base64ProfilePictureData = profileResult.ProfileInformation.Base64ProfilePictureData;
if (!string.IsNullOrEmpty(profileResult.ProfileInformation.AccountAddress))
_createdAccount.Address = profileResult.ProfileInformation.AccountAddress;
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: Calendar metadata
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
if (_createdAccount.IsCalendarAccessGranted)
{
var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions
{
AccountId = _createdAccount.Id,
Type = CalendarSynchronizationType.CalendarMetadata
});
if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata);
}
SetCurrentStepSucceeded();
// Step: Aliases
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 if (WizardContext.IsSpecialImapProvider)
{
var dialogResult = WizardContext.BuildAccountCreationDialogResult();
customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(_createdAccount, dialogResult);
if (customServerInformation == null) throw new Exception("Failed to resolve server information.");
customServerInformation.Id = Guid.NewGuid();
customServerInformation.AccountId = _createdAccount.Id;
_createdAccount.Address = WizardContext.EmailAddress;
_createdAccount.SenderName = WizardContext.DisplayName;
_createdAccount.IsCalendarAccessGranted = customServerInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled;
_createdAccount.ServerInformation = customServerInformation;
// Step: Test IMAP
SetStepInProgress(Translator.AccountSetup_Step_TestingMailAuth);
await ValidateImapConnectivityAsync(customServerInformation);
SetCurrentStepSucceeded();
// Step: CalDAV discovery and testing (if applicable)
if (customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav)
{
SetStepInProgress(Translator.AccountSetup_Step_DiscoveringCalDav);
SetCurrentStepSucceeded();
SetStepInProgress(Translator.AccountSetup_Step_TestingCalendarAuth);
await ValidateCalDavConnectivityAsync(customServerInformation);
SetCurrentStepSucceeded();
}
// Step: Save to DB
SetStepInProgress(Translator.AccountSetup_Step_SavingAccount);
await _accountService.CreateAccountAsync(_createdAccount, customServerInformation);
_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();
// Step: Calendar metadata (if not disabled)
if (_createdAccount.IsCalendarAccessGranted)
{
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions
{
AccountId = _createdAccount.Id,
Type = CalendarSynchronizationType.CalendarMetadata
});
if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata);
SetCurrentStepSucceeded();
}
// Aliases for IMAP
await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address);
}
else // Generic IMAP
{
var setupResult = WizardContext.ImapCalDavSetupResult
?? throw new Exception("IMAP setup was not completed.");
customServerInformation = setupResult.ServerInformation
?? throw new Exception("Server information is missing.");
customServerInformation.Id = Guid.NewGuid();
customServerInformation.AccountId = _createdAccount.Id;
_createdAccount.Address = setupResult.EmailAddress;
_createdAccount.SenderName = setupResult.DisplayName;
_createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted;
_createdAccount.ServerInformation = customServerInformation;
// Step: Save to DB
SetStepInProgress(Translator.AccountSetup_Step_SavingAccount);
await _accountService.CreateAccountAsync(_createdAccount, customServerInformation);
_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();
// Step: CalDAV (if applicable)
if (setupResult.IsCalendarAccessGranted &&
customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav)
{
SetStepInProgress(Translator.AccountSetup_Step_DiscoveringCalDav);
SetCurrentStepSucceeded();
SetStepInProgress(Translator.AccountSetup_Step_TestingCalendarAuth);
await ValidateCalDavConnectivityAsync(customServerInformation);
SetCurrentStepSucceeded();
}
// Step: Calendar metadata
if (setupResult.IsCalendarAccessGranted)
{
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions
{
AccountId = _createdAccount.Id,
Type = CalendarSynchronizationType.CalendarMetadata
});
if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata);
SetCurrentStepSucceeded();
}
// Aliases for IMAP
await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address);
}
// Step: Finalizing
SetStepInProgress(Translator.AccountSetup_Step_Finalizing);
SetCurrentStepSucceeded();
IsSetupComplete = true;
// Notify listeners — this triggers ShellWindow creation from App.xaml.cs
Messenger.Send(new AccountCreatedMessage(_createdAccount));
}
catch (AccountSetupCanceledException)
{
// User canceled authentication — go back silently, no error UI
Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft));
}
catch (Exception ex) when (ex.Message.Contains(nameof(AccountSetupCanceledException)))
{
// Wrapped cancellation — same silent behavior
Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft));
}
catch (Exception ex)
{
Log.Error(ex, "Account setup failed.");
SetCurrentStepFailed(ex.Message);
IsSetupFailed = true;
FailureMessage = Translator.AccountSetup_FailureMessage;
// Rollback if DB write happened
if (_dbWritten && _createdAccount != null)
{
try
{
await _accountService.DeleteAccountAsync(_createdAccount);
}
catch (Exception deleteEx)
{
Log.Error(deleteEx, "Failed to rollback account creation.");
}
_dbWritten = false;
}
}
}
private async Task ValidateImapConnectivityAsync(CustomServerInformation serverInformation)
{
var connectivityResult = await SynchronizationManager.Instance
.TestImapConnectivityAsync(serverInformation, allowSSLHandshake: false);
if (connectivityResult.IsCertificateUIRequired)
{
var certificateMessage =
$"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}\n\n" +
$"{Translator.IMAPSetupDialog_CertificateIssuer}: {connectivityResult.CertificateIssuer}\n" +
$"{Translator.IMAPSetupDialog_CertificateValidFrom}: {connectivityResult.CertificateValidFromDateString}\n" +
$"{Translator.IMAPSetupDialog_CertificateValidTo}: {connectivityResult.CertificateExpirationDateString}\n\n" +
$"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}";
var allowCertificate = await _dialogService.ShowConfirmationDialogAsync(
certificateMessage,
Translator.GeneralTitle_Warning,
Translator.Buttons_Allow);
if (!allowCertificate)
throw new InvalidOperationException(Translator.IMAPSetupDialog_CertificateDenied);
connectivityResult = await SynchronizationManager.Instance
.TestImapConnectivityAsync(serverInformation, allowSSLHandshake: true);
}
if (!connectivityResult.IsSuccess)
throw new InvalidOperationException(connectivityResult.FailedReason ?? Translator.IMAPSetupDialog_ConnectionFailedMessage);
}
private async Task ValidateCalDavConnectivityAsync(CustomServerInformation serverInformation)
{
if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl))
throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired);
var settings = new CalDavConnectionSettings
{
ServiceUri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute),
Username = serverInformation.CalDavUsername,
Password = serverInformation.CalDavPassword
};
await _calDavClient.DiscoverCalendarsAsync(settings);
}
[RelayCommand]
private void GoBack()
{
Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft));
}
[RelayCommand]
private async Task TryAgainAsync()
{
await RunSetupAsync();
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -12,7 +13,7 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.Server;
using Wino.Core.Services;
namespace Wino.Mail.ViewModels;
@@ -20,7 +21,7 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
{
private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService;
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
private readonly ISmimeCertificateService _smimeCertificateService;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronizeAliases))]
@@ -33,11 +34,11 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
public AliasManagementPageViewModel(IMailDialogService dialogService,
IAccountService accountService,
IWinoServerConnectionManager winoServerConnectionManager)
ISmimeCertificateService smimeCertificateService)
{
_dialogService = dialogService;
_accountService = accountService;
_winoServerConnectionManager = winoServerConnectionManager;
_smimeCertificateService = smimeCertificateService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -54,7 +55,22 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
private async Task LoadAliasesAsync()
{
AccountAliases = await _accountService.GetAccountAliasesAsync(Account.Id);
var aliases = await _accountService.GetAccountAliasesAsync(Account.Id);
foreach (var alias in aliases)
{
alias.Certificates.Clear();
alias.Certificates.Add(null); // First blank optioon
var certs = _smimeCertificateService.GetCertificates()
.Where(cert => cert.Subject.Contains(alias.AliasAddress, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var cert in certs)
alias.Certificates.Add(cert);
alias.SelectedSigningCertificate = !string.IsNullOrEmpty(alias.SelectedSigningCertificateThumbprint)
? alias.Certificates.FirstOrDefault(c => c?.Thumbprint == alias.SelectedSigningCertificateThumbprint)
: null;
}
AccountAliases = aliases;
}
[RelayCommand]
@@ -82,12 +98,12 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
Type = MailSynchronizationType.Alias
};
var aliasSyncResponse = await _winoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(aliasSyncOptions, SynchronizationSource.Client));
var aliasSyncResult = await SynchronizationManager.Instance.SynchronizeAliasesAsync(Account.Id);
if (aliasSyncResponse.IsSuccess)
if (aliasSyncResult.CompletedState == SynchronizationCompletedState.Success)
await LoadAliasesAsync();
else
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, aliasSyncResponse.Message, InfoBarMessageType.Error);
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, "Failed to synchronize aliases", InfoBarMessageType.Error);
}
[RelayCommand]
@@ -151,4 +167,20 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
await _accountService.DeleteAccountAliasAsync(alias.Id);
await LoadAliasesAsync();
}
public async Task SetAliasSmimeEncryption(MailAccountAlias alias, bool value)
{
alias.IsSmimeEncryptionEnabled = value;
await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases);
await LoadAliasesAsync();
}
public async Task SetSelectedSigningCertificate(MailAccountAlias alias, X509Certificate2 cert)
{
alias.SelectedSigningCertificate = cert;
alias.SelectedSigningCertificateThumbprint = cert?.Thumbprint;
await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases);
await LoadAliasesAsync();
}
}
@@ -1,92 +1,31 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
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.Ai;
using Wino.Core.Domain.Models.Navigation;
using Wino.Messaging.Server;
using Wino.Core.Domain.Models.Translations;
namespace Wino.Mail.ViewModels;
public partial class AppPreferencesPageViewModel : MailBaseViewModel
{
public IPreferencesService PreferencesService { get; }
[ObservableProperty]
private List<string> _appTerminationBehavior;
[ObservableProperty]
public partial List<string> SearchModes { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsStartupBehaviorDisabled))]
[NotifyPropertyChangedFor(nameof(IsStartupBehaviorEnabled))]
private StartupBehaviorResult startupBehaviorResult;
private int _emailSyncIntervalMinutes;
public int EmailSyncIntervalMinutes
{
get => _emailSyncIntervalMinutes;
set
{
SetProperty(ref _emailSyncIntervalMinutes, value);
PreferencesService.EmailSyncIntervalMinutes = value;
}
}
public bool IsStartupBehaviorDisabled => !IsStartupBehaviorEnabled;
public bool IsStartupBehaviorEnabled => StartupBehaviorResult == StartupBehaviorResult.Enabled;
private string _selectedAppTerminationBehavior;
public string SelectedAppTerminationBehavior
{
get => _selectedAppTerminationBehavior;
set
{
SetProperty(ref _selectedAppTerminationBehavior, value);
PreferencesService.ServerTerminationBehavior = (ServerBackgroundMode)AppTerminationBehavior.IndexOf(value);
}
}
private string _selectedDefaultSearchMode;
public string SelectedDefaultSearchMode
{
get => _selectedDefaultSearchMode;
set
{
SetProperty(ref _selectedDefaultSearchMode, value);
PreferencesService.DefaultSearchMode = (SearchMode)SearchModes.IndexOf(value);
}
}
private readonly IMailDialogService _dialogService;
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
private readonly IStartupBehaviorService _startupBehaviorService;
public AppPreferencesPageViewModel(IMailDialogService dialogService,
IPreferencesService preferencesService,
IWinoServerConnectionManager winoServerConnectionManager,
IStartupBehaviorService startupBehaviorService)
public AppPreferencesPageViewModel(
IMailDialogService dialogService,
IPreferencesService preferencesService,
IStartupBehaviorService startupBehaviorService,
ITranslationService translationService,
IAiActionOptionsService aiActionOptionsService)
{
_dialogService = dialogService;
PreferencesService = preferencesService;
_winoServerConnectionManager = winoServerConnectionManager;
_startupBehaviorService = startupBehaviorService;
// Load the app termination behavior options
_appTerminationBehavior =
[
Translator.SettingsAppPreferences_ServerBackgroundingMode_MinimizeTray_Title, // "Minimize to tray"
Translator.SettingsAppPreferences_ServerBackgroundingMode_Invisible_Title, // "Invisible"
Translator.SettingsAppPreferences_ServerBackgroundingMode_Terminate_Title // "Terminate"
];
_translationService = translationService;
_aiActionOptionsService = aiActionOptionsService;
SearchModes =
[
@@ -94,22 +33,137 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel
Translator.SettingsAppPreferences_SearchMode_Online
];
SelectedAppTerminationBehavior = _appTerminationBehavior[(int)PreferencesService.ServerTerminationBehavior];
ApplicationModes =
[
Translator.SettingsAppPreferences_ApplicationMode_Mail,
Translator.SettingsAppPreferences_ApplicationMode_Calendar,
Translator.ContactsPage_Title,
Translator.MenuSettings
];
SelectedDefaultSearchMode = SearchModes[(int)PreferencesService.DefaultSearchMode];
SelectedDefaultApplicationMode = ApplicationModes[(int)PreferencesService.DefaultApplicationMode];
EmailSyncIntervalMinutes = PreferencesService.EmailSyncIntervalMinutes;
SummarySavePath = PreferencesService.AiSummarySavePath;
}
public IPreferencesService PreferencesService { get; }
[ObservableProperty]
public partial List<string> SearchModes { get; set; }
[ObservableProperty]
public partial List<string> ApplicationModes { get; set; }
[ObservableProperty]
public partial List<AppLanguageModel> AvailableLanguages { get; set; } = [];
[ObservableProperty]
public partial AppLanguageModel SelectedLanguage { get; set; }
[ObservableProperty]
public partial List<AiTranslateLanguageOption> AvailableAiLanguages { get; set; } = [];
[ObservableProperty]
public partial AiTranslateLanguageOption SelectedDefaultTranslationLanguage { get; set; }
[ObservableProperty]
public partial AiTranslateLanguageOption SelectedSummarizeLanguage { get; set; }
[ObservableProperty]
public partial string SummarySavePath { get; set; } = string.Empty;
[ObservableProperty]
public partial bool HasInvalidSummarySavePath { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsStartupBehaviorDisabled))]
[NotifyPropertyChangedFor(nameof(IsStartupBehaviorEnabled))]
private StartupBehaviorResult startupBehaviorResult;
private readonly IMailDialogService _dialogService;
private readonly IStartupBehaviorService _startupBehaviorService;
private readonly ITranslationService _translationService;
private readonly IAiActionOptionsService _aiActionOptionsService;
private bool _isLanguageInitialized;
private bool _isAiPreferencesInitialized;
private int _emailSyncIntervalMinutes;
private string _selectedDefaultSearchMode;
private string _selectedDefaultApplicationMode;
public int EmailSyncIntervalMinutes
{
get => _emailSyncIntervalMinutes;
set
{
SetProperty(ref _emailSyncIntervalMinutes, value);
PreferencesService.EmailSyncIntervalMinutes = value;
}
}
public bool IsStartupBehaviorDisabled => !IsStartupBehaviorEnabled;
public bool IsStartupBehaviorEnabled => StartupBehaviorResult == StartupBehaviorResult.Enabled;
public string SelectedDefaultSearchMode
{
get => _selectedDefaultSearchMode;
set
{
SetProperty(ref _selectedDefaultSearchMode, value);
PreferencesService.DefaultSearchMode = (SearchMode)SearchModes.IndexOf(value);
}
}
public string SelectedDefaultApplicationMode
{
get => _selectedDefaultApplicationMode;
set
{
SetProperty(ref _selectedDefaultApplicationMode, value);
PreferencesService.DefaultApplicationMode = (WinoApplicationMode)ApplicationModes.IndexOf(value);
}
}
partial void OnSelectedLanguageChanged(AppLanguageModel value)
{
if (!_isLanguageInitialized || value == null)
return;
_ = _translationService.InitializeLanguageAsync(value.Language);
}
partial void OnSelectedDefaultTranslationLanguageChanged(AiTranslateLanguageOption value)
{
if (!_isAiPreferencesInitialized || value == null)
return;
PreferencesService.AiDefaultTranslationLanguageCode = value.Code;
}
partial void OnSelectedSummarizeLanguageChanged(AiTranslateLanguageOption value)
{
if (!_isAiPreferencesInitialized || value == null)
return;
PreferencesService.AiSummarizeLanguageCode = value.Code;
}
partial void OnSummarySavePathChanged(string value)
{
if (!_isAiPreferencesInitialized)
return;
PreferencesService.AiSummarySavePath = value ?? string.Empty;
RefreshSummarySavePathState();
}
[RelayCommand]
private async Task ToggleStartupBehaviorAsync()
{
if (IsStartupBehaviorEnabled)
{
await DisableStartupAsync();
}
else
{
await EnableStartupAsync();
}
OnPropertyChanged(nameof(IsStartupBehaviorEnabled));
}
@@ -117,14 +171,12 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel
private async Task EnableStartupAsync()
{
StartupBehaviorResult = await _startupBehaviorService.ToggleStartupBehavior(true);
NotifyCurrentStartupState();
}
private async Task DisableStartupAsync()
{
StartupBehaviorResult = await _startupBehaviorService.ToggleStartupBehavior(false);
NotifyCurrentStartupState();
}
@@ -152,25 +204,56 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel
}
}
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
[RelayCommand]
private async Task BrowseSummarySavePathAsync()
{
base.OnPropertyChanged(e);
var pickedPath = await _dialogService.PickWindowsFolderAsync();
if (string.IsNullOrWhiteSpace(pickedPath))
return;
if (e.PropertyName == nameof(SelectedAppTerminationBehavior))
{
var terminationModeChangedResult = await _winoServerConnectionManager.GetResponseAsync<bool, ServerTerminationModeChanged>(new ServerTerminationModeChanged(PreferencesService.ServerTerminationBehavior));
if (!terminationModeChangedResult.IsSuccess)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, terminationModeChangedResult.Message, InfoBarMessageType.Error);
}
}
await ExecuteUIThread(() => SummarySavePath = pickedPath);
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
StartupBehaviorResult = await _startupBehaviorService.GetCurrentStartupBehaviorAsync();
var availableLanguages = _translationService.GetAvailableLanguages();
var availableAiLanguages = new List<AiTranslateLanguageOption>(_aiActionOptionsService.GetTranslateLanguageOptions());
var startupBehaviorResult = await _startupBehaviorService.GetCurrentStartupBehaviorAsync();
await ExecuteUIThread(() =>
{
AvailableLanguages = availableLanguages;
SelectedLanguage = AvailableLanguages.Find(language => language.Language == PreferencesService.CurrentLanguage)
?? (AvailableLanguages.Count > 0 ? AvailableLanguages[0] : null);
_isLanguageInitialized = true;
AvailableAiLanguages = availableAiLanguages;
SelectedDefaultTranslationLanguage = FindAiLanguageOption(PreferencesService.AiDefaultTranslationLanguageCode)
?? FindAiLanguageOption("en-US")
?? (AvailableAiLanguages.Count > 0 ? AvailableAiLanguages[0] : null);
SelectedSummarizeLanguage = FindAiLanguageOption(PreferencesService.AiSummarizeLanguageCode)
?? FindAiLanguageOption("en-US")
?? (AvailableAiLanguages.Count > 0 ? AvailableAiLanguages[0] : null);
SummarySavePath = PreferencesService.AiSummarySavePath;
RefreshSummarySavePathState();
_isAiPreferencesInitialized = true;
StartupBehaviorResult = startupBehaviorResult;
});
}
private AiTranslateLanguageOption FindAiLanguageOption(string languageCode)
{
if (string.IsNullOrWhiteSpace(languageCode))
return null;
return AvailableAiLanguages.Find(option => option.Code == languageCode);
}
private void RefreshSummarySavePathState()
{
HasInvalidSummarySavePath = !string.IsNullOrWhiteSpace(SummarySavePath) && !Directory.Exists(SummarySavePath);
}
}
@@ -0,0 +1,79 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Wino.Mail.ViewModels.Collections;
/// <summary>
/// Base class for group headers in the flat collection
/// </summary>
public abstract partial class GroupHeaderBase : ObservableObject
{
[ObservableProperty]
private int itemCount;
[ObservableProperty]
private int unreadCount;
protected GroupHeaderBase(string key, string displayName)
{
Key = key;
DisplayName = displayName;
}
/// <summary>
/// The unique key for this group (used for sorting and identification)
/// </summary>
public string Key { get; }
/// <summary>
/// The display name shown in the UI
/// </summary>
public string DisplayName { get; }
}
/// <summary>
/// Group header for date-based grouping
/// </summary>
public partial class DateGroupHeader : GroupHeaderBase
{
public DateGroupHeader(DateTime date) : base(date.ToString("yyyy-MM-dd"), FormatDisplayName(date))
{
Date = date;
}
/// <summary>
/// The date this group represents
/// </summary>
public DateTime Date { get; }
private static string FormatDisplayName(DateTime date)
{
var today = DateTime.Today;
var yesterday = today.AddDays(-1);
return date.Date switch
{
var d when d == today => "Today",
var d when d == yesterday => "Yesterday",
var d when d >= today.AddDays(-7) => date.ToString("dddd"), // This week
var d when d.Year == today.Year => date.ToString("MMMM dd"), // This year
_ => date.ToString("MMMM dd, yyyy") // Other years
};
}
}
/// <summary>
/// Group header for sender name-based grouping
/// </summary>
public partial class SenderGroupHeader : GroupHeaderBase
{
public SenderGroupHeader(string senderName) : base(senderName, senderName)
{
SenderName = senderName;
}
/// <summary>
/// The sender name this group represents
/// </summary>
public string SenderName { get; }
}
@@ -1,31 +0,0 @@
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Mail.ViewModels.Collections;
internal class ThreadingManager
{
private readonly IThreadingStrategyProvider _threadingStrategyProvider;
public ThreadingManager(IThreadingStrategyProvider threadingStrategyProvider)
{
_threadingStrategyProvider = threadingStrategyProvider;
}
public bool ShouldThread(MailCopy newItem, IMailItem existingItem)
{
if (_threadingStrategyProvider == null) return false;
var strategy = _threadingStrategyProvider.GetStrategy(newItem.AssignedAccount.ProviderType);
return strategy?.ShouldThreadWithItem(newItem, existingItem) ?? false;
}
public ThreadMailItem CreateNewThread(IMailItem existingItem, MailCopy newItem)
{
var thread = new ThreadMailItem();
thread.AddThreadItem(existingItem);
thread.AddThreadItem(newItem);
return thread;
}
}
File diff suppressed because it is too large Load Diff
+407 -51
View File
@@ -3,39 +3,64 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MimeKit;
using MimeKit.Cryptography;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Extensions;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.ViewModels.Messages;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels;
public partial class ComposePageViewModel : MailBaseViewModel
public partial class ComposePageViewModel : MailBaseViewModel,
IRecipient<ReaderItemRefreshRequestedEvent>,
IRecipient<SynchronizationActionsAdded>,
IRecipient<SynchronizationActionsCompleted>,
IRecipient<AccountSynchronizerStateChanged>
{
private static readonly TimeSpan LocalDraftRetryGracePeriod = TimeSpan.FromSeconds(15);
public Func<Task<string>> GetHTMLBodyFunction;
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{
if (args.Handled || args.Mode != WinoApplicationMode.Mail)
return;
if (args.Action == KeyboardShortcutAction.Send)
{
await SendAsync();
args.Handled = true;
}
}
// When we send the message or discard it, we need to block the mime update
// Update is triggered when we leave the page.
private bool isUpdatingMimeBlocked = false;
private bool canSendMail => ComposingAccount != null && !IsLocalDraft && CurrentMimeMessage != null;
private bool canSendMail => ComposingAccount != null && !IsLocalDraft && CurrentMimeMessage != null && !IsDraftBusy;
private bool canSendLocalDraftToServer => ComposingAccount != null && IsLocalDraft && CurrentMimeMessage != null && !IsDraftBusy && !IsRetryingSendToServer;
[NotifyCanExecuteChangedFor(nameof(DiscardCommand))]
[NotifyCanExecuteChangedFor(nameof(SendCommand))]
[NotifyCanExecuteChangedFor(nameof(SendToServerCommand))]
[ObservableProperty]
private MimeMessage currentMimeMessage = null;
@@ -47,47 +72,72 @@ public partial class ComposePageViewModel : MailBaseViewModel
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLocalDraft))]
[NotifyPropertyChangedFor(nameof(ShouldShowSendToServerButton))]
[NotifyPropertyChangedFor(nameof(ShouldShowSendButton))]
[NotifyCanExecuteChangedFor(nameof(DiscardCommand))]
[NotifyCanExecuteChangedFor(nameof(SendCommand))]
private MailItemViewModel currentMailDraftItem;
[NotifyCanExecuteChangedFor(nameof(SendToServerCommand))]
public partial MailItemViewModel CurrentMailDraftItem { get; set; }
[ObservableProperty]
private bool isImportanceSelected;
[NotifyPropertyChangedFor(nameof(ShouldShowSendToServerButton))]
[NotifyCanExecuteChangedFor(nameof(DiscardCommand))]
[NotifyCanExecuteChangedFor(nameof(SendCommand))]
[NotifyCanExecuteChangedFor(nameof(SendToServerCommand))]
public partial bool IsDraftBusy { get; set; }
[ObservableProperty]
private MessageImportance selectedMessageImportance;
[NotifyCanExecuteChangedFor(nameof(SendToServerCommand))]
public partial bool IsRetryingSendToServer { get; set; }
[ObservableProperty]
private bool isCCBCCVisible;
public partial bool IsImportanceSelected { get; set; }
[ObservableProperty]
private string subject;
public partial MessageImportance SelectedMessageImportance { get; set; }
[ObservableProperty]
public partial bool IsCCBCCVisible { get; set; }
[ObservableProperty]
public partial string Subject { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DiscardCommand))]
[NotifyCanExecuteChangedFor(nameof(SendCommand))]
private MailAccount composingAccount;
[NotifyCanExecuteChangedFor(nameof(SendToServerCommand))]
public partial MailAccount ComposingAccount { get; set; }
[ObservableProperty]
private List<MailAccountAlias> availableAliases;
public partial List<MailAccountAlias> AvailableAliases { get; set; }
[ObservableProperty]
public partial MailAccountAlias SelectedAlias { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverComposerGrid { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverFilesDropZone { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverImagesDropZone { get; set; }
[ObservableProperty]
public partial bool IsSmimeSignatureEnabled { get; set; }
[ObservableProperty]
public partial bool IsSmimeEncryptionEnabled { get; set; }
[ObservableProperty]
private MailAccountAlias selectedAlias;
public partial X509Certificate2 SelectedSigningCertificate { get; set; }
[ObservableProperty]
private bool isDraggingOverComposerGrid;
public ObservableCollection<X509Certificate2> AvailableCertificates = [];
[ObservableProperty]
private bool isDraggingOverFilesDropZone;
[ObservableProperty]
private bool isDraggingOverImagesDropZone;
public bool AreCertificatesAvailable => AvailableCertificates.Count > 0;
public ObservableCollection<EmailTemplate> AvailableEmailTemplates { get; } = [];
public ObservableCollection<MailAttachmentViewModel> IncludedAttachments { get; set; } = [];
public ObservableCollection<MailAccount> Accounts { get; set; } = [];
public ObservableCollection<AccountContact> ToItems { get; set; } = [];
public ObservableCollection<AccountContact> CCItems { get; set; } = [];
public ObservableCollection<AccountContact> BCCItems { get; set; } = [];
public bool ShouldShowSendToServerButton => IsLocalDraft && !IsDraftBusy;
public bool ShouldShowSendButton => !IsLocalDraft;
#endregion
@@ -99,11 +149,12 @@ public partial class ComposePageViewModel : MailBaseViewModel
private readonly IFileService _fileService;
private readonly IFolderService _folderService;
private readonly IAccountService _accountService;
private readonly IEmailTemplateService _emailTemplateService;
private readonly IWinoRequestDelegator _worker;
public readonly IFontService FontService;
public readonly IPreferencesService PreferencesService;
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
public readonly IContactService ContactService;
public readonly ISmimeCertificateService _smimeCertificateService;
public ComposePageViewModel(IMailDialogService dialogService,
IMailService mailService,
@@ -112,11 +163,12 @@ public partial class ComposePageViewModel : MailBaseViewModel
INativeAppService nativeAppService,
IFolderService folderService,
IAccountService accountService,
IEmailTemplateService emailTemplateService,
IWinoRequestDelegator worker,
IContactService contactService,
IFontService fontService,
IPreferencesService preferencesService,
IWinoServerConnectionManager winoServerConnectionManager)
ISmimeCertificateService smimeCertificateService)
{
NativeAppService = nativeAppService;
ContactService = contactService;
@@ -129,8 +181,40 @@ public partial class ComposePageViewModel : MailBaseViewModel
_mimeFileService = mimeFileService;
_fileService = fileService;
_accountService = accountService;
_emailTemplateService = emailTemplateService;
_worker = worker;
_winoServerConnectionManager = winoServerConnectionManager;
_smimeCertificateService = smimeCertificateService;
foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress))
{
if (cert != null)
{
AvailableCertificates.Add(cert);
}
}
}
partial void OnSelectedAliasChanged(MailAccountAlias value)
{
if (value != null)
{
IsSmimeSignatureEnabled = value.SelectedSigningCertificateThumbprint != null;
IsSmimeEncryptionEnabled = value.IsSmimeEncryptionEnabled;
AvailableCertificates.Clear();
var certs = _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias.AliasAddress);
foreach (var cert in certs)
{
AvailableCertificates.Add(cert);
}
SelectedSigningCertificate = AvailableCertificates
.Where(c => c.Thumbprint == SelectedAlias.SelectedSigningCertificateThumbprint).FirstOrDefault() ?? AvailableCertificates.FirstOrDefault();
}
}
partial void OnSelectedSigningCertificateChanged(X509Certificate2 value)
{
IsSmimeSignatureEnabled = value != null;
}
[RelayCommand]
@@ -214,25 +298,116 @@ public partial class ComposePageViewModel : MailBaseViewModel
isUpdatingMimeBlocked = true;
var assignedAccount = CurrentMailDraftItem.AssignedAccount;
var assignedAccount = CurrentMailDraftItem.MailCopy.AssignedAccount;
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent);
// Load alias certs
var certs = _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias.AliasAddress);
if (IsSmimeSignatureEnabled)
{
var signingCertificate = !string.IsNullOrEmpty(SelectedAlias.SelectedSigningCertificateThumbprint)
? certs.FirstOrDefault(c => c?.Thumbprint == SelectedAlias.SelectedSigningCertificateThumbprint)
: null;
var signer = new CmsSigner(signingCertificate) { DigestAlgorithm = DigestAlgorithm.Sha1 };
if (IsSmimeEncryptionEnabled)
{
var recipients = new CmsRecipientCollection();
var cmsRecipients = CurrentMimeMessage.To.Mailboxes
.Select(mailbox => new CmsRecipient(
_smimeCertificateService.GetCertificates(emailAddress: mailbox.Address).FirstOrDefault() ?? _smimeCertificateService.GetCertificates(StoreName.AddressBook, emailAddress: mailbox.Address).FirstOrDefault()
));
foreach (var recipient in cmsRecipients)
{
recipients.Add(recipient);
}
CurrentMimeMessage.Body = ApplicationPkcs7Mime.SignAndEncrypt(signer, recipients, CurrentMimeMessage.Body);
}
else
{
// CurrentMimeMessage.Body = MultipartSigned.Create(signer, CurrentMimeMessage.Body);
CurrentMimeMessage.Body = ApplicationPkcs7Mime.Sign(signer, CurrentMimeMessage.Body);
}
}
else if (IsSmimeEncryptionEnabled)
{
// var encryptionCertificate = !string.IsNullOrEmpty(SelectedAlias.SelectedEncryptionCertificateThumbprint)
// ? certs.FirstOrDefault(c => c?.Thumbprint == SelectedAlias.SelectedEncryptionCertificateThumbprint)
// : null;
// Encrypt the message if encryption certificate is selected.
CurrentMimeMessage.Body = ApplicationPkcs7Mime.Encrypt(CurrentMimeMessage.To.Mailboxes, CurrentMimeMessage.Body);
}
using MemoryStream memoryStream = new();
CurrentMimeMessage.WriteTo(FormatOptions.Default, memoryStream);
byte[] buffer = memoryStream.GetBuffer();
int count = (int)memoryStream.Length;
var base64EncodedMessage = Convert.ToBase64String(buffer);
var base64EncodedMessage = Convert.ToBase64String(memoryStream.ToArray());
var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy,
SelectedAlias,
sentFolder,
CurrentMailDraftItem.AssignedFolder,
CurrentMailDraftItem.AssignedAccount.Preferences,
CurrentMailDraftItem.MailCopy.AssignedFolder,
CurrentMailDraftItem.MailCopy.AssignedAccount.Preferences,
base64EncodedMessage);
await ExecuteUIThread(() =>
{
IsDraftBusy = true;
});
await _worker.ExecuteAsync(draftSendPreparationRequest);
}
[RelayCommand(CanExecute = nameof(canSendLocalDraftToServer))]
private async Task SendToServerAsync()
{
if (CurrentMailDraftItem?.MailCopy == null || ComposingAccount == null || CurrentMimeMessage == null)
return;
try
{
await ExecuteUIThread(() =>
{
IsRetryingSendToServer = true;
IsDraftBusy = true;
NotifyComposeActionStateChanged();
});
await UpdateMimeChangesAsync().ConfigureAwait(false);
var localDraftCopy = CurrentMailDraftItem.MailCopy;
var (retryReason, referenceMailCopy) = await ResolveRetryDraftContextAsync().ConfigureAwait(false);
var draftPreparationRequest = new DraftPreparationRequest(
localDraftCopy.AssignedAccount ?? ComposingAccount,
localDraftCopy,
CurrentMimeMessage.GetBase64MimeMessage(),
retryReason,
referenceMailCopy);
await _worker.ExecuteAsync(draftPreparationRequest).ConfigureAwait(false);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(Translator.Info_RequestCreationFailedTitle, ex.Message, InfoBarMessageType.Error);
}
finally
{
await ExecuteUIThread(() =>
{
IsRetryingSendToServer = false;
});
await UpdatePendingOperationStateAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
NotifyComposeActionStateChanged();
});
}
}
public async Task UpdateMimeChangesAsync()
{
if (isUpdatingMimeBlocked || CurrentMimeMessage == null || ComposingAccount == null || CurrentMailDraftItem == null) return;
@@ -332,13 +507,13 @@ public partial class ComposePageViewModel : MailBaseViewModel
}
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
//public override void OnNavigatedFrom(NavigationMode mode, object parameters)
//{
// base.OnNavigatedFrom(mode, parameters);
/// Do not put any code here.
/// Make sure to use Page's OnNavigatedTo instead.
}
// /// Do not put any code here.
// /// Make sure to use Page's OnNavigatedTo instead.
//}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
@@ -348,17 +523,97 @@ public partial class ComposePageViewModel : MailBaseViewModel
{
CurrentMailDraftItem = mailItem;
await UpdatePendingOperationStateAsync();
await LoadEmailTemplatesAsync();
await TryPrepareComposeAsync(true);
}
}
public async void Receive(ReaderItemRefreshRequestedEvent message)
{
if (message.MailItemViewModel == null || !message.MailItemViewModel.IsDraft) return;
// Save current draft before switching.
await UpdateMimeChangesAsync();
// Reset state for the new draft.
isUpdatingMimeBlocked = false;
ComposingAccount = null;
IncludedAttachments.Clear();
// Set the new draft item and prepare it.
CurrentMailDraftItem = message.MailItemViewModel;
await UpdatePendingOperationStateAsync();
await LoadEmailTemplatesAsync();
await TryPrepareComposeAsync(true);
}
private async Task LoadEmailTemplatesAsync()
{
var templates = await _emailTemplateService.GetEmailTemplatesAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
AvailableEmailTemplates.Clear();
foreach (var template in templates)
{
AvailableEmailTemplates.Add(template);
}
});
}
public async void Receive(SynchronizationActionsAdded message)
{
if (!ShouldTrackDraftSynchronizationState(message.AccountId))
return;
await UpdatePendingOperationStateAsync().ConfigureAwait(false);
}
public async void Receive(SynchronizationActionsCompleted message)
{
if (!ShouldTrackDraftSynchronizationState(message.AccountId))
return;
await UpdatePendingOperationStateAsync().ConfigureAwait(false);
}
public async void Receive(AccountSynchronizerStateChanged message)
{
if (message.NewState != AccountSynchronizerState.Idle || !ShouldTrackDraftSynchronizationState(message.AccountId))
return;
await UpdatePendingOperationStateAsync().ConfigureAwait(false);
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<ReaderItemRefreshRequestedEvent>(this);
Messenger.Register<SynchronizationActionsAdded>(this);
Messenger.Register<SynchronizationActionsCompleted>(this);
Messenger.Register<AccountSynchronizerStateChanged>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<ReaderItemRefreshRequestedEvent>(this);
Messenger.Unregister<SynchronizationActionsAdded>(this);
Messenger.Unregister<SynchronizationActionsCompleted>(this);
Messenger.Unregister<AccountSynchronizerStateChanged>(this);
}
private async Task<bool> InitializeComposerAccountAsync()
{
if (CurrentMailDraftItem == null) return false;
if (ComposingAccount != null) return true;
var composingAccount = await _accountService.GetAccountAsync(CurrentMailDraftItem.AssignedAccount.Id).ConfigureAwait(false);
var composingAccount = await _accountService.GetAccountAsync(CurrentMailDraftItem.MailCopy.AssignedAccount.Id).ConfigureAwait(false);
if (composingAccount == null) return false;
var aliases = await _accountService.GetAccountAliasesAsync(composingAccount.Id).ConfigureAwait(false);
@@ -377,7 +632,7 @@ public partial class ComposePageViewModel : MailBaseViewModel
primaryAlias = aliases.Find(a => a.AliasAddress == CurrentMailDraftItem.FromAddress);
}
primaryAlias ??= await _accountService.GetPrimaryAccountAliasAsync(ComposingAccount.Id).ConfigureAwait(false);
primaryAlias ??= await _accountService.GetPrimaryAccountAliasAsync(composingAccount.Id).ConfigureAwait(false);
await ExecuteUIThread(() =>
{
@@ -389,6 +644,44 @@ public partial class ComposePageViewModel : MailBaseViewModel
return true;
}
private async Task UpdatePendingOperationStateAsync()
{
var hasPendingOperation = false;
var keepBusyForInitialGracePeriod = false;
if (CurrentMailDraftItem?.MailCopy == null || !CurrentMailDraftItem.MailCopy.IsDraft)
{
await ExecuteUIThread(() =>
{
IsDraftBusy = false;
NotifyComposeActionStateChanged();
});
return;
}
var accountId = CurrentMailDraftItem.MailCopy.AssignedAccount?.Id ?? Guid.Empty;
if (accountId != Guid.Empty)
{
var synchronizer = await SynchronizationManager.Instance.GetSynchronizerAsync(accountId).ConfigureAwait(false);
hasPendingOperation = synchronizer?.HasPendingOperation(CurrentMailDraftItem.MailCopy.UniqueId) ?? false;
}
// Newly created local drafts can have a short period where request queue is empty
// while folder synchronization/mapping is still in progress.
// Keep progress visible during this grace period to prevent "Send to server" flicker.
if (!hasPendingOperation && CurrentMailDraftItem.MailCopy.IsLocalDraft)
{
keepBusyForInitialGracePeriod = IsWithinLocalDraftRetryGracePeriod(CurrentMailDraftItem.MailCopy);
}
await ExecuteUIThread(() =>
{
IsDraftBusy = hasPendingOperation || keepBusyForInitialGracePeriod;
NotifyComposeActionStateChanged();
});
}
private async Task TryPrepareComposeAsync(bool downloadIfNeeded)
{
if (CurrentMailDraftItem == null) return;
@@ -397,7 +690,7 @@ public partial class ComposePageViewModel : MailBaseViewModel
if (!isComposerInitialized) return;
retry:
retry:
// Replying existing message.
MimeMessageInformation mimeMessageInformation = null;
@@ -412,13 +705,12 @@ public partial class ComposePageViewModel : MailBaseViewModel
{
downloadIfNeeded = false;
var package = new DownloadMissingMessageRequested(CurrentMailDraftItem.AssignedAccount.Id, CurrentMailDraftItem.MailCopy);
var downloadResponse = await _winoServerConnectionManager.GetResponseAsync<bool, DownloadMissingMessageRequested>(package);
// Download missing MIME message using SynchronizationManager
await SynchronizationManager.Instance.DownloadMimeMessageAsync(
CurrentMailDraftItem.MailCopy,
CurrentMailDraftItem.MailCopy.AssignedAccount.Id);
if (downloadResponse.IsSuccess)
{
goto retry;
}
goto retry;
}
else
_dialogService.InfoBarMessage(Translator.Info_ComposerMissingMIMETitle, Translator.Info_ComposerMissingMIMEMessage, InfoBarMessageType.Error);
@@ -471,6 +763,8 @@ public partial class ComposePageViewModel : MailBaseViewModel
{
if (CurrentMimeMessage == null) return;
IncludedAttachments.Clear();
foreach (var attachment in CurrentMimeMessage.Attachments)
{
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
@@ -530,6 +824,32 @@ public partial class ComposePageViewModel : MailBaseViewModel
list.Add(new MailboxAddress(item.Name, item.Address));
}
private async Task<(DraftCreationReason reason, MailCopy referenceMailCopy)> ResolveRetryDraftContextAsync()
{
if (CurrentMimeMessage == null || CurrentMailDraftItem?.MailCopy?.AssignedAccount == null)
return (DraftCreationReason.Empty, null);
var inReplyTo = CurrentMimeMessage.InReplyTo;
if (string.IsNullOrWhiteSpace(inReplyTo) && CurrentMimeMessage.Headers.Contains(HeaderId.InReplyTo))
inReplyTo = CurrentMimeMessage.Headers[HeaderId.InReplyTo];
inReplyTo = MailHeaderExtensions.StripAngleBrackets(inReplyTo);
if (string.IsNullOrWhiteSpace(inReplyTo))
return (DraftCreationReason.Empty, null);
var accountId = CurrentMailDraftItem.MailCopy.AssignedAccount.Id;
var referenceMailCopy = await _mailService.GetMailCopyByMessageIdAsync(accountId, inReplyTo).ConfigureAwait(false);
if (referenceMailCopy == null)
return (DraftCreationReason.Empty, null);
// We cannot perfectly reconstruct original intent (Reply vs ReplyAll) from persisted data.
// Infer ReplyAll when multiple recipients exist on the local MIME.
var totalRecipients = CurrentMimeMessage.To.Mailboxes.Count() + CurrentMimeMessage.Cc.Mailboxes.Count();
var reason = totalRecipients > 1 ? DraftCreationReason.ReplyAll : DraftCreationReason.Reply;
return (reason, referenceMailCopy);
}
public async Task<AccountContact> GetAddressInformationAsync(string tokenText, ObservableCollection<AccountContact> collection)
{
// Get model from the service. This will make sure the name is properly included if there is any record.
@@ -554,21 +874,57 @@ public partial class ComposePageViewModel : MailBaseViewModel
_dialogService.InfoBarMessage(Translator.Info_InvalidAddressTitle, string.Format(Translator.Info_InvalidAddressMessage, address), InfoBarMessageType.Warning);
}
protected override async void OnMailUpdated(MailCopy updatedMail)
protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties)
{
base.OnMailUpdated(updatedMail);
base.OnMailUpdated(updatedMail, source, changedProperties);
if (CurrentMailDraftItem == null) return;
if (updatedMail.UniqueId == CurrentMailDraftItem.UniqueId)
if (updatedMail.UniqueId == CurrentMailDraftItem.MailCopy.UniqueId)
{
await ExecuteUIThread(() =>
await ExecuteUIThread(async () =>
{
CurrentMailDraftItem.MailCopy = updatedMail;
DiscardCommand.NotifyCanExecuteChanged();
SendCommand.NotifyCanExecuteChanged();
CurrentMailDraftItem.UpdateFrom(updatedMail, changedProperties);
await UpdatePendingOperationStateAsync();
NotifyComposeActionStateChanged();
});
}
}
private void NotifyComposeActionStateChanged()
{
OnPropertyChanged(nameof(IsLocalDraft));
OnPropertyChanged(nameof(ShouldShowSendToServerButton));
OnPropertyChanged(nameof(ShouldShowSendButton));
DiscardCommand.NotifyCanExecuteChanged();
SendCommand.NotifyCanExecuteChanged();
SendToServerCommand.NotifyCanExecuteChanged();
}
private bool ShouldTrackDraftSynchronizationState(Guid accountId)
{
if (accountId == Guid.Empty)
return false;
var currentDraftAccountId = CurrentMailDraftItem?.MailCopy?.AssignedAccount?.Id
?? ComposingAccount?.Id
?? Guid.Empty;
return currentDraftAccountId != Guid.Empty && currentDraftAccountId == accountId;
}
private bool IsWithinLocalDraftRetryGracePeriod(MailCopy localDraft)
{
if (localDraft == null || localDraft.CreationDate == default)
return false;
var elapsed = DateTime.UtcNow - localDraft.CreationDate;
// Clock skew safety.
if (elapsed < TimeSpan.Zero)
return true;
return elapsed < LocalDraftRetryGracePeriod;
}
}
@@ -0,0 +1,489 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Contacts;
namespace Wino.Mail.ViewModels;
public partial class ContactsPageViewModel : MailBaseViewModel,
IRecipient<NewContactRequested>
{
private const int ContactPageSize = 50;
private readonly IContactService _contactService;
private readonly IMailDialogService _dialogService;
private readonly IContactPictureFileService _contactPictureFileService;
private CancellationTokenSource _searchDebounceCancellationTokenSource;
private int _currentOffset = 0;
private int _currentQueryVersion = 0;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
[NotifyPropertyChangedFor(nameof(IsEmpty))]
public partial bool IsLoading { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
public partial bool IsLoadingMore { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
public partial bool HasMoreContacts { get; set; } = false;
[ObservableProperty]
public partial bool IsSelectionMode { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedContactsCommand))]
public partial int SelectedContactsCount { get; set; } = 0;
[ObservableProperty]
public partial int TotalContactsCount { get; set; } = 0;
public bool IsEmpty => !IsLoading && Contacts.Count == 0;
public bool CanLoadMoreContacts => HasMoreContacts && !IsLoading && !IsLoadingMore;
public bool CanDeleteSelectedContacts => SelectedContactsCount > 0;
public ObservableCollection<AccountContactViewModel> Contacts { get; } = new();
public ObservableCollection<AccountContactViewModel> SelectedContacts { get; } = new();
public ContactsPageViewModel(IContactService contactService, IMailDialogService dialogService, IContactPictureFileService contactPictureFileService)
{
_contactService = contactService;
_dialogService = dialogService;
_contactPictureFileService = contactPictureFileService;
Contacts.CollectionChanged += ContactsCollectionChanged;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
SelectedContacts.CollectionChanged += SelectedContactsChanged;
await ReloadContactsAsync();
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
_searchDebounceCancellationTokenSource?.Cancel();
_searchDebounceCancellationTokenSource?.Dispose();
_searchDebounceCancellationTokenSource = null;
}
private async void SelectedContactsChanged(object sender, NotifyCollectionChangedEventArgs e)
=> await ExecuteUIThread(() => { SelectedContactsCount = SelectedContacts.Count; });
private async void ContactsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
=> await ExecuteUIThread(() => { OnPropertyChanged(nameof(IsEmpty)); });
void IRecipient<NewContactRequested>.Receive(NewContactRequested message)
=> _ = AddContactAsync();
[RelayCommand]
private async Task ReloadContactsAsync()
{
var queryVersion = ++_currentQueryVersion;
_currentOffset = 0;
await ExecuteUIThread(() =>
{
HasMoreContacts = false;
Contacts.Clear();
SelectedContacts.Clear();
});
await LoadContactsPageAsync(queryVersion, reset: true);
}
[RelayCommand(CanExecute = nameof(CanLoadMoreContacts))]
private async Task LoadMoreContactsAsync()
{
await LoadContactsPageAsync(_currentQueryVersion, reset: false);
}
private async Task LoadContactsPageAsync(int queryVersion, bool reset)
{
if (IsLoading || IsLoadingMore)
return;
await ExecuteUIThread(() =>
{
if (reset)
IsLoading = true;
else
IsLoadingMore = true;
});
try
{
var searchQuery = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery.Trim();
var page = await _contactService.GetContactsPageAsync(
_currentOffset,
ContactPageSize,
searchQuery,
excludeRootContacts: true).ConfigureAwait(false);
if (queryVersion != _currentQueryVersion)
return;
await ExecuteUIThread(() =>
{
if (reset)
{
Contacts.Clear();
}
foreach (var contact in page.Contacts)
{
Contacts.Add(new AccountContactViewModel(contact));
}
TotalContactsCount = page.TotalCount;
HasMoreContacts = page.HasMore;
_currentOffset = Contacts.Count;
});
}
catch (Exception ex)
{
if (queryVersion != _currentQueryVersion)
return;
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToLoadContacts, ex.Message),
InfoBarMessageType.Error);
}
finally
{
if (queryVersion == _currentQueryVersion)
{
await ExecuteUIThread(() =>
{
if (reset)
IsLoading = false;
else
IsLoadingMore = false;
});
}
}
}
[RelayCommand]
private async Task AddContactAsync()
{
var result = await _dialogService.ShowEditContactDialogAsync(null);
if (result == null) return;
try
{
var newContact = await _contactService.CreateNewContactAsync(result.Address, result.Name);
if (result.ContactPictureFileId.HasValue)
{
newContact.ContactPictureFileId = result.ContactPictureFileId;
await _contactService.UpdateContactAsync(newContact);
}
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactAdded,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToAddContact, ex.Message),
InfoBarMessageType.Error);
}
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<NewContactRequested>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<NewContactRequested>(this);
}
[RelayCommand]
private async Task EditContactAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null) return;
var result = await _dialogService.ShowEditContactDialogAsync(contact);
if (result == null) return;
try
{
contact.Name = result.Name;
contact.ContactPictureFileId = result.ContactPictureFileId;
contact.IsOverridden = result.IsOverridden;
await _contactService.UpdateContactAsync(contact);
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactUpdated,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToUpdateContact, ex.Message),
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task DeleteContactAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null || contact.IsRootContact)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_WarningTitle,
Translator.ContactInfoBar_CannotDeleteRoot,
InfoBarMessageType.Warning);
return;
}
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.ContactConfirmDialog_DeleteMessage, contact.Name ?? contact.Address),
Translator.ContactConfirmDialog_DeleteTitle,
Translator.ContactConfirmDialog_DeleteButton);
if (confirmed)
{
await DeleteContactsInternalAsync(new[] { contact });
}
}
[RelayCommand(CanExecute = nameof(CanDeleteSelectedContacts))]
private async Task DeleteSelectedContactsAsync()
{
if (SelectedContacts.Count == 0) return;
var deletableContacts = SelectedContacts
.Select(c => c?.SourceContact)
.Where(c => c != null && !c.IsRootContact)
.GroupBy(c => c.Address, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (deletableContacts.Count == 0)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_WarningTitle,
Translator.ContactInfoBar_CannotDeleteRoot,
InfoBarMessageType.Warning);
return;
}
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.ContactConfirmDialog_DeleteMultipleMessage, deletableContacts.Count),
Translator.ContactConfirmDialog_DeleteTitle,
Translator.ContactConfirmDialog_DeleteButton);
if (confirmed)
{
await DeleteContactsInternalAsync(deletableContacts);
}
}
private async Task DeleteContactsInternalAsync(IEnumerable<AccountContact> contactsToDelete)
{
try
{
var addresses = contactsToDelete
.Select(c => c.Address)
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (addresses.Count == 0) return;
await _contactService.DeleteContactsAsync(addresses);
await ReloadContactsAsync();
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactsDeleted,
InfoBarMessageType.Success);
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToDeleteContacts, ex.Message),
InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task ToggleSelection()
{
await ExecuteUIThread(() =>
{
IsSelectionMode = !IsSelectionMode;
if (!IsSelectionMode)
{
SelectedContacts.Clear();
}
});
}
[RelayCommand]
private async Task SelectAllContacts()
{
await ExecuteUIThread(() =>
{
SelectedContacts.Clear();
foreach (var contact in Contacts)
{
SelectedContacts.Add(contact);
}
});
}
[RelayCommand]
private async Task ClearSelection()
{
await ExecuteUIThread(() => { SelectedContacts.Clear(); });
}
[RelayCommand]
private async Task PickContactPhotoAsync(AccountContactViewModel contactViewModel)
{
var contact = contactViewModel?.SourceContact;
if (contact == null) return;
try
{
var files = await _dialogService.PickFilesAsync(".png", ".jpg", ".jpeg");
if (files?.Any() == true)
{
var file = files.First();
if (contact.ContactPictureFileId.HasValue)
await _contactPictureFileService.DeleteContactPictureAsync(contact.ContactPictureFileId.Value);
contact.ContactPictureFileId = await _contactPictureFileService
.SaveContactPictureAsync(file.Data)
.ConfigureAwait(false);
await _contactService.UpdateContactAsync(contact);
await RefreshContactInUiAsync(contact);
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_SuccessTitle,
Translator.ContactInfoBar_ContactPhotoUpdated,
InfoBarMessageType.Success);
}
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(
Translator.ContactInfoBar_ErrorTitle,
string.Format(Translator.ContactInfoBar_FailedToUpdatePhoto, ex.Message),
InfoBarMessageType.Error);
}
}
private async Task RefreshContactInUiAsync(AccountContact contact)
{
if (contact == null || string.IsNullOrWhiteSpace(contact.Address))
return;
await ExecuteUIThread(() =>
{
ReplaceContactByAddress(Contacts, contact);
ReplaceContactByAddress(SelectedContacts, contact);
});
}
private static void ReplaceContactByAddress(ObservableCollection<AccountContactViewModel> source, AccountContact updatedContact)
{
var index = source
.Select((item, i) => new { item, i })
.FirstOrDefault(x => string.Equals(x.item.Address, updatedContact.Address, StringComparison.OrdinalIgnoreCase))
?.i ?? -1;
if (index < 0) return;
source[index] = new AccountContactViewModel(CloneContact(updatedContact));
}
private static AccountContact CloneContact(AccountContact contact)
=> new()
{
Address = contact.Address,
Name = contact.Name,
ContactPictureFileId = contact.ContactPictureFileId,
IsRootContact = contact.IsRootContact,
IsOverridden = contact.IsOverridden
};
partial void OnSearchQueryChanged(string value)
{
DebounceSearchAndReload();
}
private async void DebounceSearchAndReload()
{
_searchDebounceCancellationTokenSource?.Cancel();
_searchDebounceCancellationTokenSource?.Dispose();
_searchDebounceCancellationTokenSource = new CancellationTokenSource();
try
{
await Task.Delay(250, _searchDebounceCancellationTokenSource.Token);
await ReloadContactsAsync();
}
catch (OperationCanceledException)
{
// Ignore stale search input.
}
}
}
@@ -0,0 +1,112 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels;
public partial class CreateEmailTemplatePageViewModel(
IEmailTemplateService emailTemplateService,
IMailDialogService dialogService,
INavigationService navigationService) : MailBaseViewModel
{
private readonly IEmailTemplateService _emailTemplateService = emailTemplateService;
private readonly IMailDialogService _dialogService = dialogService;
private EmailTemplate _editingTemplate;
public INavigationService NavigationService { get; } = navigationService;
[ObservableProperty]
public partial string TemplateName { get; set; } = string.Empty;
[ObservableProperty]
public partial string TemplateDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsExistingTemplate { get; set; }
public async Task<string> LoadAsync(object parameter)
{
EmailTemplate template = null;
var templateId = parameter switch
{
Guid guid when guid != Guid.Empty => guid,
string value when Guid.TryParse(value, out var parsedGuid) => parsedGuid,
EmailTemplate emailTemplate when emailTemplate.Id != Guid.Empty => emailTemplate.Id,
_ => Guid.Empty
};
if (templateId != Guid.Empty)
{
template = await _emailTemplateService.GetEmailTemplateAsync(templateId).ConfigureAwait(false);
}
_editingTemplate = template;
await ExecuteUIThread(() =>
{
IsExistingTemplate = template != null;
TemplateName = template?.Name ?? string.Empty;
TemplateDescription = template?.Description ?? string.Empty;
});
return template?.HtmlContent ?? string.Empty;
}
public async Task SaveAsync(string htmlContent)
{
var trimmedName = TemplateName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(trimmedName))
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Error,
Translator.SettingsEmailTemplates_NameRequired,
InfoBarMessageType.Warning);
return;
}
var template = _editingTemplate ?? new EmailTemplate
{
Id = Guid.NewGuid()
};
template.Name = trimmedName;
template.Description = TemplateDescription?.Trim() ?? string.Empty;
template.HtmlContent = htmlContent ?? string.Empty;
if (_editingTemplate == null)
{
await _emailTemplateService.CreateEmailTemplateAsync(template).ConfigureAwait(false);
}
else
{
await _emailTemplateService.UpdateEmailTemplateAsync(template).ConfigureAwait(false);
}
_editingTemplate = template;
NavigationService.GoBack();
}
public async Task DeleteAsync()
{
if (_editingTemplate == null)
return;
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.DialogMessage_DeleteEmailTemplateConfirmationMessage, _editingTemplate.Name),
Translator.DialogMessage_DeleteEmailTemplateConfirmationTitle,
Translator.Buttons_Delete).ConfigureAwait(false);
if (!shouldDelete)
return;
await _emailTemplateService.DeleteEmailTemplateAsync(_editingTemplate).ConfigureAwait(false);
NavigationService.GoBack();
}
}
@@ -1,23 +1,28 @@
using System;
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data;
public partial class AccountContactViewModel : ObservableObject
public partial class AccountContactViewModel : ObservableObject, IMailItemDisplayInformation
{
public AccountContact SourceContact { get; }
public string Address { get; set; }
public string Name { get; set; }
public string Base64ContactPicture { get; set; }
public Guid? ContactPictureFileId { get; set; }
public bool IsRootContact { get; set; }
public bool IsOverridden { get; set; }
public AccountContactViewModel(AccountContact contact)
{
SourceContact = contact;
Address = contact.Address;
Name = contact.Name;
Base64ContactPicture = contact.Base64ContactPicture;
ContactPictureFileId = contact.ContactPictureFileId;
IsRootContact = contact.IsRootContact;
IsOverridden = contact.IsOverridden;
}
/// <summary>
@@ -49,4 +54,26 @@ public partial class AccountContactViewModel : ObservableObject
[ObservableProperty]
public partial bool ThumbnailUpdatedEvent { get; set; }
// IMailItemDisplayInformation implementation for avatar-only rendering.
public string Subject => string.Empty;
public string FromName => Name ?? string.Empty;
public string FromAddress => Address ?? string.Empty;
public string PreviewText => string.Empty;
public bool IsRead => true;
public bool IsDraft => false;
public bool HasAttachments => false;
public bool IsCalendarEvent => false;
public bool IsFlagged => false;
public DateTime CreationDate => default;
public bool IsBusy => false;
public bool IsThreadExpanded => false;
public AccountContact SenderContact => new()
{
Address = Address,
Name = Name,
ContactPictureFileId = ContactPictureFileId,
IsRootContact = IsRootContact,
IsOverridden = IsOverridden
};
}
@@ -0,0 +1,40 @@
using System.Windows.Input;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Extensions;
namespace Wino.Mail.ViewModels.Data;
public partial class AccountStorageItemViewModel(MailAccount account, long sizeBytes, ICommand deleteAllCommand, ICommand deleteOneMonthCommand, ICommand deleteThreeMonthsCommand, ICommand deleteSixMonthsCommand, ICommand deleteYearCommand) : ObservableObject
{
public MailAccount Account { get; } = account;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
public partial long SizeBytes { get; set; } = sizeBytes;
[ObservableProperty]
public partial string SizeDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ICommand DeleteAllCommand { get; set; } = deleteAllCommand;
[ObservableProperty]
public partial ICommand DeleteOneMonthCommand { get; set; } = deleteOneMonthCommand;
[ObservableProperty]
public partial ICommand DeleteThreeMonthsCommand { get; set; } = deleteThreeMonthsCommand;
[ObservableProperty]
public partial ICommand DeleteSixMonthsCommand { get; set; } = deleteSixMonthsCommand;
[ObservableProperty]
public partial ICommand DeleteYearCommand { get; set; } = deleteYearCommand;
public string AccountName => string.IsNullOrWhiteSpace(Account.Name) ? Account.Address ?? string.Empty : Account.Name;
public string AccountAddress => Account.Address ?? string.Empty;
public string SizeText => SizeBytes.GetBytesReadable();
}
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data;
/// <summary>
/// Common interface for mail items that can be displayed in a mail list.
/// Implemented by both MailItemViewModel and ThreadMailItemViewModel.
/// </summary>
public interface IMailListItem : IMailHashContainer, IMailListItemSorting, INotifyPropertyChanged
{
/// <summary>
/// Gets the latest creation date for sorting purposes.
/// For MailItemViewModel: the mail's creation date
/// For ThreadMailItemViewModel: the latest email's creation date
/// </summary>
DateTime CreationDate { get; }
/// <summary>
/// Gets the sender's name for grouping purposes.
/// For MailItemViewModel: the mail's from name
/// For ThreadMailItemViewModel: the latest email's from name
/// </summary>
string FromName { get; }
/// <summary>
/// Gets whether this item is selected.
/// For MailItemViewModel: returns IsSelected
/// For ThreadMailItemViewModel: returns IsSelected
/// </summary>
bool IsSelected { get; set; }
/// <summary>
/// Gets whether this item is currently processing a network operation.
/// </summary>
bool IsBusy { get; }
/// <summary>
/// Gets all selected mail items within this list item.
/// For MailItemViewModel: returns itself if IsSelected is true, otherwise empty
/// For ThreadMailItemViewModel: returns all selected emails within the thread
/// </summary>
IEnumerable<MailItemViewModel> GetSelectedMailItems();
}
@@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Mail.ViewModels.Data;
public enum ImapCalDavSettingsPageMode
{
Create,
Edit,
Wizard
}
public sealed class ImapCalDavSettingsNavigationContext
{
public ImapCalDavSettingsPageMode Mode { get; init; }
public Guid AccountId { get; init; }
public AccountCreationDialogResult AccountCreationDialogResult { get; init; }
public TaskCompletionSource<ImapCalDavSetupResult> CompletionSource { get; init; }
public static ImapCalDavSettingsNavigationContext CreateForCreateMode(
AccountCreationDialogResult accountCreationDialogResult,
TaskCompletionSource<ImapCalDavSetupResult> completionSource)
=> new()
{
Mode = ImapCalDavSettingsPageMode.Create,
AccountCreationDialogResult = accountCreationDialogResult,
CompletionSource = completionSource
};
public static ImapCalDavSettingsNavigationContext CreateForEditMode(Guid accountId)
=> new()
{
Mode = ImapCalDavSettingsPageMode.Edit,
AccountId = accountId
};
public static ImapCalDavSettingsNavigationContext CreateForWizardMode(
AccountCreationDialogResult accountCreationDialogResult)
=> new()
{
Mode = ImapCalDavSettingsPageMode.Wizard,
AccountCreationDialogResult = accountCreationDialogResult
};
public bool IsWizardMode => Mode == ImapCalDavSettingsPageMode.Wizard;
}
public sealed class ImapCalDavSetupResult
{
public string DisplayName { get; init; }
public string EmailAddress { get; init; }
public bool IsCalendarAccessGranted { get; init; }
public CustomServerInformation ServerInformation { get; init; }
}
public sealed class ImapCalendarSupportModeOption(ImapCalendarSupportMode mode, string title)
{
public ImapCalendarSupportMode Mode { get; } = mode;
public string Title { get; } = title;
}
@@ -6,6 +6,25 @@ public class MailItemContainer
{
public MailItemViewModel ItemViewModel { get; set; }
public ThreadMailItemViewModel ThreadViewModel { get; set; }
/// <summary>
/// Indicates whether the mail item is currently visible in the UI's Items collection.
/// For threaded items, this indicates if the individual mail item is visible (thread must be expanded).
/// </summary>
public bool IsItemVisible { get; set; }
/// <summary>
/// Indicates whether the thread expander (if applicable) is currently visible in the UI's Items collection.
/// Only relevant when ThreadViewModel is not null.
/// </summary>
public bool IsThreadVisible { get; set; }
/// <summary>
/// Indicates whether the container can be successfully navigated to in the UI.
/// For standalone items: true if IsItemVisible is true.
/// For threaded items: true if IsThreadVisible is true (the thread expander can be navigated to).
/// </summary>
public bool CanNavigate => ThreadViewModel != null ? IsThreadVisible : IsItemVisible;
public MailItemContainer(MailItemViewModel itemViewModel, ThreadMailItemViewModel threadViewModel) : this(itemViewModel)
{
+324 -19
View File
@@ -3,34 +3,78 @@ using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data;
/// <summary>
/// Single view model for IMailItem representation.
/// </summary>
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IMailItem
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem, IMailItemDisplayInformation
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CreationDate))]
[NotifyPropertyChangedFor(nameof(IsFlagged))]
[NotifyPropertyChangedFor(nameof(FromName))]
[NotifyPropertyChangedFor(nameof(IsFocused))]
[NotifyPropertyChangedFor(nameof(IsRead))]
[NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(DraftId))]
[NotifyPropertyChangedFor(nameof(Id))]
[NotifyPropertyChangedFor(nameof(Subject))]
[NotifyPropertyChangedFor(nameof(PreviewText))]
[NotifyPropertyChangedFor(nameof(FromAddress))]
[NotifyPropertyChangedFor(nameof(HasAttachments))]
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
[NotifyPropertyChangedFor(nameof(Importance))]
[NotifyPropertyChangedFor(nameof(ThreadId))]
[NotifyPropertyChangedFor(nameof(MessageId))]
[NotifyPropertyChangedFor(nameof(References))]
[NotifyPropertyChangedFor(nameof(InReplyTo))]
[NotifyPropertyChangedFor(nameof(FileId))]
[NotifyPropertyChangedFor(nameof(FolderId))]
[NotifyPropertyChangedFor(nameof(UniqueId))]
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
[NotifyPropertyChangedFor(nameof(SenderContact))]
public partial MailCopy MailCopy { get; set; } = mailCopy;
public Guid UniqueId => ((IMailItem)MailCopy).UniqueId;
public string ThreadId => ((IMailItem)MailCopy).ThreadId;
public string MessageId => ((IMailItem)MailCopy).MessageId;
public DateTime CreationDate => ((IMailItem)MailCopy).CreationDate;
public string References => ((IMailItem)MailCopy).References;
public string InReplyTo => ((IMailItem)MailCopy).InReplyTo;
[ObservableProperty]
public partial bool IsDisplayedInThread { get; set; }
[ObservableProperty]
[NotifyPropertyChangedRecipients]
public partial bool IsSelected { get; set; }
/// <summary>
/// Direct callback invoked when <see cref="IsSelected"/> changes.
/// Used by the ListViewItem container to update its IsCustomSelected DP
/// without subscribing to INotifyPropertyChanged (faster, AOT-safe).
/// </summary>
public Action<bool> OnSelectionChanged { get; set; }
partial void OnIsSelectedChanged(bool value) => OnSelectionChanged?.Invoke(value);
/// <summary>
/// Indicates if this mail item is currently being processed by a network operation.
/// Used to show loading state in the UI.
/// </summary>
[ObservableProperty]
public partial bool IsBusy { get; set; }
public bool IsThreadExpanded => false;
public AccountContact SenderContact => MailCopy.SenderContact;
public DateTime CreationDate
{
get => MailCopy.CreationDate;
set => SetProperty(MailCopy.CreationDate, value, MailCopy, (u, n) => u.CreationDate = n);
}
[ObservableProperty]
public partial bool ThumbnailUpdatedEvent { get; set; } = false;
[ObservableProperty]
public partial bool IsCustomFocused { get; set; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public bool IsFlagged
{
get => MailCopy.IsFlagged;
@@ -97,13 +141,274 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IM
set => SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n);
}
public MailItemFolder AssignedFolder => ((IMailItem)MailCopy).AssignedFolder;
public bool IsCalendarEvent => MailCopy.ItemType == MailItemType.CalendarInvitation;
public MailAccount AssignedAccount => ((IMailItem)MailCopy).AssignedAccount;
public MailImportance Importance
{
get => MailCopy.Importance;
set => SetProperty(MailCopy.Importance, value, MailCopy, (u, n) => u.Importance = n);
}
public Guid FileId => ((IMailItem)MailCopy).FileId;
public string ThreadId
{
get => MailCopy.ThreadId;
set => SetProperty(MailCopy.ThreadId, value, MailCopy, (u, n) => u.ThreadId = n);
}
public AccountContact SenderContact => ((IMailItem)MailCopy).SenderContact;
public string MessageId
{
get => MailCopy.MessageId;
set => SetProperty(MailCopy.MessageId, value, MailCopy, (u, n) => u.MessageId = n);
}
public IEnumerable<Guid> GetContainingIds() => new[] { UniqueId };
public string References
{
get => MailCopy.References;
set => SetProperty(MailCopy.References, value, MailCopy, (u, n) => u.References = n);
}
public string InReplyTo
{
get => MailCopy.InReplyTo;
set => SetProperty(MailCopy.InReplyTo, value, MailCopy, (u, n) => u.InReplyTo = n);
}
public Guid FileId
{
get => MailCopy.FileId;
set => SetProperty(MailCopy.FileId, value, MailCopy, (u, n) => u.FileId = n);
}
public Guid FolderId
{
get => MailCopy.FolderId;
set => SetProperty(MailCopy.FolderId, value, MailCopy, (u, n) => u.FolderId = n);
}
public Guid UniqueId
{
get => MailCopy.UniqueId;
set => SetProperty(MailCopy.UniqueId, value, MailCopy, (u, n) => u.UniqueId = n);
}
public Guid? ContactPictureFileId
{
get => MailCopy.SenderContact?.ContactPictureFileId;
set => SetProperty(MailCopy.SenderContact?.ContactPictureFileId, value, MailCopy, (u, n) =>
{
if (u.SenderContact != null)
u.SenderContact.ContactPictureFileId = n;
});
}
public DateTime SortingDate => CreationDate;
public string SortingName => FromName;
public IEnumerable<Guid> GetContainingIds() => [MailCopy.UniqueId];
public IEnumerable<MailItemViewModel> GetSelectedMailItems()
{
if (IsSelected)
{
yield return this;
}
}
public static MailCopyChangeFlags GetChangeFlagsForProperty(string propertyName)
{
return propertyName switch
{
nameof(CreationDate) or nameof(SortingDate) => MailCopyChangeFlags.CreationDate,
nameof(IsFlagged) => MailCopyChangeFlags.IsFlagged,
nameof(FromName) or nameof(SortingName) => MailCopyChangeFlags.FromName,
nameof(IsFocused) => MailCopyChangeFlags.IsFocused,
nameof(IsRead) => MailCopyChangeFlags.IsRead,
nameof(IsDraft) => MailCopyChangeFlags.IsDraft,
nameof(DraftId) => MailCopyChangeFlags.DraftId,
nameof(Id) => MailCopyChangeFlags.Id,
nameof(Subject) => MailCopyChangeFlags.Subject,
nameof(PreviewText) => MailCopyChangeFlags.PreviewText,
nameof(FromAddress) => MailCopyChangeFlags.FromAddress,
nameof(HasAttachments) => MailCopyChangeFlags.HasAttachments,
nameof(IsCalendarEvent) => MailCopyChangeFlags.ItemType,
nameof(Importance) => MailCopyChangeFlags.Importance,
nameof(ThreadId) => MailCopyChangeFlags.ThreadId,
nameof(MessageId) => MailCopyChangeFlags.MessageId,
nameof(References) => MailCopyChangeFlags.References,
nameof(InReplyTo) => MailCopyChangeFlags.InReplyTo,
nameof(FileId) => MailCopyChangeFlags.FileId,
nameof(FolderId) => MailCopyChangeFlags.FolderId,
nameof(UniqueId) => MailCopyChangeFlags.UniqueId,
nameof(ContactPictureFileId) or nameof(SenderContact) => MailCopyChangeFlags.SenderContact,
_ => MailCopyChangeFlags.None
};
}
/// <summary>
/// Updates the existing <see cref="MailCopy"/> while raising only the relevant UI notifications.
/// </summary>
/// <param name="source">Source data used to update this item.</param>
/// <param name="changeHint">
/// Optional set of known changes. This is required when <paramref name="source"/> is the same instance
/// and has already been mutated by Apply/Revert flows.
/// </param>
/// <returns>The effective set of changed fields used for notifications.</returns>
public MailCopyChangeFlags UpdateFrom(MailCopy source, MailCopyChangeFlags changeHint = MailCopyChangeFlags.None)
{
if (source == null) return MailCopyChangeFlags.None;
var changedFlags = MailCopyChangeFlags.None;
var isSameReference = ReferenceEquals(MailCopy, source);
if (!isSameReference)
{
changedFlags |= SetIfChanged(MailCopy.Id, source.Id, value => MailCopy.Id = value, MailCopyChangeFlags.Id);
changedFlags |= SetIfChanged(MailCopy.FolderId, source.FolderId, value => MailCopy.FolderId = value, MailCopyChangeFlags.FolderId);
changedFlags |= SetIfChanged(MailCopy.ThreadId, source.ThreadId, value => MailCopy.ThreadId = value, MailCopyChangeFlags.ThreadId);
changedFlags |= SetIfChanged(MailCopy.MessageId, source.MessageId, value => MailCopy.MessageId = value, MailCopyChangeFlags.MessageId);
changedFlags |= SetIfChanged(MailCopy.References, source.References, value => MailCopy.References = value, MailCopyChangeFlags.References);
changedFlags |= SetIfChanged(MailCopy.InReplyTo, source.InReplyTo, value => MailCopy.InReplyTo = value, MailCopyChangeFlags.InReplyTo);
changedFlags |= SetIfChanged(MailCopy.IsDraft, source.IsDraft, value => MailCopy.IsDraft = value, MailCopyChangeFlags.IsDraft);
changedFlags |= SetIfChanged(MailCopy.DraftId, source.DraftId, value => MailCopy.DraftId = value, MailCopyChangeFlags.DraftId);
changedFlags |= SetIfChanged(MailCopy.CreationDate, source.CreationDate, value => MailCopy.CreationDate = value, MailCopyChangeFlags.CreationDate);
changedFlags |= SetIfChanged(MailCopy.Subject, source.Subject, value => MailCopy.Subject = value, MailCopyChangeFlags.Subject);
changedFlags |= SetIfChanged(MailCopy.PreviewText, source.PreviewText, value => MailCopy.PreviewText = value, MailCopyChangeFlags.PreviewText);
changedFlags |= SetIfChanged(MailCopy.FromName, source.FromName, value => MailCopy.FromName = value, MailCopyChangeFlags.FromName);
changedFlags |= SetIfChanged(MailCopy.FromAddress, source.FromAddress, value => MailCopy.FromAddress = value, MailCopyChangeFlags.FromAddress);
changedFlags |= SetIfChanged(MailCopy.HasAttachments, source.HasAttachments, value => MailCopy.HasAttachments = value, MailCopyChangeFlags.HasAttachments);
changedFlags |= SetIfChanged(MailCopy.Importance, source.Importance, value => MailCopy.Importance = value, MailCopyChangeFlags.Importance);
changedFlags |= SetIfChanged(MailCopy.IsRead, source.IsRead, value => MailCopy.IsRead = value, MailCopyChangeFlags.IsRead);
changedFlags |= SetIfChanged(MailCopy.IsFlagged, source.IsFlagged, value => MailCopy.IsFlagged = value, MailCopyChangeFlags.IsFlagged);
changedFlags |= SetIfChanged(MailCopy.IsFocused, source.IsFocused, value => MailCopy.IsFocused = value, MailCopyChangeFlags.IsFocused);
changedFlags |= SetIfChanged(MailCopy.FileId, source.FileId, value => MailCopy.FileId = value, MailCopyChangeFlags.FileId);
changedFlags |= SetIfChanged(MailCopy.ItemType, source.ItemType, value => MailCopy.ItemType = value, MailCopyChangeFlags.ItemType);
changedFlags |= SetIfChanged(MailCopy.SenderContact, source.SenderContact, value => MailCopy.SenderContact = value, MailCopyChangeFlags.SenderContact);
changedFlags |= SetIfChanged(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount);
changedFlags |= SetIfChanged(MailCopy.AssignedFolder, source.AssignedFolder, value => MailCopy.AssignedFolder = value, MailCopyChangeFlags.AssignedFolder);
changedFlags |= SetIfChanged(MailCopy.UniqueId, source.UniqueId, value => MailCopy.UniqueId = value, MailCopyChangeFlags.UniqueId);
}
changedFlags |= changeHint;
if (isSameReference && changedFlags == MailCopyChangeFlags.None)
{
// Without a hint there is no reliable way to diff in-place updates on the same instance.
// Fall back to full refresh to preserve correctness.
changedFlags = MailCopyChangeFlags.All;
}
RaisePropertyChanges(changedFlags);
return changedFlags;
}
private static MailCopyChangeFlags SetIfChanged<T>(T currentValue, T newValue, Action<T> setter, MailCopyChangeFlags flag)
{
if (EqualityComparer<T>.Default.Equals(currentValue, newValue))
return MailCopyChangeFlags.None;
setter(newValue);
return flag;
}
private void RaisePropertyChanges(MailCopyChangeFlags changedFlags)
{
if (changedFlags == MailCopyChangeFlags.None)
return;
var changedProperties = new List<string>(12);
void Queue(string propertyName)
{
if (!changedProperties.Contains(propertyName))
{
changedProperties.Add(propertyName);
}
}
if ((changedFlags & MailCopyChangeFlags.CreationDate) != 0)
{
Queue(nameof(CreationDate));
Queue(nameof(SortingDate));
}
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0)
Queue(nameof(IsFlagged));
if ((changedFlags & MailCopyChangeFlags.FromName) != 0)
{
Queue(nameof(FromName));
Queue(nameof(SortingName));
}
if ((changedFlags & MailCopyChangeFlags.FromAddress) != 0)
{
Queue(nameof(FromAddress));
Queue(nameof(FromName));
Queue(nameof(SortingName));
}
if ((changedFlags & MailCopyChangeFlags.IsFocused) != 0)
Queue(nameof(IsFocused));
if ((changedFlags & MailCopyChangeFlags.IsRead) != 0)
Queue(nameof(IsRead));
if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0)
Queue(nameof(IsDraft));
if ((changedFlags & MailCopyChangeFlags.DraftId) != 0)
Queue(nameof(DraftId));
if ((changedFlags & MailCopyChangeFlags.Id) != 0)
Queue(nameof(Id));
if ((changedFlags & MailCopyChangeFlags.Subject) != 0)
Queue(nameof(Subject));
if ((changedFlags & MailCopyChangeFlags.PreviewText) != 0)
Queue(nameof(PreviewText));
if ((changedFlags & MailCopyChangeFlags.HasAttachments) != 0)
Queue(nameof(HasAttachments));
if ((changedFlags & MailCopyChangeFlags.ItemType) != 0)
Queue(nameof(IsCalendarEvent));
if ((changedFlags & MailCopyChangeFlags.Importance) != 0)
Queue(nameof(Importance));
if ((changedFlags & MailCopyChangeFlags.ThreadId) != 0)
Queue(nameof(ThreadId));
if ((changedFlags & MailCopyChangeFlags.MessageId) != 0)
Queue(nameof(MessageId));
if ((changedFlags & MailCopyChangeFlags.References) != 0)
Queue(nameof(References));
if ((changedFlags & MailCopyChangeFlags.InReplyTo) != 0)
Queue(nameof(InReplyTo));
if ((changedFlags & MailCopyChangeFlags.FileId) != 0)
Queue(nameof(FileId));
if ((changedFlags & MailCopyChangeFlags.FolderId) != 0)
Queue(nameof(FolderId));
if ((changedFlags & MailCopyChangeFlags.UniqueId) != 0)
Queue(nameof(UniqueId));
if ((changedFlags & MailCopyChangeFlags.SenderContact) != 0)
{
Queue(nameof(ContactPictureFileId));
Queue(nameof(SenderContact));
}
foreach (var changedProperty in changedProperties)
{
OnPropertyChanged(changedProperty);
}
}
}
@@ -1,126 +1,484 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.ComponentModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data;
/// <summary>
/// Thread mail item (multiple IMailItem) view model representation.
/// </summary>
public partial class ThreadMailItemViewModel : ObservableObject, IMailItemThread, IComparable<string>, IComparable<DateTime>
public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation
{
public ObservableCollection<IMailItem> ThreadItems => (MailItem as IMailItemThread)?.ThreadItems ?? [];
public AccountContact SenderContact => ((IMailItemThread)MailItem).SenderContact;
private readonly string _threadId;
private readonly HashSet<Guid> _uniqueIdSet = [];
private MailItemViewModel _cachedLatestMailViewModel;
private int _suspendChildPropertyNotificationsCount;
[ObservableProperty]
private ThreadMailItem mailItem;
[NotifyPropertyChangedRecipients]
[NotifyPropertyChangedFor(nameof(IsSelectedOrExpanded))]
public partial bool IsThreadExpanded { get; set; }
[ObservableProperty]
private bool isThreadExpanded;
[NotifyPropertyChangedRecipients]
[NotifyPropertyChangedFor(nameof(IsSelectedOrExpanded))]
public partial bool IsSelected { get; set; }
public ThreadMailItemViewModel(ThreadMailItem threadMailItem)
/// <summary>
/// Direct callback invoked when <see cref="IsSelected"/> changes.
/// Used by the ListViewItem container to update its IsCustomSelected DP
/// without subscribing to INotifyPropertyChanged (faster, AOT-safe).
/// </summary>
public Action<bool> OnSelectionChanged { get; set; }
partial void OnIsSelectedChanged(bool value) => OnSelectionChanged?.Invoke(value);
[ObservableProperty]
public partial bool IsBusy { get; set; }
public bool IsSelectedOrExpanded => IsSelected || IsThreadExpanded;
/// <summary>
/// Gets the number of emails in this thread
/// </summary>
public int EmailCount => ThreadEmails.Count;
/// <summary>
/// Gets the latest email's subject for display
/// </summary>
public string Subject => latestMailViewModel?.MailCopy?.Subject;
/// <summary>
/// Gets the latest email's sender name for display
/// </summary>
public string FromName => latestMailViewModel?.MailCopy?.FromName ?? Translator.UnknownSender;
/// <summary>
/// Gets the latest email's creation date for sorting
/// </summary>
public DateTime CreationDate => latestMailViewModel?.MailCopy?.CreationDate ?? DateTime.MinValue;
/// <summary>
/// Gets the latest email's sender address for display
/// </summary>
public string FromAddress => latestMailViewModel?.FromAddress ?? string.Empty;
/// <summary>
/// Gets the preview text from the latest email
/// </summary>
public string PreviewText => latestMailViewModel?.PreviewText ?? string.Empty;
/// <summary>
/// Gets whether any email in this thread has attachments
/// </summary>
public bool HasAttachments => ThreadEmails.Any(e => e.HasAttachments);
/// <summary>
/// Gets whether any email in this thread is a calendar invitation.
/// </summary>
public bool IsCalendarEvent => ThreadEmails.Any(e => e.IsCalendarEvent);
/// <summary>
/// Gets whether any email in this thread is flagged
/// </summary>
public bool IsFlagged => ThreadEmails.Any(e => e.IsFlagged);
/// <summary>
/// Gets whether the latest email is focused
/// </summary>
public bool IsFocused => latestMailViewModel?.IsFocused ?? false;
/// <summary>
/// Gets whether all emails in this thread are read
/// </summary>
public bool IsRead => ThreadEmails.All(e => e.IsRead);
/// <summary>
/// Gets whether any email in this thread is a draft
/// </summary>
public bool IsDraft => ThreadEmails.Any(e => e.IsDraft);
/// <summary>
/// Gets the draft ID from the latest email if it's a draft
/// </summary>
public string DraftId => latestMailViewModel?.DraftId ?? string.Empty;
/// <summary>
/// Gets the ID from the latest email
/// </summary>
public string Id => latestMailViewModel?.Id ?? string.Empty;
/// <summary>
/// Gets the importance of the latest email
/// </summary>
public MailImportance Importance => latestMailViewModel?.Importance ?? MailImportance.Normal;
/// <summary>
/// Gets the thread ID from the latest email
/// </summary>
public string ThreadId => latestMailViewModel?.ThreadId ?? _threadId;
/// <summary>
/// Gets the message ID from the latest email
/// </summary>
public string MessageId => latestMailViewModel?.MessageId ?? string.Empty;
/// <summary>
/// Gets the references from the latest email
/// </summary>
public string References => latestMailViewModel?.References ?? string.Empty;
/// <summary>
/// Gets the in-reply-to from the latest email
/// </summary>
public string InReplyTo => latestMailViewModel?.InReplyTo ?? string.Empty;
/// <summary>
/// Gets the file ID from the latest email
/// </summary>
public Guid FileId => latestMailViewModel?.FileId ?? Guid.Empty;
/// <summary>
/// Gets the folder ID from the latest email
/// </summary>
public Guid FolderId => latestMailViewModel?.FolderId ?? Guid.Empty;
/// <summary>
/// Gets the unique ID from the latest email
/// </summary>
public Guid UniqueId => latestMailViewModel?.UniqueId ?? Guid.Empty;
public Guid? ContactPictureFileId => latestMailViewModel?.MailCopy?.SenderContact?.ContactPictureFileId;
public bool ThumbnailUpdatedEvent => latestMailViewModel?.ThumbnailUpdatedEvent ?? false;
public AccountContact SenderContact => latestMailViewModel?.MailCopy?.SenderContact;
/// <summary>
/// Gets all emails in this thread (observable)
/// </summary>
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EmailCount))]
[NotifyPropertyChangedFor(nameof(Subject))]
[NotifyPropertyChangedFor(nameof(FromName))]
[NotifyPropertyChangedFor(nameof(CreationDate))]
[NotifyPropertyChangedFor(nameof(FromAddress))]
[NotifyPropertyChangedFor(nameof(PreviewText))]
[NotifyPropertyChangedFor(nameof(HasAttachments))]
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
[NotifyPropertyChangedFor(nameof(IsFlagged))]
[NotifyPropertyChangedFor(nameof(IsFocused))]
[NotifyPropertyChangedFor(nameof(IsRead))]
[NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(DraftId))]
[NotifyPropertyChangedFor(nameof(Id))]
[NotifyPropertyChangedFor(nameof(Importance))]
[NotifyPropertyChangedFor(nameof(ThreadId))]
[NotifyPropertyChangedFor(nameof(MessageId))]
[NotifyPropertyChangedFor(nameof(References))]
[NotifyPropertyChangedFor(nameof(InReplyTo))]
[NotifyPropertyChangedFor(nameof(FileId))]
[NotifyPropertyChangedFor(nameof(FolderId))]
[NotifyPropertyChangedFor(nameof(UniqueId))]
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
[NotifyPropertyChangedFor(nameof(SenderContact))]
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel;
public DateTime SortingDate => CreationDate;
public string SortingName => FromName;
public ThreadMailItemViewModel(string threadId)
{
MailItem = new ThreadMailItem();
_threadId = threadId;
}
// Local copies
foreach (var item in threadMailItem.ThreadItems)
internal void SuspendChildPropertyNotifications() => _suspendChildPropertyNotificationsCount++;
internal void ResumeChildPropertyNotifications()
{
if (_suspendChildPropertyNotificationsCount > 0)
{
AddMailItemViewModel(item);
_suspendChildPropertyNotificationsCount--;
}
}
public ThreadMailItem GetThreadMailItem() => MailItem;
public IEnumerable<MailCopy> GetMailCopies()
=> ThreadItems.OfType<MailItemViewModel>().Select(a => a.MailCopy);
public void AddMailItemViewModel(IMailItem mailItem)
private void RefreshLatestMailCache()
{
if (mailItem == null) return;
_cachedLatestMailViewModel = ThreadEmails
.OrderByDescending(static item => item.MailCopy.CreationDate)
.FirstOrDefault();
}
if (mailItem is MailCopy mailCopy)
MailItem.AddThreadItem(new MailItemViewModel(mailCopy));
else if (mailItem is MailItemViewModel mailItemViewModel)
MailItem.AddThreadItem(mailItemViewModel);
/// <summary>
/// Adds an email to this thread
/// </summary>
public void AddEmail(MailItemViewModel email)
{
if (email.MailCopy.ThreadId != _threadId)
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'");
// Insert email in sorted order by CreationDate (newest first, oldest last)
var insertIndex = 0;
for (int i = 0; i < ThreadEmails.Count; i++)
{
if (ThreadEmails[i].MailCopy.CreationDate < email.MailCopy.CreationDate)
{
insertIndex = i;
break;
}
insertIndex = i + 1;
}
ThreadEmails.Insert(insertIndex, email);
email.PropertyChanged += ThreadEmailPropertyChanged;
_uniqueIdSet.Add(email.MailCopy.UniqueId);
RefreshLatestMailCache();
OnPropertyChanged(nameof(EmailCount));
NotifyMailItemUpdated(email, MailCopyChangeFlags.All);
}
/// <summary>
/// Removes an email from this thread
/// </summary>
public void RemoveEmail(MailItemViewModel email)
{
if (ThreadEmails.Remove(email))
{
email.PropertyChanged -= ThreadEmailPropertyChanged;
_uniqueIdSet.Remove(email.MailCopy.UniqueId);
RefreshLatestMailCache();
OnPropertyChanged(nameof(EmailCount));
NotifyMailItemUpdated(email, MailCopyChangeFlags.All);
}
}
public void UnregisterThreadEmailPropertyChangedHandlers()
{
foreach (var email in ThreadEmails)
{
email.PropertyChanged -= ThreadEmailPropertyChanged;
}
}
private void ThreadEmailPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (_suspendChildPropertyNotificationsCount > 0)
return;
if (sender is not MailItemViewModel updatedMailItem)
return;
if (e.PropertyName == nameof(MailItemViewModel.IsSelected) ||
e.PropertyName == nameof(MailItemViewModel.IsDisplayedInThread) ||
e.PropertyName == nameof(MailItemViewModel.IsBusy))
{
return;
}
if (e.PropertyName == nameof(MailItemViewModel.ThumbnailUpdatedEvent))
{
if (ReferenceEquals(updatedMailItem, latestMailViewModel))
{
OnPropertyChanged(nameof(ThumbnailUpdatedEvent));
}
return;
}
var changedFlags = string.IsNullOrEmpty(e.PropertyName)
? MailCopyChangeFlags.All
: MailItemViewModel.GetChangeFlagsForProperty(e.PropertyName);
if (changedFlags == MailCopyChangeFlags.None)
{
NotifyMailItemUpdated(updatedMailItem, MailCopyChangeFlags.All);
return;
}
NotifyMailItemUpdated(updatedMailItem, changedFlags);
}
/// <summary>
/// Notifies that a mail item within this thread has been updated.
/// </summary>
/// <param name="updatedMailItem">The mail item that was updated (can be null to refresh all).</param>
/// <param name="changedFlags">Set of changed child fields.</param>
public void NotifyMailItemUpdated(MailItemViewModel updatedMailItem, MailCopyChangeFlags changedFlags = MailCopyChangeFlags.All)
{
if (changedFlags == MailCopyChangeFlags.None)
return;
var previousLatest = latestMailViewModel;
if (changedFlags == MailCopyChangeFlags.All ||
(changedFlags & MailCopyChangeFlags.CreationDate) != 0 ||
previousLatest == null ||
!ThreadEmails.Contains(previousLatest))
{
RefreshLatestMailCache();
}
var currentLatest = latestMailViewModel;
var latestChanged = !ReferenceEquals(previousLatest, currentLatest);
var updatesDisplayedLatest = changedFlags == MailCopyChangeFlags.All ||
updatedMailItem == null ||
latestChanged ||
ReferenceEquals(updatedMailItem, previousLatest) ||
ReferenceEquals(updatedMailItem, currentLatest);
var changedProperties = new List<string>(10);
void Queue(string propertyName)
{
if (!changedProperties.Contains(propertyName))
{
changedProperties.Add(propertyName);
}
}
if (updatesDisplayedLatest)
{
if (changedFlags == MailCopyChangeFlags.All || latestChanged)
{
Queue(nameof(Subject));
Queue(nameof(FromName));
Queue(nameof(CreationDate));
Queue(nameof(FromAddress));
Queue(nameof(PreviewText));
Queue(nameof(IsFocused));
Queue(nameof(DraftId));
Queue(nameof(Id));
Queue(nameof(Importance));
Queue(nameof(ThreadId));
Queue(nameof(MessageId));
Queue(nameof(References));
Queue(nameof(InReplyTo));
Queue(nameof(FileId));
Queue(nameof(FolderId));
Queue(nameof(UniqueId));
Queue(nameof(ContactPictureFileId));
Queue(nameof(SenderContact));
Queue(nameof(ThumbnailUpdatedEvent));
Queue(nameof(SortingDate));
Queue(nameof(SortingName));
}
else
{
if ((changedFlags & MailCopyChangeFlags.Subject) != 0)
Queue(nameof(Subject));
if ((changedFlags & MailCopyChangeFlags.FromName) != 0)
{
Queue(nameof(FromName));
Queue(nameof(SortingName));
}
if ((changedFlags & MailCopyChangeFlags.CreationDate) != 0)
{
Queue(nameof(CreationDate));
Queue(nameof(SortingDate));
}
if ((changedFlags & MailCopyChangeFlags.FromAddress) != 0)
Queue(nameof(FromAddress));
if ((changedFlags & MailCopyChangeFlags.PreviewText) != 0)
Queue(nameof(PreviewText));
if ((changedFlags & MailCopyChangeFlags.IsFocused) != 0)
Queue(nameof(IsFocused));
if ((changedFlags & MailCopyChangeFlags.DraftId) != 0)
Queue(nameof(DraftId));
if ((changedFlags & MailCopyChangeFlags.Id) != 0)
Queue(nameof(Id));
if ((changedFlags & MailCopyChangeFlags.Importance) != 0)
Queue(nameof(Importance));
if ((changedFlags & MailCopyChangeFlags.ThreadId) != 0)
Queue(nameof(ThreadId));
if ((changedFlags & MailCopyChangeFlags.MessageId) != 0)
Queue(nameof(MessageId));
if ((changedFlags & MailCopyChangeFlags.References) != 0)
Queue(nameof(References));
if ((changedFlags & MailCopyChangeFlags.InReplyTo) != 0)
Queue(nameof(InReplyTo));
if ((changedFlags & MailCopyChangeFlags.FileId) != 0)
Queue(nameof(FileId));
if ((changedFlags & MailCopyChangeFlags.FolderId) != 0)
Queue(nameof(FolderId));
if ((changedFlags & MailCopyChangeFlags.UniqueId) != 0)
Queue(nameof(UniqueId));
if ((changedFlags & MailCopyChangeFlags.SenderContact) != 0)
{
Queue(nameof(ContactPictureFileId));
Queue(nameof(SenderContact));
}
}
}
if ((changedFlags & MailCopyChangeFlags.HasAttachments) != 0 || changedFlags == MailCopyChangeFlags.All)
Queue(nameof(HasAttachments));
if ((changedFlags & MailCopyChangeFlags.ItemType) != 0 || changedFlags == MailCopyChangeFlags.All)
Queue(nameof(IsCalendarEvent));
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0 || changedFlags == MailCopyChangeFlags.All)
Queue(nameof(IsFlagged));
if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All)
Queue(nameof(IsRead));
if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0 || changedFlags == MailCopyChangeFlags.All)
Queue(nameof(IsDraft));
foreach (var changedProperty in changedProperties)
{
OnPropertyChanged(changedProperty);
}
}
/// <summary>
/// Checks if this thread contains an email with the specified unique ID
/// </summary>
public bool HasUniqueId(Guid uniqueId) => _uniqueIdSet.Contains(uniqueId);
public IEnumerable<Guid> GetContainingIds() => ThreadEmails.Select(a => a.MailCopy.UniqueId);
public IEnumerable<MailItemViewModel> GetSelectedMailItems()
{
if (IsSelected)
{
// If the thread itself is selected, return all emails in the thread
return ThreadEmails;
}
else
Debugger.Break();
{
// Otherwise, return only individually selected emails within the thread
return ThreadEmails.Where(e => e.IsSelected);
}
}
public bool HasUniqueId(Guid uniqueMailId)
=> ThreadItems.Any(a => a.UniqueId == uniqueMailId);
public IMailItem GetItemById(Guid uniqueMailId)
=> ThreadItems.FirstOrDefault(a => a.UniqueId == uniqueMailId);
public void RemoveCopyItem(IMailItem item)
{
MailCopy copyToRemove = null;
if (item is MailItemViewModel mailItemViewModel)
copyToRemove = mailItemViewModel.MailCopy;
else if (item is MailCopy copyItem)
copyToRemove = copyItem;
var existedItem = ThreadItems.FirstOrDefault(a => a.Id == copyToRemove.Id);
if (existedItem == null) return;
ThreadItems.Remove(existedItem);
NotifyPropertyChanges();
}
public void NotifyPropertyChanges()
{
// TODO
// Stupid temporary fix for not updating UI.
// This view model must be reworked with ThreadMailItem together.
var current = MailItem;
MailItem = null;
MailItem = current;
}
public IMailItem LatestMailItem => ((IMailItemThread)MailItem).LatestMailItem;
public IMailItem FirstMailItem => ((IMailItemThread)MailItem).FirstMailItem;
public string Id => ((IMailItem)MailItem).Id;
public string Subject => ((IMailItem)MailItem).Subject;
public string ThreadId => ((IMailItem)MailItem).ThreadId;
public string MessageId => ((IMailItem)MailItem).MessageId;
public string References => ((IMailItem)MailItem).References;
public string PreviewText => ((IMailItem)MailItem).PreviewText;
public string FromName => ((IMailItem)MailItem).FromName;
public DateTime CreationDate => ((IMailItem)MailItem).CreationDate;
public string FromAddress => ((IMailItem)MailItem).FromAddress;
public bool HasAttachments => ((IMailItem)MailItem).HasAttachments;
public bool IsFlagged => ((IMailItem)MailItem).IsFlagged;
public bool IsFocused => ((IMailItem)MailItem).IsFocused;
public bool IsRead => ((IMailItem)MailItem).IsRead;
public bool IsDraft => ((IMailItem)MailItem).IsDraft;
public string DraftId => string.Empty;
public string InReplyTo => ((IMailItem)MailItem).InReplyTo;
public MailItemFolder AssignedFolder => ((IMailItem)MailItem).AssignedFolder;
public MailAccount AssignedAccount => ((IMailItem)MailItem).AssignedAccount;
public Guid UniqueId => ((IMailItem)MailItem).UniqueId;
public Guid FileId => ((IMailItem)MailItem).FileId;
public int CompareTo(DateTime other) => CreationDate.CompareTo(other);
public int CompareTo(string other) => FromName.CompareTo(other);
// Get single mail item view model out of the only item in thread items.
public MailItemViewModel GetSingleItemViewModel() => ThreadItems.First() as MailItemViewModel;
public IEnumerable<Guid> GetContainingIds() => ((IMailItemThread)MailItem).GetContainingIds();
}
@@ -0,0 +1,79 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Mail.ViewModels.Data;
public partial class WelcomeWizardContext : ObservableObject
{
// Step 2 — Provider selection
[ObservableProperty]
public partial IProviderDetail SelectedProvider { get; set; }
[ObservableProperty]
public partial string AccountName { get; set; }
[ObservableProperty]
public partial string AccountColorHex { get; set; }
// Special IMAP fields (iCloud/Yahoo)
[ObservableProperty]
public partial string DisplayName { get; set; }
[ObservableProperty]
public partial string EmailAddress { get; set; }
[ObservableProperty]
public partial string AppSpecificPassword { get; set; }
[ObservableProperty]
public partial ImapCalendarSupportMode CalendarSupportMode { get; set; } = ImapCalendarSupportMode.Disabled;
// Generic IMAP — populated by ImapCalDavSettingsPage
public ImapCalDavSetupResult ImapCalDavSetupResult { get; set; }
// Computed helpers
public bool IsOAuthProvider => SelectedProvider?.Type is MailProviderType.Outlook or MailProviderType.Gmail;
public bool IsSpecialImapProvider =>
SelectedProvider?.SpecialImapProvider is SpecialImapProvider.iCloud or SpecialImapProvider.Yahoo;
public bool IsGenericImap =>
SelectedProvider?.Type == MailProviderType.IMAP4
&& SelectedProvider?.SpecialImapProvider == SpecialImapProvider.None;
public SpecialImapProviderDetails BuildSpecialImapProviderDetails()
{
if (!IsSpecialImapProvider) return null;
return new SpecialImapProviderDetails(
EmailAddress,
AppSpecificPassword,
DisplayName,
SelectedProvider.SpecialImapProvider,
CalendarSupportMode);
}
public AccountCreationDialogResult BuildAccountCreationDialogResult()
{
return new AccountCreationDialogResult(
SelectedProvider.Type,
AccountName,
BuildSpecialImapProviderDetails(),
AccountColorHex);
}
public void Reset()
{
SelectedProvider = null;
AccountName = null;
AccountColorHex = null;
DisplayName = null;
EmailAddress = null;
AppSpecificPassword = null;
CalendarSupportMode = ImapCalendarSupportMode.Disabled;
ImapCalDavSetupResult = null;
}
}
@@ -1,178 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
namespace Wino.Mail.ViewModels;
public partial class EditAccountDetailsPageViewModel : MailBaseViewModel
{
private readonly IAccountService _accountService;
private readonly IThemeService _themeService;
private readonly IImapTestService _imapTestService;
private readonly IMailDialogService _mailDialogService;
[ObservableProperty]
public partial MailAccount Account { get; set; }
[ObservableProperty]
public partial string AccountName { get; set; }
[ObservableProperty]
public partial string SenderName { get; set; }
[ObservableProperty]
public partial AppColorViewModel SelectedColor { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsImapServer))]
public partial CustomServerInformation ServerInformation { get; set; }
[ObservableProperty]
public partial List<AppColorViewModel> AvailableColors { get; set; }
[ObservableProperty]
public partial int SelectedIncomingServerConnectionSecurityIndex { get; set; }
[ObservableProperty]
public partial int SelectedIncomingServerAuthenticationMethodIndex { get; set; }
[ObservableProperty]
public partial int SelectedOutgoingServerConnectionSecurityIndex { get; set; }
[ObservableProperty]
public partial int SelectedOutgoingServerAuthenticationMethodIndex { get; set; }
public List<ImapAuthenticationMethodModel> AvailableAuthenticationMethods { get; } =
[
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Auto, Translator.ImapAuthenticationMethod_Auto),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.None, Translator.ImapAuthenticationMethod_None),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.NormalPassword, Translator.ImapAuthenticationMethod_Plain),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.EncryptedPassword, Translator.ImapAuthenticationMethod_EncryptedPassword),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Ntlm, Translator.ImapAuthenticationMethod_Ntlm),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.CramMd5, Translator.ImapAuthenticationMethod_CramMD5),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.DigestMd5, Translator.ImapAuthenticationMethod_DigestMD5)
];
public List<ImapConnectionSecurityModel> AvailableConnectionSecurities { get; set; } =
[
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.Auto, Translator.ImapConnectionSecurity_Auto),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.SslTls, Translator.ImapConnectionSecurity_SslTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.StartTls, Translator.ImapConnectionSecurity_StartTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.None, Translator.ImapConnectionSecurity_None)
];
public bool IsImapServer => ServerInformation != null;
public EditAccountDetailsPageViewModel(IAccountService accountService,
IThemeService themeService,
IImapTestService imapTestService,
IMailDialogService mailDialogService)
{
_accountService = accountService;
_themeService = themeService;
_imapTestService = imapTestService;
_mailDialogService = mailDialogService;
var colorHexList = _themeService.GetAvailableAccountColors();
AvailableColors = colorHexList.Select(a => new AppColorViewModel(a)).ToList();
}
[RelayCommand]
private async Task SaveChangesAsync()
{
await UpdateAccountAsync();
Messenger.Send(new BackBreadcrumNavigationRequested());
}
[RelayCommand]
private async Task ValidateImapSettingsAsync()
{
try
{
await _imapTestService.TestImapConnectionAsync(ServerInformation, true);
_mailDialogService.InfoBarMessage(Translator.IMAPSetupDialog_ValidationSuccess_Title, Translator.IMAPSetupDialog_ValidationSuccess_Message, Core.Domain.Enums.InfoBarMessageType.Success);
}
catch (Exception ex)
{
_mailDialogService.InfoBarMessage(Translator.IMAPSetupDialog_ValidationFailed_Title, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error); ;
}
}
[RelayCommand]
private async Task UpdateCustomServerInformationAsync()
{
if (ServerInformation != null)
{
ServerInformation.IncomingAuthenticationMethod = AvailableAuthenticationMethods[SelectedIncomingServerAuthenticationMethodIndex].ImapAuthenticationMethod;
ServerInformation.IncomingServerSocketOption = AvailableConnectionSecurities[SelectedIncomingServerConnectionSecurityIndex].ImapConnectionSecurity;
ServerInformation.OutgoingAuthenticationMethod = AvailableAuthenticationMethods[SelectedOutgoingServerAuthenticationMethodIndex].ImapAuthenticationMethod;
ServerInformation.OutgoingServerSocketOption = AvailableConnectionSecurities[SelectedOutgoingServerConnectionSecurityIndex].ImapConnectionSecurity;
Account.ServerInformation = ServerInformation;
}
await _accountService.UpdateAccountCustomServerInformationAsync(Account.ServerInformation);
_mailDialogService.InfoBarMessage(Translator.IMAPSetupDialog_SaveImapSuccess_Title, Translator.IMAPSetupDialog_SaveImapSuccess_Message, Core.Domain.Enums.InfoBarMessageType.Success);
}
private Task UpdateAccountAsync()
{
Account.Name = AccountName;
Account.SenderName = SenderName;
Account.AccountColorHex = SelectedColor == null ? string.Empty : SelectedColor.Hex;
return _accountService.UpdateAccountAsync(Account);
}
[RelayCommand]
private void ResetColor()
=> SelectedColor = null;
partial void OnSelectedColorChanged(AppColorViewModel oldValue, AppColorViewModel newValue)
{
_ = UpdateAccountAsync();
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is MailAccount account)
{
Account = account;
AccountName = account.Name;
SenderName = account.SenderName;
ServerInformation = Account.ServerInformation;
if (!string.IsNullOrEmpty(account.AccountColorHex))
{
SelectedColor = AvailableColors.FirstOrDefault(a => a.Hex == account.AccountColorHex);
}
if (ServerInformation != null)
{
SelectedIncomingServerAuthenticationMethodIndex = AvailableAuthenticationMethods.FindIndex(a => a.ImapAuthenticationMethod == ServerInformation.IncomingAuthenticationMethod);
SelectedIncomingServerConnectionSecurityIndex = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == ServerInformation.IncomingServerSocketOption);
SelectedOutgoingServerAuthenticationMethodIndex = AvailableAuthenticationMethods.FindIndex(a => a.ImapAuthenticationMethod == ServerInformation.OutgoingAuthenticationMethod);
SelectedOutgoingServerConnectionSecurityIndex = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == ServerInformation.OutgoingServerSocketOption);
}
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Messaging.Client.Navigation;
namespace Wino.Mail.ViewModels;
public partial class EmailTemplatesPageViewModel(IEmailTemplateService emailTemplateService) : MailBaseViewModel
{
private readonly IEmailTemplateService _emailTemplateService = emailTemplateService;
public ObservableCollection<EmailTemplate> EmailTemplates { get; } = [];
public async Task LoadAsync()
{
var templates = await _emailTemplateService.GetEmailTemplatesAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
EmailTemplates.Clear();
foreach (var template in templates)
{
EmailTemplates.Add(template);
}
});
}
public void CreateTemplate()
{
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.SettingsEmailTemplates_CreatePageTitle,
WinoPage.CreateEmailTemplatePage));
}
public void OpenTemplate(EmailTemplate template)
{
if (template == null)
return;
var title = string.IsNullOrWhiteSpace(template.Name)
? Translator.SettingsEmailTemplates_EditPageTitle
: template.Name;
Messenger.Send(new BreadcrumbNavigationRequested(
title,
WinoPage.CreateEmailTemplatePage,
template.Id));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,46 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Translations;
namespace Wino.Mail.ViewModels;
public partial class LanguageTimePageViewModel(IPreferencesService preferencesService, ITranslationService translationService) : MailBaseViewModel
{
public IPreferencesService PreferencesService { get; } = preferencesService;
private readonly ITranslationService _translationService = translationService;
[ObservableProperty]
private List<AppLanguageModel> _availableLanguages;
[ObservableProperty]
private AppLanguageModel _selectedLanguage;
private bool isInitialized = false;
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
AvailableLanguages = _translationService.GetAvailableLanguages();
SelectedLanguage = AvailableLanguages.FirstOrDefault(a => a.Language == PreferencesService.CurrentLanguage);
isInitialized = true;
}
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (!isInitialized) return;
if (e.PropertyName == nameof(SelectedLanguage))
{
await _translationService.InitializeLanguageAsync(SelectedLanguage.Language);
}
}
}
@@ -1,11 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using MoreLinq.Extensions;
@@ -17,9 +16,12 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Client.Shell;
@@ -28,17 +30,19 @@ using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels;
public partial class AppShellViewModel : MailBaseViewModel,
IRecipient<NavigateManageAccountsRequested>,
public partial class MailAppShellViewModel : MailBaseViewModel,
IMailShellClient,
IRecipient<MailtoProtocolMessageRequested>,
IRecipient<RefreshUnreadCountsMessage>,
IRecipient<AccountsMenuRefreshRequested>,
IRecipient<MergedInboxRenamed>,
IRecipient<LanguageChanged>,
IRecipient<AccountMenuItemsReordered>,
IRecipient<AccountSynchronizationProgressUpdatedMessage>,
IRecipient<AccountSynchronizerStateChanged>,
IRecipient<NavigateAppPreferencesRequested>,
IRecipient<AccountFolderConfigurationUpdated>
IRecipient<AccountFolderConfigurationUpdated>,
IRecipient<AccountRemovedMessage>,
IRecipient<AccountUpdatedMessage>
{
#region Menu Items
@@ -51,7 +55,8 @@ public partial class AppShellViewModel : MailBaseViewModel,
public MenuItemCollection MenuItems { get; set; }
private readonly SettingsItem SettingsItem = new SettingsItem();
private readonly ManageAccountsMenuItem ManageAccountsMenuItem = new ManageAccountsMenuItem();
private readonly ContactsMenuItem ContactsMenuItem = new ContactsMenuItem();
private readonly StoreUpdateMenuItem StoreUpdateMenuItem = new StoreUpdateMenuItem();
public IMenuItem CreateMailMenuItem = new NewMailMenuItem();
@@ -60,9 +65,11 @@ public partial class AppShellViewModel : MailBaseViewModel,
private const string IsActivateStartupLaunchAskedKey = nameof(IsActivateStartupLaunchAskedKey);
public IStatePersistanceService StatePersistenceService { get; }
public IWinoServerConnectionManager ServerConnectionManager { get; }
public IPreferencesService PreferencesService { get; }
public INavigationService NavigationService { get; }
public WinoApplicationMode Mode => WinoApplicationMode.Mail;
public bool HandlesNavigationSelection => true;
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
private readonly IFolderService _folderService;
private readonly IConfigurationService _configurationService;
@@ -74,20 +81,19 @@ public partial class AppShellViewModel : MailBaseViewModel,
private readonly INotificationBuilder _notificationBuilder;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly IMailDialogService _dialogService;
private readonly IBackgroundTaskService _backgroundTaskService;
private readonly IMimeFileService _mimeFileService;
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
private readonly IStoreUpdateService _storeUpdateService;
private readonly INativeAppService _nativeAppService;
private readonly IMailService _mailService;
private bool _hasRegisteredPersistentRecipients;
private readonly SemaphoreSlim _menuRefreshSemaphore = new(1, 1);
private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1);
[ObservableProperty]
private WinoServerConnectionStatus activeConnectionStatus;
public AppShellViewModel(IMailDialogService dialogService,
public MailAppShellViewModel(IMailDialogService dialogService,
INavigationService navigationService,
IBackgroundTaskService backgroundTaskService,
IMimeFileService mimeFileService,
INativeAppService nativeAppService,
IMailService mailService,
@@ -100,21 +106,12 @@ public partial class AppShellViewModel : MailBaseViewModel,
IWinoRequestDelegator winoRequestDelegator,
IFolderService folderService,
IStatePersistanceService statePersistanceService,
IWinoServerConnectionManager serverConnectionManager,
IConfigurationService configurationService,
IStartupBehaviorService startupBehaviorService)
IStartupBehaviorService startupBehaviorService,
IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
IStoreUpdateService storeUpdateService)
{
StatePersistenceService = statePersistanceService;
ServerConnectionManager = serverConnectionManager;
ActiveConnectionStatus = serverConnectionManager.Status;
ServerConnectionManager.StatusChanged += async (sender, status) =>
{
await ExecuteUIThread(() =>
{
ActiveConnectionStatus = status;
});
};
PreferencesService = preferencesService;
_dialogService = dialogService;
@@ -122,7 +119,6 @@ public partial class AppShellViewModel : MailBaseViewModel,
_configurationService = configurationService;
_startupBehaviorService = startupBehaviorService;
_backgroundTaskService = backgroundTaskService;
_mimeFileService = mimeFileService;
_nativeAppService = nativeAppService;
_mailService = mailService;
@@ -133,11 +129,10 @@ public partial class AppShellViewModel : MailBaseViewModel,
_launchProtocolService = launchProtocolService;
_notificationBuilder = notificationBuilder;
_winoRequestDelegator = winoRequestDelegator;
_webView2RuntimeValidatorService = webView2RuntimeValidatorService;
_storeUpdateService = storeUpdateService;
}
[RelayCommand]
private Task ReconnectServerAsync() => ServerConnectionManager.ConnectAsync();
protected override void OnDispatcherAssigned()
{
base.OnDispatcherAssigned();
@@ -154,23 +149,11 @@ public partial class AppShellViewModel : MailBaseViewModel,
return _contextMenuItemService.GetFolderContextMenuActions(folder);
}
private async Task CreateFooterItemsAsync()
private async Task CreateFooterItemsAsync(bool showNotification = false)
{
await ExecuteUIThread(() =>
{
// TODO: Selected footer item container still remains selected after re-creation.
// To reproduce, go settings and change the language.
foreach (var item in FooterItems)
{
item.IsExpanded = false;
item.IsSelected = false;
}
FooterItems.Clear();
FooterItems.Add(ManageAccountsMenuItem);
FooterItems.Add(SettingsItem);
});
}
@@ -234,21 +217,82 @@ public partial class AppShellViewModel : MailBaseViewModel,
}
}
private async void PreferencesServiceChanged(object sender, string e)
{
if (e == nameof(IPreferencesService.IsStoreUpdateNotificationsEnabled))
{
await CreateFooterItemsAsync();
}
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
await CreateFooterItemsAsync();
if (!_hasRegisteredPersistentRecipients)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
}
var activationContext = parameters as ShellModeActivationContext;
var shouldRunStartupFlows = (activationContext?.IsInitialActivation ?? true) &&
activationContext?.SuppressStartupFlows != true;
var hasExistingAccountMenuItems = MenuItems?.OfType<IAccountMenuItem>().Any() == true;
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
await CreateFooterItemsAsync(true);
if (!hasExistingAccountMenuItems)
{
await RecreateMenuItemsAsync();
}
await RecreateMenuItemsAsync();
await ProcessLaunchOptionsAsync();
await ValidateWebView2RuntimeAsync();
if (!Debugger.IsAttached)
if (shouldRunStartupFlows && !Debugger.IsAttached)
{
await ForceAllAccountSynchronizationsAsync();
}
await MakeSureEnableStartupLaunchAsync();
await ConfigureBackgroundTasksAsync();
if (shouldRunStartupFlows)
{
await MakeSureEnableStartupLaunchAsync();
}
}
private async Task ValidateWebView2RuntimeAsync()
{
var isRuntimeAvailable = await _webView2RuntimeValidatorService.IsRuntimeAvailableAsync();
if (!isRuntimeAvailable)
{
await ExecuteUIThread(() => _notificationBuilder.CreateWebView2RuntimeMissingNotification());
}
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
}
public void PrepareForShellShutdown()
{
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
if (_hasRegisteredPersistentRecipients)
{
UnregisterRecipients();
_hasRegisteredPersistentRecipients = false;
}
latestSelectedAccountMenuItem = null;
SelectedMenuItem = null;
MenuItems?.Clear();
MenuItems?.Add(CreateMailMenuItem);
FooterItems?.Clear();
}
private async Task MakeSureEnableStartupLaunchAsync()
@@ -291,22 +335,6 @@ public partial class AppShellViewModel : MailBaseViewModel,
}
}
private async Task ConfigureBackgroundTasksAsync()
{
try
{
// This will only unregister once. Safe to execute multiple times.
_backgroundTaskService.UnregisterAllBackgroundTask();
await _backgroundTaskService.RegisterBackgroundTasksAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to configure background tasks.");
_dialogService.InfoBarMessage(Translator.Info_BackgroundExecutionUnknownErrorTitle, Translator.Info_BackgroundExecutionUnknownErrorMessage, InfoBarMessageType.Error);
}
}
private async Task ForceAllAccountSynchronizationsAsync()
{
@@ -321,7 +349,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
Type = MailSynchronizationType.FullFolders
};
Messenger.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
Messenger.Send(new NewMailSynchronizationRequested(options));
}
}
@@ -382,7 +410,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
{
if (PreferencesService.StartupEntityId == null)
{
NavigationService.Navigate(WinoPage.WelcomePage);
NavigateToWelcomeWizard();
}
else
{
@@ -406,8 +434,8 @@ public partial class AppShellViewModel : MailBaseViewModel,
}
else
{
// Fallback to welcome page if startup entity is not found.
NavigationService.Navigate(WinoPage.WelcomePage);
// Fallback to the welcome wizard if startup entity is not found.
NavigateToWelcomeWizard();
}
}
}
@@ -426,7 +454,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
var args = new NavigateMailFolderEventArgs(baseFolderMenuItem, folderInitAwaitTask);
NavigationService.Navigate(WinoPage.MailListPage, args, NavigationReferenceFrame.ShellFrame);
NavigationService.Navigate(WinoPage.MailListPage, args, NavigationReferenceFrame.InnerShellFrame);
UpdateWindowTitleForFolder(baseFolderMenuItem);
});
@@ -440,6 +468,11 @@ public partial class AppShellViewModel : MailBaseViewModel,
StatePersistenceService.CoreWindowTitle = $"{folder.AssignedAccountName} - {folder.FolderName}";
}
private void UpdateWindowTitle(string title)
{
StatePersistenceService.CoreWindowTitle = title;
}
private async Task NavigateSpecialFolderAsync(MailAccount account, SpecialFolderType specialFolderType, bool extendAccountMenu)
{
try
@@ -558,21 +591,75 @@ public partial class AppShellViewModel : 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)
{
// 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
{
if (account.AttentionReason == AccountAttentionReason.InvalidCredentials)
await _accountService.FixTokenIssuesAsync(account.Id);
{
if (account.ProviderType is MailProviderType.Gmail or MailProviderType.Outlook)
{
await SynchronizationManager.Instance.HandleAuthorizationAsync(
account.ProviderType,
account,
account.ProviderType == MailProviderType.Gmail);
await _accountService.ClearAccountAttentionAsync(account.Id);
_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);
await _accountService.ClearAccountAttentionAsync(account.Id);
_dialogService.InfoBarMessage(
Translator.Info_AccountIssueFixSuccessTitle,
Translator.Info_AccountIssueFixSuccessMessage,
InfoBarMessageType.Success);
_dialogService.InfoBarMessage(Translator.Info_AccountIssueFixFailedTitle, Translator.Info_AccountIssueFixSuccessMessage, InfoBarMessageType.Success);
TriggerFullSynchronization(account);
}
}
catch (Exception ex)
{
@@ -619,14 +706,6 @@ public partial class AppShellViewModel : MailBaseViewModel,
// Don't navigate to merged account if it's already selected. Preserve user's already selected folder.
await ChangeLoadedAccountAsync(clickedMergedAccountMenuItem, true);
}
else if (clickedMenuItem is SettingsItem)
{
NavigationService.Navigate(WinoPage.SettingsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None);
}
else if (clickedMenuItem is ManageAccountsMenuItem)
{
NavigationService.Navigate(WinoPage.ManageAccountsPage, parameter, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None);
}
else if (clickedMenuItem is IAccountMenuItem clickedAccountMenuItem)
{
// Changing loaded account.
@@ -801,7 +880,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
if (isManageAccountClicked)
{
SelectedMenuItem = ManageAccountsMenuItem;
NavigationService.Navigate(WinoPage.SettingsPage, WinoPage.ManageAccountsPage);
}
return;
@@ -874,49 +953,20 @@ public partial class AppShellViewModel : MailBaseViewModel,
await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest);
}
protected override async void OnAccountUpdated(MailAccount updatedAccount)
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{
await ExecuteUIThread(() =>
if (args.Handled || args.Mode != WinoApplicationMode.Mail)
return;
if (args.Action == KeyboardShortcutAction.NewMail)
{
if (MenuItems.TryGetAccountMenuItem(updatedAccount.Id, out IAccountMenuItem foundAccountMenuItem))
{
foundAccountMenuItem.UpdateAccount(updatedAccount);
}
});
}
protected override void OnAccountRemoved(MailAccount removedAccount)
=> Messenger.Send(new AccountsMenuRefreshRequested(false));
protected override async void OnAccountCreated(MailAccount createdAccount)
{
latestSelectedAccountMenuItem = null;
await RecreateMenuItemsAsync();
if (!MenuItems.TryGetAccountMenuItem(createdAccount.Id, out IAccountMenuItem createdMenuItem)) return;
await ChangeLoadedAccountAsync(createdMenuItem);
// Each created account should start a new synchronization automatically.
var options = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.FullFolders,
};
Messenger.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
try
{
await _nativeAppService.PinAppToTaskbarAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to pin Wino to taskbar.");
await HandleCreateNewMailAsync();
args.Handled = true;
}
}
// TODO: Handle by messaging.
private async Task SetAccountAttentionAsync(Guid accountId, AccountAttentionReason reason)
{
@@ -931,8 +981,6 @@ public partial class AppShellViewModel : MailBaseViewModel,
accountMenuItem.UpdateAccount(accountModel);
}
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItem = ManageAccountsMenuItem;
public async void Receive(MailtoProtocolMessageRequested message)
{
var accounts = await _accountService.GetAccountsAsync();
@@ -963,13 +1011,94 @@ public partial class AppShellViewModel : MailBaseViewModel,
private async Task RecreateMenuItemsAsync()
{
await ExecuteUIThread(() =>
await _menuRefreshSemaphore.WaitAsync().ConfigureAwait(false);
try
{
MenuItems.Clear();
MenuItems.Add(CreateMailMenuItem);
});
await ExecuteUIThread(() =>
{
MenuItems.Clear();
MenuItems.Add(CreateMailMenuItem);
});
await LoadAccountsAsync();
await LoadAccountsAsync();
}
finally
{
_menuRefreshSemaphore.Release();
}
}
private async Task RestoreSelectedAccountAfterMenuRefreshAsync(bool automaticallyNavigateFirstItem)
{
IAccountMenuItem validSelectedMenuItem = null;
bool hasPreviousSelection = latestSelectedAccountMenuItem != null;
if (hasPreviousSelection)
{
var selectedEntityId = latestSelectedAccountMenuItem.EntityId.GetValueOrDefault();
if (selectedEntityId != Guid.Empty &&
MenuItems.TryGetAccountMenuItem(selectedEntityId, out IAccountMenuItem foundSelectedMenuItem))
{
validSelectedMenuItem = foundSelectedMenuItem;
}
else
{
latestSelectedAccountMenuItem = null;
}
}
if (validSelectedMenuItem == null)
{
validSelectedMenuItem = MenuItems.FirstOrDefault(a => a is IAccountMenuItem) as IAccountMenuItem;
hasPreviousSelection = false;
}
if (validSelectedMenuItem != null)
{
await ChangeLoadedAccountAsync(validSelectedMenuItem, hasPreviousSelection || automaticallyNavigateFirstItem);
}
else
{
await ExecuteUIThread(() => SelectedMenuItem = null);
NavigateToWelcomeWizard();
}
}
private void NavigateToWelcomeWizard()
=> NavigationService.Navigate(
WinoPage.WelcomeHostPage,
null,
NavigationReferenceFrame.ShellFrame,
NavigationTransitionType.None);
private bool IsAccountCurrentlyLoaded(Guid accountId)
{
return latestSelectedAccountMenuItem?.HoldingAccounts?.Any(a => a.Id == accountId) == true;
}
private async Task RefreshLoadedAccountFolderStructureAsync(Guid accountId)
{
if (!IsAccountCurrentlyLoaded(accountId) || latestSelectedAccountMenuItem == null)
return;
var selectedFolderId = (SelectedMenuItem as IBaseFolderMenuItem)?.HandlingFolders
?.FirstOrDefault(a => a.MailAccountId == accountId)?.Id;
var folders = await _folderService.GetAccountFoldersForDisplayAsync(latestSelectedAccountMenuItem);
await MenuItems.ReplaceFoldersAsync(folders);
await UpdateUnreadItemCountAsync();
if (selectedFolderId.HasValue &&
MenuItems.TryGetFolderMenuItem(selectedFolderId.Value, out IBaseFolderMenuItem selectedFolderMenuItem))
{
await NavigateFolderAsync(selectedFolderMenuItem);
}
else
{
await NavigateInboxAsync(latestSelectedAccountMenuItem);
}
}
public async void Receive(RefreshUnreadCountsMessage message)
@@ -978,27 +1107,12 @@ public partial class AppShellViewModel : MailBaseViewModel,
public async void Receive(AccountsMenuRefreshRequested message)
{
await RecreateMenuItemsAsync();
// Try to restore latest selected account.
if (latestSelectedAccountMenuItem != null)
{
await ChangeLoadedAccountAsync(latestSelectedAccountMenuItem, navigateInbox: true);
}
else if (MenuItems.FirstOrDefault(a => a is IAccountMenuItem) is IAccountMenuItem firstAccount)
{
await ChangeLoadedAccountAsync(firstAccount, message.AutomaticallyNavigateFirstItem);
}
await RestoreSelectedAccountAfterMenuRefreshAsync(message.AutomaticallyNavigateFirstItem);
}
public async void Receive(AccountFolderConfigurationUpdated message)
{
// Reloading of folders is needed to re-create folder tree if the account is loaded.
if (MenuItems.TryGetAccountMenuItem(message.AccountId, out IAccountMenuItem accountMenuItem) &&
latestSelectedAccountMenuItem == accountMenuItem)
{
await ChangeLoadedAccountAsync(accountMenuItem, true);
}
await RefreshLoadedAccountFolderStructureAsync(message.AccountId);
}
public async void Receive(MergedInboxRenamed message)
@@ -1015,10 +1129,9 @@ public partial class AppShellViewModel : MailBaseViewModel,
public async void Receive(LanguageChanged message)
{
await CreateFooterItemsAsync();
await CreateFooterItemsAsync(true);
await RecreateMenuItemsAsync();
await ChangeLoadedAccountAsync(latestSelectedAccountMenuItem, navigateInbox: false);
await RestoreSelectedAccountAfterMenuRefreshAsync(false);
}
private void ReorderAccountMenuItems(Dictionary<Guid, int> newAccountOrder)
@@ -1056,6 +1169,24 @@ public partial class AppShellViewModel : MailBaseViewModel,
UpdateFolderCollection(mailItemFolder);
}
protected override async void OnFolderDeleted(MailItemFolder folder)
{
base.OnFolderDeleted(folder);
bool wasSelected = SelectedMenuItem is IBaseFolderMenuItem selectedFolder &&
selectedFolder.HandlingFolders.Any(a => a.Id == folder.Id);
await ExecuteUIThread(() => MenuItems.RemoveFolderMenuItem(folder.Id));
if (wasSelected && latestSelectedAccountMenuItem != null)
{
await NavigateInboxAsync(latestSelectedAccountMenuItem);
return;
}
await RefreshLoadedAccountFolderStructureAsync(folder.MailAccountId);
}
protected override void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder)
{
base.OnFolderSynchronizationEnabled(mailItemFolder);
@@ -1063,17 +1194,163 @@ public partial class AppShellViewModel : MailBaseViewModel,
UpdateFolderCollection(mailItemFolder);
}
public async void Receive(AccountSynchronizationProgressUpdatedMessage message)
public async void Receive(AccountSynchronizerStateChanged message)
{
var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId);
if (accountMenuItem == null) return;
await ExecuteUIThread(() => { accountMenuItem.SynchronizationProgress = message.Progress; });
await ExecuteUIThread(() =>
{
accountMenuItem.TotalItemsToSync = message.TotalItemsToSync;
accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync;
accountMenuItem.SynchronizationStatus = message.SynchronizationStatus;
// If this account is part of a merged inbox, update the merged inbox progress as well
if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem)
{
mergedAccountMenuItem.RefreshSynchronizationProgress();
}
});
}
public async void Receive(NavigateAppPreferencesRequested message)
{
await MenuItemInvokedOrSelectedAsync(SettingsItem, WinoPage.AppPreferencesPage);
NavigationService.Navigate(WinoPage.SettingsPage, WinoPage.AppPreferencesPage);
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<AccountRemovedMessage>(this);
Messenger.Register<AccountUpdatedMessage>(this);
Messenger.Register<MailtoProtocolMessageRequested>(this);
Messenger.Register<RefreshUnreadCountsMessage>(this);
Messenger.Register<AccountsMenuRefreshRequested>(this);
Messenger.Register<MergedInboxRenamed>(this);
Messenger.Register<LanguageChanged>(this);
Messenger.Register<AccountMenuItemsReordered>(this);
Messenger.Register<AccountSynchronizerStateChanged>(this);
Messenger.Register<NavigateAppPreferencesRequested>(this);
Messenger.Register<AccountFolderConfigurationUpdated>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<AccountRemovedMessage>(this);
Messenger.Unregister<AccountUpdatedMessage>(this);
Messenger.Unregister<MailtoProtocolMessageRequested>(this);
Messenger.Unregister<RefreshUnreadCountsMessage>(this);
Messenger.Unregister<AccountsMenuRefreshRequested>(this);
Messenger.Unregister<MergedInboxRenamed>(this);
Messenger.Unregister<LanguageChanged>(this);
Messenger.Unregister<AccountMenuItemsReordered>(this);
Messenger.Unregister<AccountSynchronizerStateChanged>(this);
Messenger.Unregister<NavigateAppPreferencesRequested>(this);
Messenger.Unregister<AccountFolderConfigurationUpdated>(this);
}
public async void Receive(AccountRemovedMessage message)
{
var remainingAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
if (!remainingAccounts.Any())
{
latestSelectedAccountMenuItem = null;
await ExecuteUIThread(() =>
{
SelectedMenuItem = null;
MenuItems?.Clear();
MenuItems?.Add(CreateMailMenuItem);
});
return;
}
if (latestSelectedAccountMenuItem?.HoldingAccounts?.Any(a => a.Id == message.Account.Id) == true)
{
latestSelectedAccountMenuItem = null;
await ExecuteUIThread(() => SelectedMenuItem = null);
}
await RecreateMenuItemsAsync();
await RestoreSelectedAccountAfterMenuRefreshAsync(false);
}
public async Task HandleAccountCreatedAsync(MailAccount createdAccount)
{
latestSelectedAccountMenuItem = null;
await RecreateMenuItemsAsync();
if (!MenuItems.TryGetAccountMenuItem(createdAccount.Id, out IAccountMenuItem createdMenuItem))
{
Log.Warning("Created account {AccountId} could not be found in menu items after refresh.", createdAccount.Id);
return;
}
await ChangeLoadedAccountAsync(createdMenuItem);
// Each created account should start a new synchronization automatically.
var options = new MailSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.FullFolders,
};
Messenger.Send(new NewMailSynchronizationRequested(options));
if (createdAccount.IsCalendarAccessGranted)
{
var calendarOptions = new CalendarSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = CalendarSynchronizationType.CalendarEvents
};
Messenger.Send(new NewCalendarSynchronizationRequested(calendarOptions));
}
try
{
await _nativeAppService.PinAppToTaskbarAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to pin Wino to taskbar.");
}
}
public async void Receive(AccountUpdatedMessage message)
{
var updatedAccount = message.Account;
await ExecuteUIThread(() =>
{
if (MenuItems.TryGetAccountMenuItem(updatedAccount.Id, out IAccountMenuItem foundAccountMenuItem))
{
foundAccountMenuItem.UpdateAccount(updatedAccount);
}
});
}
void IShellClient.Activate(ShellModeActivationContext activationContext)
=> OnNavigatedTo(NavigationMode.New, activationContext);
void IShellClient.Deactivate()
=> OnNavigatedFrom(NavigationMode.New, null!);
Task IShellClient.HandleNavigationItemInvokedAsync(IMenuItem menuItem)
=> MenuItemInvokedOrSelectedAsync(menuItem);
Task IShellClient.HandleNavigationSelectionChangedAsync(IMenuItem menuItem)
=> menuItem == null ? Task.CompletedTask : MenuItemInvokedOrSelectedAsync(menuItem);
}
+41 -17
View File
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.ViewModels;
using Wino.Messaging.UI;
@@ -12,39 +12,28 @@ public class MailBaseViewModel : CoreBaseViewModel,
IRecipient<MailAddedMessage>,
IRecipient<MailRemovedMessage>,
IRecipient<MailUpdatedMessage>,
IRecipient<BulkMailUpdatedMessage>,
IRecipient<MailDownloadedMessage>,
IRecipient<DraftCreated>,
IRecipient<DraftFailed>,
IRecipient<DraftMapped>,
IRecipient<FolderRenamed>,
IRecipient<FolderDeleted>,
IRecipient<FolderSynchronizationEnabled>
{
protected virtual void OnMailAdded(MailCopy addedMail) { }
protected virtual void OnMailRemoved(MailCopy removedMail) { }
protected virtual void OnMailUpdated(MailCopy updatedMail) { }
protected virtual void OnMailUpdated(IReadOnlyList<MailCopy> updatedMails)
{
if (updatedMails == null) return;
foreach (var mail in updatedMails)
{
OnMailUpdated(mail);
}
}
protected virtual void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { }
protected virtual void OnMailDownloaded(MailCopy downloadedMail) { }
protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { }
protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { }
protected virtual void OnDraftMapped(string localDraftCopyId, string remoteDraftCopyId) { }
protected virtual void OnFolderRenamed(IMailItemFolder mailItemFolder) { }
protected virtual void OnFolderDeleted(MailItemFolder folder) { }
protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { }
void IRecipient<MailAddedMessage>.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail);
void IRecipient<MailRemovedMessage>.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail);
void IRecipient<MailUpdatedMessage>.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail);
void IRecipient<BulkMailUpdatedMessage>.Receive(BulkMailUpdatedMessage message) => OnMailUpdated(message.UpdatedMails);
void IRecipient<MailUpdatedMessage>.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties);
void IRecipient<MailDownloadedMessage>.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail);
void IRecipient<DraftMapped>.Receive(DraftMapped message) => OnDraftMapped(message.LocalDraftCopyId, message.RemoteDraftCopyId);
@@ -52,5 +41,40 @@ public class MailBaseViewModel : CoreBaseViewModel,
void IRecipient<DraftCreated>.Receive(DraftCreated message) => OnDraftCreated(message.DraftMail, message.Account);
void IRecipient<FolderRenamed>.Receive(FolderRenamed message) => OnFolderRenamed(message.MailItemFolder);
void IRecipient<FolderDeleted>.Receive(FolderDeleted message) => OnFolderDeleted(message.MailItemFolder);
void IRecipient<FolderSynchronizationEnabled>.Receive(FolderSynchronizationEnabled message) => OnFolderSynchronizationEnabled(message.MailItemFolder);
protected override void RegisterRecipients()
{
base.RegisterRecipients();
UnregisterRecipients();
Messenger.Register<MailAddedMessage>(this);
Messenger.Register<MailRemovedMessage>(this);
Messenger.Register<MailUpdatedMessage>(this);
Messenger.Register<MailDownloadedMessage>(this);
Messenger.Register<DraftCreated>(this);
Messenger.Register<DraftFailed>(this);
Messenger.Register<DraftMapped>(this);
Messenger.Register<FolderRenamed>(this);
Messenger.Register<FolderDeleted>(this);
Messenger.Register<FolderSynchronizationEnabled>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<MailAddedMessage>(this);
Messenger.Unregister<MailRemovedMessage>(this);
Messenger.Unregister<MailUpdatedMessage>(this);
Messenger.Unregister<MailDownloadedMessage>(this);
Messenger.Unregister<DraftCreated>(this);
Messenger.Unregister<DraftFailed>(this);
Messenger.Unregister<DraftMapped>(this);
Messenger.Unregister<FolderRenamed>(this);
Messenger.Unregister<FolderDeleted>(this);
Messenger.Unregister<FolderSynchronizationEnabled>(this);
}
}
File diff suppressed because it is too large Load Diff
+221 -118
View File
@@ -11,27 +11,30 @@ using CommunityToolkit.Mvvm.Messaging;
using MailKit;
using MimeKit;
using MimeKit.Cryptography;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Menus;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Printing;
using Wino.Core.Domain.Models.Reader;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.ViewModels.Messages;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Mail.ViewModels;
public partial class MailRenderingPageViewModel : MailBaseViewModel,
IRecipient<NewMailItemRenderingRequestedEvent>,
IRecipient<ReaderItemRefreshRequestedEvent>,
IRecipient<ThumbnailAdded>,
ITransferProgress // For listening IMAP message download progress.
{
@@ -40,13 +43,13 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
private readonly IMimeFileService _mimeFileService;
private readonly Core.Domain.Interfaces.IMailService _mailService;
private readonly IFolderService _folderService;
private readonly IFileService _fileService;
private readonly IWinoRequestDelegator _requestDelegator;
private readonly IContactService _contactService;
private readonly IClipboardService _clipboardService;
private readonly IUnsubscriptionService _unsubscriptionService;
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
private bool forceImageLoading = false;
private MailItemViewModel initializedMailItemViewModel = null;
@@ -56,11 +59,17 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
// Used in 'Save as' and 'Print' functionality.
public Func<string, Task<bool>> SaveHTMLasPDFFunc { get; set; }
public Func<WebView2PrintSettingsModel, Task<PrintingResult>> DirectPrintFuncAsync { get; set; }
#region Properties
public bool ShouldDisplayDownloadProgress => IsIndetermineProgress || (CurrentDownloadPercentage > 0 && CurrentDownloadPercentage <= 100);
public bool CanUnsubscribe => CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false;
public bool IsJunkMail => initializedMailItemViewModel?.AssignedFolder != null && initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk;
public bool IsSmimeSigned => (CurrentRenderModel?.Signatures?.Count ?? 0) > 0;
public bool IsSmimeEncrypted => CurrentRenderModel?.IsSmimeEncrypted ?? false;
public bool IsJunkMail => initializedMailItemViewModel?.MailCopy.AssignedFolder != null && initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk;
public bool SmimeSignaturesValid => CurrentRenderModel?.Signatures?.Any(x => x.Value) ?? false;
public bool SmimeSignaturesInvalid => !SmimeSignaturesValid;
public bool IsImageRenderingDisabled
{
@@ -100,6 +109,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanUnsubscribe))]
[NotifyPropertyChangedFor(nameof(IsSmimeSigned))]
[NotifyPropertyChangedFor(nameof(IsSmimeEncrypted))]
[NotifyPropertyChangedFor(nameof(SmimeSignaturesValid))]
[NotifyPropertyChangedFor(nameof(SmimeSignaturesInvalid))]
public partial MailRenderModel CurrentRenderModel { get; set; }
[ObservableProperty]
@@ -112,7 +125,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
public partial string FromName { get; set; }
[ObservableProperty]
public partial string ContactPicture { get; set; }
public partial IMailItemDisplayInformation CurrentMailItemDisplayInformation { get; set; }
[ObservableProperty]
public partial DateTime CreationDate { get; set; }
@@ -120,7 +133,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
public ObservableCollection<AccountContactViewModel> CcItems { get; set; } = [];
public ObservableCollection<AccountContactViewModel> BccItems { get; set; } = [];
public ObservableCollection<MailAttachmentViewModel> Attachments { get; set; } = [];
public ObservableCollection<MailOperationMenuItem> MenuItems { get; set; } = [];
public ObservableCollection<IMenuOperation> MenuItems { get; set; } = [];
#endregion
@@ -128,22 +141,24 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
public IStatePersistanceService StatePersistenceService { get; }
public IPreferencesService PreferencesService { get; }
public IPrintService PrintService { get; }
public Guid? CurrentMailAccountId => initializedMailItemViewModel?.MailCopy.AssignedAccount?.Id;
public Guid? CurrentMailFileId => initializedMailItemViewModel?.MailCopy.FileId;
public MailRenderingPageViewModel(IMailDialogService dialogService,
INativeAppService nativeAppService,
IUnderlyingThemeService underlyingThemeService,
IMimeFileService mimeFileService,
IMailService mailService,
IFileService fileService,
IWinoRequestDelegator requestDelegator,
IStatePersistanceService statePersistenceService,
IContactService contactService,
IClipboardService clipboardService,
IUnsubscriptionService unsubscriptionService,
IPreferencesService preferencesService,
IPrintService printService,
IApplicationConfiguration applicationConfiguration,
IWinoServerConnectionManager winoServerConnectionManager)
INativeAppService nativeAppService,
IUnderlyingThemeService underlyingThemeService,
IMimeFileService mimeFileService,
IMailService mailService,
IFolderService folderService,
IFileService fileService,
IWinoRequestDelegator requestDelegator,
IStatePersistanceService statePersistenceService,
IContactService contactService,
IClipboardService clipboardService,
IUnsubscriptionService unsubscriptionService,
IPreferencesService preferencesService,
IPrintService printService,
IApplicationConfiguration applicationConfiguration)
{
_dialogService = dialogService;
NativeAppService = nativeAppService;
@@ -152,12 +167,12 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
PreferencesService = preferencesService;
PrintService = printService;
_applicationConfiguration = applicationConfiguration;
_winoServerConnectionManager = winoServerConnectionManager;
_clipboardService = clipboardService;
_unsubscriptionService = unsubscriptionService;
_underlyingThemeService = underlyingThemeService;
_mimeFileService = mimeFileService;
_mailService = mailService;
_folderService = folderService;
_fileService = fileService;
_requestDelegator = requestDelegator;
}
@@ -240,63 +255,98 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
[RelayCommand]
private async Task OperationClicked(MailOperationMenuItem menuItem)
private async Task OperationClicked(IMenuOperation menuItem)
{
if (menuItem == null) return;
if (menuItem is not MailOperationMenuItem mailOperationMenuItem) return;
await HandleMailOperationAsync(menuItem.Operation);
await HandleMailOperationAsync(mailOperationMenuItem.Operation);
}
private async Task HandleMailOperationAsync(MailOperation operation)
{
// Toggle theme
if (operation == MailOperation.DarkEditor || operation == MailOperation.LightEditor)
IsDarkWebviewRenderer = !IsDarkWebviewRenderer;
else if (operation == MailOperation.SaveAs)
try
{
await SaveAsAsync();
}
else if (operation == MailOperation.Print)
{
await PrintAsync();
}
else if (operation == MailOperation.ViewMessageSource)
{
await _dialogService.ShowMessageSourceDialogAsync(initializedMimeMessageInformation.MimeMessage.ToString());
}
else if (operation == MailOperation.Reply || operation == MailOperation.ReplyAll || operation == MailOperation.Forward)
{
if (initializedMailItemViewModel == null) return;
// Create new draft.
var draftOptions = new DraftCreationOptions()
if (operation == MailOperation.SaveAs)
{
Reason = operation switch
await SaveAsAsync();
}
else if (operation == MailOperation.Print)
{
var settings = await _dialogService.ShowPrintDialogAsync();
if (settings == null) return;
var printingResult = await DirectPrintFuncAsync.Invoke(settings);
// TODO: More detailed printing result handling.
if (printingResult == PrintingResult.Submitted)
{
MailOperation.Reply => DraftCreationReason.Reply,
MailOperation.ReplyAll => DraftCreationReason.ReplyAll,
MailOperation.Forward => DraftCreationReason.Forward,
_ => DraftCreationReason.Empty
},
ReferencedMessage = new ReferencedMessage()
{
MimeMessage = initializedMimeMessageInformation.MimeMessage,
MailCopy = initializedMailItemViewModel.MailCopy
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingSuccessTitle, Translator.DialogMessage_PrintingSuccessMessage, InfoBarMessageType.Success);
}
else if (printingResult == PrintingResult.Failed)
{
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingFailedTitle, Translator.DialogMessage_PrintingFailedMessage, InfoBarMessageType.Error);
}
};
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(initializedMailItemViewModel.AssignedAccount.Id, draftOptions).ConfigureAwait(false);
}
else if (operation == MailOperation.ViewMessageSource)
{
await _dialogService.ShowMessageSourceDialogAsync(initializedMimeMessageInformation.MimeMessage.ToString());
}
else if (operation == MailOperation.Reply || operation == MailOperation.ReplyAll || operation == MailOperation.Forward)
{
if (initializedMailItemViewModel == null) return;
var draftPreparationRequest = new DraftPreparationRequest(initializedMailItemViewModel.AssignedAccount, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason, initializedMailItemViewModel.MailCopy);
// Create new draft.
var draftOptions = new DraftCreationOptions()
{
Reason = operation switch
{
MailOperation.Reply => DraftCreationReason.Reply,
MailOperation.ReplyAll => DraftCreationReason.ReplyAll,
MailOperation.Forward => DraftCreationReason.Forward,
_ => DraftCreationReason.Empty
},
ReferencedMessage = new ReferencedMessage()
{
MimeMessage = initializedMimeMessageInformation.MimeMessage,
MailCopy = initializedMailItemViewModel.MailCopy
}
};
await _requestDelegator.ExecuteAsync(draftPreparationRequest);
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(initializedMailItemViewModel.MailCopy.AssignedAccount.Id, draftOptions).ConfigureAwait(false);
var draftPreparationRequest = new DraftPreparationRequest(initializedMailItemViewModel.MailCopy.AssignedAccount, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason, initializedMailItemViewModel.MailCopy);
await _requestDelegator.ExecuteAsync(draftPreparationRequest);
}
else if (initializedMailItemViewModel != null)
{
// All other operations require a mail item.
var prepRequest = new MailOperationPreperationRequest(operation, initializedMailItemViewModel.MailCopy);
await _requestDelegator.ExecuteAsync(prepRequest);
}
}
else if (initializedMailItemViewModel != null)
catch (UnavailableSpecialFolderException unavailableSpecialFolderException)
{
// All other operations require a mail item.
var prepRequest = new MailOperationPreperationRequest(operation, initializedMailItemViewModel.MailCopy);
await _requestDelegator.ExecuteAsync(prepRequest);
_dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle,
string.Format(Translator.Info_MissingFolderMessage, unavailableSpecialFolderException.SpecialFolderType),
InfoBarMessageType.Warning,
Translator.SettingConfigureSpecialFolders_Button,
() =>
{
_dialogService.HandleSystemFolderConfigurationDialogAsync(unavailableSpecialFolderException.AccountId, _folderService);
});
}
catch (NotImplementedException)
{
_dialogService.ShowNotSupportedMessage();
}
catch (Exception ex)
{
Log.Error(ex, "Mail operation execution failed. Operation: {Operation}", operation);
_dialogService.InfoBarMessage(Translator.Info_RequestCreationFailedTitle, ex.Message, InfoBarMessageType.Error);
}
}
@@ -310,6 +360,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
initializedMailItemViewModel = null;
initializedMimeMessageInformation = null;
CurrentMailItemDisplayInformation = null;
// Dispose existing content first.
Messenger.Send(new CancelRenderingContentRequested());
@@ -355,8 +406,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
// To show the progress on the UI.
CurrentDownloadPercentage = 1;
var package = new DownloadMissingMessageRequested(mailItemViewModel.AssignedAccount.Id, mailItemViewModel.MailCopy);
await _winoServerConnectionManager.GetResponseAsync<bool, DownloadMissingMessageRequested>(package);
// Download missing MIME message using SynchronizationManager
await SynchronizationManager.Instance.DownloadMimeMessageAsync(
mailItemViewModel.MailCopy,
mailItemViewModel.MailCopy.AssignedAccount.Id);
}
catch (OperationCanceledException)
{
@@ -375,7 +428,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
private async Task RenderAsync(MailItemViewModel mailItemViewModel, CancellationToken cancellationToken = default)
{
ResetProgress();
var isMimeExists = await _mimeFileService.IsMimeExistAsync(mailItemViewModel.AssignedAccount.Id, mailItemViewModel.MailCopy.FileId);
var isMimeExists = await _mimeFileService.IsMimeExistAsync(mailItemViewModel.MailCopy.AssignedAccount.Id, mailItemViewModel.MailCopy.FileId);
if (!isMimeExists)
{
@@ -384,7 +437,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
// Find the MIME for this item and render it.
var mimeMessageInformation = await _mimeFileService.GetMimeMessageInformationAsync(mailItemViewModel.MailCopy.FileId,
mailItemViewModel.AssignedAccount.Id,
mailItemViewModel.MailCopy.AssignedAccount.Id,
cancellationToken).ConfigureAwait(false);
if (mimeMessageInformation == null)
@@ -394,6 +447,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
initializedMailItemViewModel = mailItemViewModel;
await ExecuteUIThread(() => { CurrentMailItemDisplayInformation = mailItemViewModel; });
await RenderAsync(mimeMessageInformation);
}
@@ -435,13 +489,14 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
// TODO: FromName and FromAddress is probably not correct here for mail lists.
FromAddress = message.From.Mailboxes.FirstOrDefault()?.Address ?? Translator.UnknownAddress;
FromName = message.From.Mailboxes.FirstOrDefault()?.Name ?? Translator.UnknownSender;
CreationDate = message.Date.DateTime;
ContactPicture = initializedMailItemViewModel?.SenderContact?.Base64ContactPicture;
// Use the received date from MailCopy if available, otherwise fall back to the sent date from MIME message
CreationDate = initializedMailItemViewModel?.MailCopy.CreationDate ?? message.Date.DateTime;
// Automatically disable images for Junk folder to prevent pixel tracking.
// This can only work for selected mail item rendering, not for EML file rendering.
if (initializedMailItemViewModel != null &&
initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
{
renderingOptions.LoadImages = false;
}
@@ -480,7 +535,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
var contactViewModel = new AccountContactViewModel(foundContact);
// Make sure that user account first in the list.
if (string.Equals(contactViewModel.Address, initializedMailItemViewModel?.AssignedAccount?.Address, StringComparison.OrdinalIgnoreCase))
if (string.Equals(contactViewModel.Address, initializedMailItemViewModel?.MailCopy.AssignedAccount?.Address, StringComparison.OrdinalIgnoreCase))
{
contactViewModel.IsMe = true;
accounts.Insert(0, contactViewModel);
@@ -506,11 +561,15 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
{
base.OnNavigatedFrom(mode, parameters);
renderCancellationTokenSource.Cancel();
renderCancellationTokenSource?.Cancel();
renderCancellationTokenSource?.Dispose();
renderCancellationTokenSource = null;
CurrentDownloadPercentage = 0d;
initializedMailItemViewModel = null;
initializedMimeMessageInformation = null;
CurrentMailItemDisplayInformation = null;
forceImageLoading = false;
@@ -527,12 +586,6 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
{
MenuItems.Clear();
// Add light/dark editor theme switch.
if (IsDarkWebviewRenderer)
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
// Save As PDF
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SaveAs, true, true));
@@ -563,7 +616,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
// Archive - Unarchive
if (initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive)
if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive)
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive));
@@ -586,15 +639,15 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
}
protected override async void OnMailUpdated(MailCopy updatedMail)
protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties)
{
base.OnMailUpdated(updatedMail);
base.OnMailUpdated(updatedMail, source, changedProperties);
if (initializedMailItemViewModel == null) return;
// Check if the updated mail is the same mail item we are rendering.
// This is done with UniqueId to include FolderId into calculations.
if (initializedMailItemViewModel.UniqueId != updatedMail.UniqueId) return;
if (initializedMailItemViewModel.MailCopy.UniqueId != updatedMail.UniqueId) return;
await ExecuteUIThread(() => { InitializeCommandBarItems(); });
}
@@ -690,40 +743,6 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
}
private async Task PrintAsync()
{
// Printing:
// 1. Let WebView2 save the current HTML as PDF to temporary location.
// 2. Saving as PDF will divide pages correctly for Win2D CanvasBitmap.
// 3. Use Win2D CanvasBitmap as IPrintDocumentSource and WinRT APIs to print the PDF.
try
{
var printFilePath = Path.Combine(_applicationConfiguration.ApplicationTempFolderPath, "print.pdf");
if (File.Exists(printFilePath)) File.Delete(printFilePath);
await SaveHTMLasPDFFunc(printFilePath);
var result = await PrintService.PrintPdfFileAsync(printFilePath, Subject);
if (result == PrintingResult.Submitted)
{
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingSuccessTitle, Translator.DialogMessage_PrintingSuccessMessage, InfoBarMessageType.Success);
}
else if (result != PrintingResult.Canceled)
{
var message = string.Format(Translator.DialogMessage_PrintingFailedMessage, result);
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingFailedTitle, message, InfoBarMessageType.Warning);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to print mail.");
_dialogService.InfoBarMessage(string.Empty, ex.Message, InfoBarMessageType.Error);
}
}
private async Task SaveAsAsync()
{
try
@@ -739,8 +758,8 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
if (isSaved)
{
_dialogService.InfoBarMessage(Translator.Info_PDFSaveSuccessTitle,
string.Format(Translator.Info_PDFSaveSuccessMessage, pdfFilePath),
InfoBarMessageType.Success);
string.Format(Translator.Info_PDFSaveSuccessMessage, pdfFilePath),
InfoBarMessageType.Success);
}
}
catch (Exception ex)
@@ -782,8 +801,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
// For upload.
void ITransferProgress.Report(long bytesTransferred) { }
public async void Receive(NewMailItemRenderingRequestedEvent message)
public async void Receive(ReaderItemRefreshRequestedEvent message)
{
if (message.MailItemViewModel == null || message.MailItemViewModel.IsDraft) return;
try
{
await RenderAsync(message.MailItemViewModel, renderCancellationTokenSource.Token);
@@ -822,4 +843,86 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
});
}
[RelayCommand]
private async Task ShowSmimeSigningCertificateInfoAsync()
{
if (IsSmimeSigned)
{
MimePart signaturePart;
if (initializedMimeMessageInformation?.MimeMessage?.Body is MultipartSigned signed && signed[1] is MimePart signaturePart1)
{
signaturePart = signaturePart1;
}
else if (initializedMimeMessageInformation?.MimeMessage?.Body is ApplicationPkcs7Mime pkcs7)
{
signaturePart = null;
}
else
{
//_dialogService.InfoBarMessage(Translator.Info_SmimeSignatureNotFoundTitle, Translator.Info_SmimeSignatureNotFoundMessage, InfoBarMessageType.Error);
return;
}
string info = $"{Translator.SmimeSignaturesInMessage}:\n";
foreach (var (signature, valid) in CurrentRenderModel.Signatures)
{
info += string.Format(Translator.SmimeSignatureEntry, valid ? "✅" : "❌", signature.SignerCertificate.Name, signature.SignerCertificate.Fingerprint, signature.SignerCertificate.CreationDate, signature.SignerCertificate.ExpirationDate);
}
await ShowSmimeCertificateInfoAsync(signaturePart, info, Translator.SmimeSigningCertificateInfoTitle);
}
}
private async Task ShowSmimeCertificateInfoAsync(MimePart certificateAttachment, string additionalInfo = "", string title = null)
{
{
if (certificateAttachment == null)
{
await _dialogService.ShowConfirmationDialogAsync(
$"{additionalInfo}\n{Translator.SmimeNoCertificateFileFound}", title ?? Translator.SmimeCertificateInfoTitle, Translator.Buttons_OK);
return;
}
var fileName = certificateAttachment.FileName ?? "smime.p7s";
var contentType = certificateAttachment.ContentType?.MimeType ?? "application/pkcs7-signature";
var size = certificateAttachment.Content?.Stream?.Length ?? 0;
var info = string.Format(Translator.SmimeCertificateFileInfo, fileName, contentType, size);
var result = await _dialogService.ShowConfirmationDialogAsync(
$"{additionalInfo}\n{info}", title ?? Translator.SmimeCertificateInfoTitle,
Translator.SmimeSaveCertificate);
if (result)
{
var pickedPath = await _dialogService.PickFilePathAsync(fileName);
if (!string.IsNullOrEmpty(pickedPath))
{
var pickedDirectory = Path.GetDirectoryName(pickedPath);
var pickedFileName = Path.GetFileName(pickedPath);
await using (var stream = await _fileService.GetFileStreamAsync(pickedDirectory, pickedFileName))
{
await certificateAttachment.Content!.DecodeToAsync(stream);
}
_dialogService.InfoBarMessage(Translator.SmimeCertificate, string.Format(Translator.SmimeCertificateSavedTo, pickedPath),
InfoBarMessageType.Success);
}
}
}
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<ReaderItemRefreshRequestedEvent>(this);
Messenger.Register<ThumbnailAdded>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<ReaderItemRefreshRequestedEvent>(this);
Messenger.Unregister<ThumbnailAdded>(this);
}
}
@@ -1,13 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Wino.Core;
namespace Wino.Mail.ViewModels;
public static class MailViewModelsContainerSetup
{
public static void RegisterViewModelService(this IServiceCollection services)
{
// View models use core services.
services.RegisterCoreServices();
}
}
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
@@ -12,22 +14,8 @@ public partial class MessageListPageViewModel : MailBaseViewModel
{
public IPreferencesService PreferencesService { get; }
private readonly IThumbnailService _thumbnailService;
private int selectedMarkAsOptionIndex;
public int SelectedMarkAsOptionIndex
{
get => selectedMarkAsOptionIndex;
set
{
if (SetProperty(ref selectedMarkAsOptionIndex, value))
{
if (value >= 0)
{
PreferencesService.MarkAsPreference = (MailMarkAsOption)Enum.GetValues<MailMarkAsOption>().GetValue(value);
}
}
}
}
private readonly IStatePersistanceService _statePersistenceService;
private readonly IDialogServiceBase _dialogService;
private readonly List<MailOperation> availableHoverActions =
[
@@ -38,6 +26,13 @@ public partial class MessageListPageViewModel : MailBaseViewModel
MailOperation.MoveToJunk
];
private readonly List<MailListDisplayMode> availableMailSpacingOptions =
[
MailListDisplayMode.Compact,
MailListDisplayMode.Medium,
MailListDisplayMode.Spacious
];
public List<string> AvailableHoverActionsTranslations { get; set; } =
[
Translator.HoverActionOption_Archive,
@@ -47,6 +42,37 @@ public partial class MessageListPageViewModel : MailBaseViewModel
Translator.HoverActionOption_MoveJunk
];
public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; } = new DemoMailItemDisplayInformation();
public MailListDisplayMode SelectedMailSpacingMode => availableMailSpacingOptions[selectedMailSpacingIndex];
private int selectedMarkAsOptionIndex;
public int SelectedMarkAsOptionIndex
{
get => selectedMarkAsOptionIndex;
set
{
if (SetProperty(ref selectedMarkAsOptionIndex, value) && value >= 0)
{
PreferencesService.MarkAsPreference = (MailMarkAsOption)Enum.GetValues<MailMarkAsOption>().GetValue(value);
}
}
}
private int selectedMailSpacingIndex;
public int SelectedMailSpacingIndex
{
get => selectedMailSpacingIndex;
set
{
if (SetProperty(ref selectedMailSpacingIndex, value) && value >= 0 && value < availableMailSpacingOptions.Count)
{
PreferencesService.MailItemDisplayMode = availableMailSpacingOptions[value];
OnPropertyChanged(nameof(SelectedMailSpacingMode));
}
}
}
#region Properties
private int leftHoverActionIndex;
public int LeftHoverActionIndex
@@ -88,13 +114,19 @@ public partial class MessageListPageViewModel : MailBaseViewModel
}
#endregion
public MessageListPageViewModel(IPreferencesService preferencesService, IThumbnailService thumbnailService)
public MessageListPageViewModel(IPreferencesService preferencesService,
IThumbnailService thumbnailService,
IStatePersistanceService statePersistenceService,
IDialogServiceBase dialogService)
{
PreferencesService = preferencesService;
_thumbnailService = thumbnailService;
_statePersistenceService = statePersistenceService;
_dialogService = dialogService;
leftHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.LeftHoverAction);
centerHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.CenterHoverAction);
rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction);
selectedMailSpacingIndex = availableMailSpacingOptions.IndexOf(PreferencesService.MailItemDisplayMode);
SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues<MailMarkAsOption>(), PreferencesService.MarkAsPreference);
}
@@ -103,4 +135,39 @@ public partial class MessageListPageViewModel : MailBaseViewModel
{
await _thumbnailService.ClearCache();
}
[RelayCommand]
private void ResetMailListPaneLength()
{
_statePersistenceService.MailListPaneLength = 420;
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info, Translator.Info_MailListSizeResetSuccessMessage, InfoBarMessageType.Success);
}
private sealed class DemoMailItemDisplayInformation : IMailItemDisplayInformation
{
public event PropertyChangedEventHandler PropertyChanged
{
add { }
remove { }
}
public string Subject => "Quarterly planning notes";
public string FromName => "Ava Brooks";
public string FromAddress => "ava@contoso.com";
public string PreviewText => "Agenda draft, attendee updates, and a few follow-up items for this week.";
public bool IsRead => false;
public bool IsDraft => false;
public bool HasAttachments => true;
public bool IsCalendarEvent => false;
public bool IsFlagged => true;
public DateTime CreationDate => DateTime.Now.AddMinutes(-12);
public Guid? ContactPictureFileId => null;
public bool ThumbnailUpdatedEvent => false;
public bool IsThreadExpanded => false;
public AccountContact SenderContact => new()
{
Address = "ava@contoso.com",
Name = "Ava Brooks"
};
}
}
@@ -1,18 +0,0 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// Wino has complex selected item detection mechanism with nested ListViews that
/// supports multi selection with threads. Each list view will raise this for mail list page
/// to react.
/// </summary>
public class MailItemSelectedEvent
{
public MailItemSelectedEvent(MailItemViewModel selectedMailItem)
{
SelectedMailItem = selectedMailItem;
}
public MailItemViewModel SelectedMailItem { get; set; }
}
@@ -1,16 +0,0 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// Selected item removed event.
/// </summary>
public class MailItemSelectionRemovedEvent
{
public MailItemSelectionRemovedEvent(MailItemViewModel removedMailItem)
{
RemovedMailItem = removedMailItem;
}
public MailItemViewModel RemovedMailItem { get; set; }
}
@@ -1,10 +0,0 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// When the rendering page is active, but new item is requested to be rendered.
/// To not trigger navigation again and re-use existing Chromium.
/// </summary>
/// <param name="MailItemViewModel"></param>
public record NewMailItemRenderingRequestedEvent(MailItemViewModel MailItemViewModel);
@@ -0,0 +1,10 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// Requests refreshing the currently active reader page (mail rendering or compose)
/// with a different selected mail item without re-navigation.
/// </summary>
/// <param name="MailItemViewModel">The selected mail item to refresh with.</param>
public record ReaderItemRefreshRequestedEvent(MailItemViewModel MailItemViewModel);
@@ -1,8 +1,8 @@
using Wino.Mail.ViewModels.Data;
using System;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// When listing view model manipulated the selected mail container in the UI.
/// </summary>
public record SelectMailItemContainerEvent(MailItemViewModel SelectedMailViewModel, bool ScrollToItem = false);
public record SelectMailItemContainerEvent(Guid MailUniqueId, bool ScrollToItem = false);
@@ -0,0 +1,9 @@
using Wino.Core.Domain.Enums;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// When a swipe action is performed on a mail item container.
/// </summary>
public record SwipeActionRequested(MailOperation Operation, IMailListItem MailItem);
@@ -0,0 +1,122 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
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;
namespace Wino.Mail.ViewModels;
public partial class ProviderSelectionPageViewModel : MailBaseViewModel
{
private readonly IProviderService _providerService;
private readonly INewThemeService _themeService;
public WelcomeWizardContext WizardContext { get; }
public List<IProviderDetail> Providers { get; private set; } = [];
public List<AppColorViewModel> AvailableColors { get; private set; } = [];
[ObservableProperty]
public partial IProviderDetail SelectedProvider { get; set; }
[ObservableProperty]
public partial AppColorViewModel SelectedColor { get; set; }
[ObservableProperty]
public partial string AccountName { get; set; }
[ObservableProperty]
public partial bool CanProceed { get; set; }
public bool IsColorSelected => SelectedColor != null;
public ProviderSelectionPageViewModel(
IProviderService providerService,
INewThemeService themeService,
WelcomeWizardContext wizardContext)
{
_providerService = providerService;
_themeService = themeService;
WizardContext = wizardContext;
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
Providers = _providerService.GetAvailableProviders();
AvailableColors = _themeService.GetAvailableAccountColors()
.Select(hex => new AppColorViewModel(hex))
.ToList();
// Restore from wizard context if navigating back
if (WizardContext.SelectedProvider != null)
{
SelectedProvider = Providers.FirstOrDefault(p =>
p.Type == WizardContext.SelectedProvider.Type &&
p.SpecialImapProvider == WizardContext.SelectedProvider.SpecialImapProvider);
AccountName = WizardContext.AccountName;
if (WizardContext.AccountColorHex != null)
SelectedColor = AvailableColors.FirstOrDefault(c => c.Hex == WizardContext.AccountColorHex);
}
Validate();
}
partial void OnSelectedProviderChanged(IProviderDetail value) => Validate();
partial void OnAccountNameChanged(string value) => Validate();
partial void OnSelectedColorChanged(AppColorViewModel value) => OnPropertyChanged(nameof(IsColorSelected));
[RelayCommand]
private void ClearColor() => SelectedColor = null;
private void Validate()
{
CanProceed = SelectedProvider != null && !string.IsNullOrWhiteSpace(AccountName);
}
[RelayCommand]
private void Proceed()
{
if (!CanProceed) return;
// Persist to wizard context
WizardContext.SelectedProvider = SelectedProvider;
WizardContext.AccountName = AccountName?.Trim();
WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty;
if (WizardContext.IsGenericImap)
{
// Navigate to ImapCalDavSettingsPage in wizard mode
var context = ImapCalDavSettingsNavigationContext.CreateForWizardMode(
WizardContext.BuildAccountCreationDialogResult());
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.ImapCalDavSettingsPage_TitleCreate,
WinoPage.ImapCalDavSettingsPage,
context));
}
else if (SelectedProvider.SpecialImapProvider is SpecialImapProvider.iCloud or SpecialImapProvider.Yahoo)
{
// Navigate to credentials page for special IMAP providers
Messenger.Send(new BreadcrumbNavigationRequested(
SelectedProvider.Name,
WinoPage.SpecialImapCredentialsPage));
}
else
{
// OAuth — go directly to progress page
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.WelcomeWizard_Step3Title,
WinoPage.AccountSetupProgressPage));
}
}
}
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Common;
namespace Wino.Mail.ViewModels;
public partial class SignatureAndEncryptionPageViewModel : MailBaseViewModel
{
private readonly ISmimeCertificateService _smimeCertificateService;
private readonly IDialogServiceBase _dialogService;
private readonly IFileService _fileService;
public ObservableCollection<X509Certificate2> PersonalCertificates { get; } = [];
public ObservableCollection<X509Certificate2> RecipientCertificates { get; } = [];
public List<X509Certificate2> SelectedPersonalCertificates { get; } = [];
public List<X509Certificate2> SelectedRecipientCertificates { get; } = [];
public bool PersonalCertificatesEmpty => PersonalCertificates.Count == 0;
public SignatureAndEncryptionPageViewModel(
IDialogServiceBase dialogService,
ISmimeCertificateService smimeCertificateService,
IFileService fileService
)
{
_dialogService = dialogService;
_fileService = fileService;
_smimeCertificateService = smimeCertificateService;
PersonalCertificates.CollectionChanged += (s, e) => { OnPropertyChanged(nameof(PersonalCertificatesEmpty)); };
LoadAllCertificates();
}
private void LoadAllCertificates()
{
PersonalCertificates.Clear();
var personalCerts = _smimeCertificateService.GetCertificates();
foreach (var cert in personalCerts)
{
PersonalCertificates.Add(cert);
}
// Recipient certificates
RecipientCertificates.Clear();
var recipientCerts = _smimeCertificateService.GetCertificates(storeName: StoreName.AddressBook);
foreach (var cert in recipientCerts)
{
RecipientCertificates.Add(cert);
}
}
[RelayCommand]
public async Task ImportPersonalCertificatesAsync()
{
await ImportCertificates(StoreName.My);
}
[RelayCommand]
public async Task ImportRecipientCertificatesAsync()
{
await ImportCertificates(StoreName.AddressBook);
}
private async Task ImportCertificates(StoreName storeName)
{
var files = await PickCertificateFilesAsync();
var failedImports = new List<string>();
var successCount = 0;
foreach (var file in files)
{
string password = null;
if (file.FileExtension.Equals(".pfx") || file.FileExtension.Equals(".p12"))
{
password = await PromptForPasswordAsync(file.FileName);
}
try
{
_smimeCertificateService.ImportCertificate(file.FileExtension, file.Data, password,
storeName: storeName);
successCount++;
}
catch (Exception ex)
{
failedImports.Add($"{file.FileName}: {ex.Message}");
}
}
LoadAllCertificates();
if (successCount > 0)
{
_dialogService.InfoBarMessage(
string.Format(Translator.Smime_ImportCertificates_Success),
Translator.GeneralTitle_Info,
InfoBarMessageType.Success);
}
if (failedImports.Count > 0)
{
await _dialogService.ShowMessageAsync(
$"{Translator.Smime_ImportCertificates_Error}\n\n{string.Join("\n", failedImports)}",
Translator.GeneralTitle_Warning,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Warning);
}
}
[RelayCommand]
public async Task RemovePersonalCertificatesAsync()
{
await RemoveCertificatesAsync(SelectedPersonalCertificates, StoreName.My);
}
[RelayCommand]
public async Task RemoveRecipientCertificatesAsync()
{
await RemoveCertificatesAsync(SelectedRecipientCertificates, StoreName.AddressBook);
}
private async Task RemoveCertificatesAsync(List<X509Certificate2> certificates, StoreName storeName)
{
if (certificates.Any())
{
var confirm = await ConfirmAsync(string.Format(Translator.Smime_RemoveCertificates_Confirm,
string.Join(", ", certificates.Select(cert => cert.Subject))));
if (confirm)
{
foreach (var cert in certificates)
{
_smimeCertificateService.RemoveCertificate(cert.Thumbprint, storeName: storeName);
}
LoadAllCertificates();
_dialogService.InfoBarMessage(
Translator.Smime_RemoveCertificates_Success,
Translator.GeneralTitle_Info,
InfoBarMessageType.Success
);
}
}
}
[RelayCommand]
public async Task ExportPersonalCertificatesAsync()
{
await ExportCertificatesAsync(SelectedPersonalCertificates);
}
[RelayCommand]
public async Task ExportRecipientCertificatesAsync()
{
await ExportCertificatesAsync(SelectedRecipientCertificates);
}
// Export logic for .cer or .pem
private async Task ExportCertificatesAsync(IEnumerable<X509Certificate2> cert)
{
var failedExports = new List<string>();
var successCount = 0;
foreach (var certificate in cert)
{
var fileName = $"{certificate.Subject.Replace("CN=", "")}.cer";
var path = await _dialogService.PickFilePathAsync(fileName);
if (path != null)
{
var folderPath = System.IO.Path.GetDirectoryName(path);
await using var stream = await _fileService.GetFileStreamAsync(folderPath, fileName);
if (stream != null)
{
try
{
var certificateData = certificate.Export(X509ContentType.Cert);
await stream.WriteAsync(certificateData, 0, certificateData.Length);
await stream.FlushAsync();
successCount++;
}
catch (Exception ex)
{
failedExports.Add($"{certificate.Subject}: {ex.Message}");
}
}
else
{
failedExports.Add($"{certificate.Subject}: File stream error");
}
}
}
if (successCount > 0)
{
_dialogService.InfoBarMessage(
Translator.Smime_ExportCertificates_Success,
Translator.GeneralTitle_Info,
InfoBarMessageType.Success
);
}
if (failedExports.Count > 0)
{
await _dialogService.ShowMessageAsync(
$"{Translator.Smime_ExportCertificates_Error}\n\n{string.Join("\n", failedExports)}",
Translator.GeneralTitle_Warning,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Warning);
}
}
private async Task ShowCertificateDetailsAsync(X509Certificate2 cert)
{
var details = string.Format(Translator.Smime_CertificateDetails, cert.Subject, cert.Issuer, cert.NotBefore,
cert.NotAfter, cert.Thumbprint);
await _dialogService.ShowMessageAsync(details, Translator.GeneralTitle_Info,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Information);
}
// Confirmation dialog
private async Task<bool> ConfirmAsync(string message)
{
return await _dialogService.ShowConfirmationDialogAsync(message, Translator.Smime_Confirm_Title,
Translator.Buttons_Yes);
}
// File picker for importing certificates
private async Task<List<SharedFile>> PickCertificateFilesAsync()
{
return await _dialogService.PickFilesAsync(".pfx", ".p12", ".cer", ".crt");
}
// Ask for password for .pfx/.p12
private async Task<string> PromptForPasswordAsync(string fileName)
{
return await _dialogService.ShowTextInputDialogAsync("",
Translator.Smime_CertificatePassword_Title,
string.Format(Translator.Smime_CertificatePassword_Placeholder, fileName), Translator.Buttons_OK);
}
}
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
namespace Wino.Mail.ViewModels;
public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel
{
private static readonly Dictionary<SpecialImapProvider, string> AppPasswordHelpLinks = new()
{
{ SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" },
{ SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" },
};
private readonly INativeAppService _nativeAppService;
public WelcomeWizardContext WizardContext { get; }
[ObservableProperty]
public partial string DisplayName { get; set; }
[ObservableProperty]
public partial string EmailAddress { get; set; }
[ObservableProperty]
public partial string AppSpecificPassword { get; set; }
[ObservableProperty]
public partial int SelectedCalendarModeIndex { get; set; }
[ObservableProperty]
public partial bool CanProceed { get; set; }
public string AppPasswordHelpUrl
{
get
{
if (WizardContext.SelectedProvider == null) return null;
AppPasswordHelpLinks.TryGetValue(WizardContext.SelectedProvider.SpecialImapProvider, out var url);
return url;
}
}
public string CalendarModeCalDavDescription
=> WizardContext.SelectedProvider?.SpecialImapProvider == SpecialImapProvider.iCloud
? Translator.ProviderSelection_CalendarMode_CalDavDescription_Apple
: Translator.ProviderSelection_CalendarMode_CalDavDescription_Yahoo;
public SpecialImapCredentialsPageViewModel(
INativeAppService nativeAppService,
WelcomeWizardContext wizardContext)
{
_nativeAppService = nativeAppService;
WizardContext = wizardContext;
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
// Restore from context when navigating back
DisplayName = WizardContext.DisplayName;
EmailAddress = WizardContext.EmailAddress;
AppSpecificPassword = WizardContext.AppSpecificPassword;
SelectedCalendarModeIndex = WizardContext.CalendarSupportMode switch
{
ImapCalendarSupportMode.CalDav => 1,
ImapCalendarSupportMode.LocalOnly => 2,
_ => 0
};
OnPropertyChanged(nameof(AppPasswordHelpUrl));
OnPropertyChanged(nameof(CalendarModeCalDavDescription));
Validate();
}
partial void OnDisplayNameChanged(string value) => Validate();
partial void OnEmailAddressChanged(string value) => Validate();
partial void OnAppSpecificPasswordChanged(string value) => Validate();
private void Validate()
{
CanProceed = !string.IsNullOrWhiteSpace(DisplayName)
&& !string.IsNullOrWhiteSpace(EmailAddress)
&& EmailValidation.EmailValidator.Validate(EmailAddress ?? string.Empty)
&& !string.IsNullOrWhiteSpace(AppSpecificPassword);
}
[RelayCommand]
private void Proceed()
{
if (!CanProceed) return;
WizardContext.DisplayName = DisplayName?.Trim();
WizardContext.EmailAddress = EmailAddress?.Trim();
WizardContext.AppSpecificPassword = AppSpecificPassword?.Trim();
WizardContext.CalendarSupportMode = SelectedCalendarModeIndex switch
{
1 => ImapCalendarSupportMode.CalDav,
2 => ImapCalendarSupportMode.LocalOnly,
_ => ImapCalendarSupportMode.Disabled
};
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.WelcomeWizard_Step3Title,
WinoPage.AccountSetupProgressPage));
}
[RelayCommand]
private async Task OpenAppPasswordHelp()
{
var url = AppPasswordHelpUrl;
if (url != null)
await _nativeAppService.LaunchUriAsync(new Uri(url));
}
}
@@ -0,0 +1,237 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Extensions;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels;
public partial class StoragePageViewModel(
IAccountService accountService,
IMimeStorageService mimeStorageService,
IMailDialogService dialogService) : MailBaseViewModel
{
private readonly ILogger _logger = Log.ForContext<StoragePageViewModel>();
private readonly IAccountService _accountService = accountService;
private readonly IMimeStorageService _mimeStorageService = mimeStorageService;
private readonly IMailDialogService _dialogService = dialogService;
public ObservableCollection<AccountStorageItemViewModel> AccountStorageItems { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBusy))]
public partial bool IsLoading { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBusy))]
public partial bool IsCleaning { get; set; }
[ObservableProperty]
public partial string MimeRootPath { get; set; } = string.Empty;
[ObservableProperty]
public partial string SummaryText { get; set; } = "";
public bool IsBusy => IsLoading || IsCleaning;
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
await ExecuteUIThread(() => { SummaryText = Translator.SettingsStorage_NoLocalMimeDataFound; });
await RefreshStorageAsync();
}
partial void OnIsLoadingChanged(bool value)
{
UpdateAccountBusyState();
}
partial void OnIsCleaningChanged(bool value)
{
UpdateAccountBusyState();
}
private void UpdateAccountBusyState()
{
Dispatcher.ExecuteOnUIThread(() =>
{
foreach (var item in AccountStorageItems)
{
item.IsBusy = IsBusy;
}
});
}
[RelayCommand]
private async Task RefreshStorageAsync()
{
if (IsBusy) return;
await ExecuteUIThread(() => { IsLoading = true; });
try
{
var mimeRootPath = await _mimeStorageService.GetMimeRootPathAsync().ConfigureAwait(false);
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var sizeMap = await _mimeStorageService.GetAccountsMimeStorageSizesAsync(accounts.Select(a => a.Id)).ConfigureAwait(false);
var storageItems = accounts
.Select(account =>
{
sizeMap.TryGetValue(account.Id, out var accountSize);
var viewModel = new AccountStorageItemViewModel(account, accountSize, DeleteAllCommand, DeleteOlderThanOneMonthCommand, DeleteOlderThanThreeMonthsCommand, DeleteOlderThanSixMonthsCommand, DeleteOlderThanOneYearCommand);
viewModel.SizeDescription = string.Format(Translator.SettingsStorage_AccountUsageDescription, viewModel.SizeText);
return viewModel;
})
.OrderByDescending(a => a.SizeBytes)
.ToList();
await ExecuteUIThread(() =>
{
MimeRootPath = mimeRootPath;
AccountStorageItems.Clear();
foreach (var item in storageItems)
{
AccountStorageItems.Add(item);
}
var total = storageItems.Sum(a => a.SizeBytes);
SummaryText = storageItems.Count == 0
? Translator.SettingsStorage_NoAccountsFound
: string.Format(Translator.SettingsStorage_TotalUsage, total.GetBytesReadable());
});
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to refresh storage data.");
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsLoading = false; });
}
}
[RelayCommand]
private async Task DeleteAllAsync(AccountStorageItemViewModel accountItem)
{
if (accountItem == null || IsBusy) return;
bool approved = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.SettingsStorage_DeleteAll_Confirm_Message, accountItem.AccountName),
Translator.SettingsStorage_DeleteAll_Confirm_Title,
Translator.Buttons_Delete);
if (!approved) return;
await ExecuteUIThread(() => { IsCleaning = true; });
try
{
await _mimeStorageService.DeleteAccountMimeStorageAsync(accountItem.Account.Id).ConfigureAwait(false);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info, Translator.SettingsStorage_DeleteAll_Success, Core.Domain.Enums.InfoBarMessageType.Success);
});
await RefreshStorageAsync();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete all MIME content for account {AccountId}", accountItem.Account.Id);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsCleaning = false; });
}
}
[RelayCommand]
private Task DeleteOlderThanOneMonthAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 1);
[RelayCommand]
private Task DeleteOlderThanThreeMonthsAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 3);
[RelayCommand]
private Task DeleteOlderThanSixMonthsAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 6);
[RelayCommand]
private Task DeleteOlderThanOneYearAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 12);
private async Task DeleteOlderThanAsync(AccountStorageItemViewModel accountItem, int months)
{
if (accountItem == null || IsBusy) return;
string rangeText = GetRangeText(months);
bool approved = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.SettingsStorage_DeleteOld_Confirm_Message, rangeText, accountItem.AccountName),
Translator.SettingsStorage_DeleteOld_Confirm_Title,
Translator.Buttons_Delete);
if (!approved) return;
await ExecuteUIThread(() => { IsCleaning = true; });
try
{
var cutoffDateUtc = DateTime.UtcNow.AddMonths(-months);
var deletedDirectoryCount = await _mimeStorageService
.DeleteAccountMimeStorageOlderThanAsync(accountItem.Account.Id, cutoffDateUtc)
.ConfigureAwait(false);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Info,
string.Format(Translator.SettingsStorage_DeleteOld_Success, deletedDirectoryCount, rangeText),
Core.Domain.Enums.InfoBarMessageType.Success);
});
await RefreshStorageAsync();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete MIME content by cutoff for account {AccountId}", accountItem.Account.Id);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsCleaning = false; });
}
}
private static string GetRangeText(int months)
{
return months switch
{
1 => Translator.SettingsStorage_1Month,
3 => Translator.SettingsStorage_3Months,
6 => Translator.SettingsStorage_6Months,
12 => Translator.SettingsStorage_1Year,
_ => string.Format(Translator.SettingsStorage_Months, months)
};
}
}
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Updates;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels;
public partial class WelcomePageV2ViewModel : MailBaseViewModel
{
private readonly IUpdateManager _updateManager;
private readonly IMailDialogService _dialogService;
private readonly IWinoAccountDataSyncService _syncService;
[ObservableProperty]
public partial List<UpdateNoteSection> UpdateSections { get; set; } = [];
[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;
_dialogService = dialogService;
_syncService = syncService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
try
{
var updateNotes = await _updateManager.GetLatestUpdateNotesAsync();
UpdateSections = updateNotes.Sections;
}
catch (Exception)
{
UpdateSections = [];
}
}
[RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))]
private void GetStarted()
{
Messenger.Send(new BreadcrumbNavigationRequested(
Translator.WelcomeWizard_Step2Title,
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;
}
}
@@ -1,37 +0,0 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
namespace Wino.Mail.ViewModels;
public partial class WelcomePageViewModel : MailBaseViewModel
{
public const string VersionFile = "1102.md";
private readonly IMailDialogService _dialogService;
private readonly IFileService _fileService;
[ObservableProperty]
private string currentVersionNotes;
public WelcomePageViewModel(IMailDialogService dialogService, IFileService fileService)
{
_dialogService = dialogService;
_fileService = fileService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
try
{
CurrentVersionNotes = await _fileService.GetFileContentByApplicationUriAsync($"ms-appx:///Assets/ReleaseNotes/{VersionFile}");
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, "Can't find the patch notes.", Core.Domain.Enums.InfoBarMessageType.Information);
}
}
}
@@ -1,15 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Platforms>x86;x64;arm64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EmailValidation" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Sentry.Serilog" />
<PackageReference Include="MimeKit" />
<PackageReference Include="System.Reactive" />
</ItemGroup>
<ItemGroup>
@@ -19,4 +23,7 @@
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Helpers\" />
</ItemGroup>
</Project>