diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizationActionItem.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizationActionItem.cs
new file mode 100644
index 00000000..655e8918
--- /dev/null
+++ b/Wino.Core.Domain/Models/Synchronization/SynchronizationActionItem.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Wino.Core.Domain.Models.Synchronization;
+
+///
+/// Represents a single grouped synchronization action displayed in the sync status flyout.
+/// For example: "Deleting 3 mail(s)" or "Marking folder as read".
+///
+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; }
+}
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index 613221ac..4ba0ed11 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -84,6 +84,22 @@
"Buttons_Yes": "Yes",
"Sync_SynchronizingFolder": "Synchronizing {0} {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",
"CalendarDisplayOptions_Color": "Color",
"CalendarDisplayOptions_Expand": "Expand",
diff --git a/Wino.Core/Helpers/SynchronizationActionHelper.cs b/Wino.Core/Helpers/SynchronizationActionHelper.cs
new file mode 100644
index 00000000..87985236
--- /dev/null
+++ b/Wino.Core/Helpers/SynchronizationActionHelper.cs
@@ -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;
+
+///
+/// Converts queued synchronization requests into user-facing action descriptions.
+///
+public static class SynchronizationActionHelper
+{
+ public static List CreateActionItems(
+ IEnumerable requests, Guid accountId, string accountName)
+ {
+ var items = new List();
+
+ // Group mail action requests by operation
+ var mailRequests = requests.OfType();
+ 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();
+ foreach (var folderRequest in folderRequests)
+ {
+ var description = GetFolderActionDescription(folderRequest);
+
+ if (description != null)
+ {
+ items.Add(new SynchronizationActionItem
+ {
+ AccountId = accountId,
+ AccountName = accountName,
+ Description = description
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Returns a key that differentiates MarkRead vs MarkUnread, Flag vs Unflag, Archive vs Unarchive.
+ ///
+ 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 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
+ };
+ }
+}
diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs
index 693826b1..35ca5edc 100644
--- a/Wino.Core/Services/WinoRequestDelegator.cs
+++ b/Wino.Core/Services/WinoRequestDelegator.cs
@@ -13,9 +13,11 @@ using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
+using Wino.Core.Helpers;
using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Mail;
using Wino.Messaging.Server;
+using Wino.Messaging.UI;
namespace Wino.Core.Services;
@@ -24,14 +26,17 @@ public class WinoRequestDelegator : IWinoRequestDelegator
private readonly IWinoRequestProcessor _winoRequestProcessor;
private readonly IFolderService _folderService;
private readonly IMailDialogService _dialogService;
+ private readonly IAccountService _accountService;
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
IFolderService folderService,
- IMailDialogService dialogService)
+ IMailDialogService dialogService,
+ IAccountService accountService)
{
_winoRequestProcessor = winoRequestProcessor;
_folderService = folderService;
_dialogService = dialogService;
+ _accountService = accountService;
}
public async Task ExecuteAsync(MailOperationPreperationRequest request)
@@ -82,14 +87,20 @@ public class WinoRequestDelegator : IWinoRequestDelegator
var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id);
// 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;
await QueueRequestAsync(request, accountId);
+ await SendSyncActionsAddedAsync([request], accountId);
await QueueSynchronizationAsync(accountId);
}
public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest)
{
var request = new CreateDraftRequest(draftPreperationRequest);
+ var accountId = draftPreperationRequest.Account.Id;
- await QueueRequestAsync(request, draftPreperationRequest.Account.Id);
- await QueueSynchronizationAsync(draftPreperationRequest.Account.Id);
+ await QueueRequestAsync(request, accountId);
+ await SendSyncActionsAddedAsync([request], accountId, draftPreperationRequest.Account.Name);
+ await QueueSynchronizationAsync(accountId);
}
public async Task ExecuteAsync(SendDraftPreparationRequest sendDraftPreperationRequest)
{
var request = new SendDraftRequest(sendDraftPreperationRequest);
+ var account = sendDraftPreperationRequest.MailItem.AssignedAccount;
- await QueueRequestAsync(request, sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
- await QueueSynchronizationAsync(sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
+ await QueueRequestAsync(request, account.Id);
+ await SendSyncActionsAddedAsync([request], account.Id, account.Name);
+ await QueueSynchronizationAsync(account.Id);
}
public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
@@ -187,6 +203,20 @@ public class WinoRequestDelegator : IWinoRequestDelegator
return Task.CompletedTask;
}
+ private async Task SendSyncActionsAddedAsync(IEnumerable 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)
{
var options = new CalendarSynchronizationOptions()
diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs
index 432ac1e6..ecd96502 100644
--- a/Wino.Core/Synchronizers/WinoSynchronizer.cs
+++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs
@@ -215,6 +215,8 @@ public abstract class WinoSynchronizer a.ResynchronizationDelay > 0);
diff --git a/Wino.Mail.WinUI/ShellWindow.xaml b/Wino.Mail.WinUI/ShellWindow.xaml
index 3ae6a173..23fa2770 100644
--- a/Wino.Mail.WinUI/ShellWindow.xaml
+++ b/Wino.Mail.WinUI/ShellWindow.xaml
@@ -9,6 +9,7 @@
xmlns:local="using:Wino.Mail.WinUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:notifyicon="using:H.NotifyIcon"
+ xmlns:syncModels="using:Wino.Core.Domain.Models.Synchronization"
xmlns:winuiex="using:WinUIEx"
Title="ShellWindow"
mc:Ignorable="d">
@@ -34,7 +35,58 @@
IsPaneToggleButtonVisible="True"
PaneToggleRequested="PaneButtonClicked">
-
+
+
+
+
@@ -53,7 +105,7 @@
-
+
diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs
index 2d47b747..2c7f6b9d 100644
--- a/Wino.Mail.WinUI/ShellWindow.xaml.cs
+++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.ObjectModel;
+using System.Linq;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
@@ -7,7 +9,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.UI;
+using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.WinUI.Interfaces;
using Wino.Messaging.Client.Shell;
using Wino.Messaging.UI;
@@ -16,7 +20,11 @@ using WinUIEx;
namespace Wino.Mail.WinUI;
-public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient, IRecipient
+public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
+ IRecipient,
+ IRecipient,
+ IRecipient,
+ IRecipient
{
public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("StatePersistanceService not registered in DI container.");
public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService() ?? 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 ExitWinoCommand { get; set; }
+ public ObservableCollection SyncActionItems { get; } = new();
+
public ShellWindow()
{
RegisterRecipients();
@@ -172,6 +182,46 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
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)
{
DispatcherQueue.TryEnqueue(() =>
@@ -250,12 +300,16 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
{
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
}
private void UnregisterRecipients()
{
WeakReferenceMessenger.Default.Unregister(this);
WeakReferenceMessenger.Default.Unregister(this);
+ WeakReferenceMessenger.Default.Unregister(this);
+ WeakReferenceMessenger.Default.Unregister(this);
}
private void SegmentedChanged(object sender, SelectionChangedEventArgs e)
diff --git a/Wino.Messages/UI/SynchronizationActionsAdded.cs b/Wino.Messages/UI/SynchronizationActionsAdded.cs
new file mode 100644
index 00000000..7655d884
--- /dev/null
+++ b/Wino.Messages/UI/SynchronizationActionsAdded.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using Wino.Core.Domain.Models.Synchronization;
+
+namespace Wino.Messaging.UI;
+
+///
+/// Sent when synchronization requests are queued for an account.
+/// Contains grouped action descriptions for the UI to display.
+///
+public record SynchronizationActionsAdded(
+ Guid AccountId,
+ string AccountName,
+ List Actions) : UIMessageBase;
diff --git a/Wino.Messages/UI/SynchronizationActionsCompleted.cs b/Wino.Messages/UI/SynchronizationActionsCompleted.cs
new file mode 100644
index 00000000..e5a04b39
--- /dev/null
+++ b/Wino.Messages/UI/SynchronizationActionsCompleted.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace Wino.Messaging.UI;
+
+///
+/// Sent when all queued synchronization requests for an account have been executed.
+///
+public record SynchronizationActionsCompleted(
+ Guid AccountId) : UIMessageBase;