Info panel for synchronizers in shell.

This commit is contained in:
Burak Kaan Köse
2026-02-07 14:03:41 +01:00
parent d28de50ec6
commit 331b966556
9 changed files with 314 additions and 12 deletions
@@ -0,0 +1,15 @@
using System;
namespace Wino.Core.Domain.Models.Synchronization;
/// <summary>
/// Represents a single grouped synchronization action displayed in the sync status flyout.
/// For example: "Deleting 3 mail(s)" or "Marking folder as read".
/// </summary>
public class SynchronizationActionItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid AccountId { get; set; }
public string AccountName { get; set; }
public string Description { get; set; }
}
@@ -84,6 +84,22 @@
"Buttons_Yes": "Yes", "Buttons_Yes": "Yes",
"Sync_SynchronizingFolder": "Synchronizing {0} {1}%", "Sync_SynchronizingFolder": "Synchronizing {0} {1}%",
"Sync_DownloadedMessages": "Downloaded {0} messages from {1}", "Sync_DownloadedMessages": "Downloaded {0} messages from {1}",
"SyncAction_Archiving": "Archiving {0} mail(s)",
"SyncAction_ClearingFlag": "Unflagging {0} mail(s)",
"SyncAction_CreatingDraft": "Creating draft",
"SyncAction_Deleting": "Deleting {0} mail(s)",
"SyncAction_EmptyingFolder": "Emptying folder",
"SyncAction_MarkingAsRead": "Marking {0} mail(s) as read",
"SyncAction_MarkingAsUnread": "Marking {0} mail(s) as unread",
"SyncAction_MarkingFolderAsRead": "Marking folder as read",
"SyncAction_Moving": "Moving {0} mail(s)",
"SyncAction_MovingToFocused": "Moving {0} mail(s) to Focused",
"SyncAction_RenamingFolder": "Renaming folder",
"SyncAction_SendingMail": "Sending mail",
"SyncAction_SettingFlag": "Flagging {0} mail(s)",
"SyncAction_SynchronizingAccount": "Synchronizing {0}",
"SyncAction_SynchronizingAccounts": "Synchronizing {0} account(s)",
"SyncAction_Unarchiving": "Unarchiving {0} mail(s)",
"CalendarAllDayEventSummary": "all-day events", "CalendarAllDayEventSummary": "all-day events",
"CalendarDisplayOptions_Color": "Color", "CalendarDisplayOptions_Color": "Color",
"CalendarDisplayOptions_Expand": "Expand", "CalendarDisplayOptions_Expand": "Expand",
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
namespace Wino.Core.Helpers;
/// <summary>
/// Converts queued synchronization requests into user-facing action descriptions.
/// </summary>
public static class SynchronizationActionHelper
{
public static List<SynchronizationActionItem> CreateActionItems(
IEnumerable<IRequestBase> requests, Guid accountId, string accountName)
{
var items = new List<SynchronizationActionItem>();
// Group mail action requests by operation
var mailRequests = requests.OfType<IMailActionRequest>();
var mailGroups = mailRequests.GroupBy(r => GetMailActionKey(r));
foreach (var group in mailGroups)
{
var description = GetMailActionDescription(group.Key, group.ToList());
if (description != null)
{
items.Add(new SynchronizationActionItem
{
AccountId = accountId,
AccountName = accountName,
Description = description
});
}
}
// Handle folder action requests individually
var folderRequests = requests.OfType<IFolderActionRequest>();
foreach (var folderRequest in folderRequests)
{
var description = GetFolderActionDescription(folderRequest);
if (description != null)
{
items.Add(new SynchronizationActionItem
{
AccountId = accountId,
AccountName = accountName,
Description = description
});
}
}
return items;
}
/// <summary>
/// Returns a key that differentiates MarkRead vs MarkUnread, Flag vs Unflag, Archive vs Unarchive.
/// </summary>
private static string GetMailActionKey(IMailActionRequest request)
{
return request switch
{
MarkReadRequest r => r.IsRead ? "MarkRead" : "MarkUnread",
ChangeFlagRequest r => r.IsFlagged ? "SetFlag" : "ClearFlag",
ArchiveRequest r => r.IsArchiving ? "Archive" : "Unarchive",
_ => request.Operation.ToString()
};
}
private static string GetMailActionDescription(string actionKey, List<IMailActionRequest> requests)
{
int count = requests.Count;
return actionKey switch
{
"MarkRead" => string.Format(Translator.SyncAction_MarkingAsRead, count),
"MarkUnread" => string.Format(Translator.SyncAction_MarkingAsUnread, count),
"Delete" => string.Format(Translator.SyncAction_Deleting, count),
"Move" => string.Format(Translator.SyncAction_Moving, count),
"Archive" => string.Format(Translator.SyncAction_Archiving, count),
"Unarchive" => string.Format(Translator.SyncAction_Unarchiving, count),
"SetFlag" => string.Format(Translator.SyncAction_SettingFlag, count),
"ClearFlag" => string.Format(Translator.SyncAction_ClearingFlag, count),
"CreateDraft" => Translator.SyncAction_CreatingDraft,
"Send" => Translator.SyncAction_SendingMail,
"MoveToFocused" => string.Format(Translator.SyncAction_MovingToFocused, count),
"AlwaysMoveTo" => string.Format(Translator.SyncAction_Moving, count),
_ => null
};
}
private static string GetFolderActionDescription(IFolderActionRequest request)
{
return request switch
{
RenameFolderRequest => Translator.SyncAction_RenamingFolder,
EmptyFolderRequest => Translator.SyncAction_EmptyingFolder,
MarkFolderAsReadRequest => Translator.SyncAction_MarkingFolderAsRead,
_ => null
};
}
}
+39 -9
View File
@@ -13,9 +13,11 @@ using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Helpers;
using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Mail; using Wino.Core.Requests.Mail;
using Wino.Messaging.Server; using Wino.Messaging.Server;
using Wino.Messaging.UI;
namespace Wino.Core.Services; namespace Wino.Core.Services;
@@ -24,14 +26,17 @@ public class WinoRequestDelegator : IWinoRequestDelegator
private readonly IWinoRequestProcessor _winoRequestProcessor; private readonly IWinoRequestProcessor _winoRequestProcessor;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService;
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor, public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
IFolderService folderService, IFolderService folderService,
IMailDialogService dialogService) IMailDialogService dialogService,
IAccountService accountService)
{ {
_winoRequestProcessor = winoRequestProcessor; _winoRequestProcessor = winoRequestProcessor;
_folderService = folderService; _folderService = folderService;
_dialogService = dialogService; _dialogService = dialogService;
_accountService = accountService;
} }
public async Task ExecuteAsync(MailOperationPreperationRequest request) public async Task ExecuteAsync(MailOperationPreperationRequest request)
@@ -82,14 +87,20 @@ public class WinoRequestDelegator : IWinoRequestDelegator
var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id); var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id);
// Queue requests for each account and start synchronization. // Queue requests for each account and start synchronization.
foreach (var accountId in accountIds) foreach (var accountGroup in accountIds)
{ {
foreach (var accountRequest in accountId) foreach (var accountRequest in accountGroup)
{ {
await QueueRequestAsync(accountRequest, accountId.Key); await QueueRequestAsync(accountRequest, accountGroup.Key);
} }
await QueueSynchronizationAsync(accountId.Key); var account = accountGroup.First().Item.AssignedAccount;
var actionItems = SynchronizationActionHelper.CreateActionItems(accountGroup, accountGroup.Key, account.Name);
if (actionItems.Count > 0)
WeakReferenceMessenger.Default.Send(new SynchronizationActionsAdded(accountGroup.Key, account.Name, actionItems));
await QueueSynchronizationAsync(accountGroup.Key);
} }
} }
@@ -117,23 +128,28 @@ public class WinoRequestDelegator : IWinoRequestDelegator
if (request == null) return; if (request == null) return;
await QueueRequestAsync(request, accountId); await QueueRequestAsync(request, accountId);
await SendSyncActionsAddedAsync([request], accountId);
await QueueSynchronizationAsync(accountId); await QueueSynchronizationAsync(accountId);
} }
public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest) public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest)
{ {
var request = new CreateDraftRequest(draftPreperationRequest); var request = new CreateDraftRequest(draftPreperationRequest);
var accountId = draftPreperationRequest.Account.Id;
await QueueRequestAsync(request, draftPreperationRequest.Account.Id); await QueueRequestAsync(request, accountId);
await QueueSynchronizationAsync(draftPreperationRequest.Account.Id); await SendSyncActionsAddedAsync([request], accountId, draftPreperationRequest.Account.Name);
await QueueSynchronizationAsync(accountId);
} }
public async Task ExecuteAsync(SendDraftPreparationRequest sendDraftPreperationRequest) public async Task ExecuteAsync(SendDraftPreparationRequest sendDraftPreperationRequest)
{ {
var request = new SendDraftRequest(sendDraftPreperationRequest); var request = new SendDraftRequest(sendDraftPreperationRequest);
var account = sendDraftPreperationRequest.MailItem.AssignedAccount;
await QueueRequestAsync(request, sendDraftPreperationRequest.MailItem.AssignedAccount.Id); await QueueRequestAsync(request, account.Id);
await QueueSynchronizationAsync(sendDraftPreperationRequest.MailItem.AssignedAccount.Id); await SendSyncActionsAddedAsync([request], account.Id, account.Name);
await QueueSynchronizationAsync(account.Id);
} }
public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest) public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
@@ -187,6 +203,20 @@ public class WinoRequestDelegator : IWinoRequestDelegator
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task SendSyncActionsAddedAsync(IEnumerable<IRequestBase> requests, Guid accountId, string accountName = null)
{
if (accountName == null)
{
var account = await _accountService.GetAccountAsync(accountId);
accountName = account?.Name ?? string.Empty;
}
var actionItems = SynchronizationActionHelper.CreateActionItems(requests, accountId, accountName);
if (actionItems.Count > 0)
WeakReferenceMessenger.Default.Send(new SynchronizationActionsAdded(accountId, accountName, actionItems));
}
private Task QueueCalendarSynchronizationAsync(Guid accountId) private Task QueueCalendarSynchronizationAsync(Guid accountId)
{ {
var options = new CalendarSynchronizationOptions() var options = new CalendarSynchronizationOptions()
@@ -215,6 +215,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false); await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false);
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
PublishUnreadItemChanges(); PublishUnreadItemChanges();
// Execute request sync options should be re-calculated after execution. // Execute request sync options should be re-calculated after execution.
@@ -399,6 +401,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false); await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false);
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
// Let servers to finish their job. Sometimes the servers don't respond immediately. // Let servers to finish their job. Sometimes the servers don't respond immediately.
shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0); shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0);
+54 -2
View File
@@ -9,6 +9,7 @@
xmlns:local="using:Wino.Mail.WinUI" xmlns:local="using:Wino.Mail.WinUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:notifyicon="using:H.NotifyIcon" xmlns:notifyicon="using:H.NotifyIcon"
xmlns:syncModels="using:Wino.Core.Domain.Models.Synchronization"
xmlns:winuiex="using:WinUIEx" xmlns:winuiex="using:WinUIEx"
Title="ShellWindow" Title="ShellWindow"
mc:Ignorable="d"> mc:Ignorable="d">
@@ -34,7 +35,58 @@
IsPaneToggleButtonVisible="True" IsPaneToggleButtonVisible="True"
PaneToggleRequested="PaneButtonClicked"> PaneToggleRequested="PaneButtonClicked">
<TitleBar.RightHeader> <TitleBar.RightHeader>
<Grid> <StackPanel Orientation="Horizontal" Spacing="8">
<!-- Sync Status Button -->
<Button
x:Name="SyncStatusButton"
Padding="8,4"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Visibility="Collapsed">
<StackPanel Orientation="Horizontal" Spacing="6">
<ProgressRing
Width="16"
Height="16"
IsActive="True" />
<TextBlock
x:Name="SyncStatusText"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
</StackPanel>
<Button.Flyout>
<Flyout x:Name="SyncStatusFlyout" Placement="Bottom">
<ItemsRepeater x:Name="SyncActionsRepeater" ItemsSource="{x:Bind SyncActionItems}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="syncModels:SynchronizationActionItem">
<Grid
MinWidth="280"
MaxWidth="400"
Padding="0,6"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ProgressRing
Width="16"
Height="16"
IsActive="True" />
<StackPanel Grid.Column="1">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind AccountName}" />
<TextBlock Text="{x:Bind Description}" TextWrapping="Wrap" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Flyout>
</Button.Flyout>
</Button>
<!-- Mail/Calendar Mode Switcher -->
<controls:Segmented x:Name="AppModeSegmentedControl" SelectionChanged="SegmentedChanged"> <controls:Segmented x:Name="AppModeSegmentedControl" SelectionChanged="SegmentedChanged">
<controls:SegmentedItem> <controls:SegmentedItem>
<controls:SegmentedItem.Icon> <controls:SegmentedItem.Icon>
@@ -53,7 +105,7 @@
</controls:SegmentedItem.Icon> </controls:SegmentedItem.Icon>
</controls:SegmentedItem> </controls:SegmentedItem>
</controls:Segmented> </controls:Segmented>
</Grid> </StackPanel>
</TitleBar.RightHeader> </TitleBar.RightHeader>
</TitleBar> </TitleBar>
+55 -1
View File
@@ -1,4 +1,6 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input; using System.Windows.Input;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
@@ -7,7 +9,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Windows.UI; using Windows.UI;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Interfaces;
using Wino.Messaging.Client.Shell; using Wino.Messaging.Client.Shell;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -16,7 +20,11 @@ using WinUIEx;
namespace Wino.Mail.WinUI; namespace Wino.Mail.WinUI;
public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient<ApplicationThemeChanged>, IRecipient<TitleBarShellContentUpdated> public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
IRecipient<ApplicationThemeChanged>,
IRecipient<TitleBarShellContentUpdated>,
IRecipient<SynchronizationActionsAdded>,
IRecipient<SynchronizationActionsCompleted>
{ {
public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService<IStatePersistanceService>() ?? throw new Exception("StatePersistanceService not registered in DI container."); public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService<IStatePersistanceService>() ?? throw new Exception("StatePersistanceService not registered in DI container.");
public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService<IPreferencesService>() ?? throw new Exception("PreferencesService not registered in DI container."); public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService<IPreferencesService>() ?? throw new Exception("PreferencesService not registered in DI container.");
@@ -25,6 +33,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
public ICommand ShowWinoCommand { get; set; } public ICommand ShowWinoCommand { get; set; }
public ICommand ExitWinoCommand { get; set; } public ICommand ExitWinoCommand { get; set; }
public ObservableCollection<SynchronizationActionItem> SyncActionItems { get; } = new();
public ShellWindow() public ShellWindow()
{ {
RegisterRecipients(); RegisterRecipients();
@@ -172,6 +182,46 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
UpdateTitleBarColors(message.IsUnderlyingThemeDark); UpdateTitleBarColors(message.IsUnderlyingThemeDark);
} }
public void Receive(SynchronizationActionsAdded message)
{
DispatcherQueue.TryEnqueue(() =>
{
foreach (var action in message.Actions)
SyncActionItems.Add(action);
UpdateSyncStatusVisibility();
});
}
public void Receive(SynchronizationActionsCompleted message)
{
DispatcherQueue.TryEnqueue(() =>
{
var toRemove = SyncActionItems.Where(a => a.AccountId == message.AccountId).ToList();
foreach (var item in toRemove)
SyncActionItems.Remove(item);
UpdateSyncStatusVisibility();
});
}
private void UpdateSyncStatusVisibility()
{
SyncStatusButton.Visibility = SyncActionItems.Any()
? Visibility.Visible
: Visibility.Collapsed;
var distinctAccounts = SyncActionItems.Select(a => a.AccountId).Distinct().Count();
SyncStatusText.Text = distinctAccounts switch
{
0 => string.Empty,
1 => string.Format(Translator.SyncAction_SynchronizingAccount, SyncActionItems.First().AccountName),
_ => string.Format(Translator.SyncAction_SynchronizingAccounts, distinctAccounts)
};
}
private void UpdateTitleBarColors(bool isDarkTheme) private void UpdateTitleBarColors(bool isDarkTheme)
{ {
DispatcherQueue.TryEnqueue(() => DispatcherQueue.TryEnqueue(() =>
@@ -250,12 +300,16 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
{ {
WeakReferenceMessenger.Default.Register<TitleBarShellContentUpdated>(this); WeakReferenceMessenger.Default.Register<TitleBarShellContentUpdated>(this);
WeakReferenceMessenger.Default.Register<ApplicationThemeChanged>(this); WeakReferenceMessenger.Default.Register<ApplicationThemeChanged>(this);
WeakReferenceMessenger.Default.Register<SynchronizationActionsAdded>(this);
WeakReferenceMessenger.Default.Register<SynchronizationActionsCompleted>(this);
} }
private void UnregisterRecipients() private void UnregisterRecipients()
{ {
WeakReferenceMessenger.Default.Unregister<TitleBarShellContentUpdated>(this); WeakReferenceMessenger.Default.Unregister<TitleBarShellContentUpdated>(this);
WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this); WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this);
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsAdded>(this);
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsCompleted>(this);
} }
private void SegmentedChanged(object sender, SelectionChangedEventArgs e) private void SegmentedChanged(object sender, SelectionChangedEventArgs e)
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Messaging.UI;
/// <summary>
/// Sent when synchronization requests are queued for an account.
/// Contains grouped action descriptions for the UI to display.
/// </summary>
public record SynchronizationActionsAdded(
Guid AccountId,
string AccountName,
List<SynchronizationActionItem> Actions) : UIMessageBase<SynchronizationActionsAdded>;
@@ -0,0 +1,9 @@
using System;
namespace Wino.Messaging.UI;
/// <summary>
/// Sent when all queued synchronization requests for an account have been executed.
/// </summary>
public record SynchronizationActionsCompleted(
Guid AccountId) : UIMessageBase<SynchronizationActionsCompleted>;