Toast notification navigations and some improvements for list view selection.

This commit is contained in:
Burak Kaan Köse
2025-11-12 15:44:43 +01:00
parent 16e06af76f
commit 777219ab87
14 changed files with 300 additions and 190 deletions
@@ -10,7 +10,7 @@ public interface INotificationBuilder
/// <summary>
/// Creates toast notifications for new mails.
/// </summary>
Task CreateNotificationsAsync(Guid inboxFolderId, IEnumerable<MailCopy> newMailItems);
Task CreateNotificationsAsync(IEnumerable<MailCopy> newMailItems);
/// <summary>
/// Gets the unread Inbox messages for each account and updates the taskbar icon.
@@ -18,11 +18,6 @@ public interface INotificationBuilder
/// <returns></returns>
Task UpdateTaskbarIconBadgeAsync();
/// <summary>
/// Creates test notification for test purposes.
/// </summary>
Task CreateTestNotificationAsync(string title, string message);
/// <summary>
/// Removes the toast notification for a specific mail by unique id.
/// </summary>
@@ -19,9 +19,10 @@ public interface ISynchronizationManager
/// <summary>
/// Initializes the SynchronizationManager with required dependencies.
/// </summary>
Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
IImapTestService imapTestService,
IAccountService accountService,
INotificationBuilder notificationBuilder,
IAuthenticationProvider authenticationProvider);
/// <summary>
@@ -32,7 +33,7 @@ public interface ISynchronizationManager
/// <summary>
/// Starts a new mail synchronization for the given account.
/// </summary>
Task<MailSynchronizationResult> SynchronizeMailAsync(MailSynchronizationOptions options,
Task<MailSynchronizationResult> SynchronizeMailAsync(MailSynchronizationOptions options,
CancellationToken cancellationToken = default);
/// <summary>
@@ -53,25 +54,25 @@ public interface ISynchronizationManager
/// <summary>
/// Handles folder synchronization for the given account.
/// </summary>
Task<MailSynchronizationResult> SynchronizeFoldersAsync(Guid accountId,
Task<MailSynchronizationResult> SynchronizeFoldersAsync(Guid accountId,
CancellationToken cancellationToken = default);
/// <summary>
/// Handles alias synchronization for the given account.
/// </summary>
Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
CancellationToken cancellationToken = default);
/// <summary>
/// Handles profile synchronization for the given account.
/// </summary>
Task<MailSynchronizationResult> SynchronizeProfileAsync(Guid accountId,
Task<MailSynchronizationResult> SynchronizeProfileAsync(Guid accountId,
CancellationToken cancellationToken = default);
/// <summary>
/// Downloads a MIME message for the given mail item.
/// </summary>
Task<string> DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId,
Task<string> DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId,
CancellationToken cancellationToken = default);
/// <summary>
@@ -97,7 +98,7 @@ public interface ISynchronizationManager
/// <summary>
/// Handles OAuth authentication for the specified provider.
/// </summary>
Task<TokenInformationEx> HandleAuthorizationAsync(MailProviderType providerType,
MailAccount account = null,
Task<TokenInformationEx> HandleAuthorizationAsync(MailProviderType providerType,
MailAccount account = null,
bool proposeCopyAuthorizationURL = false);
}
}
@@ -10,7 +10,7 @@ public class ListItemComparer : IComparer<object>
public int Compare(object x, object y)
{
if (x is IMailListItemSorting xSorting && y is IMailListItemSorting ySorting)
return SortByName ? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(xSorting.SortingDate, ySorting.SortingDate);
return SortByName ? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(ySorting.SortingDate, xSorting.SortingDate);
else if (x is MailCopy xMail && y is MailCopy yMail)
return SortByName ? string.Compare(xMail.FromName, yMail.FromName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(yMail.CreationDate, xMail.CreationDate);
else if (x is DateTime dateX && y is DateTime dateY)
+75 -91
View File
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Toolkit.Uwp.Notifications;
@@ -16,8 +15,6 @@ using Wino.Messaging.UI;
namespace Wino.Core.WinUI.Services;
// TODO: Refactor this thing. It's garbage.
public class NotificationBuilder : INotificationBuilder
{
private readonly IUnderlyingThemeService _underlyingThemeService;
@@ -44,12 +41,43 @@ public class NotificationBuilder : INotificationBuilder
});
}
public async Task CreateNotificationsAsync(Guid inboxFolderId, IEnumerable<MailCopy> downloadedMailItems)
public async Task CreateNotificationsAsync(IEnumerable<MailCopy> downloadedMailItems)
{
var mailCount = downloadedMailItems.Count();
try
{
// Filter mails to only include Inbox folder items
var inboxMailItems = new List<MailCopy>();
foreach (var item in downloadedMailItems)
{
var mailItem = await _mailService.GetSingleMailItemAsync(item.UniqueId);
//if (mailItem == null || mailItem.AssignedFolder == null)
// continue;
//// Only create notifications for Inbox folder mails
//if (mailItem.AssignedFolder.SpecialFolderType != SpecialFolderType.Inbox)
// continue;
//// Skip folders with synchronization disabled
//if (!mailItem.AssignedFolder.IsSynchronizationEnabled)
// continue;
//// Skip already read mails
//if (mailItem.IsRead)
//{
// RemoveNotification(mailItem.UniqueId);
// continue;
//}
inboxMailItems.Add(mailItem);
}
var mailCount = inboxMailItems.Count;
if (mailCount == 0)
return;
// If there are more than 3 mails, just display 1 general toast.
if (mailCount > 3)
{
@@ -69,66 +97,9 @@ public class NotificationBuilder : INotificationBuilder
}
else
{
var validItems = new List<MailCopy>();
// Fetch mails again to fill up assigned folder data and latest statuses.
// They've been marked as read by executing synchronizer tasks until inital sync finishes.
foreach (var item in downloadedMailItems)
foreach (var mailItem in inboxMailItems)
{
var mailItem = await _mailService.GetSingleMailItemAsync(item.UniqueId);
if (mailItem != null && mailItem.AssignedFolder != null)
{
validItems.Add(mailItem);
}
}
foreach (var mailItem in validItems)
{
if (mailItem.IsRead)
{
// Remove the notification for a specific mail if it exists
ToastNotificationManager.History.Remove(mailItem.UniqueId.ToString());
continue;
}
var builder = new ToastContentBuilder();
builder.SetToastScenario(ToastScenario.Default);
var avatarThumbnail = await _thumbnailService.GetThumbnailAsync(mailItem.FromAddress, awaitLoad: true);
if (!string.IsNullOrEmpty(avatarThumbnail))
{
var tempFile = await Windows.Storage.ApplicationData.Current.TemporaryFolder.CreateFileAsync($"{Guid.NewGuid()}.png", Windows.Storage.CreationCollisionOption.ReplaceExisting);
await using (var stream = await tempFile.OpenStreamForWriteAsync())
{
var bytes = Convert.FromBase64String(avatarThumbnail);
await stream.WriteAsync(bytes);
}
builder.AddAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), hintCrop: ToastGenericAppLogoCrop.Default);
}
// Override system notification timetamp with received date of the mail.
// It may create confusion for some users, but still it's the truth...
builder.AddCustomTimeStamp(mailItem.CreationDate.ToLocalTime());
builder.AddText(mailItem.FromName);
builder.AddText(mailItem.Subject);
builder.AddText(mailItem.PreviewText);
builder.AddArgument(Constants.ToastMailUniqueIdKey, mailItem.UniqueId.ToString());
builder.AddArgument(Constants.ToastActionKey, MailOperation.Navigate);
builder.AddButton(GetMarkAsReadButton(mailItem.UniqueId));
builder.AddButton(GetDeleteButton(mailItem.UniqueId));
builder.AddButton(GetArchiveButton(mailItem.UniqueId));
builder.AddAudio(new ToastAudio()
{
Src = new Uri("ms-winsoundevent:Notification.Mail")
});
// Use UniqueId as tag to allow removal
builder.Show(toast => toast.Tag = mailItem.UniqueId.ToString());
await CreateSingleNotificationAsync(mailItem);
}
await UpdateTaskbarIconBadgeAsync();
@@ -140,6 +111,45 @@ public class NotificationBuilder : INotificationBuilder
}
}
private async Task CreateSingleNotificationAsync(MailCopy mailItem)
{
var builder = new ToastContentBuilder();
builder.SetToastScenario(ToastScenario.Default);
var avatarThumbnail = await _thumbnailService.GetThumbnailAsync(mailItem.FromAddress, awaitLoad: true);
if (!string.IsNullOrEmpty(avatarThumbnail))
{
var tempFile = await Windows.Storage.ApplicationData.Current.TemporaryFolder.CreateFileAsync($"{Guid.NewGuid()}.png", Windows.Storage.CreationCollisionOption.ReplaceExisting);
await using (var stream = await tempFile.OpenStreamForWriteAsync())
{
var bytes = Convert.FromBase64String(avatarThumbnail);
await stream.WriteAsync(bytes);
}
builder.AddAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), hintCrop: ToastGenericAppLogoCrop.Default);
}
// Override system notification timestamp with received date of the mail.
builder.AddCustomTimeStamp(mailItem.CreationDate.ToLocalTime());
builder.AddText(mailItem.FromName);
builder.AddText(mailItem.Subject);
builder.AddText(mailItem.PreviewText);
builder.AddArgument(Constants.ToastMailUniqueIdKey, mailItem.UniqueId.ToString());
builder.AddArgument(Constants.ToastActionKey, MailOperation.Navigate);
builder.AddButton(GetMarkAsReadButton(mailItem.UniqueId));
builder.AddButton(GetDeleteButton(mailItem.UniqueId));
builder.AddButton(GetArchiveButton(mailItem.UniqueId));
builder.AddAudio(new ToastAudio()
{
Src = new Uri("ms-winsoundevent:Notification.Mail")
});
// Use UniqueId as tag to allow removal
builder.Show(toast => toast.Tag = mailItem.UniqueId.ToString());
}
private ToastButton GetDismissButton()
=> new ToastButton()
.SetDismissActivation()
@@ -216,32 +226,6 @@ public class NotificationBuilder : INotificationBuilder
}
}
public async Task CreateTestNotificationAsync(string title, string message)
{
// with args test.
//await CreateNotificationsAsync(Guid.Parse("28c3c39b-7147-4de3-b209-949bd19eede6"), new List<IMailItem>()
//{
// new MailCopy()
// {
// Subject = "test subject",
// PreviewText = "preview html",
// CreationDate = DateTime.UtcNow,
// FromAddress = "bkaankose@outlook.com",
// Id = "AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AnMdP0zg8wkS_Ib2Eeh80LAAGq91I3QAA",
// }
//});
//var builder = new ToastContentBuilder();
//builder.SetToastScenario(ToastScenario.Default);
//builder.AddText(title);
//builder.AddText(message);
//builder.Show();
//await Task.CompletedTask;
}
public void RemoveNotification(Guid mailUniqueId)
{
try
+38 -32
View File
@@ -13,7 +13,6 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication;
using Wino.Core.Domain.Models.Connectivity;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.Server;
namespace Wino.Core.Services;
@@ -30,11 +29,12 @@ public class SynchronizationManager : ISynchronizationManager
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private readonly ILogger _logger = Log.ForContext<SynchronizationManager>();
private ISynchronizerFactory _synchronizerFactory;
private SynchronizerFactory _concreteSynchronizerFactory;
private IImapTestService _imapTestService;
private IAccountService _accountService;
private IAuthenticationProvider _authenticationProvider;
private INotificationBuilder _notificationBuilder;
private bool _isInitialized = false;
private SynchronizationManager() { }
@@ -47,38 +47,40 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="imapTestService">Service for testing IMAP connectivity</param>
/// <param name="accountService">Service for account operations</param>
/// <param name="authenticationProvider">Provider for OAuth authentication</param>
public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
IImapTestService imapTestService,
IAccountService accountService,
INotificationBuilder notificationBuilder,
IAuthenticationProvider authenticationProvider)
{
await _initializationSemaphore.WaitAsync();
try
{
if (_isInitialized) return;
_synchronizerFactory = synchronizerFactory ?? throw new ArgumentNullException(nameof(synchronizerFactory));
_concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory ?? throw new ArgumentException("SynchronizerFactory must be the concrete implementation");
_imapTestService = imapTestService ?? throw new ArgumentNullException(nameof(imapTestService));
_accountService = accountService ?? throw new ArgumentNullException(nameof(accountService));
_authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));
_notificationBuilder = notificationBuilder ?? throw new ArgumentNullException(nameof(notificationBuilder));
// Get all accounts and create synchronizers for them
var accounts = await _accountService.GetAccountsAsync();
foreach (var account in accounts)
{
try
{
var synchronizer = _concreteSynchronizerFactory.CreateNewSynchronizer(account);
_synchronizerCache.TryAdd(account.Id, synchronizer);
_logger.Information("Created synchronizer for account {AccountName} ({AccountId})",
_logger.Information("Created synchronizer for account {AccountName} ({AccountId})",
account.Name, account.Id);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})",
_logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})",
account.Name, account.Id);
}
}
@@ -104,12 +106,12 @@ public class SynchronizationManager : ISynchronizationManager
try
{
_logger.Information("Testing IMAP connectivity for {Server}:{Port}",
serverInformation.IncomingServer,
_logger.Information("Testing IMAP connectivity for {Server}:{Port}",
serverInformation.IncomingServer,
serverInformation.IncomingServerPort);
await _imapTestService.TestImapConnectionAsync(serverInformation, allowSSLHandshake);
_logger.Information("IMAP connectivity test successful");
return ImapConnectivityTestResults.Success();
}
@@ -117,8 +119,8 @@ public class SynchronizationManager : ISynchronizationManager
{
_logger.Warning("IMAP connectivity test requires SSL certificate confirmation");
return ImapConnectivityTestResults.CertificateUIRequired(
sslTestException.Issuer,
sslTestException.ExpirationDateString,
sslTestException.Issuer,
sslTestException.ExpirationDateString,
sslTestException.ValidFromDateString);
}
catch (ImapClientPoolException clientPoolException)
@@ -139,7 +141,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="options">Mail synchronization options</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Synchronization result</returns>
public async Task<MailSynchronizationResult> SynchronizeMailAsync(MailSynchronizationOptions options,
public async Task<MailSynchronizationResult> SynchronizeMailAsync(MailSynchronizationOptions options,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
@@ -151,16 +153,20 @@ public class SynchronizationManager : ISynchronizationManager
return MailSynchronizationResult.Failed;
}
_logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}",
_logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type);
try
{
var result = await synchronizer.SynchronizeMailsAsync(options, cancellationToken);
_logger.Information("Mail synchronization completed for account {AccountId} with state {State}",
_logger.Information("Mail synchronization completed for account {AccountId} with state {State}",
options.AccountId, result.CompletedState);
// Create notifications.
if (result.DownloadedMessages?.Any() ?? false)
await _notificationBuilder.CreateNotificationsAsync(result.DownloadedMessages);
return result;
}
catch (Exception ex)
@@ -181,7 +187,7 @@ public class SynchronizationManager : ISynchronizationManager
if (_synchronizerCache.TryGetValue(accountId, out var synchronizer))
{
return synchronizer.State == AccountSynchronizerState.Synchronizing ||
return synchronizer.State == AccountSynchronizerState.Synchronizing ||
synchronizer.State == AccountSynchronizerState.ExecutingRequests;
}
@@ -215,7 +221,7 @@ public class SynchronizationManager : ISynchronizationManager
return;
}
_logger.Debug("Queuing request {RequestType} for account {AccountId}",
_logger.Debug("Queuing request {RequestType} for account {AccountId}",
request.GetType().Name, accountId);
synchronizer.QueueRequest(request);
@@ -224,7 +230,7 @@ public class SynchronizationManager : ISynchronizationManager
{
// Trigger synchronization to execute the queued request
_logger.Debug("Triggering synchronization to execute queued request for account {AccountId}", accountId);
var synchronizationOptions = new MailSynchronizationOptions()
{
AccountId = accountId,
@@ -253,7 +259,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="accountId">Account ID to synchronize folders for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Synchronization result</returns>
public async Task<MailSynchronizationResult> SynchronizeFoldersAsync(Guid accountId,
public async Task<MailSynchronizationResult> SynchronizeFoldersAsync(Guid accountId,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
@@ -273,7 +279,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="accountId">Account ID to synchronize aliases for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Synchronization result</returns>
public async Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
public async Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
@@ -293,7 +299,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="accountId">Account ID to synchronize profile for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Synchronization result</returns>
public async Task<MailSynchronizationResult> SynchronizeProfileAsync(Guid accountId,
public async Task<MailSynchronizationResult> SynchronizeProfileAsync(Guid accountId,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
@@ -314,7 +320,7 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="accountId">Account ID that owns the mail item</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Downloaded MIME content path</returns>
public async Task<string> DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId,
public async Task<string> DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
@@ -353,15 +359,15 @@ public class SynchronizationManager : ISynchronizationManager
{
var synchronizer = _concreteSynchronizerFactory.CreateNewSynchronizer(account);
_synchronizerCache.TryAdd(account.Id, synchronizer);
_logger.Information("Created new synchronizer for account {AccountName} ({AccountId})",
_logger.Information("Created new synchronizer for account {AccountName} ({AccountId})",
account.Name, account.Id);
return Task.FromResult(synchronizer);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})",
_logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})",
account.Name, account.Id);
return Task.FromResult<IWinoSynchronizerBase>(null);
}
@@ -434,8 +440,8 @@ public class SynchronizationManager : ISynchronizationManager
/// <param name="account">Optional account to authenticate (null for initial authentication)</param>
/// <param name="proposeCopyAuthorizationURL">Whether to propose copying auth URL for Gmail</param>
/// <returns>Token information containing access token and username</returns>
public async Task<TokenInformationEx> HandleAuthorizationAsync(MailProviderType providerType,
MailAccount account = null,
public async Task<TokenInformationEx> HandleAuthorizationAsync(MailProviderType providerType,
MailAccount account = null,
bool proposeCopyAuthorizationURL = false)
{
EnsureInitialized();
@@ -485,4 +491,4 @@ public class SynchronizationManager : ISynchronizationManager
throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first.");
}
}
}
}
@@ -2,7 +2,6 @@ using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
namespace Wino.Core.Services;
@@ -24,10 +23,11 @@ public class SynchronizationManagerInitializer : IInitializeAsync
var imapTestService = _serviceProvider.GetRequiredService<IImapTestService>();
var accountService = _serviceProvider.GetRequiredService<IAccountService>();
var authenticationProvider = _serviceProvider.GetRequiredService<IAuthenticationProvider>();
var notificationBuilder = _serviceProvider.GetRequiredService<INotificationBuilder>();
// Cast to concrete type to access CreateNewSynchronizer method
var concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory;
await SynchronizationManager.Instance.InitializeAsync(concreteSynchronizerFactory, imapTestService, accountService, authenticationProvider);
await SynchronizationManager.Instance.InitializeAsync(concreteSynchronizerFactory, imapTestService, accountService, notificationBuilder, authenticationProvider);
}
}
}
@@ -217,6 +217,42 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return !string.IsNullOrEmpty(threadId) && _threadIdToItemsMap.ContainsKey(threadId);
}
/// <summary>
/// Finds a MailItemViewModel by its UniqueId, searching through all items including those inside threads.
/// </summary>
/// <param name="uniqueId">The UniqueId of the mail item to find.</param>
/// <returns>The MailItemViewModel if found, otherwise null.</returns>
public MailItemViewModel Find(Guid uniqueId)
{
// First check the cache for fast lookup
if (_uniqueIdToMailItemMap.TryGetValue(uniqueId, out var cachedMailItem))
{
return cachedMailItem;
}
// If not in cache, search through all groups
foreach (var group in _mailItemSource)
{
foreach (var item in group)
{
if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == uniqueId)
{
return mailItem;
}
else if (item is ThreadMailItemViewModel threadItem)
{
var foundInThread = threadItem.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueId);
if (foundInThread != null)
{
return foundInThread;
}
}
}
}
return null;
}
private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem)
{
UpdateUniqueIdHashes(mailItem, true);
@@ -184,7 +184,19 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
if (email.MailCopy.ThreadId != _threadId)
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'");
ThreadEmails.Add(email);
// Insert email in sorted order by CreationDate (newest first, oldest last)
var insertIndex = 0;
for (int i = 0; i < ThreadEmails.Count; i++)
{
if (ThreadEmails[i].MailCopy.CreationDate < email.MailCopy.CreationDate)
{
insertIndex = i;
break;
}
insertIndex = i + 1;
}
ThreadEmails.Insert(insertIndex, email);
// Reassign to trigger property change notifications
ThreadEmails = ThreadEmails;
}
+10 -24
View File
@@ -72,6 +72,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
private readonly IAccountService _accountService;
private readonly IMailDialogService _mailDialogService;
private readonly IMailService _mailService;
private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService;
private readonly IContextMenuItemService _contextMenuItemService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
@@ -155,6 +156,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
IMailDialogService mailDialogService,
IMailService mailService,
IStatePersistanceService statePersistenceService,
INotificationBuilder notificationBuilder,
IFolderService folderService,
IContextMenuItemService contextMenuItemService,
IWinoRequestDelegator winoRequestDelegator,
@@ -175,6 +177,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
PreferencesService = preferencesService;
ThemeService = themeService;
StatePersistenceService = statePersistenceService;
_notificationBuilder = notificationBuilder;
NavigationService = navigationService;
SelectedFilterOption = FilterOptions[0];
@@ -468,6 +471,10 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[RelayCommand]
private void SyncFolder()
{
var mails = MailCollection.SelectedItems;
_notificationBuilder.CreateNotificationsAsync(mails.Select(a => a.MailCopy));
return;
if (!CanSynchronize) return;
// Only synchronize listed folders.
@@ -710,7 +717,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
await MailCollection.RemoveAsync(removedMail);
if (nextItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true));
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem.UniqueId, ScrollToItem: true));
else if (isDeletedMailSelected)
{
// There are no next item to select, but we removed the last item which was selected.
@@ -1022,30 +1029,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
void IRecipient<MailItemNavigationRequested>.Receive(MailItemNavigationRequested message)
{
Debug.WriteLine($"Mail item navigation requested");
// Find mail item and add to selected items.
// TODO: Remove this.
MailItemViewModel navigatingMailItem = null;
ThreadMailItemViewModel threadMailItemViewModel = null;
for (int i = 0; i < 3; i++)
{
var mailContainer = MailCollection.GetMailItemContainer(message.UniqueMailId);
if (mailContainer != null)
{
navigatingMailItem = mailContainer.ItemViewModel;
threadMailItemViewModel = mailContainer.ThreadViewModel;
break;
}
}
if (threadMailItemViewModel != null)
threadMailItemViewModel.IsThreadExpanded = true;
if (navigatingMailItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(navigatingMailItem, message.ScrollToItem));
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(message.UniqueMailId, message.ScrollToItem));
}
#endregion
@@ -1,8 +1,8 @@
using Wino.Mail.ViewModels.Data;
using System;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// When listing view model manipulated the selected mail container in the UI.
/// </summary>
public record SelectMailItemContainerEvent(MailItemViewModel SelectedMailViewModel, bool ScrollToItem = false);
public record SelectMailItemContainerEvent(Guid MailUniqueId, bool ScrollToItem = false);
+42
View File
@@ -2,13 +2,16 @@
using System.Text;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.Windows.AppLifecycle;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.WinUI;
using Wino.Core.WinUI.Interfaces;
using Wino.Mail.Services;
using Wino.Mail.ViewModels;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Server;
using Wino.Services;
namespace Wino.Mail.WinUI;
@@ -22,10 +25,45 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
InitializeComponent();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
ToastNotificationManagerCompat.OnActivated += ToastActivationHandler;
RegisterRecipients();
}
private async void ToastActivationHandler(ToastNotificationActivatedEventArgsCompat e)
{
// If we weren't launched by an app, launch our window like normal.
// Otherwise if launched by a toast, our OnActivated callback will be triggered.
var toastArgs = ToastArguments.Parse(e.Argument);
var mailService = Services.GetRequiredService<IMailService>();
var accountService = Services.GetRequiredService<IAccountService>();
if (Guid.TryParse(toastArgs[Constants.ToastMailUniqueIdKey], out Guid mailItemUniqueId))
{
var account = await mailService.GetMailAccountByUniqueIdAsync(mailItemUniqueId).ConfigureAwait(false);
if (account == null) return;
var mailItem = await mailService.GetSingleMailItemAsync(mailItemUniqueId).ConfigureAwait(false);
if (mailItem == null) return;
var message = new AccountMenuItemExtended(mailItem.AssignedFolder.Id, mailItem);
// Delegate this event to LaunchProtocolService so app shell can pick it up on launch if app doesn't work.
var launchProtocolService = Services.GetRequiredService<ILaunchProtocolService>();
launchProtocolService.LaunchParameter = message;
// Send the messsage anyways. Launch protocol service will be ignored if the message is picked up by subscriber shell.
WeakReferenceMessenger.Default.Send(message);
}
if (ToastNotificationManagerCompat.WasCurrentProcessToastActivated())
{
MainWindow.BringToFront();
}
}
#region Dependency Injection
@@ -80,9 +118,13 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
}
private bool IsStartupTaskLaunch() => AppInstance.GetCurrent().GetActivatedEventArgs()?.Kind == ExtendedActivationKind.StartupTask;
public bool IsAppRunning() => MainWindow != null;
protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
// If it's toast activation, compat will handle it.
if (IsAppRunning()) return;
// TODO: Check app relaunch mutex before loading anything.
// Initialize NewThemeService first to get backdrop settings before creating window
@@ -177,8 +177,22 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
if (innerListViewControl != null)
{
innerListView = innerListViewControl;
// TODO: What if it wasn't realized in the thread?
itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
// Item thread has been found but container is not realized yet.
// This could happen when Sent item passed to navigate for Inbox or vice-versa.
// Ideally, we should select the first UniqueId match in the thread in this case.
if (itemContainer == null)
{
var realThreadItem = innerListViewControl.Items.Cast<MailItemViewModel>().FirstOrDefault(a => a.UniqueId == mailItemViewModel.MailCopy.UniqueId);
if (realThreadItem != null)
{
itemContainer = innerListViewControl.ContainerFromItem(realThreadItem) as WinoMailItemViewModelListViewItem;
}
}
}
}
break;
+16 -1
View File
@@ -5,8 +5,10 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
IgnorableNamespaces="uap rescap com">
<!-- Publisher Cache Folders -->
<Extensions>
@@ -60,6 +62,19 @@
Enabled="true"
DisplayName="Wino Startup Service" />
</uap5:Extension>
<!-- App notification activation -->
<desktop:Extension Category="windows.toastNotificationActivation">
<desktop:ToastNotificationActivation ToastActivatorCLSID="72c6d2d0-2538-44fe-a1b1-499f47bb1181" />
</desktop:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="-ToastActivated" DisplayName="Toast activator">
<com:Class Id="72c6d2d0-2538-44fe-a1b1-499f47bb1181" DisplayName="Toast activator"/>
</com:ExeServer>
</com:ComServer>
</com:Extension>
<!-- Protocol activation: mailto -->
<uap:Extension Category="windows.protocol">
+37 -18
View File
@@ -313,13 +313,18 @@ public sealed partial class MailListPage : MailListPageAbstract,
public async void Receive(SelectMailItemContainerEvent message)
{
if (message.SelectedMailViewModel == null) return;
if (message.MailUniqueId == Guid.Empty) return;
// Find the item from the collection.
// Folder should be initialized already.
var item = ViewModel.MailCollection.Find(message.MailUniqueId);
if (item == null) return;
await DispatcherQueue.EnqueueAsync(async () =>
{
// MailListView.ClearSelections(message.SelectedMailViewModel, true);
var collectionContainer = await MailListView.GetItemContainersAsync(message.SelectedMailViewModel);
var collectionContainer = await MailListView.GetItemContainersAsync(item);
if (collectionContainer.Item1 == null && collectionContainer.Item2 == null) return;
@@ -347,11 +352,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
}
var listView = collectionContainer.Item3 ?? MailListView;
var mailItemViewModelContainer = collectionContainer.Item1;
var threadMailItemViewModelContainer = collectionContainer.Item2;
await WinoClickItemInternalAsync(listView, collectionContainer.Item1?.Item ?? null);
await WinoClickItemInternalAsync(item, true);
});
}
@@ -554,7 +555,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
}
private async Task WinoClickItemInternalAsync(WinoListView listView, object? clickedItem)
private async Task WinoClickItemInternalAsync(object? clickedItem, bool selectExpandThread = false)
{
if (clickedItem == null) return;
@@ -621,29 +622,40 @@ public sealed partial class MailListPage : MailListPageAbstract,
if (clickedItem is ThreadMailItemViewModel clickedThread)
{
bool wasThreadSelected = clickedThread.IsSelected;
bool wasThreadExpanded = clickedThread.IsThreadExpanded;
// Check if any child in this thread is already selected (e.g., from notification click)
var alreadySelectedChild = clickedThread.ThreadEmails.FirstOrDefault(e => e.IsSelected);
// Reset everything first (exclusive selection scenario)
await ViewModel.MailCollection.UnselectAllAsync();
await CollapseAllThreadsExceptAsync(clickedThread);
if (wasThreadSelected)
if (wasThreadSelected && wasThreadExpanded)
{
// Toggle off -> leave nothing selected (all unselected, thread collapsed)
clickedThread.IsThreadExpanded = false;
return;
}
// Select thread + first child only
// Select thread header
clickedThread.IsSelected = true;
var firstChild = clickedThread.ThreadEmails.FirstOrDefault();
if (firstChild != null)
// If a child was already selected (e.g., from notification), keep that selection
// Otherwise, select the first child
if (alreadySelectedChild != null)
{
// Ensure only first child selected
foreach (var child in clickedThread.ThreadEmails)
alreadySelectedChild.IsSelected = true;
}
else
{
var firstChild = clickedThread.ThreadEmails.FirstOrDefault();
if (firstChild != null)
{
child.IsSelected = child == firstChild;
firstChild.IsSelected = true;
}
}
clickedThread.IsThreadExpanded = true; // Show contents of active thread
}
else if (clickedItem is MailItemViewModel clickedMail)
@@ -695,6 +707,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
await ViewModel.MailCollection.UnselectAllAsync();
await ViewModel.MailCollection.CollapseAllThreadsAsync();
if (parentThread != null && selectExpandThread)
{
// We're clicking an item inside a thread; select & expand the thread header as well.
parentThread.IsSelected = true;
parentThread.IsThreadExpanded = true;
}
if (!wasSelected)
{
clickedMail.IsSelected = true; // Toggle on
@@ -706,6 +725,6 @@ public sealed partial class MailListPage : MailListPageAbstract,
{
if (sender is not WinoListView listView) return;
await WinoClickItemInternalAsync(listView, e.ClickedItem);
await WinoClickItemInternalAsync(e.ClickedItem);
}
}