diff --git a/Directory.Packages.props b/Directory.Packages.props
index dde3211e..700a216d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,7 +33,7 @@
-
+
diff --git a/Wino.Core.Domain/Interfaces/IMenuOperation.cs b/Wino.Core.Domain/Interfaces/IMenuOperation.cs
index 2d588edb..340260c3 100644
--- a/Wino.Core.Domain/Interfaces/IMenuOperation.cs
+++ b/Wino.Core.Domain/Interfaces/IMenuOperation.cs
@@ -4,4 +4,5 @@ public interface IMenuOperation
{
bool IsEnabled { get; }
string Identifier { get; }
+ bool IsSecondaryMenuPreferred { get; }
}
diff --git a/Wino.Core.Domain/Interfaces/IMimeFileService.cs b/Wino.Core.Domain/Interfaces/IMimeFileService.cs
index bf4a79f7..b27a9df8 100644
--- a/Wino.Core.Domain/Interfaces/IMimeFileService.cs
+++ b/Wino.Core.Domain/Interfaces/IMimeFileService.cs
@@ -69,6 +69,16 @@ public interface IMimeFileService
///
Task SaveTranslatedHtmlAsync(Guid accountId, Guid fileId, string targetLanguage, string html, CancellationToken cancellationToken = default);
+ ///
+ /// Returns cached summary text for the given mime resource if it exists.
+ ///
+ Task GetSummaryTextAsync(Guid accountId, Guid fileId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves summary text for the given mime resource.
+ ///
+ Task SaveSummaryTextAsync(Guid accountId, Guid fileId, string summary, CancellationToken cancellationToken = default);
+
///
/// Prepares the final model containing rendering details.
///
diff --git a/Wino.Core.Domain/Models/Folders/FolderOperationMenuItem.cs b/Wino.Core.Domain/Models/Folders/FolderOperationMenuItem.cs
index ac81ad54..f0b2e5bc 100644
--- a/Wino.Core.Domain/Models/Folders/FolderOperationMenuItem.cs
+++ b/Wino.Core.Domain/Models/Folders/FolderOperationMenuItem.cs
@@ -1,13 +1,15 @@
-using Wino.Core.Domain.Enums;
-using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Menus;
namespace Wino.Core.Domain.Models.Folders;
-public class FolderOperationMenuItem : MenuOperationItemBase, IMenuOperation
+public class FolderOperationMenuItem : MenuOperationItemBase
{
- protected FolderOperationMenuItem(FolderOperation operation, bool isEnabled) : base(operation, isEnabled) { }
+ protected FolderOperationMenuItem(FolderOperation operation, bool isEnabled, bool isSecondaryMenuItem = false) : base(operation, isEnabled)
+ {
+ IsSecondaryMenuPreferred = isSecondaryMenuItem;
+ }
- public static FolderOperationMenuItem Create(FolderOperation operation, bool isEnabled = true)
- => new FolderOperationMenuItem(operation, isEnabled);
+ public static FolderOperationMenuItem Create(FolderOperation operation, bool isEnabled = true, bool isSecondaryMenuItem = false)
+ => new FolderOperationMenuItem(operation, isEnabled, isSecondaryMenuItem);
}
diff --git a/Wino.Core.Domain/Models/Menus/MailOperationMenuItem.cs b/Wino.Core.Domain/Models/Menus/MailOperationMenuItem.cs
index 7c243f55..a3e228b7 100644
--- a/Wino.Core.Domain/Models/Menus/MailOperationMenuItem.cs
+++ b/Wino.Core.Domain/Models/Menus/MailOperationMenuItem.cs
@@ -1,15 +1,9 @@
-using Wino.Core.Domain.Enums;
-using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Menus;
-public class MailOperationMenuItem : MenuOperationItemBase, IMenuOperation
+public class MailOperationMenuItem : MenuOperationItemBase
{
- ///
- /// Gets or sets whether this menu item should be placed in SecondaryCommands if used in CommandBar.
- ///
- public bool IsSecondaryMenuPreferred { get; set; }
-
protected MailOperationMenuItem(MailOperation operation, bool isEnabled, bool isSecondaryMenuItem = false) : base(operation, isEnabled)
{
IsSecondaryMenuPreferred = isSecondaryMenuItem;
diff --git a/Wino.Core.Domain/Models/Menus/MenuOperationItemBase.cs b/Wino.Core.Domain/Models/Menus/MenuOperationItemBase.cs
index 30f4a1a0..cc2bcdcb 100644
--- a/Wino.Core.Domain/Models/Menus/MenuOperationItemBase.cs
+++ b/Wino.Core.Domain/Models/Menus/MenuOperationItemBase.cs
@@ -1,9 +1,16 @@
-using System;
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Menus;
-public class MenuOperationItemBase where TOperation : Enum
+public class MenuOperationItemBase : ObservableObject, IMenuOperation where TOperation : Enum
{
+ private TOperation _operation;
+ private string _identifier = string.Empty;
+ private bool _isEnabled;
+ private bool _isSecondaryMenuPreferred;
+
public MenuOperationItemBase(TOperation operation, bool isEnabled)
{
Operation = operation;
@@ -11,7 +18,33 @@ public class MenuOperationItemBase where TOperation : Enum
Identifier = operation.ToString();
}
- public TOperation Operation { get; set; }
- public string Identifier { get; set; }
- public bool IsEnabled { get; set; }
+ public TOperation Operation
+ {
+ get => _operation;
+ set
+ {
+ if (SetProperty(ref _operation, value))
+ {
+ Identifier = value.ToString();
+ }
+ }
+ }
+
+ public string Identifier
+ {
+ get => _identifier;
+ protected set => SetProperty(ref _identifier, value);
+ }
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set => SetProperty(ref _isEnabled, value);
+ }
+
+ public bool IsSecondaryMenuPreferred
+ {
+ get => _isSecondaryMenuPreferred;
+ set => SetProperty(ref _isSecondaryMenuPreferred, value);
+ }
}
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index 33a1310b..a8fd04fa 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -1062,6 +1062,7 @@
"Composer_SmimeEncryption": "S/MIME Encryption",
"Composer_EmailTemplatesPlaceholder": "E-mail templates",
"Composer_AiSummarize": "Summarize with AI",
+ "Composer_AiSummarizeDescription": "Extract key points, action items, and decisions from this email.",
"Composer_AiTranslate": "Translate with AI",
"Composer_AiActions": "AI Actions",
"Composer_AiRewrite": "Rewrite with AI",
diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs
index cfcd9632..a1f1771f 100644
--- a/Wino.Mail.ViewModels/MailListPageViewModel.cs
+++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs
@@ -60,7 +60,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public WinoMailCollection MailCollection { get; set; } = new WinoMailCollection();
public ObservableCollection PivotFolders { get; set; } = [];
- public ObservableCollection ActionItems { get; set; } = [];
+ public ObservableCollection ActionItems { get; set; } = [];
private readonly SemaphoreSlim listManipulationSemepahore = new SemaphoreSlim(1);
private CancellationTokenSource listManipulationCancellationTokenSource = new CancellationTokenSource();
@@ -439,11 +439,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public Task ExecuteHoverAction(MailOperationPreperationRequest request) => ExecuteMailOperationAsync(request);
[RelayCommand]
- private async Task ExecuteTopBarAction(MailOperationMenuItem menuItem)
+ private async Task ExecuteTopBarAction(IMenuOperation menuItem)
{
- if (menuItem == null || MailCollection.SelectedItemsCount == 0) return;
+ if (menuItem is not MailOperationMenuItem mailOperationMenuItem || MailCollection.SelectedItemsCount == 0) return;
- await HandleMailOperation(menuItem.Operation, MailCollection.SelectedItems);
+ await HandleMailOperation(mailOperationMenuItem.Operation, MailCollection.SelectedItems);
}
///
diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs
index 24611d8b..ee132fb3 100644
--- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs
+++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs
@@ -133,7 +133,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
public ObservableCollection CcItems { get; set; } = [];
public ObservableCollection BccItems { get; set; } = [];
public ObservableCollection Attachments { get; set; } = [];
- public ObservableCollection MenuItems { get; set; } = [];
+ public ObservableCollection MenuItems { get; set; } = [];
#endregion
@@ -255,21 +255,18 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
}
[RelayCommand]
- private async Task OperationClicked(MailOperationMenuItem menuItem)
+ private async Task OperationClicked(IMenuOperation menuItem)
{
- if (menuItem == null) return;
+ if (menuItem is not MailOperationMenuItem mailOperationMenuItem) return;
- await HandleMailOperationAsync(menuItem.Operation);
+ await HandleMailOperationAsync(mailOperationMenuItem.Operation);
}
private async Task HandleMailOperationAsync(MailOperation operation)
{
try
{
- // Toggle theme
- if (operation == MailOperation.DarkEditor || operation == MailOperation.LightEditor)
- IsDarkWebviewRenderer = !IsDarkWebviewRenderer;
- else if (operation == MailOperation.SaveAs)
+ if (operation == MailOperation.SaveAs)
{
await SaveAsAsync();
}
@@ -589,12 +586,6 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
{
MenuItems.Clear();
- // Add light/dark editor theme switch.
- if (IsDarkWebviewRenderer)
- MenuItems.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
- else
- MenuItems.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
-
// Save As PDF
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SaveAs, true, true));
diff --git a/Wino.Mail.WinUI/App.xaml b/Wino.Mail.WinUI/App.xaml
index 5d6ab8d9..21d04fe6 100644
--- a/Wino.Mail.WinUI/App.xaml
+++ b/Wino.Mail.WinUI/App.xaml
@@ -19,6 +19,7 @@
+
diff --git a/Wino.Mail.WinUI/Behaviors/BindableCommandBarBehavior.cs b/Wino.Mail.WinUI/Behaviors/BindableCommandBarBehavior.cs
deleted file mode 100644
index a8228679..00000000
--- a/Wino.Mail.WinUI/Behaviors/BindableCommandBarBehavior.cs
+++ /dev/null
@@ -1,208 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Specialized;
-using System.Windows.Input;
-using CommunityToolkit.WinUI;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Controls.Primitives;
-using Microsoft.Xaml.Interactivity;
-using Wino.Core.Domain.Interfaces;
-using Wino.Core.Domain.Models.Menus;
-using Wino.Helpers;
-using Wino.Mail.WinUI;
-using Wino.Mail.WinUI.Controls;
-
-namespace Wino.Behaviors;
-
-public partial class BindableCommandBarBehavior : Behavior
-{
- private readonly IPreferencesService? _preferencesService = App.Current.Services.GetService();
- public static readonly DependencyProperty PrimaryCommandsProperty = DependencyProperty.Register(
- "PrimaryCommands", typeof(object), typeof(BindableCommandBarBehavior),
- new PropertyMetadata(null, UpdateCommands));
-
- [GeneratedDependencyProperty]
- public partial ICommand? ItemClickedCommand { get; set; }
-
- public object PrimaryCommands
- {
- get { return GetValue(PrimaryCommandsProperty); }
- set { SetValue(PrimaryCommandsProperty, value); }
- }
-
- protected override void OnDetaching()
- {
- base.OnDetaching();
-
- AttachChanges(false);
-
- if (PrimaryCommands is IEnumerable enumerable)
- {
- foreach (var item in enumerable)
- {
- DetachCommandElement(item);
- }
- }
- }
-
- private void DetachCommandElement(object item)
- {
- if (item is ButtonBase button)
- {
- button.Click -= Button_Click;
- return;
- }
-
- if (item is AppBarElementContainer container && container.Content is IDisposable disposable)
- {
- disposable.Dispose();
- }
- }
-
- private void UpdatePrimaryCommands()
- {
- if (AssociatedObject == null)
- return;
-
- if (PrimaryCommands == null)
- return;
-
- if (AssociatedObject.PrimaryCommands is IEnumerable enumerableObjects)
- {
- foreach (var item in enumerableObjects)
- {
- DetachCommandElement(item);
- }
- }
-
- if (AssociatedObject.SecondaryCommands is IEnumerable secondaryObject)
- {
- foreach (var item in secondaryObject)
- {
- DetachCommandElement(item);
- }
- }
-
- AssociatedObject.PrimaryCommands.Clear();
- AssociatedObject.SecondaryCommands.Clear();
-
- if (PrimaryCommands is not IEnumerable enumerable) return;
-
- foreach (var command in enumerable)
- {
- if (command is MailOperationMenuItem mailOperationMenuItem)
- {
- ICommandBarElement? menuItem = null;
-
- if (mailOperationMenuItem.Operation == Core.Domain.Enums.MailOperation.Seperator)
- {
- menuItem = new AppBarSeparator();
- }
- else
- {
- var label = XamlHelpers.GetOperationString(mailOperationMenuItem.Operation);
- var labelPosition = string.IsNullOrWhiteSpace(label) || _preferencesService == null || !_preferencesService.IsShowActionLabelsEnabled ?
- CommandBarLabelPosition.Collapsed : CommandBarLabelPosition.Default;
-
- var iconGlyph = XamlHelpers.GetWinoIconGlyph(mailOperationMenuItem.Operation);
- var glyphValue = ControlConstants.WinoIconFontDictionary.TryGetValue(iconGlyph, out var glyph) ? glyph : string.Empty;
-
- menuItem = new AppBarButton
- {
- Width = double.NaN,
- MinWidth = 40,
- Icon = new WinoFontIcon() { Glyph = glyphValue },
- Label = label,
- LabelPosition = labelPosition,
- DataContext = mailOperationMenuItem,
- };
-
- if (!string.IsNullOrWhiteSpace(label))
- {
- var toolTip = new ToolTip
- {
- Content = label
- };
- ToolTipService.SetToolTip((DependencyObject)menuItem, toolTip);
- }
-
- ((AppBarButton)menuItem).Click -= Button_Click;
- ((AppBarButton)menuItem).Click += Button_Click;
- }
-
- if (mailOperationMenuItem.IsSecondaryMenuPreferred)
- {
- AssociatedObject.SecondaryCommands.Add(menuItem);
- }
- else
- {
- AssociatedObject.PrimaryCommands.Add(menuItem);
- }
- }
- //if (dependencyObject is ICommandBarElement icommandBarElement)
- //{
- // if (dependencyObject is ButtonBase button)
- // {
- // button.Click -= Button_Click;
- // button.Click += Button_Click;
- // }
-
- // if (command is MailOperationMenuItem mailOperationMenuItem)
- // {
-
- // }
- //}
- }
- }
-
- private void Button_Click(object sender, RoutedEventArgs e)
- {
- ItemClickedCommand?.Execute(((ButtonBase)sender).DataContext);
- }
-
- protected override void OnAttached()
- {
- base.OnAttached();
-
- AttachChanges(true);
-
- UpdatePrimaryCommands();
- }
-
- private void PrimaryCommandsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
- {
- UpdatePrimaryCommands();
- }
-
- private static void UpdateCommands(DependencyObject dependencyObject,
- DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs)
- {
- if (dependencyObject is not BindableCommandBarBehavior behavior) return;
-
- if (dependencyPropertyChangedEventArgs.OldValue is INotifyCollectionChanged oldList)
- {
- oldList.CollectionChanged -= behavior.PrimaryCommandsCollectionChanged;
- }
-
- behavior.AttachChanges(true);
- behavior.UpdatePrimaryCommands();
- }
-
- private void AttachChanges(bool register)
- {
- if (PrimaryCommands is null) return;
-
- if (PrimaryCommands is INotifyCollectionChanged collectionChanged)
- {
- if (register)
- {
- collectionChanged.CollectionChanged -= PrimaryCommandsCollectionChanged;
- collectionChanged.CollectionChanged += PrimaryCommandsCollectionChanged;
- }
- else
- collectionChanged.CollectionChanged -= PrimaryCommandsCollectionChanged;
- }
- }
-}
diff --git a/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml b/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml
index d308f517..b799c1e2 100644
--- a/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml
+++ b/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml
@@ -5,8 +5,8 @@
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
- xmlns:models="using:Wino.Core.Domain.Models.Ai"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:models="using:Wino.Core.Domain.Models.Ai"
x:Name="root"
Loaded="OnLoaded"
Unloaded="OnUnloaded"
@@ -20,11 +20,17 @@
CornerRadius="10">
-
+
-
+
-
-
+
+
@@ -47,7 +59,12 @@
-
+
-
-
+
+
-
+
-
+
-
+
@@ -110,121 +137,178 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maximum="1000"
+ Value="0" />
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ Text="{x:Bind domain:Translator.Composer_AiRewriteMode, Mode=OneWay}" />
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml.cs b/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml.cs
index 021bf98a..d9b2b383 100644
--- a/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml.cs
+++ b/Wino.Mail.WinUI/Controls/AiActionsPanel.xaml.cs
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.WinUI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
@@ -18,6 +20,7 @@ namespace Wino.Mail.WinUI.Controls;
public sealed partial class AiActionsPanel : UserControl, IDisposable
{
+ public event EventHandler? CloseRequested;
private readonly IWinoAccountProfileService _profileService = App.Current.Services.GetRequiredService();
private readonly IStoreManagementService _storeManagementService = App.Current.Services.GetRequiredService();
private readonly IMailDialogService _dialogService = App.Current.Services.GetRequiredService();
@@ -27,6 +30,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
private bool _isRefreshing;
private bool _isBusy;
private AiActionType _lastConfigurableAction = AiActionType.Translate;
+ private bool _hasCachedSummary;
private CancellationTokenSource? _actionCancellationTokenSource;
private IReadOnlyList _translateOptions = Array.Empty();
private IReadOnlyList _rewriteOptions = Array.Empty();
@@ -66,6 +70,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
{
UpdateActionAvailability();
ApplySelectedAction(SelectDefaultAction());
+ _ = RefreshCachedSummaryStateAsync();
}
private void OnLoaded(object sender, RoutedEventArgs e)
@@ -83,25 +88,44 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
private void LoadOptions()
{
+ // Save current selections before replacing ItemsSource (which clears SelectedItem).
+ var previousTranslateCode = SelectedTranslateLanguageOption?.Code;
+ var previousRewriteMode = SelectedRewriteModeOption?.Mode;
+
_translateOptions = _optionsService.GetTranslateLanguageOptions();
_rewriteOptions = _optionsService.GetRewriteModeOptions();
TranslateLanguageComboBox.ItemsSource = _translateOptions;
RewriteModeComboBox.ItemsSource = _rewriteOptions;
- SelectedTranslateLanguageOption ??= _translateOptions.Count > 0 ? _translateOptions[0] : null;
- SelectedRewriteModeOption ??= _rewriteOptions.Count > 0 ? _rewriteOptions[0] : null;
+ // Restore selection by matching on value, falling back to first item.
+ SelectedTranslateLanguageOption = FindOption(_translateOptions, o => o.Code == previousTranslateCode) ?? (_translateOptions.Count > 0 ? _translateOptions[0] : null);
+ SelectedRewriteModeOption = FindOption(_rewriteOptions, o => o.Mode == previousRewriteMode) ?? (_rewriteOptions.Count > 0 ? _rewriteOptions[0] : null);
TranslateLanguageComboBox.SelectedItem = SelectedTranslateLanguageOption;
RewriteModeComboBox.SelectedItem = SelectedRewriteModeOption;
UpdateRewriteOptionState();
}
+ private static T? FindOption(IReadOnlyList options, Func predicate) where T : class
+ {
+ foreach (var option in options)
+ {
+ if (predicate(option))
+ {
+ return option;
+ }
+ }
+
+ return null;
+ }
+
private void UpdateActionAvailability()
{
TranslateSegment.Visibility = HasAction(AiActionType.Translate) ? Visibility.Visible : Visibility.Collapsed;
RewriteSegment.Visibility = HasAction(AiActionType.Rewrite) ? Visibility.Visible : Visibility.Collapsed;
SummarizeSegment.Visibility = HasAction(AiActionType.Summarize) ? Visibility.Visible : Visibility.Collapsed;
+ SummarizeCachedIndicator.Visibility = HasAction(AiActionType.Summarize) && _hasCachedSummary ? Visibility.Visible : Visibility.Collapsed;
}
private bool HasAction(AiActionType action) => (AvailableActions & action) == action;
@@ -143,6 +167,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
TranslateOptionsPanel.Visibility = action == AiActionType.Translate ? Visibility.Visible : Visibility.Collapsed;
RewriteOptionsPanel.Visibility = action == AiActionType.Rewrite ? Visibility.Visible : Visibility.Collapsed;
+ SummarizeOptionsPanel.Visibility = action == AiActionType.Summarize ? Visibility.Visible : Visibility.Collapsed;
}
public async Task RefreshAvailabilityAsync()
@@ -160,7 +185,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(true);
if (account == null)
{
- UpdateUsageSummary(string.Empty, string.Empty);
+ UpdateUsageSummary(string.Empty);
UpdatePanelState(showSignedOut: true);
return;
}
@@ -168,7 +193,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
var hasAiPack = await _storeManagementService.HasProductAsync(WinoAddOnProductType.AI_PACK).ConfigureAwait(true);
if (!hasAiPack)
{
- UpdateUsageSummary(string.Empty, string.Empty);
+ UpdateUsageSummary(string.Empty);
UpdatePanelState(showPurchase: true);
return;
}
@@ -176,13 +201,17 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(true);
if (aiStatusResponse.IsSuccess && aiStatusResponse.Result != null)
{
- UpdateUsageSummary(CreateUsageSummary(aiStatusResponse.Result), CreateUsageResetText(aiStatusResponse.Result));
+ UpdateUsageSummary(
+ CreateUsageSummary(aiStatusResponse.Result),
+ GetUsedCount(aiStatusResponse.Result),
+ GetUsageLimit(aiStatusResponse.Result));
}
else
{
- UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed, string.Empty);
+ UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed);
}
+ await RefreshCachedSummaryStateAsync().ConfigureAwait(true);
ApplySelectedAction(SelectDefaultAction());
UpdatePanelState(showReady: true);
}
@@ -191,7 +220,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
}
catch (Exception)
{
- UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed, string.Empty);
+ UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed);
UpdatePanelState(showReady: true);
}
finally
@@ -211,13 +240,29 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
return Translator.WinoAccount_Management_AiPackUsageLoadFailed;
}
- private static string CreateUsageResetText(AiStatusResultDto aiStatus)
+ private static int GetUsedCount(AiStatusResultDto aiStatus)
+ => aiStatus.Used is int used ? used : 0;
+
+ private static string CreateUsageSummary(QuotaInfoDto quotaInfo)
{
- return aiStatus.CurrentPeriodEndUtc is DateTimeOffset resetDateUtc
- ? string.Format(Translator.WinoAccount_Management_AiPackResets, resetDateUtc.LocalDateTime)
- : string.Empty;
+ if (quotaInfo.Used is int used && quotaInfo.MonthlyLimit is int limit && limit > 0)
+ {
+ return string.Format(Translator.AiActions_UsageSummary, used, limit);
+ }
+
+ return Translator.WinoAccount_Management_AiPackUsageLoadFailed;
}
+ private static int GetUsedCount(QuotaInfoDto quotaInfo)
+ => quotaInfo.Used is int used ? used : 0;
+
+ private static int GetUsageLimit(QuotaInfoDto quotaInfo)
+ => quotaInfo.MonthlyLimit is int limit && limit > 0 ? limit : 1000;
+
+ private static int GetUsageLimit(AiStatusResultDto aiStatus)
+ => aiStatus.MonthlyLimit is int limit && limit > 0 ? limit : 1000;
+
+
private void UpdatePanelState(bool showLoading = false, bool showSignedOut = false, bool showPurchase = false, bool showReady = false)
{
LoadingPanel.Visibility = showLoading ? Visibility.Visible : Visibility.Collapsed;
@@ -226,11 +271,19 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
ReadyPanel.Visibility = showReady ? Visibility.Visible : Visibility.Collapsed;
}
- private void UpdateUsageSummary(string usageText, string resetText)
+ private void UpdateUsageSummary(string usageText, int usedCount = 0)
{
UsageSummaryTextBlock.Text = usageText;
- UsageResetTextBlock.Text = resetText;
- UsageResetTextBlock.Visibility = string.IsNullOrWhiteSpace(resetText) ? Visibility.Collapsed : Visibility.Visible;
+ UsageProgressBar.Maximum = 1000;
+ UsageProgressBar.Value = Math.Min(usedCount, 1000);
+ }
+
+ private void UpdateUsageSummary(string usageText, int usedCount, int usageLimit)
+ {
+ var normalizedLimit = usageLimit > 0 ? usageLimit : 1000;
+ UsageSummaryTextBlock.Text = usageText;
+ UsageProgressBar.Maximum = normalizedLimit;
+ UsageProgressBar.Value = Math.Min(usedCount, normalizedLimit);
}
private void SetBusyUi(bool isBusy, bool showLoading)
@@ -244,6 +297,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
CustomRewriteTextBox.IsEnabled = !isBusy;
RunTranslateButton.IsEnabled = !isBusy;
RunRewriteButton.IsEnabled = !isBusy;
+ RunSummarizeButton.IsEnabled = !isBusy;
SignedOutPanel.IsHitTestVisible = !isBusy;
PurchasePanel.IsHitTestVisible = !isBusy;
@@ -363,7 +417,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
if (ReferenceEquals(ActionSelector.SelectedItem, SummarizeSegment))
{
ApplySelectedAction(AiActionType.Summarize);
- _ = ExecuteAiActionAsync(AiActionType.Summarize);
+ _ = RefreshCachedSummaryStateAsync();
}
}
@@ -402,6 +456,17 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
await ExecuteAiActionAsync(AiActionType.Rewrite);
}
+ private async void RunSummarizeButton_Click(object sender, RoutedEventArgs e)
+ {
+ await ExecuteAiActionAsync(AiActionType.Summarize);
+ }
+
+ private void CloseButton_Click(object sender, RoutedEventArgs e)
+ {
+ CancelPendingOperation();
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+
private async Task ExecuteAiActionAsync(AiActionType action)
{
if (_isBusy)
@@ -423,15 +488,6 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
try
{
- var html = await HtmlHost.GetCurrentHtmlAsync(cancellationToken).ConfigureAwait(true);
- cancellationToken.ThrowIfCancellationRequested();
-
- if (string.IsNullOrWhiteSpace(html))
- {
- _dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_AiHtmlEmpty, InfoBarMessageType.Error);
- return;
- }
-
if (action == AiActionType.Translate && SelectedTranslateLanguageOption == null)
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_ValidationFailed, InfoBarMessageType.Error);
@@ -444,6 +500,41 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
return;
}
+ if (action == AiActionType.Translate)
+ {
+ var cachedTranslation = await HtmlHost.TryGetCachedTranslationHtmlAsync(SelectedTranslateLanguageOption?.Code ?? string.Empty, cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrWhiteSpace(cachedTranslation))
+ {
+ await HtmlHost.ApplyHtmlResultAsync(cachedTranslation, cancellationToken).ConfigureAwait(true);
+ return;
+ }
+ }
+
+ if (action == AiActionType.Summarize)
+ {
+ var cachedSummary = await HtmlHost.TryGetCachedSummaryTextAsync(cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrWhiteSpace(cachedSummary))
+ {
+ _hasCachedSummary = true;
+ UpdateActionAvailability();
+ await ShowSummaryDialogAsync(cachedSummary).ConfigureAwait(true);
+ return;
+ }
+ }
+
+ var html = await HtmlHost.GetCurrentHtmlAsync(cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ _dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_AiHtmlEmpty, InfoBarMessageType.Error);
+ return;
+ }
+
var response = action switch
{
AiActionType.Translate => await _profileService.TranslateAsync(html, SelectedTranslateLanguageOption?.Code ?? string.Empty, cancellationToken).ConfigureAwait(true),
@@ -460,6 +551,37 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
return;
}
+ if (response.Quota != null)
+ {
+ UpdateUsageSummary(
+ CreateUsageSummary(response.Quota),
+ GetUsedCount(response.Quota),
+ GetUsageLimit(response.Quota));
+ }
+
+ if (action == AiActionType.Translate)
+ {
+ await HtmlHost.SaveCachedTranslationHtmlAsync(SelectedTranslateLanguageOption?.Code ?? string.Empty, response.Result.Html, cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+ await HtmlHost.ApplyHtmlResultAsync(response.Result.Html, cancellationToken).ConfigureAwait(true);
+ return;
+ }
+
+ if (action == AiActionType.Summarize)
+ {
+ await HtmlHost.SaveCachedSummaryTextAsync(response.Result.Html, cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _hasCachedSummary = true;
+ UpdateActionAvailability();
+
+ var savedSummary = await HtmlHost.TryGetCachedSummaryTextAsync(cancellationToken).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ShowSummaryDialogAsync(string.IsNullOrWhiteSpace(savedSummary) ? response.Result.Html : savedSummary).ConfigureAwait(true);
+ return;
+ }
+
await HtmlHost.ApplyHtmlResultAsync(response.Result.Html, cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
@@ -479,14 +601,7 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
_actionCancellationTokenSource = null;
}
- if (action == AiActionType.Summarize)
- {
- var fallbackAction = _lastConfigurableAction != AiActionType.None && HasAction(_lastConfigurableAction)
- ? _lastConfigurableAction
- : SelectDefaultAction();
-
- ApplySelectedAction(fallbackAction);
- }
+ // Summarize no longer auto-switches back; the user explicitly selected the tab.
}
}
@@ -505,6 +620,94 @@ public sealed partial class AiActionsPanel : UserControl, IDisposable
return CustomRewriteTextBox.Text?.Trim() ?? string.Empty;
}
+ private async Task RefreshCachedSummaryStateAsync()
+ {
+ if (HtmlHost == null || !HasAction(AiActionType.Summarize))
+ {
+ _hasCachedSummary = false;
+ UpdateActionAvailability();
+ return;
+ }
+
+ try
+ {
+ var cachedSummary = await HtmlHost.TryGetCachedSummaryTextAsync(CancellationToken.None).ConfigureAwait(true);
+ _hasCachedSummary = !string.IsNullOrWhiteSpace(cachedSummary);
+ }
+ catch (Exception)
+ {
+ _hasCachedSummary = false;
+ }
+
+ UpdateActionAvailability();
+ }
+
+ private async Task ShowSummaryDialogAsync(string summary)
+ {
+ if (HtmlHost == null)
+ {
+ await _dialogService.ShowMessageAsync(summary, Translator.Composer_AiSummarize, WinoCustomMessageDialogIcon.Information);
+ return;
+ }
+
+ var summaryTextBox = new TextBox
+ {
+ Text = summary,
+ IsReadOnly = true,
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.Wrap,
+ MinHeight = 240,
+ MaxHeight = 420,
+ BorderThickness = new Thickness(0),
+ Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0))
+ };
+
+ var dialog = new ContentDialog
+ {
+ XamlRoot = XamlRoot,
+ RequestedTheme = ActualTheme,
+ Title = Translator.Composer_AiSummarize,
+ PrimaryButtonText = Translator.Buttons_Save,
+ SecondaryButtonText = Translator.Buttons_Close,
+ DefaultButton = ContentDialogButton.Secondary,
+ Content = new ScrollViewer
+ {
+ Content = summaryTextBox,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
+ }
+ };
+
+ dialog.PrimaryButtonClick += async (sender, args) =>
+ {
+ var deferral = args.GetDeferral();
+
+ try
+ {
+ var path = await _dialogService.PickFilePathAsync(HtmlHost.GetSuggestedSummaryFileName());
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ await File.WriteAllTextAsync(path, summary);
+ _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.ClipboardTextCopied_Message, Path.GetFileName(path)), InfoBarMessageType.Success);
+ }
+ catch (Exception ex)
+ {
+ args.Cancel = true;
+ _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error);
+ }
+ finally
+ {
+ deferral.Complete();
+ }
+ };
+
+ await dialog.ShowAsync();
+ }
+
private void CancelAndDisposeActionCancellationToken()
{
if (_actionCancellationTokenSource == null)
diff --git a/Wino.Mail.WinUI/Controls/IAiHtmlActionHost.cs b/Wino.Mail.WinUI/Controls/IAiHtmlActionHost.cs
index d6954e58..24ca2b93 100644
--- a/Wino.Mail.WinUI/Controls/IAiHtmlActionHost.cs
+++ b/Wino.Mail.WinUI/Controls/IAiHtmlActionHost.cs
@@ -9,4 +9,7 @@ public interface IAiHtmlActionHost
Task ApplyHtmlResultAsync(string html, CancellationToken cancellationToken);
Task TryGetCachedTranslationHtmlAsync(string languageCode, CancellationToken cancellationToken);
Task SaveCachedTranslationHtmlAsync(string languageCode, string html, CancellationToken cancellationToken);
+ Task TryGetCachedSummaryTextAsync(CancellationToken cancellationToken);
+ Task SaveCachedSummaryTextAsync(string summary, CancellationToken cancellationToken);
+ string GetSuggestedSummaryFileName();
}
diff --git a/Wino.Mail.WinUI/Controls/OperationCommandBar.cs b/Wino.Mail.WinUI/Controls/OperationCommandBar.cs
new file mode 100644
index 00000000..f5233952
--- /dev/null
+++ b/Wino.Mail.WinUI/Controls/OperationCommandBar.cs
@@ -0,0 +1,477 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Windows.Input;
+using CommunityToolkit.WinUI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Data;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Folders;
+using Wino.Core.Domain.Models.Menus;
+using Wino.Helpers;
+
+namespace Wino.Mail.WinUI.Controls;
+
+public sealed partial class OperationCommandBar : CommandBar
+{
+ private const string MailOperationTemplateKey = "OperationCommandBarMailOperationTemplate";
+ private const string FolderOperationTemplateKey = "OperationCommandBarFolderOperationTemplate";
+ private const string AIActionsTemplateKey = "OperationCommandBarAIActionsTemplate";
+ private const string ThemeToggleTemplateKey = "OperationCommandBarThemeToggleTemplate";
+ private const string SeparatorTemplateKey = "OperationCommandBarSeparatorTemplate";
+
+ private readonly IPreferencesService? _preferencesService;
+ private readonly HashSet _trackedMenuItems = [];
+
+ [GeneratedDependencyProperty]
+ public partial ObservableCollection? MenuItems { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial ICommand? ItemInvokedCommand { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial bool IsAIActionsEnabled { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial bool IsAIActionsPaneToggleVisible { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial bool IsEditorThemeDark { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial bool IsEditorThemeToggleVisible { get; set; }
+
+ public event EventHandler? AIActionsEnabledChanged;
+
+ public OperationCommandBar()
+ {
+ _preferencesService = App.Current.Services.GetService();
+
+ DefaultLabelPosition = CommandBarDefaultLabelPosition.Right;
+ IsDynamicOverflowEnabled = true;
+ OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Auto;
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ DynamicOverflowItemsChanging += OperationCommandBar_DynamicOverflowItemsChanging;
+ }
+
+ partial void OnMenuItemsPropertyChanged(DependencyPropertyChangedEventArgs e)
+ {
+ if (e.OldValue is INotifyCollectionChanged oldCollection)
+ {
+ oldCollection.CollectionChanged -= MenuItems_CollectionChanged;
+ }
+
+ DetachTrackedMenuItemHandlers();
+
+ if (e.NewValue is ObservableCollection newItems)
+ {
+ newItems.CollectionChanged += MenuItems_CollectionChanged;
+ TrackMenuItemHandlers((IEnumerable)newItems);
+ }
+
+ RefreshCommands();
+ }
+
+ partial void OnIsAIActionsEnabledChanged(bool newValue)
+ {
+ AIActionsEnabledChanged?.Invoke(this, newValue);
+ }
+
+ partial void OnIsAIActionsPaneToggleVisibleChanged(bool newValue)
+ {
+ RefreshCommands();
+ }
+
+ partial void OnIsEditorThemeDarkChanged(bool newValue)
+ {
+ RefreshCommands();
+ }
+
+ partial void OnIsEditorThemeToggleVisibleChanged(bool newValue)
+ {
+ RefreshCommands();
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ RefreshCommands();
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ ClearGeneratedCommands();
+ }
+
+ private void OperationCommandBar_DynamicOverflowItemsChanging(CommandBar sender, DynamicOverflowItemsChangingEventArgs args)
+ {
+ if (args.Action == CommandBarDynamicOverflowAction.AddingToOverflow || sender.SecondaryCommands.Count > 0)
+ {
+ sender.OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Visible;
+ }
+ else
+ {
+ sender.OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Collapsed;
+ }
+ }
+
+ private void MenuItems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Reset)
+ {
+ DetachTrackedMenuItemHandlers();
+
+ if (sender is IEnumerable refreshedItems)
+ {
+ TrackMenuItemHandlers(refreshedItems);
+ }
+ }
+ else
+ {
+ UntrackMenuItemHandlers(e.OldItems);
+ TrackMenuItemHandlers(e.NewItems);
+ }
+
+ RefreshCommands();
+ }
+
+ private void MenuItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(e.PropertyName)
+ || e.PropertyName == nameof(IMenuOperation.IsEnabled)
+ || e.PropertyName == nameof(IMenuOperation.IsSecondaryMenuPreferred)
+ || e.PropertyName == nameof(MenuOperationItemBase.Operation)
+ || e.PropertyName == nameof(MenuOperationItemBase.Identifier))
+ {
+ RefreshCommands();
+ }
+ }
+
+ private void TrackMenuItemHandlers(IEnumerable items)
+ {
+ foreach (var item in items)
+ {
+ if (item is INotifyPropertyChanged propertyChanged && _trackedMenuItems.Add(propertyChanged))
+ {
+ propertyChanged.PropertyChanged += MenuItem_PropertyChanged;
+ }
+ }
+ }
+
+ private void TrackMenuItemHandlers(System.Collections.IList? items)
+ {
+ if (items == null)
+ {
+ return;
+ }
+
+ foreach (var item in items)
+ {
+ if (item is IMenuOperation menuItem)
+ {
+ TrackMenuItemHandlers([menuItem]);
+ }
+ }
+ }
+
+ private void UntrackMenuItemHandlers(System.Collections.IList? items)
+ {
+ if (items == null)
+ {
+ return;
+ }
+
+ foreach (var item in items)
+ {
+ if (item is INotifyPropertyChanged propertyChanged && _trackedMenuItems.Remove(propertyChanged))
+ {
+ propertyChanged.PropertyChanged -= MenuItem_PropertyChanged;
+ }
+ }
+ }
+
+ private void DetachTrackedMenuItemHandlers()
+ {
+ foreach (var item in _trackedMenuItems)
+ {
+ item.PropertyChanged -= MenuItem_PropertyChanged;
+ }
+
+ _trackedMenuItems.Clear();
+ }
+
+ private void RefreshCommands()
+ {
+ ClearGeneratedCommands();
+
+ if (IsAIActionsPaneToggleVisible)
+ {
+ PrimaryCommands.Add(CreateAIActionsToggleButton());
+ }
+
+ if (IsEditorThemeToggleVisible)
+ {
+ PrimaryCommands.Add(CreateThemeToggleButton());
+ }
+
+ if (MenuItems == null)
+ {
+ UpdateOverflowButtonVisibility();
+ return;
+ }
+
+ foreach (var item in MenuItems)
+ {
+ var element = CreateCommandElement(item);
+ if (element == null)
+ {
+ continue;
+ }
+
+ if (item.IsSecondaryMenuPreferred)
+ {
+ SecondaryCommands.Add(element);
+ }
+ else
+ {
+ PrimaryCommands.Add(element);
+ }
+ }
+
+ UpdateOverflowButtonVisibility();
+ }
+
+ private void ClearGeneratedCommands()
+ {
+ DetachCommandHandlers(PrimaryCommands);
+ DetachCommandHandlers(SecondaryCommands);
+
+ PrimaryCommands.Clear();
+ SecondaryCommands.Clear();
+ }
+
+ private void DetachCommandHandlers(IEnumerable commands)
+ {
+ foreach (var command in commands)
+ {
+ switch (command)
+ {
+ case AppBarButton button:
+ button.Click -= OperationButton_Click;
+ button.Click -= ThemeButton_Click;
+ break;
+ case AppBarToggleButton toggleButton:
+ toggleButton.ClearValue(AppBarToggleButton.IsCheckedProperty);
+ break;
+ }
+ }
+ }
+
+ private ICommandBarElement? CreateCommandElement(IMenuOperation item)
+ {
+ if (item is MailOperationMenuItem mailOperation && mailOperation.Operation == MailOperation.Seperator)
+ {
+ return LoadCommandBarElementTemplate(SeparatorTemplateKey, new SeparatorCommandBarItemViewModel());
+ }
+
+ if (item is MailOperationMenuItem mailOperationItem)
+ {
+ var button = LoadCommandBarElementTemplate(
+ MailOperationTemplateKey,
+ new MenuOperationCommandBarItemViewModel(
+ mailOperationItem,
+ XamlHelpers.GetOperationString(mailOperationItem.Operation),
+ XamlHelpers.GetWinoIconGlyph(mailOperationItem.Operation),
+ GetOperationLabelPosition(XamlHelpers.GetOperationString(mailOperationItem.Operation))))
+ as AppBarButton;
+
+ if (button == null)
+ {
+ return null;
+ }
+
+ button.Tag = mailOperationItem;
+ button.Click += OperationButton_Click;
+ return button;
+ }
+
+ if (item is FolderOperationMenuItem folderOperationItem)
+ {
+ var label = XamlHelpers.GetOperationString(folderOperationItem.Operation);
+ var button = LoadCommandBarElementTemplate(
+ FolderOperationTemplateKey,
+ new MenuOperationCommandBarItemViewModel(
+ folderOperationItem,
+ label,
+ XamlHelpers.GetPathGeometry(folderOperationItem.Operation),
+ GetOperationLabelPosition(label)))
+ as AppBarButton;
+
+ if (button == null)
+ {
+ return null;
+ }
+
+ button.Tag = folderOperationItem;
+ button.Click += OperationButton_Click;
+ return button;
+ }
+
+ return null;
+ }
+
+ private AppBarToggleButton CreateAIActionsToggleButton()
+ {
+ var button = (AppBarToggleButton)LoadCommandBarElementTemplate(
+ AIActionsTemplateKey,
+ new AIActionsCommandBarItemViewModel(Translator.Composer_AiActions, "\uE945"));
+
+ button.SetBinding(AppBarToggleButton.IsCheckedProperty, new Binding
+ {
+ Mode = BindingMode.TwoWay,
+ Path = new PropertyPath(nameof(IsAIActionsEnabled)),
+ Source = this
+ });
+
+ return button;
+ }
+
+ private AppBarButton CreateThemeToggleButton()
+ {
+ var label = IsEditorThemeDark ? Translator.Composer_LightTheme : Translator.Composer_DarkTheme;
+ var icon = IsEditorThemeDark ? WinoIconGlyph.LightEditor : WinoIconGlyph.DarkEditor;
+
+ var button = (AppBarButton)LoadCommandBarElementTemplate(
+ ThemeToggleTemplateKey,
+ new ThemeCommandBarItemViewModel(label, icon));
+
+ button.Click += ThemeButton_Click;
+ return button;
+ }
+
+ private void OperationButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is AppBarButton button && button.Tag is IMenuOperation operation)
+ {
+ ItemInvokedCommand?.Execute(operation);
+ }
+ }
+
+ private void ThemeButton_Click(object sender, RoutedEventArgs e)
+ {
+ IsEditorThemeDark = !IsEditorThemeDark;
+ }
+
+ private object? FindTemplateResource(string key)
+ {
+ if (TryGetResourceRecursive(Resources, key, out var resource))
+ {
+ return resource;
+ }
+
+ return TryGetResourceRecursive(Application.Current.Resources, key, out resource) ? resource : null;
+ }
+
+ private static bool TryGetResourceRecursive(ResourceDictionary dictionary, string key, out object? resource)
+ {
+ if (dictionary.TryGetValue(key, out resource))
+ {
+ return true;
+ }
+
+ foreach (var mergedDictionary in dictionary.MergedDictionaries)
+ {
+ if (TryGetResourceRecursive(mergedDictionary, key, out resource))
+ {
+ return true;
+ }
+ }
+
+ resource = null;
+ return false;
+ }
+
+ private ICommandBarElement LoadCommandBarElementTemplate(string resourceKey, object dataContext)
+ {
+ var template = FindTemplateResource(resourceKey) as DataTemplate
+ ?? throw new InvalidOperationException($"Unable to resolve resource '{resourceKey}'.");
+
+ if (template.LoadContent() is not ICommandBarElement element)
+ {
+ throw new InvalidOperationException($"Resource '{resourceKey}' did not create an ICommandBarElement.");
+ }
+
+ if (element is FrameworkElement frameworkElement)
+ {
+ frameworkElement.DataContext = dataContext;
+ }
+
+ return element;
+ }
+
+ private CommandBarLabelPosition GetOperationLabelPosition(string label)
+ {
+ return string.IsNullOrWhiteSpace(label) || _preferencesService == null || !_preferencesService.IsShowActionLabelsEnabled
+ ? CommandBarLabelPosition.Collapsed
+ : CommandBarLabelPosition.Default;
+ }
+
+ private void UpdateOverflowButtonVisibility()
+ {
+ OverflowButtonVisibility = SecondaryCommands.Count > 0
+ ? CommandBarOverflowButtonVisibility.Visible
+ : CommandBarOverflowButtonVisibility.Auto;
+ }
+
+ private sealed class MenuOperationCommandBarItemViewModel
+ {
+ public MenuOperationCommandBarItemViewModel(IMenuOperation operation, string label, WinoIconGlyph icon, CommandBarLabelPosition labelPosition)
+ {
+ Operation = operation;
+ Label = label;
+ Icon = icon;
+ ToolTip = label;
+ LabelPosition = labelPosition;
+ }
+
+ public IMenuOperation Operation { get; }
+ public string Label { get; }
+ public WinoIconGlyph Icon { get; }
+ public string ToolTip { get; }
+ public bool IsEnabled => Operation.IsEnabled;
+ public CommandBarLabelPosition LabelPosition { get; }
+ }
+
+ private sealed class AIActionsCommandBarItemViewModel
+ {
+ public AIActionsCommandBarItemViewModel(string toolTip, string glyph)
+ {
+ ToolTip = toolTip;
+ Glyph = glyph;
+ }
+
+ public string ToolTip { get; }
+ public string Glyph { get; }
+ }
+
+ private sealed class ThemeCommandBarItemViewModel
+ {
+ public ThemeCommandBarItemViewModel(string toolTip, WinoIconGlyph icon)
+ {
+ ToolTip = toolTip;
+ Icon = icon;
+ }
+
+ public string ToolTip { get; }
+ public WinoIconGlyph Icon { get; }
+ }
+
+ private sealed class SeparatorCommandBarItemViewModel;
+}
diff --git a/Wino.Mail.WinUI/Styles/OperationCommandBar.xaml b/Wino.Mail.WinUI/Styles/OperationCommandBar.xaml
new file mode 100644
index 00000000..63a5b4be
--- /dev/null
+++ b/Wino.Mail.WinUI/Styles/OperationCommandBar.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs
index 1a193db1..94624486 100644
--- a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs
@@ -451,6 +451,20 @@ public sealed partial class ComposePage : ComposePageAbstract,
return Task.CompletedTask;
}
+ public Task TryGetCachedSummaryTextAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(null);
+ }
+
+ public Task SaveCachedSummaryTextAsync(string summary, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.CompletedTask;
+ }
+
+ public string GetSuggestedSummaryFileName() => "email-summary.txt";
+
private void OpenAttachment_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.CommandParameter is MailAttachmentViewModel attachment)
diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
index ca18a921..fac7b3f0 100644
--- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
+++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
@@ -15,10 +15,7 @@
xmlns:domain="using:Wino.Core.Domain"
xmlns:enums="using:Wino.Core.Domain.Enums"
xmlns:helpers="using:Wino.Helpers"
- xmlns:i="using:Microsoft.Xaml.Interactivity"
- xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:listview="using:Wino.Mail.WinUI.Controls.ListView"
- xmlns:local="using:Wino.Behaviors"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:menuflyouts="using:Wino.MenuFlyouts"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
@@ -176,15 +173,15 @@
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
CornerRadius="8"
Visibility="{x:Bind ViewModel.PreferencesService.IsMailListActionBarEnabled}">
-
-
-
-
-
+
diff --git a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml
index fa63c7bf..9a32f838 100644
--- a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml
+++ b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml
@@ -4,16 +4,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:Wino.Controls"
+ xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:customcontrols="using:Wino.Mail.WinUI.Controls.CustomControls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
xmlns:helpers="using:Wino.Helpers"
- xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
- xmlns:local="using:Wino.Behaviors"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
- xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:viewModelData="using:Wino.Mail.ViewModels.Data"
x:Name="root"
@@ -266,20 +264,17 @@
-
-
-
-
-
+ IsAIActionsPaneToggleVisible="True"
+ IsEditorThemeDark="{x:Bind ViewModel.IsDarkWebviewRenderer, Mode=TwoWay}"
+ IsEditorThemeToggleVisible="True"
+ ItemInvokedCommand="{x:Bind ViewModel.OperationClickedCommand}"
+ MenuItems="{x:Bind ViewModel.MenuItems, Mode=OneWay}">
+
@@ -322,22 +317,9 @@
-
-
-
-
-
-
-
-
+
+
+ Visibility="{x:Bind helpers:XamlHelpers.BoolToVisibilityConverter(RendererCommandBar.IsAIActionsEnabled), Mode=OneWay}" />
diff --git a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml.cs
index 50f3c6eb..d6f8277d 100644
--- a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml.cs
@@ -1,5 +1,5 @@
using System;
-using System.Linq;
+using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -42,8 +42,6 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
public WebView2 GetWebView() => Chromium;
- public Visibility GetAiActionsPanelVisibility(bool? isChecked) => isChecked == true ? Visibility.Visible : Visibility.Collapsed;
-
public MailRenderingPage()
{
InitializeComponent();
@@ -56,6 +54,7 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
{
return Chromium.CoreWebView2.PrintToPdfAsync(path, null).AsTask();
});
+
}
private async Task DirectPrintAsync(WebView2PrintSettingsModel settings)
@@ -150,6 +149,8 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
ViewModel.SaveHTMLasPDFFunc = null;
ViewModel.DirectPrintFuncAsync = null;
_currentRenderedHtml = string.Empty;
+ RendererCommandBar.AIActionsEnabledChanged -= RendererCommandBar_AIActionsEnabledChanged;
+ RendererCommandBar.IsAIActionsEnabled = false;
ReaderAiActionsPanel.CancelPendingOperation();
DisposeWebView2();
@@ -168,9 +169,12 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
cancellationToken.ThrowIfCancellationRequested();
}
- private async void ReaderAiActionsToggleButton_Checked(object sender, RoutedEventArgs e)
+ private async void RendererCommandBar_AIActionsEnabledChanged(object? sender, bool isEnabled)
{
- await ReaderAiActionsPanel.RefreshAvailabilityAsync();
+ if (isEnabled)
+ {
+ await ReaderAiActionsPanel.RefreshAvailabilityAsync();
+ }
}
public async Task TryGetCachedTranslationHtmlAsync(string languageCode, CancellationToken cancellationToken)
@@ -206,6 +210,43 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
cancellationToken).ConfigureAwait(false);
}
+ public async Task TryGetCachedSummaryTextAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!ViewModel.CurrentMailAccountId.HasValue || !ViewModel.CurrentMailFileId.HasValue)
+ {
+ return null;
+ }
+
+ return await _mimeFileService.GetSummaryTextAsync(
+ ViewModel.CurrentMailAccountId.Value,
+ ViewModel.CurrentMailFileId.Value,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task SaveCachedSummaryTextAsync(string summary, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!ViewModel.CurrentMailAccountId.HasValue || !ViewModel.CurrentMailFileId.HasValue)
+ {
+ return;
+ }
+
+ await _mimeFileService.SaveSummaryTextAsync(
+ ViewModel.CurrentMailAccountId.Value,
+ ViewModel.CurrentMailFileId.Value,
+ summary,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public string GetSuggestedSummaryFileName()
+ {
+ var subject = string.IsNullOrWhiteSpace(ViewModel.Subject) ? "email-summary" : ViewModel.Subject;
+ return $"{SanitizeFileNamePart(subject)}.txt";
+ }
+
private void DisposeWebView2()
{
if (Chromium == null) return;
@@ -228,6 +269,9 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
// Initialize WebView2 wiring before base navigation invokes ViewModel rendering.
// Base.OnNavigatedTo triggers VM.OnNavigatedTo, which can send HtmlRenderingRequested.
DOMLoadedTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ RendererCommandBar.AIActionsEnabledChanged -= RendererCommandBar_AIActionsEnabledChanged;
+ RendererCommandBar.AIActionsEnabledChanged += RendererCommandBar_AIActionsEnabledChanged;
+ RendererCommandBar.IsAIActionsEnabled = false;
Chromium.CoreWebView2Initialized -= CoreWebViewInitialized;
Chromium.CoreWebView2Initialized += CoreWebViewInitialized;
_ = Chromium.EnsureCoreWebView2Async();
@@ -300,14 +344,6 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
}
}
- private void BarDynamicOverflowChanging(CommandBar sender, DynamicOverflowItemsChangingEventArgs args)
- {
- if (args.Action == CommandBarDynamicOverflowAction.AddingToOverflow || sender.SecondaryCommands.Any())
- sender.OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Visible;
- else
- sender.OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Collapsed;
- }
-
private async Task UpdateEditorThemeAsync()
{
await DOMLoadedTask.Task;
@@ -404,4 +440,21 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
{
WeakReferenceMessenger.Default.Send(new ClearMailSelectionsRequested());
}
+
+ private static string SanitizeFileNamePart(string value)
+ {
+ var invalidCharacters = Path.GetInvalidFileNameChars();
+ var sanitizedChars = value.Trim().ToCharArray();
+
+ for (var i = 0; i < sanitizedChars.Length; i++)
+ {
+ if (Array.IndexOf(invalidCharacters, sanitizedChars[i]) >= 0)
+ {
+ sanitizedChars[i] = '_';
+ }
+ }
+
+ var sanitized = new string(sanitizedChars).Trim();
+ return string.IsNullOrWhiteSpace(sanitized) ? "email-summary" : sanitized;
+ }
}
diff --git a/Wino.Services/MimeFileService.cs b/Wino.Services/MimeFileService.cs
index 1314b16a..2a01fe04 100644
--- a/Wino.Services/MimeFileService.cs
+++ b/Wino.Services/MimeFileService.cs
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using HtmlAgilityPack;
using MimeKit;
using MimeKit.Cryptography;
using Serilog;
@@ -162,6 +163,43 @@ public class MimeFileService : IMimeFileService
}
}
+ public async Task GetSummaryTextAsync(Guid accountId, Guid fileId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var summaryPath = await GetSummaryTextPathAsync(accountId, fileId).ConfigureAwait(false);
+ if (!File.Exists(summaryPath))
+ {
+ return null;
+ }
+
+ return await File.ReadAllTextAsync(summaryPath, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Could not read summary cache for FileId: {FileId}", fileId);
+ return null;
+ }
+ }
+
+ public async Task SaveSummaryTextAsync(Guid accountId, Guid fileId, string summary, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(summary))
+ {
+ return;
+ }
+
+ try
+ {
+ var summaryPath = await GetSummaryTextPathAsync(accountId, fileId).ConfigureAwait(false);
+ await File.WriteAllTextAsync(summaryPath, NormalizeSummaryText(summary), cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Could not save summary cache for FileId: {FileId}", fileId);
+ }
+ }
+
public MailRenderModel GetMailRenderModel(MimeMessage message, string mimeLocalPath, MailRenderingOptions options = null)
{
var visitor = CreateHTMLPreviewVisitor(message, mimeLocalPath);
@@ -258,6 +296,12 @@ public class MimeFileService : IMimeFileService
return Path.Combine(resourcePath, $"translated-{SanitizeFileNamePart(targetLanguage)}.html");
}
+ private async Task GetSummaryTextPathAsync(Guid accountId, Guid fileId)
+ {
+ var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
+ return Path.Combine(resourcePath, "summary.txt");
+ }
+
private static string SanitizeFileNamePart(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
@@ -268,4 +312,44 @@ public class MimeFileService : IMimeFileService
return sanitizedChars.Length == 0 ? "default" : new string(sanitizedChars);
}
+
+ private static string NormalizeSummaryText(string summary)
+ {
+ if (string.IsNullOrWhiteSpace(summary))
+ {
+ return string.Empty;
+ }
+
+ if (!summary.Contains('<'))
+ {
+ return summary.Trim();
+ }
+
+ var document = new HtmlDocument();
+ document.LoadHtml(summary);
+
+ var lineBreakNodes = document.DocumentNode.SelectNodes("//br|//p|//div|//li");
+ if (lineBreakNodes != null)
+ {
+ foreach (var node in lineBreakNodes)
+ {
+ if (node.Name.Equals("li", StringComparison.OrdinalIgnoreCase))
+ {
+ node.ParentNode?.InsertBefore(document.CreateTextNode(Environment.NewLine + "- "), node);
+ }
+ else
+ {
+ node.ParentNode?.InsertBefore(document.CreateTextNode(Environment.NewLine), node);
+ }
+ }
+ }
+
+ var plainText = HtmlEntity.DeEntitize(document.DocumentNode.InnerText ?? string.Empty);
+ return string.Join(
+ Environment.NewLine,
+ plainText
+ .Split([Environment.NewLine], StringSplitOptions.None)
+ .Select(line => line.Trim())
+ .Where(line => !string.IsNullOrWhiteSpace(line)));
+ }
}