Initial commit.

This commit is contained in:
Burak Kaan Köse
2024-04-18 01:44:37 +02:00
parent 524ea4c0e1
commit 12d3814626
671 changed files with 77295 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
namespace Wino.Mail.ViewModels
{
public class AboutPageViewModel : BaseViewModel
{
private readonly IStoreRatingService _storeRatingService;
private readonly INativeAppService _nativeAppService;
private readonly IAppInitializerService _appInitializerService;
private readonly IFileService _fileService;
private readonly ILogInitializer _logInitializer;
public string VersionName => _nativeAppService.GetFullAppVersion();
public string DiscordChannelUrl => "https://discord.gg/windows-apps-hub-714581497222398064";
public string GitHubUrl => "https://github.com/bkaankose/Wino-Mail/";
public string PrivacyPolicyUrl => "https://www.winomail.app/privacy_policy.html";
public string PaypalUrl => "https://paypal.me/bkaankose?country.x=PL&locale.x=en_US";
public AsyncRelayCommand<object> NavigateCommand { get; set; }
public AsyncRelayCommand ShareWinoLogCommand { get; set; }
public AsyncRelayCommand ShareProtocolLogCommand { get; set; }
public IPreferencesService PreferencesService { get; }
public AboutPageViewModel(IStoreRatingService storeRatingService,
IDialogService dialogService,
INativeAppService nativeAppService,
IPreferencesService preferencesService,
IAppInitializerService appInitializerService,
IFileService fileService,
ILogInitializer logInitializer) : base(dialogService)
{
_storeRatingService = storeRatingService;
_nativeAppService = nativeAppService;
_logInitializer = logInitializer;
_appInitializerService = appInitializerService;
_fileService = fileService;
PreferencesService = preferencesService;
NavigateCommand = new AsyncRelayCommand<object>(Navigate);
ShareWinoLogCommand = new AsyncRelayCommand(ShareWinoLogAsync);
ShareProtocolLogCommand = new AsyncRelayCommand(ShareProtocolLogAsync);
}
protected override void OnActivated()
{
base.OnActivated();
PreferencesService.PreferenceChanged -= PreferencesChanged;
PreferencesService.PreferenceChanged += PreferencesChanged;
}
protected override void OnDeactivated()
{
base.OnDeactivated();
PreferencesService.PreferenceChanged -= PreferencesChanged;
}
private void PreferencesChanged(object sender, string e)
{
if (e == nameof(PreferencesService.IsLoggingEnabled))
{
_logInitializer.RefreshLoggingLevel();
}
}
private Task ShareProtocolLogAsync()
=> SaveLogInternalAsync(ImapTestService.ProtocolLogFileName);
private Task ShareWinoLogAsync()
=> SaveLogInternalAsync(LogInitializer.WinoLogFileName);
private async Task SaveLogInternalAsync(string sourceFileName)
{
var appDataFolder = _appInitializerService.GetApplicationDataFolder();
var logFile = Path.Combine(appDataFolder, sourceFileName);
if (!File.Exists(logFile))
{
DialogService.InfoBarMessage(Translator.Info_LogsNotFoundTitle, Translator.Info_LogsNotFoundMessage, Core.Domain.Enums.InfoBarMessageType.Warning);
return;
}
var selectedFolderPath = await DialogService.PickWindowsFolderAsync();
if (string.IsNullOrEmpty(selectedFolderPath)) return;
var copiedFilePath = await _fileService.CopyFileAsync(logFile, selectedFolderPath);
var copiedFileName = Path.GetFileName(copiedFilePath);
DialogService.InfoBarMessage(Translator.Info_LogsSavedTitle, string.Format(Translator.Info_LogsSavedMessage, copiedFileName), Core.Domain.Enums.InfoBarMessageType.Success);
}
private async Task Navigate(object url)
{
if (url is string stringUrl)
{
if (stringUrl == "Store")
await ShowRateDialogAsync();
else
{
// Discord disclaimer message about server.
if (stringUrl == DiscordChannelUrl)
await DialogService.ShowMessageAsync(Translator.DiscordChannelDisclaimerMessage, Translator.DiscordChannelDisclaimerTitle);
await _nativeAppService.LaunchUriAsync(new Uri(stringUrl));
}
}
}
private Task ShowRateDialogAsync() => _storeRatingService.LaunchStorePageForReviewAsync();
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Messages.Navigation;
using Wino.Core.Requests;
namespace Wino.Mail.ViewModels
{
public partial class AccountDetailsPageViewModel : BaseViewModel
{
private readonly IWinoSynchronizerFactory _synchronizerFactory;
private readonly IAccountService _accountService;
private readonly IFolderService _folderService;
public MailAccount Account { get; set; }
public ObservableCollection<IMailItemFolder> CurrentFolders { get; set; } = new ObservableCollection<IMailItemFolder>();
[ObservableProperty]
private bool isFocusedInboxEnabled;
[ObservableProperty]
private bool areNotificationsEnabled;
[ObservableProperty]
private bool isAppendMessageSettingVisible;
[ObservableProperty]
private bool isAppendMessageSettinEnabled;
public bool IsFocusedInboxSupportedForAccount => Account != null && Account.Preferences.IsFocusedInboxEnabled != null;
public AccountDetailsPageViewModel(IDialogService dialogService,
IWinoSynchronizerFactory synchronizerFactory,
IAccountService accountService,
IFolderService folderService) : base(dialogService)
{
_synchronizerFactory = synchronizerFactory;
_accountService = accountService;
_folderService = folderService;
}
[RelayCommand]
private Task SetupSpecialFolders()
=> DialogService.HandleSystemFolderConfigurationDialogAsync(Account.Id, _folderService);
[RelayCommand]
private void EditSignature()
=> Messenger.Send(new BreadcrumbNavigationRequested("Signature", WinoPage.SignatureManagementPage, 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 async Task RenameAccount()
{
if (Account == null)
return;
var updatedAccount = await DialogService.ShowEditAccountDialogAsync(Account);
if (updatedAccount != null)
{
await _accountService.UpdateAccountAsync(updatedAccount);
ReportUIChange(new AccountUpdatedMessage(updatedAccount));
}
}
[RelayCommand]
private async Task DeleteAccount()
{
if (Account == null)
return;
var confirmation = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_DeleteAccountConfirmationTitle,
string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, Account.Name),
Translator.Buttons_Delete);
if (!confirmation)
return;
await _accountService.DeleteAccountAsync(Account);
_synchronizerFactory.DeleteSynchronizer(Account);
// TODO: Clear existing requests.
// _synchronizationWorker.ClearRequests(Account.Id);
DialogService.InfoBarMessage(Translator.Info_AccountDeletedTitle, string.Format(Translator.Info_AccountDeletedMessage, Account.Name), InfoBarMessageType.Success);
Messenger.Send(new BackBreadcrumNavigationRequested());
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is Guid accountId)
{
Account = await _accountService.GetAccountAsync(accountId);
IsFocusedInboxEnabled = Account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault();
AreNotificationsEnabled = Account.Preferences.IsNotificationsEnabled;
IsAppendMessageSettingVisible = Account.ProviderType == MailProviderType.IMAP4;
IsAppendMessageSettinEnabled = Account.Preferences.ShouldAppendMessagesToSentFolder;
OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount));
var folderStructures = (await _folderService.GetFolderStructureForAccountAsync(Account.Id, true)).Folders;
foreach (var folder in folderStructures)
{
CurrentFolders.Add(folder);
}
}
}
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (e.PropertyName == nameof(IsFocusedInboxEnabled) && IsFocusedInboxSupportedForAccount)
{
Account.Preferences.IsFocusedInboxEnabled = IsFocusedInboxEnabled;
await _accountService.UpdateAccountAsync(Account);
}
else if (e.PropertyName == nameof(AreNotificationsEnabled))
{
Account.Preferences.IsNotificationsEnabled = AreNotificationsEnabled;
await _accountService.UpdateAccountAsync(Account);
}
else if (e.PropertyName == nameof(IsAppendMessageSettinEnabled))
{
Account.Preferences.ShouldAppendMessagesToSentFolder = IsAppendMessageSettinEnabled;
await _accountService.UpdateAccountAsync(Account);
}
}
}
}

View File

@@ -0,0 +1,377 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.AppCenter.Crashes;
using Serilog;
using Wino.Core;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Store;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Messages.Authorization;
using Wino.Core.Messages.Navigation;
using Wino.Core.Requests;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels
{
public partial class AccountManagementViewModel : BaseViewModel, IRecipient<ProtocolAuthorizationCallbackReceived>
{
public int FREE_ACCOUNT_COUNT { get; } = 3;
private readonly IDialogService _dialogService;
private readonly IAccountService _accountService;
private readonly IProviderService _providerService;
private readonly IFolderService _folderService;
private readonly IStoreManagementService _storeManagementService;
private readonly IPreferencesService _preferencesService;
private readonly IAuthenticationProvider _authenticationProvider;
private readonly IWinoSynchronizerFactory _synchronizerFactory;
public ObservableCollection<IAccountProviderDetailViewModel> Accounts { get; set; } = [];
public bool IsPurchasePanelVisible => !HasUnlimitedAccountProduct;
public bool IsAccountCreationAlmostOnLimit => Accounts != null && Accounts.Count == FREE_ACCOUNT_COUNT - 1;
public bool HasAccountsDefined => Accounts != null && Accounts.Any();
public string UsedAccountsString => string.Format(Translator.WinoUpgradeRemainingAccountsMessage, Accounts.Count, FREE_ACCOUNT_COUNT);
[ObservableProperty]
private IAccountProviderDetailViewModel _startupAccount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))]
private bool hasUnlimitedAccountProduct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAccountCreationAlmostOnLimit))]
[NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))]
private bool isAccountCreationBlocked;
public AccountManagementViewModel(IDialogService dialogService,
IWinoNavigationService navigationService,
IWinoSynchronizerFactory synchronizerFactory,
IAccountService accountService,
IProviderService providerService,
IFolderService folderService,
IStoreManagementService storeManagementService,
IPreferencesService preferencesService,
IAuthenticationProvider authenticationProvider) : base(dialogService)
{
_accountService = accountService;
_synchronizerFactory = synchronizerFactory;
_dialogService = dialogService;
_providerService = providerService;
_folderService = folderService;
_storeManagementService = storeManagementService;
_preferencesService = preferencesService;
_authenticationProvider = authenticationProvider;
}
[RelayCommand]
private void NavigateAccountDetails(AccountProviderDetailViewModel accountDetails)
{
Messenger.Send(new BreadcrumbNavigationRequested(accountDetails.Account.Name,
WinoPage.AccountDetailsPage,
accountDetails.Account.Id));
}
[RelayCommand]
private async Task CreateMergedAccountAsync()
{
var linkName = await DialogService.ShowTextInputDialogAsync(string.Empty, Translator.DialogMessage_CreateLinkedAccountTitle, Translator.DialogMessage_CreateLinkedAccountMessage);
if (string.IsNullOrEmpty(linkName)) return;
// Create arbitary empty merged inbox with an empty Guid and go to edit page.
var mergedInbox = new MergedInbox()
{
Id = Guid.Empty,
Name = linkName
};
var mergedAccountProviderDetailViewModel = new MergedAccountProviderDetailViewModel(mergedInbox, new List<AccountProviderDetailViewModel>());
Messenger.Send(new BreadcrumbNavigationRequested(mergedAccountProviderDetailViewModel.MergedInbox.Name,
WinoPage.MergedAccountDetailsPage,
mergedAccountProviderDetailViewModel));
}
[RelayCommand]
private async Task PurchaseUnlimitedAccountAsync()
{
var purchaseResult = await _storeManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts);
if (purchaseResult == StorePurchaseResult.Succeeded)
DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
else if (purchaseResult == StorePurchaseResult.AlreadyPurchased)
DialogService.InfoBarMessage(Translator.Info_PurchaseExistsTitle, Translator.Info_PurchaseExistsMessage, InfoBarMessageType.Warning);
bool shouldRefreshPurchasePanel = purchaseResult == StorePurchaseResult.Succeeded || purchaseResult == StorePurchaseResult.AlreadyPurchased;
if (shouldRefreshPurchasePanel)
{
await ManageStorePurchasesAsync();
}
}
[RelayCommand]
private async Task AddNewAccountAsync()
{
if (IsAccountCreationBlocked)
{
var isPurchaseClicked = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_AccountLimitMessage, Translator.DialogMessage_AccountLimitTitle, Translator.Buttons_Purchase);
if (!isPurchaseClicked) return;
await PurchaseUnlimitedAccountAsync();
return;
}
MailAccount createdAccount = null;
IAccountCreationDialog creationDialog = null;
try
{
var providers = _providerService.GetProviderDetails();
// Select provider.
var accountInformationTuple = await _dialogService.ShowNewAccountMailProviderDialogAsync(providers);
if (accountInformationTuple != null)
{
creationDialog = _dialogService.GetAccountCreationDialog(accountInformationTuple.Item2);
var accountName = accountInformationTuple.Item1;
var providerType = accountInformationTuple.Item2;
_accountService.ExternalAuthenticationAuthenticator = _authenticationProvider.GetAuthenticator(providerType);
CustomServerInformation customServerInformation = null;
createdAccount = new MailAccount()
{
ProviderType = providerType,
Name = accountName,
Id = Guid.NewGuid()
};
creationDialog.ShowDialog();
creationDialog.State = AccountCreationDialogState.SigningIn;
TokenInformation tokenInformation = null;
// Custom server implementation requires more async waiting.
if (creationDialog is ICustomServerAccountCreationDialog customServerDialog)
{
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;
}
else
{
// For OAuth authentications, we just generate token and assign it to the MailAccount.
tokenInformation = await _accountService.ExternalAuthenticationAuthenticator.GenerateTokenAsync(createdAccount, false)
?? throw new AuthenticationException(Translator.Exception_TokenInfoRetrivalFailed);
createdAccount.Address = tokenInformation.Address;
tokenInformation.AccountId = createdAccount.Id;
}
await _accountService.CreateAccountAsync(createdAccount, tokenInformation, customServerInformation);
// Local account has been created.
// Create new synchronizer and start synchronization.
var synchronizer = _synchronizerFactory.CreateNewSynchronizer(createdAccount);
if (creationDialog is ICustomServerAccountCreationDialog customServerAccountCreationDialog)
customServerAccountCreationDialog.ShowPreparingFolders();
else
creationDialog.State = AccountCreationDialogState.PreparingFolders;
var options = new SynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = SynchronizationType.FoldersOnly
};
var synchronizationResult = await synchronizer.SynchronizeAsync(options);
if (synchronizationResult.CompletedState != SynchronizationCompletedState.Success)
throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
// Check if Inbox folder is available for the account after synchronization.
var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id);
if (!isInboxAvailable)
throw new Exception(Translator.Exception_InboxNotAvailable);
// 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 (AccountSetupCanceledException)
{
// Ignore
}
catch (Exception ex)
{
Log.Error(ex, WinoErrors.AccountCreation);
Crashes.TrackError(ex);
_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();
}
}
[RelayCommand]
private void EditMergedAccounts(MergedAccountProviderDetailViewModel mergedAccountProviderDetailViewModel)
{
Messenger.Send(new BreadcrumbNavigationRequested(mergedAccountProviderDetailViewModel.MergedInbox.Name,
WinoPage.MergedAccountDetailsPage,
mergedAccountProviderDetailViewModel));
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
Accounts.CollectionChanged -= AccountCollectionChanged;
PropertyChanged -= PagePropertyChanged;
}
private void AccountCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasAccountsDefined));
OnPropertyChanged(nameof(UsedAccountsString));
}
private void PagePropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(StartupAccount) && StartupAccount != null)
{
_preferencesService.StartupEntityId = StartupAccount.StartupEntityId;
}
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
Accounts.CollectionChanged -= AccountCollectionChanged;
Accounts.CollectionChanged += AccountCollectionChanged;
await InitializeAccountsAsync();
PropertyChanged -= PagePropertyChanged;
PropertyChanged += PagePropertyChanged;
}
private async Task InitializeAccountsAsync()
{
StartupAccount = null;
Accounts.Clear();
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
// Group accounts and display merged ones at the top.
var groupedAccounts = accounts.GroupBy(a => a.MergedInboxId);
await ExecuteUIThread(() =>
{
foreach (var accountGroup in groupedAccounts)
{
var mergedInboxId = accountGroup.Key;
if (mergedInboxId == null)
{
foreach (var account in accountGroup)
{
var accountDetails = GetAccountProviderDetails(account);
Accounts.Add(accountDetails);
}
}
else
{
var mergedInbox = accountGroup.First(a => a.MergedInboxId == mergedInboxId).MergedInbox;
var holdingAccountProviderDetails = accountGroup.Select(a => GetAccountProviderDetails(a)).ToList();
var mergedAccountViewModel = new MergedAccountProviderDetailViewModel(mergedInbox, holdingAccountProviderDetails);
Accounts.Add(mergedAccountViewModel);
}
}
// Handle startup entity.
if (_preferencesService.StartupEntityId != null)
{
StartupAccount = Accounts.FirstOrDefault(a => a.StartupEntityId == _preferencesService.StartupEntityId);
}
});
await ManageStorePurchasesAsync().ConfigureAwait(false);
}
private async Task ManageStorePurchasesAsync()
{
await ExecuteUIThread(async () =>
{
HasUnlimitedAccountProduct = await _storeManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts);
if (!HasUnlimitedAccountProduct)
IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT;
else
IsAccountCreationBlocked = false;
});
}
private AccountProviderDetailViewModel GetAccountProviderDetails(MailAccount account)
{
var provider = _providerService.GetProviderDetail(account.ProviderType);
return new AccountProviderDetailViewModel(provider, account);
}
public void Receive(ProtocolAuthorizationCallbackReceived message)
{
// Authorization must be completed in account service.
_accountService.ExternalAuthenticationAuthenticator?.ContinueAuthorization(message.AuthorizationResponseUri);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Requests;
namespace Wino.Mail.ViewModels
{
public class BaseViewModel : ObservableRecipient,
INavigationAware,
IRecipient<AccountCreatedMessage>,
IRecipient<AccountRemovedMessage>,
IRecipient<AccountUpdatedMessage>,
IRecipient<FolderAddedMessage>,
IRecipient<FolderUpdatedMessage>,
IRecipient<FolderRemovedMessage>,
IRecipient<MailAddedMessage>,
IRecipient<MailRemovedMessage>,
IRecipient<MailUpdatedMessage>,
IRecipient<MailDownloadedMessage>,
IRecipient<DraftCreated>,
IRecipient<DraftFailed>,
IRecipient<DraftMapped>
{
private IDispatcher _dispatcher;
public IDispatcher Dispatcher
{
get
{
return _dispatcher;
}
set
{
_dispatcher = value;
if (value != null)
{
OnDispatcherAssigned();
}
}
}
protected IDialogService DialogService { get; }
public BaseViewModel(IDialogService dialogService) => DialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
public async Task ExecuteUIThread(Action action) => await Dispatcher?.ExecuteOnUIThread(action);
public virtual void OnNavigatedTo(NavigationMode mode, object parameters) { IsActive = true; }
public virtual void OnNavigatedFrom(NavigationMode mode, object parameters) { IsActive = false; }
protected virtual void OnDispatcherAssigned() { }
protected virtual void OnMailAdded(MailCopy addedMail) { }
protected virtual void OnMailRemoved(MailCopy removedMail) { }
protected virtual void OnMailUpdated(MailCopy updatedMail) { }
protected virtual void OnMailDownloaded(MailCopy downloadedMail) { }
protected virtual void OnAccountCreated(MailAccount createdAccount) { }
protected virtual void OnAccountRemoved(MailAccount removedAccount) { }
protected virtual void OnAccountUpdated(MailAccount updatedAccount) { }
protected virtual void OnFolderAdded(MailItemFolder addedFolder, MailAccount account) { }
protected virtual void OnFolderRemoved(MailItemFolder removedFolder, MailAccount account) { }
protected virtual void OnFolderUpdated(MailItemFolder updatedFolder, MailAccount account) { }
protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { }
protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { }
protected virtual void OnDraftMapped(string localDraftCopyId, string remoteDraftCopyId) { }
public void ReportUIChange<TMessage>(TMessage message) where TMessage : class, IUIMessage
=> Messenger.Send(message);
void IRecipient<AccountCreatedMessage>.Receive(AccountCreatedMessage message) => OnAccountCreated(message.Account);
void IRecipient<AccountRemovedMessage>.Receive(AccountRemovedMessage message) => OnAccountRemoved(message.Account);
void IRecipient<AccountUpdatedMessage>.Receive(AccountUpdatedMessage message) => OnAccountUpdated(message.Account);
void IRecipient<FolderAddedMessage>.Receive(FolderAddedMessage message) => OnFolderAdded(message.AddedFolder, message.Account);
void IRecipient<FolderUpdatedMessage>.Receive(FolderUpdatedMessage message) => OnFolderUpdated(message.UpdatedFolder, message.Account);
void IRecipient<FolderRemovedMessage>.Receive(FolderRemovedMessage message) => OnFolderAdded(message.RemovedFolder, message.Account);
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<MailDownloadedMessage>.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail);
void IRecipient<DraftMapped>.Receive(DraftMapped message) => OnDraftMapped(message.LocalDraftCopyId, message.RemoteDraftCopyId);
void IRecipient<DraftFailed>.Receive(DraftFailed message) => OnDraftFailed(message.DraftMail, message.Account);
void IRecipient<DraftCreated>.Receive(DraftCreated message) => OnDraftCreated(message.DraftMail, message.Account);
}
}

View File

@@ -0,0 +1,467 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Collections;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Comparers;
using Wino.Core.Domain.Models.MailItem;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Collections
{
public class WinoMailCollection
{
// We cache each mail copy id for faster access on updates.
// If the item provider here for update or removal doesn't exist here
// we can ignore the operation.
public HashSet<Guid> MailCopyIdHashSet = new HashSet<Guid>();
private ListItemComparer listComparer = new ListItemComparer();
private readonly ObservableGroupedCollection<object, IMailItem> _mailItemSource = new ObservableGroupedCollection<object, IMailItem>();
public ReadOnlyObservableGroupedCollection<object, IMailItem> MailItems { get; }
/// <summary>
/// Property that defines how the item sorting should be done in the collection.
/// </summary>
public SortingOptionType SortingType { get; set; }
/// <summary>
/// Threading strategy that will help thread items according to the account type.
/// </summary>
public IThreadingStrategyProvider ThreadingStrategyProvider { get; set; }
/// <summary>
/// Automatically deletes single mail items after the delete operation or thread->single transition.
/// This is useful when reply draft is discarded in the thread. Only enabled for Draft folder for now.
/// </summary>
public bool PruneSingleNonDraftItems { get; set; }
public int Count => _mailItemSource.Count;
public IDispatcher CoreDispatcher { get; set; }
public WinoMailCollection()
{
MailItems = new ReadOnlyObservableGroupedCollection<object, IMailItem>(_mailItemSource);
}
public void Clear() => _mailItemSource.Clear();
private object GetGroupingKey(IMailItem mailItem)
{
if (SortingType == SortingOptionType.ReceiveDate)
return mailItem.CreationDate.ToLocalTime().Date;
else
return mailItem.FromName;
}
private async Task InsertItemInternalAsync(object groupKey, IMailItem mailItem)
=> await ExecuteUIThread(() =>
{
if (mailItem is MailCopy mailCopy)
{
MailCopyIdHashSet.Add(mailCopy.UniqueId);
_mailItemSource.InsertItem(groupKey, listComparer, new MailItemViewModel(mailCopy), listComparer.GetItemComparer());
}
else if (mailItem is ThreadMailItem threadMailItem)
{
foreach (var item in threadMailItem.ThreadItems)
{
MailCopyIdHashSet.Add(item.UniqueId);
}
_mailItemSource.InsertItem(groupKey, listComparer, new ThreadMailItemViewModel(threadMailItem), listComparer.GetItemComparer());
}
else if (mailItem is MailItemViewModel)
{
MailCopyIdHashSet.Add(mailItem.UniqueId);
_mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer.GetItemComparer());
}
});
private async Task RemoveItemInternalAsync(ObservableGroup<object, IMailItem> group, IMailItem mailItem)
{
MailCopyIdHashSet.Remove(mailItem.UniqueId);
await ExecuteUIThread(() =>
{
group.Remove(mailItem);
if (group.Count == 0)
{
_mailItemSource.RemoveGroup(group.Key);
}
});
}
public async Task AddAsync(MailCopy addedItem)
{
// Check all items for whether this item should be threaded with them.
bool shouldExit = false;
var groupCount = _mailItemSource.Count;
for (int i = 0; i < groupCount; i++)
{
if (shouldExit) break;
var group = _mailItemSource[i];
for (int k = 0; k < group.Count; k++)
{
var item = group[k];
var addedAccountProviderType = addedItem.AssignedAccount.ProviderType;
var threadingStrategy = ThreadingStrategyProvider.GetStrategy(addedAccountProviderType);
if (threadingStrategy?.ShouldThreadWithItem(addedItem, item) ?? false)
{
shouldExit = true;
if (item is ThreadMailItemViewModel threadMailItemViewModel)
{
// Item belongs to existing thread.
/* Add original item to the thread.
* If new group key is not the same as existing thread:
* -> Remove the whole thread from list
* -> Add the thread to the list again for sorting.
* Update thread properties.
*/
var existingGroupKey = GetGroupingKey(threadMailItemViewModel);
threadMailItemViewModel.AddMailItemViewModel(addedItem);
var newGroupKey = GetGroupingKey(threadMailItemViewModel);
if (!existingGroupKey.Equals(newGroupKey))
{
await RemoveItemInternalAsync(group, threadMailItemViewModel);
await InsertItemInternalAsync(newGroupKey, threadMailItemViewModel);
}
await ExecuteUIThread(() => { threadMailItemViewModel.NotifyPropertyChanges(); });
break;
}
else
{
// Item belongs to a single mail item that is not threaded yet.
// Same item might've been tried to added as well.
// In that case we must just update the item but not thread it.
/* Remove target item.
* Create a new thread with both items.
* Add new thread to the list.
*/
if (item.Id == addedItem.Id)
{
// Item is already added to the list.
// We need to update the copy it holds.
if (item is MailItemViewModel itemViewModel)
{
itemViewModel.Update(addedItem);
}
}
else
{
// Single item that must be threaded together with added item.
var threadMailItem = new ThreadMailItem();
threadMailItem.AddThreadItem(item);
threadMailItem.AddThreadItem(addedItem);
if (threadMailItem.ThreadItems.Count == 1) return;
var newGroupKey = GetGroupingKey(threadMailItem);
await RemoveItemInternalAsync(group, item);
await InsertItemInternalAsync(newGroupKey, threadMailItem);
}
break;
}
}
else
{
// Update properties.
if (item.Id == addedItem.Id && item is MailItemViewModel itemViewModel)
{
await ExecuteUIThread(() => { itemViewModel.Update(addedItem); });
shouldExit = true;
}
}
}
}
if (!shouldExit)
{
// At this point all items are already checked and not suitable option was available.
// Item doesn't belong to any thread.
// Just add it to the collection.
var groupKey = GetGroupingKey(addedItem);
await InsertItemInternalAsync(groupKey, addedItem);
}
}
public void AddRange(IEnumerable<IMailItem> items, bool clearIdCache)
{
if (clearIdCache)
{
MailCopyIdHashSet.Clear();
}
var groupedByName = items
.GroupBy(a => GetGroupingKey(a))
.Select(a => new ObservableGroup<object, IMailItem>(a.Key, a));
foreach (var group in groupedByName)
{
// Store all mail copy ids for faster access.
foreach (var item in group)
{
if (item is MailItemViewModel mailCopyItem && !MailCopyIdHashSet.Contains(item.UniqueId))
{
MailCopyIdHashSet.Add(item.UniqueId);
}
else if (item is ThreadMailItemViewModel threadMailItem)
{
foreach (var mailItem in threadMailItem.ThreadItems)
{
if (!MailCopyIdHashSet.Contains(mailItem.UniqueId))
{
MailCopyIdHashSet.Add(mailItem.UniqueId);
}
}
}
}
var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key);
if (existingGroup == null)
{
_mailItemSource.AddGroup(group.Key, group);
}
else
{
foreach (var item in group)
{
existingGroup.Add(item);
// _mailItemSource.InsertItem(existingGroup, item);
}
}
}
}
public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
{
var groupCount = _mailItemSource.Count;
for (int i = 0; i < groupCount; i++)
{
var group = _mailItemSource[i];
for (int k = 0; k < group.Count; k++)
{
var item = group[k];
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.UniqueId == uniqueMailId)
return new MailItemContainer(singleMailItemViewModel);
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
{
var singleItemViewModel = threadMailItemViewModel.GetItemById(uniqueMailId) as MailItemViewModel;
return new MailItemContainer(singleItemViewModel, threadMailItemViewModel);
}
}
}
return null;
}
/// <summary>
/// Fins the item container that updated mail copy belongs to and updates it.
/// </summary>
/// <param name="updatedMailCopy">Updated mail copy.</param>
/// <returns></returns>
public async Task UpdateMailCopy(MailCopy updatedMailCopy)
{
// This item doesn't exist in the list.
if (!MailCopyIdHashSet.Contains(updatedMailCopy.UniqueId))
{
return;
}
await ExecuteUIThread(() =>
{
var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId);
if (itemContainer == null) return;
// mailCopyIdHashSet.Remove(itemContainer.ItemViewModel.UniqueId);
itemContainer.ItemViewModel?.Update(updatedMailCopy);
// mailCopyIdHashSet.Add(updatedMailCopy.UniqueId);
// Call thread notifications if possible.
itemContainer.ThreadViewModel?.NotifyPropertyChanges();
});
}
public MailItemViewModel GetNextItem(MailCopy mailCopy)
{
var groupCount = _mailItemSource.Count;
for (int i = 0; i < groupCount; i++)
{
var group = _mailItemSource[i];
for (int k = 0; k < group.Count; k++)
{
var item = group[k];
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.UniqueId == mailCopy.UniqueId)
{
if (k + 1 < group.Count)
{
return group[k + 1] as MailItemViewModel;
}
else if (i + 1 < groupCount)
{
return _mailItemSource[i + 1][0] as MailItemViewModel;
}
else
{
return null;
}
}
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailCopy.UniqueId))
{
var singleItemViewModel = threadMailItemViewModel.GetItemById(mailCopy.UniqueId) as MailItemViewModel;
if (singleItemViewModel == null) return null;
var singleItemIndex = threadMailItemViewModel.ThreadItems.IndexOf(singleItemViewModel);
if (singleItemIndex + 1 < threadMailItemViewModel.ThreadItems.Count)
{
return threadMailItemViewModel.ThreadItems[singleItemIndex + 1] as MailItemViewModel;
}
else if (i + 1 < groupCount)
{
return _mailItemSource[i + 1][0] as MailItemViewModel;
}
else
{
return null;
}
}
}
}
return null;
}
public async Task RemoveAsync(MailCopy removeItem)
{
// This item doesn't exist in the list.
if (!MailCopyIdHashSet.Contains(removeItem.UniqueId)) return;
// Check all items for whether this item should be threaded with them.
bool shouldExit = false;
var groupCount = _mailItemSource.Count;
for (int i = 0; i < groupCount; i++)
{
if (shouldExit) break;
var group = _mailItemSource[i];
for (int k = 0; k < group.Count; k++)
{
var item = group[k];
if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(removeItem.UniqueId))
{
var removalItem = threadMailItemViewModel.GetItemById(removeItem.UniqueId);
if (removalItem == null) return;
// Threads' Id is equal to the last item they hold.
// We can't do Id check here because that'd remove the whole thread.
/* Remove item from the thread.
* If thread had 1 item inside:
* -> Remove the thread and insert item as single item.
* If thread had 0 item inside:
* -> Remove the thread.
*/
await ExecuteUIThread(() => { threadMailItemViewModel.RemoveCopyItem(removalItem); });
if (threadMailItemViewModel.ThreadItems.Count == 1)
{
// Convert to single item.
var singleViewModel = threadMailItemViewModel.GetSingleItemViewModel();
var groupKey = GetGroupingKey(singleViewModel);
await RemoveItemInternalAsync(group, threadMailItemViewModel);
// If thread->single conversion is being done, we should ignore it for non-draft items.
// eg. Deleting a reply message from draft folder. Single non-draft item should not be re-added.
if (!PruneSingleNonDraftItems || singleViewModel.IsDraft)
{
await InsertItemInternalAsync(groupKey, singleViewModel);
}
}
else if (threadMailItemViewModel.ThreadItems.Count == 0)
{
await RemoveItemInternalAsync(group, threadMailItemViewModel);
}
else
{
// Item inside the thread is removed.
threadMailItemViewModel.ThreadItems.Remove(removalItem);
}
shouldExit = true;
break;
}
else if (item.UniqueId == removeItem.UniqueId)
{
await RemoveItemInternalAsync(group, item);
shouldExit = true;
break;
}
}
}
}
private async Task ExecuteUIThread(Action action) => await CoreDispatcher?.ExecuteOnUIThread(action);
}
}

View File

@@ -0,0 +1,494 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MimeKit;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
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.Navigation;
using Wino.Core.Domain.Models.Reader;
using Wino.Core.Extensions;
using Wino.Core.Messages.Mails;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels
{
public partial class ComposePageViewModel : BaseViewModel
{
public Func<Task<string>> GetHTMLBodyFunction;
// 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;
public bool CanSendMail => ComposingAccount != null && !IsLocalDraft && currentMimeMessage != null;
private MimeMessage currentMimeMessage = null;
private readonly BodyBuilder bodyBuilder = new BodyBuilder();
public bool IsLocalDraft => CurrentMailDraftItem != null
&& !string.IsNullOrEmpty(CurrentMailDraftItem.DraftId)
&& CurrentMailDraftItem.DraftId.StartsWith(Constants.LocalDraftStartPrefix);
#region Properties
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLocalDraft))]
[NotifyPropertyChangedFor(nameof(CanSendMail))]
private MailItemViewModel currentMailDraftItem;
[ObservableProperty]
private bool isImportanceSelected;
[ObservableProperty]
private MessageImportance selectedMessageImportance;
[ObservableProperty]
private bool isCCBCCVisible = true;
[ObservableProperty]
private string subject;
[ObservableProperty]
private MailAccount composingAccount;
public ObservableCollection<MailAttachmentViewModel> IncludedAttachments { get; set; } = new ObservableCollection<MailAttachmentViewModel>();
public ObservableCollection<MailAccount> Accounts { get; set; } = new ObservableCollection<MailAccount>();
public ObservableCollection<AddressInformation> ToItems { get; set; } = new ObservableCollection<AddressInformation>();
public ObservableCollection<AddressInformation> CCItemsItems { get; set; } = new ObservableCollection<AddressInformation>();
public ObservableCollection<AddressInformation> BCCItems { get; set; } = new ObservableCollection<AddressInformation>();
public List<EditorToolbarSection> ToolbarSections { get; set; } = new List<EditorToolbarSection>()
{
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Format },
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Insert },
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Draw },
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Options }
};
private EditorToolbarSection selectedToolbarSection;
public EditorToolbarSection SelectedToolbarSection
{
get => selectedToolbarSection;
set => SetProperty(ref selectedToolbarSection, value);
}
#endregion
public INativeAppService NativeAppService { get; }
private readonly IMailService _mailService;
private readonly ILaunchProtocolService _launchProtocolService;
private readonly IMimeFileService _mimeFileService;
private readonly IStatePersistanceService _statePersistanceService;
private readonly IFolderService _folderService;
private readonly IAccountService _accountService;
private readonly IWinoRequestDelegator _worker;
public readonly IContactService ContactService;
public ComposePageViewModel(IDialogService dialogService,
IMailService mailService,
ILaunchProtocolService launchProtocolService,
IMimeFileService mimeFileService,
IStatePersistanceService statePersistanceService,
INativeAppService nativeAppService,
IFolderService folderService,
IAccountService accountService,
IWinoRequestDelegator worker,
IContactService contactService) : base(dialogService)
{
NativeAppService = nativeAppService;
_folderService = folderService;
ContactService = contactService;
_mailService = mailService;
_launchProtocolService = launchProtocolService;
_mimeFileService = mimeFileService;
_statePersistanceService = statePersistanceService;
_accountService = accountService;
_worker = worker;
SelectedToolbarSection = ToolbarSections[0];
}
[RelayCommand]
private void RemoveAttachment(MailAttachmentViewModel attachmentViewModel)
=> IncludedAttachments.Remove(attachmentViewModel);
[RelayCommand]
private async Task SendAsync()
{
// TODO: More detailed mail validations.
if (!ToItems.Any())
{
await DialogService.ShowMessageAsync(Translator.DialogMessage_ComposerMissingRecipientMessage, Translator.DialogMessage_ComposerValidationFailedTitle);
return;
}
if (string.IsNullOrEmpty(Subject))
{
var isConfirmed = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_EmptySubjectConfirmationMessage, Translator.DialogMessage_EmptySubjectConfirmation, Translator.Buttons_Yes);
if (!isConfirmed) return;
}
// Save mime changes before sending.
await UpdateMimeChangesAsync().ConfigureAwait(false);
isUpdatingMimeBlocked = true;
var assignedAccount = CurrentMailDraftItem.AssignedAccount;
MailItemFolder sentFolder = null;
// Load the Sent folder if user wanted to have a copy there.
if (assignedAccount.Preferences.ShouldAppendMessagesToSentFolder)
{
sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent);
}
var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy, currentMimeMessage, CurrentMailDraftItem.AssignedFolder, sentFolder, CurrentMailDraftItem.AssignedAccount.Preferences);
await _worker.ExecuteAsync(draftSendPreparationRequest);
}
private async Task UpdateMimeChangesAsync()
{
if (isUpdatingMimeBlocked || currentMimeMessage == null || ComposingAccount == null || CurrentMailDraftItem == null) return;
// Save recipients.
SaveAddressInfo(ToItems, currentMimeMessage.To);
SaveAddressInfo(CCItemsItems, currentMimeMessage.Cc);
SaveAddressInfo(BCCItems, currentMimeMessage.Bcc);
SaveImportance();
SaveSubject();
await SaveAttachmentsAsync();
await SaveBodyAsync();
await UpdateMailCopyAsync();
// Save mime file.
await _mimeFileService.SaveMimeMessageAsync(CurrentMailDraftItem.MailCopy.FileId, currentMimeMessage, ComposingAccount.Id).ConfigureAwait(false);
}
private async Task UpdateMailCopyAsync()
{
CurrentMailDraftItem.Subject = currentMimeMessage.Subject;
CurrentMailDraftItem.PreviewText = currentMimeMessage.TextBody;
// Update database.
await _mailService.UpdateMailAsync(CurrentMailDraftItem.MailCopy);
}
private async Task SaveAttachmentsAsync()
{
bodyBuilder.Attachments.Clear();
foreach (var path in IncludedAttachments)
{
if (path.Content == null) continue;
await bodyBuilder.Attachments.AddAsync(path.FileName, new MemoryStream(path.Content));
}
}
private void SaveImportance() { currentMimeMessage.Importance = IsImportanceSelected ? SelectedMessageImportance : MessageImportance.Normal; }
private void SaveSubject()
{
if (Subject != null)
{
currentMimeMessage.Subject = Subject;
}
}
private async Task SaveBodyAsync()
{
if (GetHTMLBodyFunction != null)
bodyBuilder.HtmlBody = Regex.Unescape(await GetHTMLBodyFunction());
if (!string.IsNullOrEmpty(bodyBuilder.HtmlBody))
bodyBuilder.TextBody = HtmlAgilityPackExtensions.GetPreviewText(bodyBuilder.HtmlBody);
if (bodyBuilder.HtmlBody != null && bodyBuilder.TextBody != null)
currentMimeMessage.Body = bodyBuilder.ToMessageBody();
}
[RelayCommand]
private async Task DiscardAsync()
{
if (ComposingAccount == null)
{
DialogService.InfoBarMessage(Translator.Info_MessageCorruptedTitle, Translator.Info_MessageCorruptedMessage, InfoBarMessageType.Error);
return;
}
var confirmation = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_DiscardDraftConfirmationMessage,
Translator.DialogMessage_DiscardDraftConfirmationTitle,
Translator.Buttons_Yes);
if (confirmation)
{
isUpdatingMimeBlocked = true;
// Don't send delete request for local drafts. Just delete the record and mime locally.
if (CurrentMailDraftItem.IsLocalDraft)
{
await _mailService.DeleteMailAsync(ComposingAccount.Id, CurrentMailDraftItem.Id);
}
else
{
var deletePackage = new MailOperationPreperationRequest(MailOperation.HardDelete, CurrentMailDraftItem.MailCopy, ignoreHardDeleteProtection: true);
await _worker.ExecuteAsync(deletePackage).ConfigureAwait(false);
}
}
}
public override async void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
await UpdateMimeChangesAsync().ConfigureAwait(false);
await ExecuteUIThread(() => { _statePersistanceService.IsReadingMail = false; });
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters != null && parameters is MailItemViewModel mailItem)
{
await LoadAccountsAsync();
CurrentMailDraftItem = mailItem;
_ = TryPrepareComposeAsync(true);
}
ToItems.CollectionChanged -= ContactListCollectionChanged;
ToItems.CollectionChanged += ContactListCollectionChanged;
_statePersistanceService.IsReadingMail = true;
// Check if there is any delivering mail address from protocol launch.
if (_launchProtocolService.MailtoParameters != null)
{
// TODO
//var requestedMailContact = await GetAddressInformationAsync(_launchProtocolService.MailtoParameters, ToItems);
//if (requestedMailContact != null)
//{
// ToItems.Add(requestedMailContact);
//}
//else
// DialogService.InfoBarMessage("Invalid Address", "Address is not a valid e-mail address.", InfoBarMessageType.Warning);
// Clear the address.
_launchProtocolService.MailtoParameters = null;
}
}
private void ContactListCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
// Prevent duplicates.
if (!(sender is ObservableCollection<AddressInformation> list))
return;
foreach (var item in e.NewItems)
{
if (item is AddressInformation addedInfo && list.Count(a => a == addedInfo) > 1)
{
var addedIndex = list.IndexOf(addedInfo);
list.RemoveAt(addedIndex);
}
}
}
}
private async Task LoadAccountsAsync()
{
// Load accounts
var accounts = await _accountService.GetAccountsAsync();
foreach (var account in accounts)
{
Accounts.Add(account);
}
}
private async Task<bool> InitializeComposerAccountAsync()
{
if (ComposingAccount != null) return true;
if (CurrentMailDraftItem == null)
return false;
await ExecuteUIThread(() =>
{
ComposingAccount = Accounts.FirstOrDefault(a => a.Id == CurrentMailDraftItem.AssignedAccount.Id);
});
return ComposingAccount != null;
}
private async Task TryPrepareComposeAsync(bool downloadIfNeeded)
{
if (CurrentMailDraftItem == null)
return;
bool isComposerInitialized = await InitializeComposerAccountAsync();
if (!isComposerInitialized)
{
return;
}
// Replying existing message.
MimeMessageInformation mimeMessageInformation = null;
try
{
mimeMessageInformation = await _mimeFileService.GetMimeMessageInformationAsync(CurrentMailDraftItem.MailCopy.FileId, ComposingAccount.Id).ConfigureAwait(false);
}
catch (FileNotFoundException)
{
if (downloadIfNeeded)
{
// TODO: Folder id needs to be passed.
// TODO: Send mail retrieve request.
// _worker.Queue(new FetchSingleItemRequest(ComposingAccount.Id, CurrentMailDraftItem.Id, string.Empty));
}
//else
// DialogService.ShowMIMENotFoundMessage();
return;
}
catch (ComposerMimeNotFoundException)
{
DialogService.InfoBarMessage(Translator.Info_ComposerMissingMIMETitle, Translator.Info_ComposerMissingMIMEMessage, InfoBarMessageType.Error);
}
if (mimeMessageInformation == null)
return;
var replyingMime = mimeMessageInformation.MimeMessage;
var mimeFilePath = mimeMessageInformation.Path;
var renderModel = _mimeFileService.GetMailRenderModel(replyingMime, mimeFilePath);
await ExecuteUIThread(() =>
{
// Extract information
ToItems.Clear();
CCItemsItems.Clear();
BCCItems.Clear();
LoadAddressInfo(replyingMime.To, ToItems);
LoadAddressInfo(replyingMime.Cc, CCItemsItems);
LoadAddressInfo(replyingMime.Bcc, BCCItems);
LoadAttachments(replyingMime.Attachments);
Subject = replyingMime.Subject;
currentMimeMessage = replyingMime;
OnPropertyChanged(nameof(CanSendMail));
Messenger.Send(new CreateNewComposeMailRequested(renderModel));
});
}
private void LoadAttachments(IEnumerable<MimeEntity> mimeEntities)
{
foreach (var attachment in mimeEntities)
{
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
{
IncludedAttachments.Add(new MailAttachmentViewModel(attachmentPart));
}
}
}
private void LoadAddressInfo(InternetAddressList list, ObservableCollection<AddressInformation> collection)
{
foreach (var item in list)
{
if (item is MailboxAddress mailboxAddress)
collection.Add(mailboxAddress.ToAddressInformation());
else if (item is GroupAddress groupAddress)
LoadAddressInfo(groupAddress.Members, collection);
}
}
private void SaveAddressInfo(IEnumerable<AddressInformation> addresses, InternetAddressList list)
{
list.Clear();
foreach (var item in addresses)
list.Add(new MailboxAddress(item.Name, item.Address));
}
public async Task<AddressInformation> GetAddressInformationAsync(string tokenText, ObservableCollection<AddressInformation> collection)
{
// Get model from the service. This will make sure the name is properly included if there is any record.
var info = await ContactService.GetAddressInformationByAddressAsync(tokenText);
// Don't add if there is already that address in the collection.
if (collection.Any(a => a.Address == info.Address))
return null;
return info;
}
public void NotifyAddressExists()
{
DialogService.InfoBarMessage(Translator.Info_ContactExistsTitle, Translator.Info_ContactExistsMessage, InfoBarMessageType.Warning);
}
public void NotifyInvalidEmail(string address)
{
DialogService.InfoBarMessage(Translator.Info_InvalidAddressTitle, string.Format(Translator.Info_InvalidAddressMessage, address), InfoBarMessageType.Warning);
}
protected override async void OnMailUpdated(MailCopy updatedMail)
{
base.OnMailUpdated(updatedMail);
if (CurrentMailDraftItem == null) return;
if (updatedMail.UniqueId == CurrentMailDraftItem.UniqueId)
{
await ExecuteUIThread(() =>
{
CurrentMailDraftItem.Update(updatedMail);
OnPropertyChanged(nameof(CanSendMail));
});
}
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data
{
public partial class AccountProviderDetailViewModel : ObservableObject, IAccountProviderDetailViewModel
{
[ObservableProperty]
private MailAccount account;
public IProviderDetail ProviderDetail { get; set; }
public Guid StartupEntityId => Account.Id;
public string StartupEntityTitle => Account.Name;
public AccountProviderDetailViewModel(IProviderDetail providerDetail, MailAccount account)
{
ProviderDetail = providerDetail;
Account = account;
}
}
}

View File

@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Wino.Mail.ViewModels.Data
{
public class AppColorViewModel : ObservableObject
{
private string _hex;
public string Hex
{
get => _hex;
set => SetProperty(ref _hex, value);
}
public bool IsAccentColor { get; }
public AppColorViewModel(string hex, bool isAccentColor = false)
{
IsAccentColor = isAccentColor;
Hex = hex;
}
}
}

View File

@@ -0,0 +1,33 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Messages.Navigation;
namespace Wino.Mail.ViewModels.Data
{
public class BreadcrumbNavigationItemViewModel : ObservableObject
{
public BreadcrumbNavigationRequested Request { get; set; }
public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive)
{
Request = request;
Title = request.PageTitle;
this.isActive = isActive;
}
private string title;
public string Title
{
get => title;
set => SetProperty(ref title, value);
}
private bool isActive;
public bool IsActive
{
get => isActive;
set => SetProperty(ref isActive, value);
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
namespace Wino.Mail.ViewModels.Data
{
[DebuggerDisplay("{FolderTitle}")]
public partial class FolderPivotViewModel : ObservableObject
{
public bool? IsFocused { get; set; }
public string FolderTitle { get; }
public bool ShouldDisplaySelectedItemCount => IsExtendedMode ? SelectedItemCount > 1 : SelectedItemCount > 0;
[ObservableProperty]
private bool isSelected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShouldDisplaySelectedItemCount))]
private int selectedItemCount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShouldDisplaySelectedItemCount))]
private bool isExtendedMode = true;
public FolderPivotViewModel(string folderName, bool? isFocused)
{
IsFocused = isFocused;
FolderTitle = IsFocused == null ? folderName : (IsFocused == true ? Translator.Focused : Translator.Other);
}
}
}

View File

@@ -0,0 +1,100 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using MimeKit;
using Wino.Core.Domain.Enums;
using Wino.Core.Extensions;
namespace Wino.Mail.ViewModels.Data
{
public class MailAttachmentViewModel : ObservableObject
{
private bool isBusy;
private readonly MimePart _mimePart;
public MailAttachmentType AttachmentType { get; }
public string FileName { get; }
public string FilePath { get; set; }
public string ReadableSize { get; }
public byte[] Content { get; set; }
public IMimeContent MimeContent => _mimePart.Content;
/// <summary>
/// Gets or sets whether attachment is busy with opening or saving etc.
/// </summary>
public bool IsBusy
{
get => isBusy;
set => SetProperty(ref isBusy, value);
}
public MailAttachmentViewModel(MimePart mimePart)
{
_mimePart = mimePart;
var array = new byte[_mimePart.Content.Stream.Length];
_mimePart.Content.Stream.Read(array, 0, (int)_mimePart.Content.Stream.Length);
Content = array;
FileName = mimePart.FileName;
ReadableSize = mimePart.Content.Stream.Length.GetBytesReadable();
var extension = Path.GetExtension(FileName);
AttachmentType = GetAttachmentType(extension);
}
public MailAttachmentViewModel(string fullFilePath, byte[] content)
{
Content = content;
FileName = Path.GetFileName(fullFilePath);
FilePath = fullFilePath;
ReadableSize = ((long)content.Length).GetBytesReadable();
var extension = Path.GetExtension(FileName);
AttachmentType = GetAttachmentType(extension);
}
public MailAttachmentType GetAttachmentType(string mediaSubtype)
{
if (string.IsNullOrEmpty(mediaSubtype))
return MailAttachmentType.None;
switch (mediaSubtype.ToLower())
{
case ".exe":
return MailAttachmentType.Executable;
case ".rar":
return MailAttachmentType.RarArchive;
case ".zip":
return MailAttachmentType.Archive;
case ".ogg":
case ".mp3":
case ".wav":
case ".aac":
case ".alac":
return MailAttachmentType.Audio;
case ".mp4":
case ".wmv":
case ".avi":
case ".flv":
return MailAttachmentType.Video;
case ".pdf":
return MailAttachmentType.PDF;
case ".htm":
case ".html":
return MailAttachmentType.HTML;
case ".png":
case ".jpg":
case ".jpeg":
case ".gif":
case ".jiff":
return MailAttachmentType.Image;
default:
return MailAttachmentType.Other;
}
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Wino.Mail.ViewModels.Data
{
public class MailItemContainer
{
public MailItemViewModel ItemViewModel { get; set; }
public ThreadMailItemViewModel ThreadViewModel { get; set; }
public MailItemContainer(MailItemViewModel itemViewModel, ThreadMailItemViewModel threadViewModel) : this(itemViewModel)
{
ThreadViewModel = threadViewModel ?? throw new ArgumentNullException(nameof(threadViewModel));
}
public MailItemContainer(MailItemViewModel itemViewModel)
{
ItemViewModel = itemViewModel ?? throw new ArgumentNullException(nameof(itemViewModel));
}
}
}

View File

@@ -0,0 +1,106 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Mail.ViewModels.Data
{
/// <summary>
/// Single view model for IMailItem representation.
/// </summary>
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IMailItem
{
public MailCopy MailCopy { get; private set; } = mailCopy;
public bool IsLocalDraft => !string.IsNullOrEmpty(DraftId) && DraftId.StartsWith(Constants.LocalDraftStartPrefix);
public Guid UniqueId => ((IMailItem)MailCopy).UniqueId;
public string ThreadId => ((IMailItem)MailCopy).ThreadId;
public string MessageId => ((IMailItem)MailCopy).MessageId;
public string FromName => ((IMailItem)MailCopy).FromName ?? FromAddress;
public DateTime CreationDate => ((IMailItem)MailCopy).CreationDate;
public string FromAddress => ((IMailItem)MailCopy).FromAddress;
public bool HasAttachments => ((IMailItem)MailCopy).HasAttachments;
public string References => ((IMailItem)MailCopy).References;
public string InReplyTo => ((IMailItem)MailCopy).InReplyTo;
[ObservableProperty]
private bool isCustomFocused;
[ObservableProperty]
private bool isSelected;
public bool IsFlagged
{
get => MailCopy.IsFlagged;
set => SetProperty(MailCopy.IsFlagged, value, MailCopy, (u, n) => u.IsFlagged = n);
}
public bool IsFocused
{
get => MailCopy.IsFocused;
set => SetProperty(MailCopy.IsFocused, value, MailCopy, (u, n) => u.IsFocused = n);
}
public bool IsRead
{
get => MailCopy.IsRead;
set => SetProperty(MailCopy.IsRead, value, MailCopy, (u, n) => u.IsRead = n);
}
public bool IsDraft
{
get => MailCopy.IsDraft;
set => SetProperty(MailCopy.IsDraft, value, MailCopy, (u, n) => u.IsDraft = n);
}
public string DraftId
{
get => MailCopy.DraftId;
set => SetProperty(MailCopy.DraftId, value, MailCopy, (u, n) => u.DraftId = n);
}
public string Id
{
get => MailCopy.Id;
set => SetProperty(MailCopy.Id, value, MailCopy, (u, n) => u.Id = n);
}
public string Subject
{
get => MailCopy.Subject;
set => SetProperty(MailCopy.Subject, value, MailCopy, (u, n) => u.Subject = n);
}
public string PreviewText
{
get => MailCopy.PreviewText;
set => SetProperty(MailCopy.PreviewText, value, MailCopy, (u, n) => u.PreviewText = n);
}
public MailItemFolder AssignedFolder => ((IMailItem)MailCopy).AssignedFolder;
public MailAccount AssignedAccount => ((IMailItem)MailCopy).AssignedAccount;
public Guid FileId => ((IMailItem)MailCopy).FileId;
public void Update(MailCopy updatedMailItem)
{
MailCopy = updatedMailItem;
// DEBUG
//if (updatedMailItem.AssignedAccount == null || updatedMailItem.AssignedFolder == null)
// throw new Exception("Assigned account or folder is null.");
OnPropertyChanged(nameof(IsRead));
OnPropertyChanged(nameof(IsFocused));
OnPropertyChanged(nameof(IsFlagged));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(DraftId));
OnPropertyChanged(nameof(Subject));
OnPropertyChanged(nameof(PreviewText));
OnPropertyChanged(nameof(IsLocalDraft));
}
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels.Data
{
public partial class MergedAccountProviderDetailViewModel : ObservableObject, IAccountProviderDetailViewModel
{
public List<AccountProviderDetailViewModel> HoldingAccounts { get; }
public MergedInbox MergedInbox { get; }
public string AccountAddresses => string.Join(", ", HoldingAccounts.Select(a => a.Account.Address));
public Guid StartupEntityId => MergedInbox.Id;
public string StartupEntityTitle => MergedInbox.Name;
public MergedAccountProviderDetailViewModel(MergedInbox mergedInbox, List<AccountProviderDetailViewModel> holdingAccounts)
{
MergedInbox = mergedInbox;
HoldingAccounts = holdingAccounts;
}
}
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Mail.ViewModels.Data
{
/// <summary>
/// Thread mail item (multiple IMailItem) view model representation.
/// </summary>
public class ThreadMailItemViewModel : ObservableObject, IMailItemThread, IComparable<string>, IComparable<DateTime>
{
public ObservableCollection<IMailItem> ThreadItems => ((IMailItemThread)_threadMailItem).ThreadItems;
private readonly ThreadMailItem _threadMailItem;
private bool isThreadExpanded;
public bool IsThreadExpanded
{
get => isThreadExpanded;
set => SetProperty(ref isThreadExpanded, value);
}
public ThreadMailItemViewModel(ThreadMailItem threadMailItem)
{
_threadMailItem = new ThreadMailItem();
// Local copies
foreach (var item in threadMailItem.ThreadItems)
{
AddMailItemViewModel(item);
}
}
public IEnumerable<MailCopy> GetMailCopies()
=> ThreadItems.OfType<MailItemViewModel>().Select(a => a.MailCopy);
public void AddMailItemViewModel(IMailItem mailItem)
{
if (mailItem == null) return;
if (mailItem is MailCopy mailCopy)
_threadMailItem.AddThreadItem(new MailItemViewModel(mailCopy));
else if (mailItem is MailItemViewModel mailItemViewModel)
_threadMailItem.AddThreadItem(mailItemViewModel);
else
Debugger.Break();
}
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()
{
OnPropertyChanged(nameof(Subject));
OnPropertyChanged(nameof(PreviewText));
OnPropertyChanged(nameof(FromName));
OnPropertyChanged(nameof(FromAddress));
OnPropertyChanged(nameof(HasAttachments));
OnPropertyChanged(nameof(IsFlagged));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsRead));
OnPropertyChanged(nameof(IsFocused));
OnPropertyChanged(nameof(CreationDate));
}
public IMailItem LatestMailItem => ((IMailItemThread)_threadMailItem).LatestMailItem;
public IMailItem FirstMailItem => ((IMailItemThread)_threadMailItem).FirstMailItem;
public string Id => ((IMailItem)_threadMailItem).Id;
public string Subject => ((IMailItem)_threadMailItem).Subject;
public string ThreadId => ((IMailItem)_threadMailItem).ThreadId;
public string MessageId => ((IMailItem)_threadMailItem).MessageId;
public string References => ((IMailItem)_threadMailItem).References;
public string PreviewText => ((IMailItem)_threadMailItem).PreviewText;
public string FromName => ((IMailItem)_threadMailItem).FromName;
public DateTime CreationDate => ((IMailItem)_threadMailItem).CreationDate;
public string FromAddress => ((IMailItem)_threadMailItem).FromAddress;
public bool HasAttachments => ((IMailItem)_threadMailItem).HasAttachments;
public bool IsFlagged => ((IMailItem)_threadMailItem).IsFlagged;
public bool IsFocused => ((IMailItem)_threadMailItem).IsFocused;
public bool IsRead => ((IMailItem)_threadMailItem).IsRead;
public bool IsDraft => ((IMailItem)_threadMailItem).IsDraft;
public string DraftId => string.Empty;
public string InReplyTo => ((IMailItem)_threadMailItem).InReplyTo;
public MailItemFolder AssignedFolder => ((IMailItem)_threadMailItem).AssignedFolder;
public MailAccount AssignedAccount => ((IMailItem)_threadMailItem).AssignedAccount;
public Guid UniqueId => ((IMailItem)_threadMailItem).UniqueId;
public Guid FileId => ((IMailItem)_threadMailItem).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;
}
}

View File

@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "member", Target = "~F:Wino.Mail.ViewModels.ComposePageViewModel.isImportanceSelected")]

View File

@@ -0,0 +1,38 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Messages.Mails;
namespace Wino.Mail.ViewModels
{
public partial class IdlePageViewModel : BaseViewModel, IRecipient<SelectedMailItemsChanged>
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelectedItems))]
[NotifyPropertyChangedFor(nameof(SelectedMessageText))]
private int selectedItemCount;
public bool HasSelectedItems => SelectedItemCount > 0;
public string SelectedMessageText => HasSelectedItems ? string.Format(Translator.MailsSelected, SelectedItemCount) : Translator.NoMailSelected;
public IdlePageViewModel(IDialogService dialogService) : base(dialogService) { }
public void Receive(SelectedMailItemsChanged message)
{
SelectedItemCount = message.SelectedItemCount;
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters != null && parameters is int selectedItemCount)
SelectedItemCount = selectedItemCount;
else
SelectedItemCount = 0;
}
}
}

View File

@@ -0,0 +1,922 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.AppCenter.Crashes;
using MoreLinq;
using Nito.AsyncEx;
using Serilog;
using Wino.Core;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Menus;
using Wino.Core.Domain.Models.Reader;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Messages.Mails;
using Wino.Core.Messages.Synchronization;
using Wino.Mail.ViewModels.Collections;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.ViewModels.Messages;
namespace Wino.Mail.ViewModels
{
public partial class MailListPageViewModel : BaseViewModel,
IRecipient<MailItemNavigationRequested>,
IRecipient<ActiveMailFolderChangedEvent>,
IRecipient<MailItemSelectedEvent>,
IRecipient<MailItemSelectionRemovedEvent>,
IRecipient<AccountSynchronizationCompleted>,
IRecipient<NewSynchronizationRequested>,
IRecipient<AccountSynchronizerStateChanged>
{
private bool isChangingFolder = false;
private Guid? trackingSynchronizationId = null;
private int completedTrackingSynchronizationCount = 0;
private IObservable<System.Reactive.EventPattern<NotifyCollectionChangedEventArgs>> selectionChangedObservable = null;
public WinoMailCollection MailCollection { get; } = new WinoMailCollection();
public ObservableCollection<MailItemViewModel> SelectedItems { get; set; } = new ObservableCollection<MailItemViewModel>();
public ObservableCollection<FolderPivotViewModel> PivotFolders { get; set; } = new ObservableCollection<FolderPivotViewModel>();
private readonly SemaphoreSlim listManipulationSemepahore = new SemaphoreSlim(1);
private CancellationTokenSource listManipulationCancellationTokenSource = new CancellationTokenSource();
public IWinoNavigationService NavigationService { get; }
public IStatePersistanceService StatePersistanceService { get; }
public IPreferencesService PreferencesService { get; }
private readonly IMailService _mailService;
private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService;
private readonly IWinoSynchronizerFactory _winoSynchronizerFactory;
private readonly IThreadingStrategyProvider _threadingStrategyProvider;
private readonly IContextMenuItemService _contextMenuItemService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly IKeyPressService _keyPressService;
private MailItemViewModel _activeMailItem;
public List<SortingOption> SortingOptions { get; } =
[
new(Translator.SortingOption_Date, SortingOptionType.ReceiveDate),
new(Translator.SortingOption_Name, SortingOptionType.Sender),
];
public List<FilterOption> FilterOptions { get; } =
[
new (Translator.FilteringOption_All, FilterOptionType.All),
new (Translator.FilteringOption_Unread, FilterOptionType.Unread),
new (Translator.FilteringOption_Flagged, FilterOptionType.Flagged)
];
private FolderPivotViewModel _selectedFolderPivot;
[ObservableProperty]
private string searchQuery;
[ObservableProperty]
private FilterOption _selectedFilterOption;
private SortingOption _selectedSortingOption;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEmpty))]
[NotifyPropertyChangedFor(nameof(IsCriteriaFailed))]
[NotifyPropertyChangedFor(nameof(IsFolderEmpty))]
private bool isInitializingFolder;
[ObservableProperty]
private InfoBarMessageType barSeverity;
[ObservableProperty]
private string barMessage;
[ObservableProperty]
private string barTitle;
[ObservableProperty]
private bool isBarOpen;
public MailListPageViewModel(IDialogService dialogService,
IWinoNavigationService navigationService,
IMailService mailService,
INotificationBuilder notificationBuilder,
IStatePersistanceService statePersistanceService,
IFolderService folderService,
IWinoSynchronizerFactory winoSynchronizerFactory,
IThreadingStrategyProvider threadingStrategyProvider,
IContextMenuItemService contextMenuItemService,
IWinoRequestDelegator winoRequestDelegator,
IKeyPressService keyPressService,
IPreferencesService preferencesService) : base(dialogService)
{
PreferencesService = preferencesService;
StatePersistanceService = statePersistanceService;
NavigationService = navigationService;
_mailService = mailService;
_notificationBuilder = notificationBuilder;
_folderService = folderService;
_winoSynchronizerFactory = winoSynchronizerFactory;
_threadingStrategyProvider = threadingStrategyProvider;
_contextMenuItemService = contextMenuItemService;
_winoRequestDelegator = winoRequestDelegator;
_keyPressService = keyPressService;
SelectedFilterOption = FilterOptions[0];
SelectedSortingOption = SortingOptions[0];
selectionChangedObservable = Observable.FromEventPattern<NotifyCollectionChangedEventArgs>(SelectedItems, nameof(SelectedItems.CollectionChanged));
selectionChangedObservable
.Throttle(TimeSpan.FromMilliseconds(100))
.Subscribe(async a =>
{
await ExecuteUIThread(() => { SelectedItemCollectionUpdated(a.EventArgs); });
});
}
/// <summary>
/// Executes the requested mail operation for currently selected items.
/// </summary>
/// <param name="operation">Action to execute for selected items.</param>
[RelayCommand]
private async Task MailOperationAsync(int mailOperationIndex)
{
if (!SelectedItems.Any()) return;
// Commands don't like enums. So it has to be int.
var operation = (MailOperation)mailOperationIndex;
var package = new MailOperationPreperationRequest(operation, SelectedItems.Select(a => a.MailCopy));
await ExecuteMailOperationAsync(package);
}
/// <summary>
/// Sens a new message to synchronize current folder.
/// </summary>
[RelayCommand]
private void SyncFolder()
{
if (!CanSynchronize) return;
// Only synchronize listed folders.
// When doing linked inbox sync, we need to save the sync id to report progress back only once.
// Otherwise, we will report progress for each folder and that's what we don't want.
trackingSynchronizationId = Guid.NewGuid();
completedTrackingSynchronizationCount = 0;
foreach (var folder in ActiveFolder.HandlingFolders)
{
var options = new SynchronizationOptions()
{
AccountId = folder.MailAccountId,
Type = SynchronizationType.Custom,
SynchronizationFolderIds = [folder.Id],
GroupedSynchronizationTrackingId = trackingSynchronizationId
};
Messenger.Send(new NewSynchronizationRequested(options));
}
}
private async void ActiveMailItemChanged(MailItemViewModel selectedMailItemViewModel)
{
if (_activeMailItem == selectedMailItemViewModel) return;
// Don't update active mail item if Ctrl key is pressed.
// User is probably trying to select multiple items.
// This is not the same behavior in Windows Mail,
// but it's a trash behavior.
var isCtrlKeyPressed = _keyPressService.IsCtrlKeyPressed();
if (isCtrlKeyPressed) return;
_activeMailItem = selectedMailItemViewModel;
Messenger.Send(new ActiveMailItemChangedEvent(selectedMailItemViewModel));
if (selectedMailItemViewModel == null) return;
// Automatically set mark as read or not based on preferences.
var markAsPreference = PreferencesService.MarkAsPreference;
if (markAsPreference == MailMarkAsOption.WhenSelected)
{
if (selectedMailItemViewModel != null && !selectedMailItemViewModel.IsRead)
{
var operation = MailOperation.MarkAsRead;
var package = new MailOperationPreperationRequest(operation,_activeMailItem.MailCopy);
await ExecuteMailOperationAsync(package);
}
}
else if (markAsPreference == MailMarkAsOption.AfterDelay && PreferencesService.MarkAsDelay >= 0)
{
// TODO: Start a timer then queue.
}
}
/// <summary>
/// Selected internal folder. This can be either folder's own name or Focused-Other.
/// </summary>
public FolderPivotViewModel SelectedFolderPivot
{
get => _selectedFolderPivot;
set
{
if (_selectedFolderPivot != null)
_selectedFolderPivot.SelectedItemCount = 0;
SetProperty(ref _selectedFolderPivot, value);
}
}
/// <summary>
/// Selected sorting option.
/// </summary>
public SortingOption SelectedSortingOption
{
get => _selectedSortingOption;
set
{
if (SetProperty(ref _selectedSortingOption, value))
{
if (value != null && MailCollection != null)
{
MailCollection.SortingType = value.Type;
}
}
}
}
/// <summary>
/// Current folder that is being represented from the menu.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
private IBaseFolderMenuItem activeFolder;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
private bool isAccountSynchronizerInSynchronization;
public bool CanSynchronize => !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
#region Properties
public int SelectedItemCount => SelectedItems.Count;
public bool HasMultipleItemSelections => SelectedItemCount > 1;
public bool HasSelectedItems => SelectedItems.Any();
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
public bool IsEmpty => !IsPerformingSearch && MailCollection.Count == 0;
public bool IsCriteriaFailed => IsEmpty && IsInSearchMode;
public bool IsFolderEmpty => !IsInitializingFolder && IsEmpty && !IsInSearchMode;
private bool _isPerformingSearch;
public bool IsPerformingSearch
{
get => _isPerformingSearch;
set
{
if (SetProperty(ref _isPerformingSearch, value))
{
NotifyItemFoundState();
}
}
}
public bool IsInSearchMode => !string.IsNullOrEmpty(SearchQuery);
#endregion
public void NotifyItemSelected()
{
OnPropertyChanged(nameof(HasSelectedItems));
OnPropertyChanged(nameof(SelectedItemCount));
OnPropertyChanged(nameof(HasMultipleItemSelections));
if (SelectedFolderPivot != null)
SelectedFolderPivot.SelectedItemCount = SelectedItemCount;
}
private void NotifyItemFoundState()
{
OnPropertyChanged(nameof(IsEmpty));
OnPropertyChanged(nameof(IsCriteriaFailed));
OnPropertyChanged(nameof(IsFolderEmpty));
}
[RelayCommand]
public Task ExecuteHoverAction(MailOperationPreperationRequest request) => ExecuteMailOperationAsync(request);
public Task ExecuteMailOperationAsync(MailOperationPreperationRequest package) => _winoRequestDelegator.ExecuteAsync(package);
protected override void OnDispatcherAssigned()
{
base.OnDispatcherAssigned();
MailCollection.CoreDispatcher = Dispatcher;
}
protected override async void OnFolderUpdated(MailItemFolder updatedFolder, MailAccount account)
{
base.OnFolderUpdated(updatedFolder, account);
// Don't need to update if the folder update does not belong to the current folder menu item.
if (ActiveFolder == null || updatedFolder == null || !ActiveFolder.HandlingFolders.Any(a => a.Id == updatedFolder.Id)) return;
await ExecuteUIThread(() =>
{
ActiveFolder.UpdateFolder(updatedFolder);
OnPropertyChanged(nameof(CanSynchronize));
OnPropertyChanged(nameof(IsFolderSynchronizationEnabled));
});
// Force synchronization after enabling the folder.
SyncFolder();
}
private async void UpdateBarMessage(InfoBarMessageType severity, string title, string message)
{
await ExecuteUIThread(() =>
{
BarSeverity = severity;
BarTitle = title;
BarMessage = message;
IsBarOpen = true;
});
}
private void SelectedItemCollectionUpdated(NotifyCollectionChangedEventArgs e)
{
if (SelectedItems.Count == 1)
{
ActiveMailItemChanged(SelectedItems[0]);
}
else
{
// At this point, either we don't have any item selected
// or we have multiple item selected. In either case
// there should be no active item.
ActiveMailItemChanged(null);
}
NotifyItemSelected();
Messenger.Send(new SelectedMailItemsChanged(SelectedItems.Count));
}
private void UpdateFolderPivots()
{
PivotFolders.Clear();
SelectedFolderPivot = null;
if (ActiveFolder == null) return;
// Merged folders don't support focused feature.
if (ActiveFolder is IMergedAccountFolderMenuItem)
{
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
}
else if (ActiveFolder is IFolderMenuItem singleFolderMenuItem)
{
var parentAccount = singleFolderMenuItem.ParentAccount;
bool isAccountSupportsFocusedInbox = parentAccount.Preferences.IsFocusedInboxEnabled != null;
bool isFocusedInboxEnabled = isAccountSupportsFocusedInbox && parentAccount.Preferences.IsFocusedInboxEnabled.GetValueOrDefault();
bool isInboxFolder = ActiveFolder.SpecialFolderType == SpecialFolderType.Inbox;
// Folder supports Focused - Other
if (isInboxFolder && isFocusedInboxEnabled)
{
// Can be passed as empty string. Focused - Other will be used regardless.
var focusedItem = new FolderPivotViewModel(string.Empty, true);
var otherItem = new FolderPivotViewModel(string.Empty, false);
PivotFolders.Add(focusedItem);
PivotFolders.Add(otherItem);
}
else
{
// If the account and folder doesn't support focused feature, just add itself.
PivotFolders.Add(new FolderPivotViewModel(singleFolderMenuItem.FolderName, null));
}
}
// This will trigger refresh.
SelectedFolderPivot = PivotFolders.FirstOrDefault();
}
[RelayCommand]
private async Task SelectedPivotChanged()
{
if (isChangingFolder) return;
await InitializeFolderAsync();
}
[RelayCommand]
private async Task SelectedSortingChanged(SortingOption option)
{
SelectedSortingOption = option;
if (isChangingFolder) return;
await InitializeFolderAsync();
}
[RelayCommand]
private async Task SelectedFilterChanged(FilterOption option)
{
SelectedFilterOption = option;
if (isChangingFolder) return;
await InitializeFolderAsync();
}
public IEnumerable<MailItemViewModel> GetTargetMailItemViewModels(IMailItem clickedItem)
{
// Threat threads as a whole and include everything in the group. Except single selections outside of the thread.
IEnumerable<MailItemViewModel> contextMailItems = null;
if (clickedItem is ThreadMailItemViewModel clickedThreadItem)
{
// Clicked item is a thread.
clickedThreadItem.IsThreadExpanded = true;
contextMailItems = clickedThreadItem.ThreadItems.Cast<MailItemViewModel>();
// contextMailItems = clickedThreadItem.GetMailCopies();
}
else if (clickedItem is MailItemViewModel clickedMailItemViewModel)
{
// If the clicked item is included in SelectedItems, then we need to thing them as whole.
// If there are selected items, but clicked item is not one of them, then it's a single context menu.
bool includedInSelectedItems = SelectedItems.Contains(clickedItem);
if (includedInSelectedItems)
contextMailItems = SelectedItems;
else
contextMailItems = new List<MailItemViewModel>() { clickedMailItemViewModel };
}
return contextMailItems;
}
public IEnumerable<MailOperationMenuItem> GetAvailableMailActions(IEnumerable<IMailItem> contextMailItems)
=> _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems);
public void ChangeCustomFocusedState(IEnumerable<IMailItem> mailItems, bool isFocused)
=> mailItems.Where(a => a is MailItemViewModel).Cast<MailItemViewModel>().ForEach(a => a.IsCustomFocused = isFocused);
private bool ShouldPreventItemAdd(IMailItem mailItem)
{
bool condition2 = false;
bool condition1 = mailItem.IsRead
&& SelectedFilterOption.Type == FilterOptionType.Unread
|| !mailItem.IsFlagged
&& SelectedFilterOption.Type == FilterOptionType.Flagged;
return condition1 || condition2;
}
protected override async void OnMailAdded(MailCopy addedMail)
{
base.OnMailAdded(addedMail);
try
{
await listManipulationSemepahore.WaitAsync();
if (ActiveFolder == null) return;
// Messages coming to sent or draft folder must be inserted regardless of the filter.
bool shouldPreventIgnoringFilter = addedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft ||
addedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent;
// Item does not belong to this folder and doesn't have special type to be inserted.
if (!shouldPreventIgnoringFilter && !ActiveFolder.HandlingFolders.Any(a => a.Id == addedMail.AssignedFolder.Id)) return;
if (!shouldPreventIgnoringFilter && ShouldPreventItemAdd(addedMail)) return;
await ExecuteUIThread(async () =>
{
await MailCollection.AddAsync(addedMail);
NotifyItemFoundState();
});
}
catch (Exception) { }
finally
{
listManipulationSemepahore.Release();
}
}
protected override async void OnMailUpdated(MailCopy updatedMail)
{
base.OnMailUpdated(updatedMail);
Debug.WriteLine($"Updating {updatedMail.Id}-> {updatedMail.UniqueId}");
await MailCollection.UpdateMailCopy(updatedMail);
}
protected override async void OnMailRemoved(MailCopy removedMail)
{
base.OnMailRemoved(removedMail);
// We should delete the items only if:
// 1. They are deleted from the active folder.
// 2. Deleted from draft or sent folder.
// Delete/sent are special folders that can list their items in other folders.
bool removedFromActiveFolder = ActiveFolder.HandlingFolders.Any(a => a.Id == removedMail.AssignedFolder.Id);
bool removedFromDraftOrSent = removedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft ||
removedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent;
if (removedFromActiveFolder || removedFromDraftOrSent)
{
bool isDeletedMailSelected = SelectedItems.Any(a => a.MailCopy.UniqueId == removedMail.UniqueId);
// Automatically select the next item in the list if the setting is enabled.
MailItemViewModel nextItem = null;
if (isDeletedMailSelected && PreferencesService.AutoSelectNextItem)
{
nextItem = MailCollection.GetNextItem(removedMail);
}
// Remove the deleted item from the list.
await MailCollection.RemoveAsync(removedMail);
if (nextItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true));
else if (isDeletedMailSelected)
{
// There are no next item to select, but we removed the last item which was selected.
// Clearing selected item will dispose rendering page.
SelectedItems.Clear();
}
await ExecuteUIThread(() => { NotifyItemFoundState(); });
}
}
protected override async void OnDraftCreated(MailCopy draftMail, MailAccount account)
{
base.OnDraftCreated(draftMail, account);
try
{
// If the draft is created in another folder, we need to wait for that folder to be initialized.
// Otherwise the draft mail item will be duplicated on the next add execution.
await listManipulationSemepahore.WaitAsync();
// Create the item. Draft folder navigation is already done at this point.
await ExecuteUIThread(async () =>
{
await MailCollection.AddAsync(draftMail);
// New draft is created by user. Select the item.
Messenger.Send(new MailItemNavigationRequested(draftMail.UniqueId, ScrollToItem: true));
NotifyItemFoundState();
});
}
finally
{
listManipulationSemepahore.Release();
}
}
private IEnumerable<IMailItem> PrepareMailViewModels(IEnumerable<IMailItem> mailItems)
{
foreach (var item in mailItems)
{
if (item is MailCopy singleMailItem)
yield return new MailItemViewModel(singleMailItem);
else if (item is ThreadMailItem threadMailItem)
yield return new ThreadMailItemViewModel(threadMailItem);
}
}
[RelayCommand]
private async Task LoadMoreItemsAsync()
{
if (IsInitializingFolder) return;
await ExecuteUIThread(() => { IsInitializingFolder = true; });
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
SelectedFilterOption.Type,
SelectedSortingOption.Type,
PreferencesService.IsThreadingEnabled,
SelectedFolderPivot.IsFocused,
SearchQuery,
MailCollection.MailCopyIdHashSet);
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
var viewModels = PrepareMailViewModels(items);
await ExecuteUIThread(() => { MailCollection.AddRange(viewModels, clearIdCache: false); });
await ExecuteUIThread(() => { IsInitializingFolder = false; });
}
private async Task InitializeFolderAsync()
{
if (SelectedFilterOption == null || SelectedFolderPivot == null || SelectedSortingOption == null)
return;
try
{
// Clear search query if not performing search.
if (!IsPerformingSearch)
SearchQuery = string.Empty;
MailCollection.Clear();
MailCollection.MailCopyIdHashSet.Clear();
SelectedItems.Clear();
if (ActiveFolder == null)
return;
await ExecuteUIThread(() => { IsInitializingFolder = true; });
// Folder is changed during initialization.
// Just cancel the existing one and wait for new initialization.
if (listManipulationSemepahore.CurrentCount == 0)
{
Debug.WriteLine("Canceling initialization of mails.");
listManipulationCancellationTokenSource.Cancel();
listManipulationCancellationTokenSource.Token.ThrowIfCancellationRequested();
}
listManipulationCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = listManipulationCancellationTokenSource.Token;
await listManipulationSemepahore.WaitAsync(cancellationToken);
// Setup MailCollection configuration.
// Don't pass any threading strategy if disabled in settings.
MailCollection.ThreadingStrategyProvider = PreferencesService.IsThreadingEnabled ? _threadingStrategyProvider : null;
// TODO: This should go inside
MailCollection.PruneSingleNonDraftItems = ActiveFolder.SpecialFolderType == SpecialFolderType.Draft;
// Here items are sorted and filtered.
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
SelectedFilterOption.Type,
SelectedSortingOption.Type,
PreferencesService.IsThreadingEnabled,
SelectedFolderPivot.IsFocused,
SearchQuery,
MailCollection.MailCopyIdHashSet);
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
// Here they are already threaded if needed.
// We don't need to insert them one by one.
// Just create VMs and do bulk insert.
var viewModels = PrepareMailViewModels(items);
await ExecuteUIThread(() => { MailCollection.AddRange(viewModels, true); });
}
catch (OperationCanceledException)
{
Debug.WriteLine("Initialization of mails canceled.");
}
catch (Exception ex)
{
Debugger.Break();
if (IsInSearchMode)
Log.Error(ex, WinoErrors.SearchFailed);
else
Log.Error(ex, WinoErrors.MailListRefreshFolder);
Crashes.TrackError(ex);
}
finally
{
listManipulationSemepahore.Release();
await ExecuteUIThread(() =>
{
IsInitializingFolder = false;
OnPropertyChanged(nameof(CanSynchronize));
NotifyItemFoundState();
});
}
}
[RelayCommand]
private async Task EnableFolderSynchronizationAsync()
{
if (ActiveFolder == null) return;
foreach (var folder in ActiveFolder.HandlingFolders)
{
await _folderService.ChangeFolderSynchronizationStateAsync(folder.Id, true);
}
// TODO
//ActiveFolder.IsSynchronizationEnabled = true;
//OnPropertyChanged(nameof(IsFolderSynchronizationEnabled));
//OnPropertyChanged(nameof(CanSynchronize));
//SyncFolderCommand?.Execute(null);
}
void IRecipient<MailItemNavigationRequested>.Receive(MailItemNavigationRequested message)
{
// Find mail item and add to selected items.
MailItemViewModel navigatingMailItem = null;
ThreadMailItemViewModel threadMailItemViewModel = null;
for (int i = 0; i < 3; i++)
{
var mailContainer = MailCollection.GetMailItemContainer(message.UniqueMailId);
if (mailContainer != null)
{
navigatingMailItem = mailContainer.ItemViewModel;
threadMailItemViewModel = mailContainer.ThreadViewModel;
break;
}
}
if (threadMailItemViewModel != null)
threadMailItemViewModel.IsThreadExpanded = true;
if (navigatingMailItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(navigatingMailItem, message.ScrollToItem));
else
Debugger.Break();
}
async void IRecipient<ActiveMailFolderChangedEvent>.Receive(ActiveMailFolderChangedEvent message)
{
isChangingFolder = true;
ActiveFolder = message.BaseFolderMenuItem;
trackingSynchronizationId = null;
completedTrackingSynchronizationCount = 0;
// Check whether the account synchronizer that this folder belongs to is already in synchronization.
await CheckIfAccountIsSynchronizingAsync();
// Notify change for archive-unarchive app bar button.
OnPropertyChanged(nameof(IsArchiveSpecialFolder));
// Prepare Focused - Other or folder name tabs.
UpdateFolderPivots();
await InitializeFolderAsync();
// TODO: This should be done in a better way.
while (IsInitializingFolder)
{
await Task.Delay(100);
}
// Let awaiters know about the completion of mail init.
message.FolderInitLoadAwaitTask?.TrySetResult(true);
isChangingFolder = false;
}
void IRecipient<MailItemSelectedEvent>.Receive(MailItemSelectedEvent message)
=> SelectedItems.Add(message.SelectedMailItem);
void IRecipient<MailItemSelectionRemovedEvent>.Receive(MailItemSelectionRemovedEvent message)
=> SelectedItems.Remove(message.RemovedMailItem);
public void Receive(AccountSynchronizationCompleted message)
{
if (ActiveFolder == null) return;
bool isLinkedInboxSyncResult = message.SynchronizationTrackingId == trackingSynchronizationId;
if (isLinkedInboxSyncResult)
{
var isCompletedAccountListed = ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == message.AccountId);
if (isCompletedAccountListed) completedTrackingSynchronizationCount++;
// Group sync is started but not all folders are synchronized yet. Don't report progress.
if (completedTrackingSynchronizationCount < ActiveFolder.HandlingFolders.Count()) return;
}
bool isReportingActiveAccountResult = ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == message.AccountId);
if (!isReportingActiveAccountResult) return;
// At this point either all folders or a single folder sync is completed.
switch (message.Result)
{
case SynchronizationCompletedState.Success:
UpdateBarMessage(InfoBarMessageType.Success, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Success);
break;
case SynchronizationCompletedState.Failed:
UpdateBarMessage(InfoBarMessageType.Error, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Failed);
break;
default:
break;
}
}
public async void Receive(NewSynchronizationRequested message)
=> await ExecuteUIThread(() => { OnPropertyChanged(nameof(CanSynchronize)); });
[RelayCommand]
public async Task PerformSearchAsync()
{
try
{
IsPerformingSearch = !string.IsNullOrEmpty(SearchQuery);
await InitializeFolderAsync();
}
finally
{
IsPerformingSearch = false;
}
}
public async void Receive(AccountSynchronizerStateChanged message)
=> await CheckIfAccountIsSynchronizingAsync();
private async Task CheckIfAccountIsSynchronizingAsync()
{
bool isAnyAccountSynchronizing = false;
// Check each account that this page is listing folders from.
// If any of the synchronizers are synchronizing, we disable sync.
if (ActiveFolder != null)
{
var accountIds = ActiveFolder.HandlingFolders.Select(a => a.MailAccountId);
foreach (var accountId in accountIds)
{
var synchronizer = _winoSynchronizerFactory.GetAccountSynchronizer(accountId);
if (synchronizer == null) continue;
bool isAccountSynchronizing = synchronizer.State != AccountSynchronizerState.Idle;
if (isAccountSynchronizing)
{
isAnyAccountSynchronizing = true;
break;
}
}
}
await ExecuteUIThread(() => { IsAccountSynchronizerInSynchronization = isAnyAccountSynchronizing; });
}
}
}

View File

@@ -0,0 +1,619 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MailKit;
using Microsoft.AppCenter.Crashes;
using MimeKit;
using Serilog;
using Wino.Core;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
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.Reader;
using Wino.Core.Extensions;
using Wino.Core.Messages.Mails;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels
{
public partial class MailRenderingPageViewModel : BaseViewModel,
ITransferProgress // For listening IMAP message download progress.
{
private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly IMimeFileService _mimeFileService;
private readonly Core.Domain.Interfaces.IMailService _mailService;
private readonly IFileService _fileService;
private readonly IWinoSynchronizerFactory _winoSynchronizerFactory;
private readonly IWinoRequestDelegator _requestDelegator;
private readonly IClipboardService _clipboardService;
private bool forceImageLoading = false;
private MailItemViewModel initializedMailItemViewModel = null;
private MimeMessageInformation initializedMimeMessageInformation = null;
#region Properties
public bool ShouldDisplayDownloadProgress => IsIndetermineProgress || (CurrentDownloadPercentage > 0 && CurrentDownloadPercentage <= 100);
public bool CanUnsubscribe => CurrentRenderModel?.CanUnsubscribe ?? false;
public bool IsJunkMail => initializedMailItemViewModel?.AssignedFolder != null && initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk;
public bool IsImageRenderingDisabled
{
get
{
if (IsJunkMail)
{
return !forceImageLoading;
}
else
{
return !CurrentRenderModel?.MailRenderingOptions?.LoadImages ?? false;
}
}
}
private bool isDarkWebviewRenderer;
public bool IsDarkWebviewRenderer
{
get => isDarkWebviewRenderer;
set
{
if (SetProperty(ref isDarkWebviewRenderer, value))
{
InitializeCommandBarItems();
}
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShouldDisplayDownloadProgress))]
private bool isIndetermineProgress;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShouldDisplayDownloadProgress))]
private double currentDownloadPercentage;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanUnsubscribe))]
private MailRenderModel currentRenderModel;
[ObservableProperty]
private string subject;
[ObservableProperty]
private string fromAddress;
[ObservableProperty]
private string fromName;
[ObservableProperty]
private DateTime creationDate;
public ObservableCollection<AddressInformation> ToItems { get; set; } = new ObservableCollection<AddressInformation>();
public ObservableCollection<AddressInformation> CCItemsItems { get; set; } = new ObservableCollection<AddressInformation>();
public ObservableCollection<AddressInformation> BCCItems { get; set; } = new ObservableCollection<AddressInformation>();
public ObservableCollection<MailAttachmentViewModel> Attachments { get; set; } = new ObservableCollection<MailAttachmentViewModel>();
public ObservableCollection<MailOperationMenuItem> MenuItems { get; set; } = new ObservableCollection<MailOperationMenuItem>();
#endregion
public INativeAppService NativeAppService { get; }
public IStatePersistanceService StatePersistanceService { get; }
public IPreferencesService PreferencesService { get; }
public MailRenderingPageViewModel(IDialogService dialogService,
INativeAppService nativeAppService,
IUnderlyingThemeService underlyingThemeService,
IMimeFileService mimeFileService,
Core.Domain.Interfaces.IMailService mailService,
IFileService fileService,
IWinoSynchronizerFactory winoSynchronizerFactory,
IWinoRequestDelegator requestDelegator,
IStatePersistanceService statePersistanceService,
IClipboardService clipboardService,
IPreferencesService preferencesService) : base(dialogService)
{
NativeAppService = nativeAppService;
StatePersistanceService = statePersistanceService;
PreferencesService = preferencesService;
_clipboardService = clipboardService;
_underlyingThemeService = underlyingThemeService;
_mimeFileService = mimeFileService;
_mailService = mailService;
_fileService = fileService;
_winoSynchronizerFactory = winoSynchronizerFactory;
_requestDelegator = requestDelegator;
}
[RelayCommand]
private async Task CopyClipboard(string copyText)
{
try
{
await _clipboardService.CopyClipboardAsync(copyText);
DialogService.InfoBarMessage(Translator.ClipboardTextCopied_Title, string.Format(Translator.ClipboardTextCopied_Message, copyText), InfoBarMessageType.Information);
}
catch (Exception)
{
DialogService.InfoBarMessage(Translator.GeneralTitle_Error, string.Format(Translator.ClipboardTextCopyFailed_Message, copyText), InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task ForceImageLoading()
{
forceImageLoading = true;
if (initializedMailItemViewModel == null && initializedMimeMessageInformation == null) return;
if (initializedMailItemViewModel != null)
await RenderAsync(initializedMimeMessageInformation);
else
await RenderAsync(initializedMimeMessageInformation);
}
[RelayCommand]
private async Task UnsubscribeAsync()
{
if (!CurrentRenderModel?.CanUnsubscribe ?? false) return;
// TODO: Support for List-Unsubscribe-Post header. It can be done without launching browser.
// https://certified-senders.org/wp-content/uploads/2017/07/CSA_one-click_list-unsubscribe.pdf
// TODO: Sometimes unsubscribe link can be a mailto: link.
// or sometimes with mailto AND http link. We need to handle this.
if (Uri.IsWellFormedUriString(CurrentRenderModel.UnsubscribeLink, UriKind.RelativeOrAbsolute))
await NativeAppService.LaunchUriAsync(new Uri((CurrentRenderModel.UnsubscribeLink)));
else
DialogService.InfoBarMessage(Translator.Info_UnsubscribeLinkInvalidTitle, Translator.Info_UnsubscribeLinkInvalidMessage, InfoBarMessageType.Error);
}
[RelayCommand]
private async Task OperationClicked(MailOperationMenuItem menuItem)
{
if (menuItem == null) return;
await HandleMailOperationAsync(menuItem.Operation);
}
private async Task HandleMailOperationAsync(MailOperation operation)
{
// Toggle theme
if (operation == MailOperation.DarkEditor || operation == MailOperation.LightEditor)
IsDarkWebviewRenderer = !IsDarkWebviewRenderer;
else if (operation == MailOperation.SaveAs)
{
// Save as PDF
var pickedFolder = await DialogService.PickWindowsFolderAsync();
if (!string.IsNullOrEmpty(pickedFolder))
{
var fullPath = Path.Combine(pickedFolder, $"{initializedMailItemViewModel.FromAddress}.pdf");
Messenger.Send(new SaveAsPDFRequested(fullPath));
}
}
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.Reply)
draftOptions.Reason = DraftCreationReason.Reply;
else if (operation == MailOperation.ReplyAll)
draftOptions.Reason = DraftCreationReason.ReplyAll;
else if (operation == MailOperation.Forward)
draftOptions.Reason = DraftCreationReason.Forward;
// TODO: Separate mailto related stuff out of DraftCreationOptions and provide better
// model for draft preperation request. Right now it's a mess.
draftOptions.ReferenceMailCopy = initializedMailItemViewModel.MailCopy;
draftOptions.ReferenceMimeMessage = initializedMimeMessageInformation.MimeMessage;
var createdMimeMessage = await _mailService.CreateDraftMimeMessageAsync(initializedMailItemViewModel.AssignedAccount.Id, draftOptions).ConfigureAwait(false);
var createdDraftMailMessage = await _mailService.CreateDraftAsync(initializedMailItemViewModel.AssignedAccount,
createdMimeMessage,
initializedMimeMessageInformation.MimeMessage,
initializedMailItemViewModel).ConfigureAwait(false);
var draftPreperationRequest = new DraftPreperationRequest(initializedMailItemViewModel.AssignedAccount, createdDraftMailMessage, createdMimeMessage)
{
ReferenceMimeMessage = initializedMimeMessageInformation.MimeMessage,
ReferenceMailCopy = initializedMailItemViewModel.MailCopy
};
await _requestDelegator.ExecuteAsync(draftPreperationRequest);
}
else if (initializedMailItemViewModel != null)
{
// All other operations require a mail item.
var prepRequest = new MailOperationPreperationRequest(operation, initializedMailItemViewModel.MailCopy);
await _requestDelegator.ExecuteAsync(prepRequest);
}
}
private CancellationTokenSource renderCancellationTokenSource = new CancellationTokenSource();
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
renderCancellationTokenSource.Cancel();
initializedMailItemViewModel = null;
initializedMimeMessageInformation = null;
// This page can be accessed for 2 purposes.
// 1. Rendering a mail item when the user selects.
// 2. Rendering an existing EML file with MimeMessage.
// MimeMessage rendering must be readonly and no command bar items must be shown except common
// items like dark/light editor, zoom, print etc.
// Configure common rendering properties first.
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
await ResetPagePropertiesAsync();
renderCancellationTokenSource = new CancellationTokenSource();
// Mime content might not be available for now and might require a download.
try
{
if (parameters is MailItemViewModel selectedMailItemViewModel)
await RenderAsync(selectedMailItemViewModel, renderCancellationTokenSource.Token);
else if (parameters is MimeMessageInformation mimeMessageInformation)
await RenderAsync(mimeMessageInformation);
InitializeCommandBarItems();
}
catch (OperationCanceledException)
{
Log.Information("Canceled mail rendering.");
}
catch (Exception ex)
{
DialogService.InfoBarMessage(Translator.Info_MailRenderingFailedTitle, string.Format(Translator.Info_MailRenderingFailedMessage, ex.Message), InfoBarMessageType.Error);
Crashes.TrackError(ex);
Log.Error(ex, "Render Failed");
}
finally
{
StatePersistanceService.IsReadingMail = true;
}
}
private async Task HandleSingleItemDownloadAsync(MailItemViewModel mailItemViewModel)
{
var synchronizer = _winoSynchronizerFactory.GetAccountSynchronizer(mailItemViewModel.AssignedAccount.Id);
try
{
// To show the progress on the UI.
CurrentDownloadPercentage = 1;
await synchronizer.DownloadMissingMimeMessageAsync(mailItemViewModel.MailCopy, this, renderCancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
Log.Information("MIME download is canceled.");
}
catch (Exception ex)
{
DialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error);
}
finally
{
ResetProgress();
}
}
private async Task RenderAsync(MailItemViewModel mailItemViewModel, CancellationToken cancellationToken = default)
{
var isMimeExists = await _mimeFileService.IsMimeExistAsync(mailItemViewModel.AssignedAccount.Id, mailItemViewModel.MailCopy.FileId);
if (!isMimeExists)
{
await HandleSingleItemDownloadAsync(mailItemViewModel);
}
// Find the MIME for this item and render it.
var mimeMessageInformation = await _mimeFileService.GetMimeMessageInformationAsync(mailItemViewModel.MailCopy.FileId,
mailItemViewModel.AssignedAccount.Id,
cancellationToken)
.ConfigureAwait(false);
if (mimeMessageInformation == null)
{
DialogService.InfoBarMessage(Translator.Info_MessageCorruptedTitle, Translator.Info_MessageCorruptedMessage, InfoBarMessageType.Error);
return;
}
initializedMailItemViewModel = mailItemViewModel;
await RenderAsync(mimeMessageInformation);
}
private async Task RenderAsync(MimeMessageInformation mimeMessageInformation)
{
var message = mimeMessageInformation.MimeMessage;
var messagePath = mimeMessageInformation.Path;
initializedMimeMessageInformation = mimeMessageInformation;
// TODO: Handle S/MIME decryption.
// initializedMimeMessageInformation.MimeMessage.Body is MultipartSigned
var renderingOptions = PreferencesService.GetRenderingOptions();
await ExecuteUIThread(() =>
{
Subject = message.Subject;
// 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;
// Extract to,cc and bcc
LoadAddressInfo(message.To, ToItems);
LoadAddressInfo(message.Cc, CCItemsItems);
LoadAddressInfo(message.Bcc, BCCItems);
// 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)
{
renderingOptions.LoadImages = false;
}
// Load images if forced.
if (forceImageLoading)
{
renderingOptions.LoadImages = true;
}
CurrentRenderModel = _mimeFileService.GetMailRenderModel(message, messagePath, renderingOptions);
Messenger.Send(new HtmlRenderingRequested(CurrentRenderModel.RenderHtml));
foreach (var attachment in CurrentRenderModel.Attachments)
{
Attachments.Add(new MailAttachmentViewModel(attachment));
}
OnPropertyChanged(nameof(IsImageRenderingDisabled));
});
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
renderCancellationTokenSource.Cancel();
CurrentDownloadPercentage = 0d;
initializedMailItemViewModel = null;
initializedMimeMessageInformation = null;
StatePersistanceService.IsReadingMail = false;
forceImageLoading = false;
}
private void LoadAddressInfo(InternetAddressList list, ObservableCollection<AddressInformation> collection)
{
foreach (var item in list)
{
if (item is MailboxAddress mailboxAddress)
collection.Add(mailboxAddress.ToAddressInformation());
else if (item is GroupAddress groupAddress)
LoadAddressInfo(groupAddress.Members, collection);
}
}
private void ResetProgress()
{
CurrentDownloadPercentage = 0;
IsIndetermineProgress = false;
}
private async Task ResetPagePropertiesAsync()
{
await ExecuteUIThread(() =>
{
ResetProgress();
ToItems.Clear();
CCItemsItems.Clear();
BCCItems.Clear();
Attachments.Clear();
// Dispose existing content first.
Messenger.Send(new CancelRenderingContentRequested());
});
}
private void InitializeCommandBarItems()
{
MenuItems.Clear();
// Add light/dark editor theme switch.
if (_underlyingThemeService.IsUnderlyingThemeDark())
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
// Save As PDF
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SaveAs, true, true));
if (initializedMailItemViewModel == null)
return;
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
// You can't do these to draft items.
if (!initializedMailItemViewModel.IsDraft)
{
// Reply
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Reply));
// Reply All
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.ReplyAll));
// Forward
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Forward));
}
// Archive - Unarchive
if (initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive)
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive));
// Delete
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
// Flag - Clear Flag
if (initializedMailItemViewModel.IsFlagged)
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
// Secondary items.
// Read - Unread
if (initializedMailItemViewModel.IsRead)
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread, true, false));
else
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
}
protected override async void OnMailUpdated(MailCopy updatedMail)
{
base.OnMailUpdated(updatedMail);
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;
// Mail operation might change the mail item like mark read/unread or change flag.
// So we need to update the mail item view model when this happens.
// Also command bar items must be re-initialized since the items loaded based on the mail item.
await ExecuteUIThread(() => { InitializeCommandBarItems(); });
}
[RelayCommand]
private async Task OpenAttachmentAsync(MailAttachmentViewModel attachmentViewModel)
{
try
{
var fileFolderPath = Path.Combine(initializedMimeMessageInformation.Path, attachmentViewModel.FileName);
var directoryInfo = new DirectoryInfo(initializedMimeMessageInformation.Path);
var fileExists = File.Exists(fileFolderPath);
if (!fileExists)
await SaveAttachmentInternalAsync(attachmentViewModel, initializedMimeMessageInformation.Path);
await LaunchFileInternalAsync(fileFolderPath);
}
catch (Exception ex)
{
Log.Error(ex, WinoErrors.OpenAttachment);
Crashes.TrackError(ex);
DialogService.InfoBarMessage(Translator.Info_AttachmentOpenFailedTitle, Translator.Info_AttachmentOpenFailedMessage, InfoBarMessageType.Error);
}
}
[RelayCommand]
private async Task SaveAttachmentAsync(MailAttachmentViewModel attachmentViewModel)
{
if (attachmentViewModel == null)
return;
try
{
attachmentViewModel.IsBusy = true;
var pickedPath = await DialogService.PickWindowsFolderAsync();
if (string.IsNullOrEmpty(pickedPath)) return;
await SaveAttachmentInternalAsync(attachmentViewModel, pickedPath);
DialogService.InfoBarMessage(Translator.Info_AttachmentSaveSuccessTitle, Translator.Info_AttachmentSaveSuccessMessage, InfoBarMessageType.Success);
}
catch (Exception ex)
{
Log.Error(ex, WinoErrors.SaveAttachment);
Crashes.TrackError(ex);
DialogService.InfoBarMessage(Translator.Info_AttachmentSaveFailedTitle, Translator.Info_AttachmentSaveFailedMessage, InfoBarMessageType.Error);
}
finally
{
attachmentViewModel.IsBusy = false;
}
}
// Returns created file path.
private async Task<string> SaveAttachmentInternalAsync(MailAttachmentViewModel attachmentViewModel, string saveFolderPath)
{
var fullFilePath = Path.Combine(saveFolderPath, attachmentViewModel.FileName);
var stream = await _fileService.GetFileStreamAsync(saveFolderPath, attachmentViewModel.FileName);
using (stream)
{
await attachmentViewModel.MimeContent.DecodeToAsync(stream);
}
return fullFilePath;
}
private async Task LaunchFileInternalAsync(string filePath)
{
try
{
await NativeAppService.LaunchFileAsync(filePath);
}
catch (Exception ex)
{
DialogService.InfoBarMessage(Translator.Info_FileLaunchFailedTitle, ex.Message, InfoBarMessageType.Error);
}
}
void ITransferProgress.Report(long bytesTransferred, long totalSize)
=> _ = ExecuteUIThread(() => { CurrentDownloadPercentage = bytesTransferred * 100 / Math.Max(1, totalSize); });
// For upload.
void ITransferProgress.Report(long bytesTransferred) { }
}
}

View File

@@ -0,0 +1,216 @@
using System;
using System.Collections.ObjectModel;
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.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Messages.Navigation;
using Wino.Core.Requests;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels
{
public partial class MergedAccountDetailsPageViewModel : BaseViewModel,
IRecipient<MergedInboxRenamed>
{
[ObservableProperty]
private MergedAccountProviderDetailViewModel editingMergedAccount;
[ObservableProperty]
private string mergedAccountName;
public ObservableCollection<AccountProviderDetailViewModel> LinkedAccounts { get; set; } = [];
public ObservableCollection<AccountProviderDetailViewModel> UnlinkedAccounts { get; set; } = [];
// Empty Guid is passed for new created merged inboxes.
public bool IsMergedInboxSaved => EditingMergedAccount != null && EditingMergedAccount.MergedInbox.Id != Guid.Empty;
public bool CanUnlink => IsMergedInboxSaved;
// There must be at least 2 accounts linked to a merged account for link to exist.
public bool ShouldDeleteMergedAccount => LinkedAccounts.Count < 2;
public bool CanSaveChanges
{
get
{
if (IsMergedInboxSaved)
{
return ShouldDeleteMergedAccount || IsEditingAccountsDirty();
}
else
{
return LinkedAccounts.Any();
}
}
}
private readonly IAccountService _accountService;
private readonly IPreferencesService _preferencesService;
private readonly IProviderService _providerService;
public MergedAccountDetailsPageViewModel(IDialogService dialogService,
IAccountService accountService,
IPreferencesService preferencesService,
IProviderService providerService) : base(dialogService)
{
_accountService = accountService;
_preferencesService = preferencesService;
_providerService = providerService;
}
[RelayCommand(CanExecute = nameof(CanUnlink))]
private async Task UnlinkAccountsAsync()
{
if (EditingMergedAccount == null) return;
var isConfirmed = await DialogService.ShowConfirmationDialogAsync(Translator.DialogMessage_UnlinkAccountsConfirmationMessage, Translator.DialogMessage_UnlinkAccountsConfirmationTitle, Translator.Buttons_Yes);
if (!isConfirmed) return;
await _accountService.UnlinkMergedInboxAsync(EditingMergedAccount.MergedInbox.Id);
Messenger.Send(new BackBreadcrumNavigationRequested());
}
[RelayCommand(CanExecute = nameof(CanSaveChanges))]
private async Task SaveChangesAsync()
{
if (ShouldDeleteMergedAccount)
{
await UnlinkAccountsAsync();
}
else
{
if (IsMergedInboxSaved)
{
await _accountService.UpdateMergedInboxAsync(EditingMergedAccount.MergedInbox.Id, LinkedAccounts.Select(a => a.Account.Id).ToList());
}
else
{
await _accountService.CreateMergeAccountsAsync(EditingMergedAccount.MergedInbox, LinkedAccounts.Select(a => a.Account).ToList());
}
// Startup entity is linked now. Change the startup entity.
if (_preferencesService.StartupEntityId != null && LinkedAccounts.Any(a => a.StartupEntityId == _preferencesService.StartupEntityId))
{
_preferencesService.StartupEntityId = EditingMergedAccount.MergedInbox.Id;
}
}
Messenger.Send(new BackBreadcrumNavigationRequested());
}
[RelayCommand]
private async Task RenameLinkAsync()
{
if (EditingMergedAccount == null) return;
var newName = await DialogService.ShowTextInputDialogAsync(EditingMergedAccount.MergedInbox.Name,
Translator.DialogMessage_RenameLinkedAccountsTitle,
Translator.DialogMessage_RenameLinkedAccountsMessage);
if (string.IsNullOrWhiteSpace(newName)) return;
EditingMergedAccount.MergedInbox.Name = newName;
// Update database record as well.
if (IsMergedInboxSaved)
{
await _accountService.RenameMergedAccountAsync(EditingMergedAccount.MergedInbox.Id, newName);
}
else
{
// Publish the message manually since the merged inbox is not saved yet.
// This is only for breadcrump item update.
Messenger.Send(new MergedInboxRenamed(EditingMergedAccount.MergedInbox.Id, newName));
}
}
[RelayCommand]
private void LinkAccount(AccountProviderDetailViewModel account)
{
LinkedAccounts.Add(account);
UnlinkedAccounts.Remove(account);
}
[RelayCommand]
private void UnlinkAccount(AccountProviderDetailViewModel account)
{
UnlinkedAccounts.Add(account);
LinkedAccounts.Remove(account);
}
private bool IsEditingAccountsDirty()
{
if (EditingMergedAccount == null) return false;
return EditingMergedAccount.HoldingAccounts.Count != LinkedAccounts.Count ||
EditingMergedAccount.HoldingAccounts.Any(a => !LinkedAccounts.Any(la => la.Account.Id == a.Account.Id));
}
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
LinkedAccounts.CollectionChanged -= LinkedAccountsUpdated;
}
private void LinkedAccountsUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(ShouldDeleteMergedAccount));
SaveChangesCommand.NotifyCanExecuteChanged();
// TODO: Preview common folders for all linked accounts.
// Basically showing a preview of how menu items will look.
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
LinkedAccounts.CollectionChanged -= LinkedAccountsUpdated;
LinkedAccounts.CollectionChanged += LinkedAccountsUpdated;
if (parameters is MergedAccountProviderDetailViewModel editingMergedAccount)
{
MergedAccountName = editingMergedAccount.MergedInbox.Name;
EditingMergedAccount = editingMergedAccount;
foreach (var account in editingMergedAccount.HoldingAccounts)
{
LinkedAccounts.Add(account);
}
// Load unlinked accounts.
var allAccounts = await _accountService.GetAccountsAsync();
foreach (var account in allAccounts)
{
if (!LinkedAccounts.Any(a => a.Account.Id == account.Id))
{
var provider = _providerService.GetProviderDetail(account.ProviderType);
UnlinkedAccounts.Add(new AccountProviderDetailViewModel(provider, account));
}
}
}
UnlinkAccountsCommand.NotifyCanExecuteChanged();
}
public void Receive(MergedInboxRenamed message)
{
if (EditingMergedAccount?.MergedInbox.Id == message.MergedInboxId)
{
EditingMergedAccount.MergedInbox.Name = message.NewName;
}
}
}
}

View File

@@ -0,0 +1,87 @@
using System.Collections.Generic;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels
{
public class MessageListPageViewModel : BaseViewModel
{
public IPreferencesService PreferencesService { get; }
private List<MailOperation> availableHoverActions = new List<MailOperation>
{
MailOperation.Archive,
MailOperation.SoftDelete,
MailOperation.SetFlag,
MailOperation.MarkAsRead,
MailOperation.MoveToJunk
};
public List<string> AvailableHoverActionsTranslations { get; set; } = new List<string>()
{
Translator.HoverActionOption_Archive,
Translator.HoverActionOption_Delete,
Translator.HoverActionOption_ToggleFlag,
Translator.HoverActionOption_ToggleRead,
Translator.HoverActionOption_MoveJunk
};
#region Properties
private int leftHoverActionIndex;
public int LeftHoverActionIndex
{
get => leftHoverActionIndex;
set
{
if (SetProperty(ref leftHoverActionIndex, value))
{
PreferencesService.LeftHoverAction = availableHoverActions[value];
}
}
}
private int centerHoverActionIndex;
public int CenterHoverActionIndex
{
get => centerHoverActionIndex;
set
{
if (SetProperty(ref centerHoverActionIndex, value))
{
PreferencesService.CenterHoverAction = availableHoverActions[value];
}
}
}
private int rightHoverActionIndex;
public int RightHoverActionIndex
{
get => rightHoverActionIndex;
set
{
if (SetProperty(ref rightHoverActionIndex, value))
{
PreferencesService.RightHoverAction = availableHoverActions[value];
}
}
}
#endregion
public MessageListPageViewModel(IDialogService dialogService,
IPreferencesService preferencesService) : base(dialogService)
{
PreferencesService = preferencesService;
leftHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.LeftHoverAction);
centerHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.CenterHoverAction);
rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction);
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
namespace Wino.Mail.ViewModels.Messages
{
public class ActiveMailFolderChangedEvent : NavigateMailFolderEventArgs
{
public ActiveMailFolderChangedEvent(IBaseFolderMenuItem baseFolderMenuItem,
TaskCompletionSource<bool> folderInitLoadAwaitTask = null) : base(baseFolderMenuItem, folderInitLoadAwaitTask)
{
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
using Wino.Core.MenuItems;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages
{
/// <summary>
/// When active mail item in the reader is updated.
/// </summary>
public class ActiveMailItemChangedEvent
{
public ActiveMailItemChangedEvent(MailItemViewModel selectedMailItemViewModel)
{
// SelectedMailItemViewModel can be null.
SelectedMailItemViewModel = selectedMailItemViewModel;
}
public MailItemViewModel SelectedMailItemViewModel { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
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; }
}
}

View File

@@ -0,0 +1,17 @@
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; }
}
}

View File

@@ -0,0 +1,19 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages
{
/// <summary>
/// When a thread conversation listview has single selection, all other listviews
/// must unselect all their items.
/// </summary>
public class ResetSingleMailItemSelectionEvent
{
public ResetSingleMailItemSelectionEvent(MailItemViewModel selectedViewModel)
{
SelectedViewModel = selectedViewModel;
}
public MailItemViewModel SelectedViewModel { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
using Wino.Mail.ViewModels.Data;
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);
}

View File

@@ -0,0 +1,11 @@
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels
{
public class NewAccountManagementPageViewModel : BaseViewModel
{
public NewAccountManagementPageViewModel(IDialogService dialogService) : base(dialogService)
{
}
}
}

View File

@@ -0,0 +1,303 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Personalization;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels
{
public partial class PersonalizationPageViewModel : BaseViewModel
{
public IStatePersistanceService StatePersistanceService { get; }
public IPreferencesService PreferencesService { get; }
private readonly IThemeService _themeService;
private bool isPropChangeDisabled = false;
#region Personalization
public bool IsSelectedWindowsAccentColor => SelectedAppColor == Colors.LastOrDefault();
public ObservableCollection<AppColorViewModel> Colors { get; set; } = new ObservableCollection<AppColorViewModel>();
public List<ElementThemeContainer> ElementThemes { get; set; } = new List<ElementThemeContainer>()
{
new ElementThemeContainer(ApplicationElementTheme.Light, Translator.ElementTheme_Light),
new ElementThemeContainer(ApplicationElementTheme.Dark, Translator.ElementTheme_Dark),
new ElementThemeContainer(ApplicationElementTheme.Default, Translator.ElementTheme_Default),
};
public List<MailListPaneLengthPreferences> PaneLengths { get; set; } = new List<MailListPaneLengthPreferences>()
{
new MailListPaneLengthPreferences(Translator.PaneLengthOption_Micro, 300),
new MailListPaneLengthPreferences(Translator.PaneLengthOption_Small, 350),
new MailListPaneLengthPreferences(Translator.PaneLengthOption_Default, 420),
new MailListPaneLengthPreferences(Translator.PaneLengthOption_Medium, 700),
new MailListPaneLengthPreferences(Translator.PaneLengthOption_Large, 900),
new MailListPaneLengthPreferences(Translator.PaneLengthOption_ExtraLarge, 1200),
};
public List<MailListDisplayMode> InformationDisplayModes { get; set; } = new List<MailListDisplayMode>()
{
MailListDisplayMode.Compact,
MailListDisplayMode.Medium,
MailListDisplayMode.Spacious
};
public List<AppThemeBase> AppThemes { get; set; }
[ObservableProperty]
private MailListPaneLengthPreferences selectedMailListPaneLength;
[ObservableProperty]
private ElementThemeContainer selectedElementTheme;
[ObservableProperty]
private MailListDisplayMode selectedInfoDisplayMode;
private AppColorViewModel _selectedAppColor;
public AppColorViewModel SelectedAppColor
{
get => _selectedAppColor;
set
{
if (SetProperty(ref _selectedAppColor, value))
{
UseAccentColor = value == Colors?.LastOrDefault();
}
}
}
private bool _useAccentColor;
public bool UseAccentColor
{
get => _useAccentColor;
set
{
if (SetProperty(ref _useAccentColor, value))
{
if (value)
{
SelectedAppColor = Colors?.LastOrDefault();
}
else if (SelectedAppColor == Colors?.LastOrDefault())
{
// Unchecking from accent color.
SelectedAppColor = Colors?.FirstOrDefault();
}
}
}
}
// Allow app theme change for system themes.
public bool CanSelectElementTheme => SelectedAppTheme != null &&
(SelectedAppTheme.AppThemeType == AppThemeType.System || SelectedAppTheme.AppThemeType == AppThemeType.Custom);
private AppThemeBase _selectedAppTheme;
public AppThemeBase SelectedAppTheme
{
get => _selectedAppTheme;
set
{
if (SetProperty(ref _selectedAppTheme, value))
{
OnPropertyChanged(nameof(CanSelectElementTheme));
if (!CanSelectElementTheme)
{
SelectedElementTheme = null;
}
}
}
}
#endregion
public AsyncRelayCommand CreateCustomThemeCommand { get; set; }
public PersonalizationPageViewModel(IDialogService dialogService,
IStatePersistanceService statePersistanceService,
IThemeService themeService,
IPreferencesService preferencesService) : base(dialogService)
{
CreateCustomThemeCommand = new AsyncRelayCommand(CreateCustomThemeAsync);
StatePersistanceService = statePersistanceService;
_themeService = themeService;
PreferencesService = preferencesService;
}
private async Task CreateCustomThemeAsync()
{
bool isThemeCreated = await DialogService.ShowCustomThemeBuilderDialogAsync();
if (isThemeCreated)
{
// Reload themes.
await InitializeSettingsAsync();
}
}
private void InitializeColors()
{
Colors.Add(new AppColorViewModel("#0078d7"));
Colors.Add(new AppColorViewModel("#00838c"));
Colors.Add(new AppColorViewModel("#e3008c"));
Colors.Add(new AppColorViewModel("#ca4f07"));
Colors.Add(new AppColorViewModel("#e81123"));
Colors.Add(new AppColorViewModel("#00819e"));
Colors.Add(new AppColorViewModel("#10893e"));
Colors.Add(new AppColorViewModel("#881798"));
Colors.Add(new AppColorViewModel("#c239b3"));
Colors.Add(new AppColorViewModel("#767676"));
Colors.Add(new AppColorViewModel("#e1b12c"));
Colors.Add(new AppColorViewModel("#16a085"));
Colors.Add(new AppColorViewModel("#0984e3"));
Colors.Add(new AppColorViewModel("#4a69bd"));
Colors.Add(new AppColorViewModel("#05c46b"));
// Add system accent color as last item.
Colors.Add(new AppColorViewModel(_themeService.GetSystemAccentColorHex(), true));
}
/// <summary>
/// Set selections from settings service.
/// </summary>
private void SetInitialValues()
{
SelectedElementTheme = ElementThemes.Find(a => a.NativeTheme == _themeService.RootTheme);
SelectedInfoDisplayMode = PreferencesService.MailItemDisplayMode;
SelectedMailListPaneLength = PaneLengths.Find(a => a.Length == StatePersistanceService.MailListPaneLength);
var currentAccentColor = _themeService.AccentColor;
bool isWindowsColor = string.IsNullOrEmpty(currentAccentColor);
if (isWindowsColor)
{
SelectedAppColor = Colors.LastOrDefault();
UseAccentColor = true;
}
else
SelectedAppColor = Colors.FirstOrDefault(a => a.Hex == currentAccentColor);
SelectedAppTheme = AppThemes.Find(a => a.Id == _themeService.CurrentApplicationThemeId);
}
protected override async void OnActivated()
{
base.OnActivated();
await InitializeSettingsAsync();
}
private async Task InitializeSettingsAsync()
{
Deactivate();
AppThemes = await _themeService.GetAvailableThemesAsync();
OnPropertyChanged(nameof(AppThemes));
InitializeColors();
SetInitialValues();
PropertyChanged -= PersonalizationSettingsUpdated;
PropertyChanged += PersonalizationSettingsUpdated;
_themeService.AccentColorChanged -= AccentColorChanged;
_themeService.ElementThemeChanged -= ElementThemeChanged;
_themeService.AccentColorChangedBySystem -= AccentColorChangedBySystem;
_themeService.AccentColorChanged += AccentColorChanged;
_themeService.ElementThemeChanged += ElementThemeChanged;
_themeService.AccentColorChangedBySystem += AccentColorChangedBySystem;
}
private void AccentColorChangedBySystem(object sender, string newAccentColorHex)
{
var accentInList = Colors.FirstOrDefault(a => a.IsAccentColor);
if (accentInList != null)
{
accentInList.Hex = newAccentColorHex;
}
}
private void AccentColorChanged(object sender, string e)
{
isPropChangeDisabled = true;
SelectedAppColor = Colors.FirstOrDefault(a => a.Hex == e);
isPropChangeDisabled = false;
}
private void ElementThemeChanged(object sender, ApplicationElementTheme e)
{
isPropChangeDisabled = true;
SelectedElementTheme = ElementThemes.Find(a => a.NativeTheme == e);
isPropChangeDisabled = false;
}
protected override void OnDeactivated()
{
base.OnDeactivated();
Deactivate();
}
private void Deactivate()
{
PropertyChanged -= PersonalizationSettingsUpdated;
_themeService.AccentColorChanged -= AccentColorChanged;
_themeService.ElementThemeChanged -= ElementThemeChanged;
_themeService.AccentColorChangedBySystem -= AccentColorChangedBySystem;
if (AppThemes != null)
{
AppThemes.Clear();
AppThemes = null;
}
}
private void PersonalizationSettingsUpdated(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (isPropChangeDisabled)
return;
if (e.PropertyName == nameof(SelectedElementTheme) && SelectedElementTheme != null)
{
_themeService.RootTheme = SelectedElementTheme.NativeTheme;
}
else if (e.PropertyName == nameof(SelectedAppTheme))
{
_themeService.CurrentApplicationThemeId = SelectedAppTheme.Id;
}
else if (e.PropertyName == nameof(SelectedMailListPaneLength) && SelectedMailListPaneLength != null)
StatePersistanceService.MailListPaneLength = SelectedMailListPaneLength.Length;
else
{
if (e.PropertyName == nameof(SelectedInfoDisplayMode))
PreferencesService.MailItemDisplayMode = SelectedInfoDisplayMode;
else if (e.PropertyName == nameof(SelectedAppColor))
_themeService.AccentColor = SelectedAppColor.Hex;
}
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Reader;
namespace Wino.Mail.ViewModels
{
public partial class ReadingPanePageViewModel : BaseViewModel,
IRecipient<PropertyChangedMessage<ReaderFontModel>>,
IRecipient<PropertyChangedMessage<int>>
{
public IPreferencesService PreferencesService { get; set; }
private int selectedMarkAsOptionIndex;
private readonly IFontService _fontService;
public int SelectedMarkAsOptionIndex
{
get => selectedMarkAsOptionIndex;
set
{
if (SetProperty(ref selectedMarkAsOptionIndex, value))
{
if (value >= 0)
{
PreferencesService.MarkAsPreference = (MailMarkAsOption)Enum.GetValues(typeof(MailMarkAsOption)).GetValue(value);
}
}
}
}
public List<ReaderFontModel> ReaderFonts => _fontService.GetReaderFonts();
[ObservableProperty]
[NotifyPropertyChangedRecipients]
ReaderFontModel currentReaderFont;
[ObservableProperty]
[NotifyPropertyChangedRecipients]
int currentReaderFontSize;
public ReadingPanePageViewModel(IDialogService dialogService,
IFontService fontService,
IPreferencesService preferencesService) : base(dialogService)
{
_fontService = fontService;
PreferencesService = preferencesService;
SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues(typeof(MailMarkAsOption)), PreferencesService.MarkAsPreference);
CurrentReaderFont = fontService.GetCurrentReaderFont();
CurrentReaderFontSize = fontService.GetCurrentReaderFontSize();
}
public void Receive(PropertyChangedMessage<ReaderFontModel> message)
{
if (message.OldValue != message.NewValue)
{
_fontService.ChangeReaderFont(message.NewValue.Font);
Debug.WriteLine("Changed reader font.");
}
}
public void Receive(PropertyChangedMessage<int> message)
{
if (message.PropertyName == nameof(CurrentReaderFontSize))
{
_fontService.ChangeReaderFontSize(CurrentReaderFontSize);
}
}
}
}

View File

@@ -0,0 +1,85 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Translations;
using Wino.Core.Messages.Navigation;
namespace Wino.Mail.ViewModels
{
public partial class SettingOptionsPageViewModel : BaseViewModel
{
private readonly ITranslationService _translationService;
private readonly IPreferencesService _preferencesService;
[ObservableProperty]
private List<AppLanguageModel> _availableLanguages;
[ObservableProperty]
private AppLanguageModel _selectedLanguage;
private bool isInitialized = false;
public SettingOptionsPageViewModel(IDialogService dialogService,
ITranslationService translationService,
IPreferencesService preferencesService) : base(dialogService)
{
_translationService = translationService;
_preferencesService = preferencesService;
}
[RelayCommand]
private void GoAccountSettings() => Messenger.Send<NavigateSettingsRequested>();
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);
}
}
[RelayCommand]
public void NavigateSubDetail(object type)
{
if (type is string stringParameter)
{
WinoPage pageType = default;
string pageTitle = stringParameter;
// They are just params and don't have to be localized. Don't change.
if (stringParameter == "Personalization")
pageType = WinoPage.PersonalizationPage;
else if (stringParameter == "About")
pageType = WinoPage.AboutPage;
else if (stringParameter == "Message List")
pageType = WinoPage.MessageListPage;
else if (stringParameter == "Reading Pane")
pageType = WinoPage.ReadingPanePage;
Messenger.Send(new BreadcrumbNavigationRequested(pageTitle, pageType));
}
}
}
}

View File

@@ -0,0 +1,11 @@
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels
{
public class SettingsPageViewModel : BaseViewModel
{
public SettingsPageViewModel(IDialogService dialogService) : base(dialogService)
{
}
}
}

View File

@@ -0,0 +1,11 @@
using Wino.Core.Domain.Interfaces;
namespace Wino.Mail.ViewModels
{
public class SettingsDialogViewModel : BaseViewModel
{
public SettingsDialogViewModel(IDialogService dialogService) : base(dialogService)
{
}
}
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
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;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Reader;
using Wino.Core.Messages.Mails;
namespace Wino.Mail.ViewModels
{
public partial class SignatureManagementPageViewModel : BaseViewModel
{
public Func<Task<string>> GetHTMLBodyFunction;
public Func<Task<string>> GetTextBodyFunction;
public List<EditorToolbarSection> ToolbarSections { get; set; } = new List<EditorToolbarSection>()
{
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Format },
new EditorToolbarSection(){ SectionType = EditorToolbarSectionType.Insert },
};
[ObservableProperty]
private EditorToolbarSection selectedToolbarSection;
[ObservableProperty]
private bool isSignatureEnabled;
public MailAccount Account { get; set; }
public AsyncRelayCommand SaveSignatureCommand { get; set; }
public INativeAppService NativeAppService { get; }
private readonly ISignatureService _signatureService;
private readonly IAccountService _accountService;
public SignatureManagementPageViewModel(IDialogService dialogService,
INativeAppService nativeAppService,
ISignatureService signatureService,
IAccountService accountService) : base(dialogService)
{
SelectedToolbarSection = ToolbarSections[0];
NativeAppService = nativeAppService;
_signatureService = signatureService;
_accountService = accountService;
SaveSignatureCommand = new AsyncRelayCommand(SaveSignatureAsync);
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (parameters is Guid accountId)
Account = await _accountService.GetAccountAsync(accountId);
if (Account != null)
{
var accountSignature = await _signatureService.GetAccountSignatureAsync(Account.Id);
IsSignatureEnabled = accountSignature != null;
if (IsSignatureEnabled)
Messenger.Send(new HtmlRenderingRequested(accountSignature.HtmlBody));
else
Messenger.Send(new HtmlRenderingRequested(string.Empty)); // To get the theme changes. Render empty html.
}
}
private async Task SaveSignatureAsync()
{
if (IsSignatureEnabled)
{
var newSignature = Regex.Unescape(await GetHTMLBodyFunction());
await _signatureService.UpdateAccountSignatureAsync(Account.Id, newSignature);
DialogService.InfoBarMessage(Translator.Info_SignatureSavedTitle, Translator.Info_SignatureSavedMessage, Core.Domain.Enums.InfoBarMessageType.Success);
}
else
{
await _signatureService.DeleteAccountSignatureAssignment(Account.Id);
DialogService.InfoBarMessage(Translator.Info_SignatureDisabledTitle, Translator.Info_SignatureDisabledMessage, Core.Domain.Enums.InfoBarMessageType.Success);
}
}
}
}

View File

@@ -0,0 +1,37 @@
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 : BaseViewModel
{
public const string VersionFile = "170.md";
private readonly IFileService _fileService;
[ObservableProperty]
private string currentVersionNotes;
public WelcomePageViewModel(IDialogService dialogService, IFileService fileService) : base(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);
}
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="IsExternalInit" Version="1.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AppCenter.Crashes" Version="5.0.3" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj" />
</ItemGroup>
</Project>