using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI.Controls; using EmailValidation; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; using Microsoft.Web.WebView2.Core; using MimeKit; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; using Windows.Storage; using Windows.UI.Core.Preview; using Windows.UI.ViewManagement.Core; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Input; using Windows.UI.Xaml.Media.Animation; using Windows.UI.Xaml.Navigation; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Reader; using Wino.Core.UWP.Extensions; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Mails; using Wino.Messaging.Client.Shell; using Wino.Views.Abstract; namespace Wino.Views { public sealed partial class ComposePage : ComposePageAbstract, IRecipient, IRecipient { public bool IsComposerDarkMode { get { return (bool)GetValue(IsComposerDarkModeProperty); } set { SetValue(IsComposerDarkModeProperty, value); } } public static readonly DependencyProperty IsComposerDarkModeProperty = DependencyProperty.Register(nameof(IsComposerDarkMode), typeof(bool), typeof(ComposePage), new PropertyMetadata(false, OnIsComposerDarkModeChanged)); public WebView2 GetWebView() => Chromium; private readonly TaskCompletionSource _domLoadedTask = new TaskCompletionSource(); private readonly List _disposables = new List(); private readonly SystemNavigationManagerPreview _navManagerPreview = SystemNavigationManagerPreview.GetForCurrentView(); public ComposePage() { InitializeComponent(); _navManagerPreview.CloseRequested += OnClose; Environment.SetEnvironmentVariable("WEBVIEW2_DEFAULT_BACKGROUND_COLOR", "00FFFFFF"); Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--enable-features=OverlayScrollbar,msOverlayScrollbarWinStyle,msOverlayScrollbarWinStyleAnimation"); } private async void GlobalFocusManagerGotFocus(object sender, FocusManagerGotFocusEventArgs e) { // In order to delegate cursor to the inner editor for WebView2. // When the control got focus, we invoke script to focus the editor. // This is not done on the WebView2 handlers, because somehow it is // repeatedly focusing itself, even though when it has the focus already. if (e.NewFocusedElement == Chromium) { await FocusEditorAsync(false); } } private static async void OnIsComposerDarkModeChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { if (obj is ComposePage page) { await page.UpdateEditorThemeAsync(); } } private IDisposable GetSuggestionBoxDisposable(TokenizingTextBox box) { return Observable.FromEventPattern, AutoSuggestBoxTextChangedEventArgs>( x => box.TextChanged += x, x => box.TextChanged -= x) .Throttle(TimeSpan.FromMilliseconds(120)) .ObserveOn(SynchronizationContext.Current) .Subscribe(t => { if (t.EventArgs.Reason == AutoSuggestionBoxTextChangeReason.UserInput) { if (t.Sender is AutoSuggestBox senderBox && senderBox.Text.Length >= 3) { _ = ViewModel.ContactService.GetAddressInformationAsync(senderBox.Text).ContinueWith(x => { _ = ViewModel.ExecuteUIThread(() => { var addresses = x.Result; senderBox.ItemsSource = addresses; }); }); } } }); } private void OnComposeGridDragOver(object sender, DragEventArgs e) { ViewModel.IsDraggingOverComposerGrid = true; } private void OnComposeGridDragLeave(object sender, DragEventArgs e) { ViewModel.IsDraggingOverComposerGrid = false; } private void OnFileDropGridDragOver(object sender, DragEventArgs e) { ViewModel.IsDraggingOverFilesDropZone = true; e.AcceptedOperation = DataPackageOperation.Copy; e.DragUIOverride.Caption = Translator.ComposerAttachmentsDragDropAttach_Message; e.DragUIOverride.IsCaptionVisible = true; e.DragUIOverride.IsGlyphVisible = true; e.DragUIOverride.IsContentVisible = true; } private void OnFileDropGridDragLeave(object sender, DragEventArgs e) { ViewModel.IsDraggingOverFilesDropZone = false; } private async void OnFileDropGridFileDropped(object sender, DragEventArgs e) { try { if (e.DataView.Contains(StandardDataFormats.StorageItems)) { var storageItems = await e.DataView.GetStorageItemsAsync(); var files = storageItems.OfType(); await AttachFiles(files); } } // State should be reset even when an exception occurs, otherwise the UI will be stuck in a dragging state. finally { ViewModel.IsDraggingOverComposerGrid = false; ViewModel.IsDraggingOverFilesDropZone = false; } } private void OnImageDropGridDragEnter(object sender, DragEventArgs e) { bool isValid = false; if (e.DataView.Contains(StandardDataFormats.StorageItems)) { // We can't use async/await here because DragUIOverride becomes inaccessible. // https://github.com/microsoft/microsoft-ui-xaml/issues/9296 var files = e.DataView.GetStorageItemsAsync().GetAwaiter().GetResult().OfType(); foreach (var file in files) { if (IsValidImageFile(file)) { isValid = true; } } } e.AcceptedOperation = isValid ? DataPackageOperation.Copy : DataPackageOperation.None; if (isValid) { ViewModel.IsDraggingOverImagesDropZone = true; e.DragUIOverride.Caption = Translator.ComposerAttachmentsDragDropAttach_Message; e.DragUIOverride.IsCaptionVisible = true; e.DragUIOverride.IsGlyphVisible = true; e.DragUIOverride.IsContentVisible = true; } } private void OnImageDropGridDragLeave(object sender, DragEventArgs e) { ViewModel.IsDraggingOverImagesDropZone = false; } private async void OnImageDropGridImageDropped(object sender, DragEventArgs e) { try { if (e.DataView.Contains(StandardDataFormats.StorageItems)) { var storageItems = await e.DataView.GetStorageItemsAsync(); var files = storageItems.OfType(); var imagesInformation = new List(); foreach (var file in files) { if (IsValidImageFile(file)) { imagesInformation.Add(new ImageInfo { Data = await GetDataURL(file), Name = file.Name }); } } await InvokeScriptSafeAsync($"insertImages({JsonSerializer.Serialize(imagesInformation, ComposerPageJsonContext.Default.ListImageInfo)});"); } } // State should be reset even when an exception occurs, otherwise the UI will be stuck in a dragging state. finally { ViewModel.IsDraggingOverComposerGrid = false; ViewModel.IsDraggingOverImagesDropZone = false; } static async Task GetDataURL(StorageFile file) { return $"data:image/{file.FileType.Replace(".", "")};base64,{Convert.ToBase64String(await file.ToByteArrayAsync())}"; } } private async Task AttachFiles(IEnumerable files) { if (files?.Any() != true) return; // Convert files to MailAttachmentViewModel. foreach (var file in files) { var sharedFile = await file.ToSharedFileAsync(); ViewModel.IncludedAttachments.Add(new MailAttachmentViewModel(sharedFile)); } } private bool IsValidImageFile(StorageFile file) { string[] allowedTypes = new string[] { ".jpg", ".jpeg", ".png" }; var fileType = file.FileType.ToLower(); return allowedTypes.Contains(fileType); } private async void BoldButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('bold')"); } private async void ItalicButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('italic')"); } private async void UnderlineButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('underline')"); } private async void StrokeButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('strikethrough')"); } private async void BulletListButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('insertunorderedlist')"); } private async void OrderedListButtonClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('insertorderedlist')"); } private async void IncreaseIndentClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('indent')"); } private async void DecreaseIndentClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("editor.execCommand('outdent')"); } private async void AlignmentChanged(object sender, SelectionChangedEventArgs e) { var selectedItem = AlignmentListView.SelectedItem as ComboBoxItem; var alignment = selectedItem.Tag.ToString(); switch (alignment) { case "left": await InvokeScriptSafeAsync("editor.execCommand('justifyleft')"); break; case "center": await InvokeScriptSafeAsync("editor.execCommand('justifycenter')"); break; case "right": await InvokeScriptSafeAsync("editor.execCommand('justifyright')"); break; case "justify": await InvokeScriptSafeAsync("editor.execCommand('justifyfull')"); break; } } private async void WebViewToggleButtonClicked(object sender, RoutedEventArgs e) { var enable = WebviewToolBarButton.IsChecked == true ? "true" : "false"; await InvokeScriptSafeAsync($"toggleToolbar('{enable}');"); } private async Task InvokeScriptSafeAsync(string function) { if (Chromium == null) return string.Empty; try { return await Chromium.ExecuteScriptAsync(function); } catch (Exception ex) { Console.WriteLine(ex.Message); } return string.Empty; } private async void AddImageClicked(object sender, RoutedEventArgs e) { await InvokeScriptSafeAsync("imageInput.click();"); } /// /// Places the cursor in the composer. /// /// Whether control itself should be focused as well or not. private async Task FocusEditorAsync(bool focusControlAsWell) { await InvokeScriptSafeAsync("editor.selection.setCursorIn(editor.editor.firstChild, true)"); if (focusControlAsWell) { Chromium.Focus(FocusState.Keyboard); Chromium.Focus(FocusState.Programmatic); } } private async void EmojiButtonClicked(object sender, RoutedEventArgs e) { CoreInputView.GetForCurrentView().TryShow(CoreInputViewKind.Emoji); await FocusEditorAsync(focusControlAsWell: true); } public async Task UpdateEditorThemeAsync() { await _domLoadedTask.Task; if (IsComposerDarkMode) { Chromium.CoreWebView2.Profile.PreferredColorScheme = CoreWebView2PreferredColorScheme.Dark; await InvokeScriptSafeAsync("SetDarkEditor();"); } else { Chromium.CoreWebView2.Profile.PreferredColorScheme = CoreWebView2PreferredColorScheme.Light; await InvokeScriptSafeAsync("SetLightEditor();"); } } private async Task RenderInternalAsync(string htmlBody) { await _domLoadedTask.Task; await UpdateEditorThemeAsync(); await InitializeEditorAsync(); if (string.IsNullOrEmpty(htmlBody)) { await Chromium.ExecuteScriptFunctionAsync("RenderHTML", parameters: JsonSerializer.Serialize(" ", BasicTypesJsonContext.Default.String)); } else { await Chromium.ExecuteScriptFunctionAsync("RenderHTML", parameters: JsonSerializer.Serialize(htmlBody, BasicTypesJsonContext.Default.String)); } } private async Task InitializeEditorAsync() { var fonts = ViewModel.FontService.GetFonts(); var composerFont = ViewModel.PreferencesService.ComposerFont; int composerFontSize = ViewModel.PreferencesService.ComposerFontSize; var readerFont = ViewModel.PreferencesService.ReaderFont; int readerFontSize = ViewModel.PreferencesService.ReaderFontSize; return await Chromium.ExecuteScriptFunctionAsync("initializeJodit", false, JsonSerializer.Serialize(fonts, BasicTypesJsonContext.Default.ListString), JsonSerializer.Serialize(composerFont, BasicTypesJsonContext.Default.String), JsonSerializer.Serialize(composerFontSize, BasicTypesJsonContext.Default.Int32), JsonSerializer.Serialize(readerFont, BasicTypesJsonContext.Default.String), JsonSerializer.Serialize(readerFontSize, BasicTypesJsonContext.Default.Int32)); } private void DisposeWebView2() { if (Chromium == null) return; Chromium.CoreWebView2Initialized -= ChromiumInitialized; if (Chromium.CoreWebView2 != null) { Chromium.CoreWebView2.DOMContentLoaded -= DOMLoaded; Chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; } Chromium.Close(); GC.Collect(); } private void DisposeDisposables() { if (_disposables.Any()) _disposables.ForEach(a => a.Dispose()); } protected override async void OnNavigatedTo(NavigationEventArgs e) { base.OnNavigatedTo(e); FocusManager.GotFocus += GlobalFocusManagerGotFocus; var anim = ConnectedAnimationService.GetForCurrentView().GetAnimation("WebViewConnectedAnimation"); anim?.TryStart(Chromium); DisposeDisposables(); _disposables.Add(GetSuggestionBoxDisposable(ToBox)); _disposables.Add(GetSuggestionBoxDisposable(CCBox)); _disposables.Add(GetSuggestionBoxDisposable(BccBox)); Chromium.CoreWebView2Initialized -= ChromiumInitialized; Chromium.CoreWebView2Initialized += ChromiumInitialized; await Chromium.EnsureCoreWebView2Async(); ViewModel.GetHTMLBodyFunction = new Func>(async () => { var editorContent = await InvokeScriptSafeAsync("GetHTMLContent();"); return JsonSerializer.Deserialize(editorContent, BasicTypesJsonContext.Default.String); }); var underlyingThemeService = App.Current.Services.GetService(); IsComposerDarkMode = underlyingThemeService.IsUnderlyingThemeDark(); } private async void ChromiumInitialized(Microsoft.UI.Xaml.Controls.WebView2 sender, Microsoft.UI.Xaml.Controls.CoreWebView2InitializedEventArgs args) { var editorBundlePath = (await ViewModel.NativeAppService.GetEditorBundlePathAsync()).Replace("editor.html", string.Empty); Chromium.CoreWebView2.SetVirtualHostNameToFolderMapping("app.editor", editorBundlePath, CoreWebView2HostResourceAccessKind.Allow); Chromium.Source = new Uri("https://app.editor/editor.html"); Chromium.CoreWebView2.DOMContentLoaded -= DOMLoaded; Chromium.CoreWebView2.DOMContentLoaded += DOMLoaded; Chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; Chromium.CoreWebView2.WebMessageReceived += ScriptMessageReceived; } private void ScriptMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs args) { var change = JsonSerializer.Deserialize(args.WebMessageAsJson, DomainModelsJsonContext.Default.WebViewMessage); if (change.Type == "bold") { BoldButton.IsChecked = change.Value == "true"; } else if (change.Type == "italic") { ItalicButton.IsChecked = change.Value == "true"; } else if (change.Type == "underline") { UnderlineButton.IsChecked = change.Value == "true"; } else if (change.Type == "strikethrough") { StrokeButton.IsChecked = change.Value == "true"; } else if (change.Type == "ol") { OrderedListButton.IsChecked = change.Value == "true"; } else if (change.Type == "ul") { BulletListButton.IsChecked = change.Value == "true"; } else if (change.Type == "indent") { IncreaseIndentButton.IsEnabled = change.Value == "disabled" ? false : true; } else if (change.Type == "outdent") { DecreaseIndentButton.IsEnabled = change.Value == "disabled" ? false : true; } else if (change.Type == "alignment") { var parsedValue = change.Value switch { "jodit-icon_left" => 0, "jodit-icon_center" => 1, "jodit-icon_right" => 2, "jodit-icon_justify" => 3, _ => 0 }; AlignmentListView.SelectionChanged -= AlignmentChanged; AlignmentListView.SelectedIndex = parsedValue; AlignmentListView.SelectionChanged += AlignmentChanged; } } private void DOMLoaded(CoreWebView2 sender, CoreWebView2DOMContentLoadedEventArgs args) => _domLoadedTask.TrySetResult(true); async void IRecipient.Receive(CreateNewComposeMailRequested message) { await RenderInternalAsync(message.RenderModel.RenderHtml); } private void ShowCCBCCClicked(object sender, RoutedEventArgs e) { ViewModel.IsCCBCCVisible = true; } private async void TokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArgs args) { // Check is valid email. if (!EmailValidator.Validate(args.TokenText)) { args.Cancel = true; ViewModel.NotifyInvalidEmail(args.TokenText); return; } var deferal = args.GetDeferral(); AccountContact addedItem = null; var boxTag = sender.Tag?.ToString(); if (boxTag == "ToBox") addedItem = await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.ToItems); else if (boxTag == "CCBox") addedItem = await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.CCItems); else if (boxTag == "BCCBox") addedItem = await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.BCCItems); if (addedItem == null) { args.Cancel = true; ViewModel.NotifyAddressExists(); } else { args.Item = addedItem; } deferal.Complete(); } void IRecipient.Receive(ApplicationThemeChanged message) { IsComposerDarkMode = message.IsUnderlyingThemeDark; } private void InvertComposerThemeClicked(object sender, RoutedEventArgs e) { IsComposerDarkMode = !IsComposerDarkMode; } private void ImportanceClicked(object sender, RoutedEventArgs e) { ImportanceFlyout.Hide(); ImportanceSplitButton.IsChecked = true; if (sender is Button senderButton) { var selectedImportance = (MessageImportance)senderButton.Tag; ViewModel.SelectedMessageImportance = selectedImportance; ((ImportanceSplitButton.Content as Viewbox).Child as SymbolIcon).Symbol = (senderButton.Content as SymbolIcon).Symbol; } } private async void AddressBoxLostFocus(object sender, RoutedEventArgs e) { // Automatically add current text as item if it is valid mail address. if (sender is TokenizingTextBox tokenizingTextBox) { if (!(tokenizingTextBox.Items.LastOrDefault() is ITokenStringContainer info)) return; var currentText = info.Text; if (!string.IsNullOrEmpty(currentText) && EmailValidator.Validate(currentText)) { var boxTag = tokenizingTextBox.Tag?.ToString(); AccountContact addedItem = null; ObservableCollection addressCollection = null; if (boxTag == "ToBox") addressCollection = ViewModel.ToItems; else if (boxTag == "CCBox") addressCollection = ViewModel.CCItems; else if (boxTag == "BCCBox") addressCollection = ViewModel.BCCItems; if (addressCollection != null) addedItem = await ViewModel.GetAddressInformationAsync(currentText, addressCollection); // Item has already been added. if (addedItem == null) { tokenizingTextBox.Text = string.Empty; } else if (addressCollection != null) { addressCollection.Add(addedItem); tokenizingTextBox.Text = string.Empty; } } } } // Hack: Tokenizing text box losing focus somehow on page Loaded and shifting focus to this element. // For once we'll switch back to it once CCBBCGotFocus element got focus. private bool isInitialFocusHandled = false; private void ComposerLoaded(object sender, RoutedEventArgs e) { ToBox.Focus(FocusState.Programmatic); } private void CCBBCGotFocus(object sender, RoutedEventArgs e) { if (!isInitialFocusHandled) { isInitialFocusHandled = true; ToBox.Focus(FocusState.Programmatic); } } protected override async void OnNavigatingFrom(NavigatingCancelEventArgs e) { base.OnNavigatingFrom(e); FocusManager.GotFocus -= GlobalFocusManagerGotFocus; _navManagerPreview.CloseRequested -= OnClose; await ViewModel.UpdateMimeChangesAsync(); DisposeDisposables(); DisposeWebView2(); } private async void OnClose(object sender, SystemNavigationCloseRequestedPreviewEventArgs e) { var deferral = e.GetDeferral(); try { await ViewModel.UpdateMimeChangesAsync(); } finally { deferral.Complete(); } } } [JsonSerializable(typeof(List))] public partial class ComposerPageJsonContext: JsonSerializerContext; }