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> /// <summary>
/// Creates toast notifications for new mails. /// Creates toast notifications for new mails.
/// </summary> /// </summary>
Task CreateNotificationsAsync(Guid inboxFolderId, IEnumerable<MailCopy> newMailItems); Task CreateNotificationsAsync(IEnumerable<MailCopy> newMailItems);
/// <summary> /// <summary>
/// Gets the unread Inbox messages for each account and updates the taskbar icon. /// Gets the unread Inbox messages for each account and updates the taskbar icon.
@@ -18,11 +18,6 @@ public interface INotificationBuilder
/// <returns></returns> /// <returns></returns>
Task UpdateTaskbarIconBadgeAsync(); Task UpdateTaskbarIconBadgeAsync();
/// <summary>
/// Creates test notification for test purposes.
/// </summary>
Task CreateTestNotificationAsync(string title, string message);
/// <summary> /// <summary>
/// Removes the toast notification for a specific mail by unique id. /// Removes the toast notification for a specific mail by unique id.
/// </summary> /// </summary>
@@ -22,6 +22,7 @@ public interface ISynchronizationManager
Task InitializeAsync(ISynchronizerFactory synchronizerFactory, Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
IImapTestService imapTestService, IImapTestService imapTestService,
IAccountService accountService, IAccountService accountService,
INotificationBuilder notificationBuilder,
IAuthenticationProvider authenticationProvider); IAuthenticationProvider authenticationProvider);
/// <summary> /// <summary>
@@ -10,7 +10,7 @@ public class ListItemComparer : IComparer<object>
public int Compare(object x, object y) public int Compare(object x, object y)
{ {
if (x is IMailListItemSorting xSorting && y is IMailListItemSorting ySorting) 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) 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); 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) else if (x is DateTime dateX && y is DateTime dateY)
+75 -91
View File
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.Toolkit.Uwp.Notifications;
@@ -16,8 +15,6 @@ using Wino.Messaging.UI;
namespace Wino.Core.WinUI.Services; namespace Wino.Core.WinUI.Services;
// TODO: Refactor this thing. It's garbage.
public class NotificationBuilder : INotificationBuilder public class NotificationBuilder : INotificationBuilder
{ {
private readonly IUnderlyingThemeService _underlyingThemeService; 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 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 there are more than 3 mails, just display 1 general toast.
if (mailCount > 3) if (mailCount > 3)
{ {
@@ -69,66 +97,9 @@ public class NotificationBuilder : INotificationBuilder
} }
else else
{ {
var validItems = new List<MailCopy>(); foreach (var mailItem in inboxMailItems)
// 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)
{ {
var mailItem = await _mailService.GetSingleMailItemAsync(item.UniqueId); await CreateSingleNotificationAsync(mailItem);
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 UpdateTaskbarIconBadgeAsync(); 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() private ToastButton GetDismissButton()
=> new ToastButton() => new ToastButton()
.SetDismissActivation() .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) public void RemoveNotification(Guid mailUniqueId)
{ {
try try
+9 -3
View File
@@ -13,7 +13,6 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication; using Wino.Core.Domain.Models.Authentication;
using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Connectivity;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.Server;
namespace Wino.Core.Services; namespace Wino.Core.Services;
@@ -30,11 +29,12 @@ public class SynchronizationManager : ISynchronizationManager
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private readonly ILogger _logger = Log.ForContext<SynchronizationManager>(); private readonly ILogger _logger = Log.ForContext<SynchronizationManager>();
private ISynchronizerFactory _synchronizerFactory;
private SynchronizerFactory _concreteSynchronizerFactory; private SynchronizerFactory _concreteSynchronizerFactory;
private IImapTestService _imapTestService; private IImapTestService _imapTestService;
private IAccountService _accountService; private IAccountService _accountService;
private IAuthenticationProvider _authenticationProvider; private IAuthenticationProvider _authenticationProvider;
private INotificationBuilder _notificationBuilder;
private bool _isInitialized = false; private bool _isInitialized = false;
private SynchronizationManager() { } private SynchronizationManager() { }
@@ -50,18 +50,20 @@ public class SynchronizationManager : ISynchronizationManager
public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory, public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
IImapTestService imapTestService, IImapTestService imapTestService,
IAccountService accountService, IAccountService accountService,
INotificationBuilder notificationBuilder,
IAuthenticationProvider authenticationProvider) IAuthenticationProvider authenticationProvider)
{ {
await _initializationSemaphore.WaitAsync(); await _initializationSemaphore.WaitAsync();
try try
{ {
if (_isInitialized) return; if (_isInitialized) return;
_synchronizerFactory = synchronizerFactory ?? throw new ArgumentNullException(nameof(synchronizerFactory));
_concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory ?? throw new ArgumentException("SynchronizerFactory must be the concrete implementation"); _concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory ?? throw new ArgumentException("SynchronizerFactory must be the concrete implementation");
_imapTestService = imapTestService ?? throw new ArgumentNullException(nameof(imapTestService)); _imapTestService = imapTestService ?? throw new ArgumentNullException(nameof(imapTestService));
_accountService = accountService ?? throw new ArgumentNullException(nameof(accountService)); _accountService = accountService ?? throw new ArgumentNullException(nameof(accountService));
_authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); _authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));
_notificationBuilder = notificationBuilder ?? throw new ArgumentNullException(nameof(notificationBuilder));
// Get all accounts and create synchronizers for them // Get all accounts and create synchronizers for them
var accounts = await _accountService.GetAccountsAsync(); var accounts = await _accountService.GetAccountsAsync();
@@ -161,6 +163,10 @@ public class SynchronizationManager : ISynchronizationManager
_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); options.AccountId, result.CompletedState);
// Create notifications.
if (result.DownloadedMessages?.Any() ?? false)
await _notificationBuilder.CreateNotificationsAsync(result.DownloadedMessages);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -2,7 +2,6 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
namespace Wino.Core.Services; namespace Wino.Core.Services;
@@ -24,10 +23,11 @@ public class SynchronizationManagerInitializer : IInitializeAsync
var imapTestService = _serviceProvider.GetRequiredService<IImapTestService>(); var imapTestService = _serviceProvider.GetRequiredService<IImapTestService>();
var accountService = _serviceProvider.GetRequiredService<IAccountService>(); var accountService = _serviceProvider.GetRequiredService<IAccountService>();
var authenticationProvider = _serviceProvider.GetRequiredService<IAuthenticationProvider>(); var authenticationProvider = _serviceProvider.GetRequiredService<IAuthenticationProvider>();
var notificationBuilder = _serviceProvider.GetRequiredService<INotificationBuilder>();
// Cast to concrete type to access CreateNewSynchronizer method // Cast to concrete type to access CreateNewSynchronizer method
var concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory; 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); 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) private async Task InsertItemInternalAsync(object groupKey, IMailListItem mailItem)
{ {
UpdateUniqueIdHashes(mailItem, true); UpdateUniqueIdHashes(mailItem, true);
@@ -184,7 +184,19 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
if (email.MailCopy.ThreadId != _threadId) if (email.MailCopy.ThreadId != _threadId)
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander 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 // Reassign to trigger property change notifications
ThreadEmails = ThreadEmails; ThreadEmails = ThreadEmails;
} }
+10 -24
View File
@@ -72,6 +72,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IMailDialogService _mailDialogService; private readonly IMailDialogService _mailDialogService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
private readonly IContextMenuItemService _contextMenuItemService; private readonly IContextMenuItemService _contextMenuItemService;
private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IWinoRequestDelegator _winoRequestDelegator;
@@ -155,6 +156,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
IMailDialogService mailDialogService, IMailDialogService mailDialogService,
IMailService mailService, IMailService mailService,
IStatePersistanceService statePersistenceService, IStatePersistanceService statePersistenceService,
INotificationBuilder notificationBuilder,
IFolderService folderService, IFolderService folderService,
IContextMenuItemService contextMenuItemService, IContextMenuItemService contextMenuItemService,
IWinoRequestDelegator winoRequestDelegator, IWinoRequestDelegator winoRequestDelegator,
@@ -175,6 +177,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
PreferencesService = preferencesService; PreferencesService = preferencesService;
ThemeService = themeService; ThemeService = themeService;
StatePersistenceService = statePersistenceService; StatePersistenceService = statePersistenceService;
_notificationBuilder = notificationBuilder;
NavigationService = navigationService; NavigationService = navigationService;
SelectedFilterOption = FilterOptions[0]; SelectedFilterOption = FilterOptions[0];
@@ -468,6 +471,10 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[RelayCommand] [RelayCommand]
private void SyncFolder() private void SyncFolder()
{ {
var mails = MailCollection.SelectedItems;
_notificationBuilder.CreateNotificationsAsync(mails.Select(a => a.MailCopy));
return;
if (!CanSynchronize) return; if (!CanSynchronize) return;
// Only synchronize listed folders. // Only synchronize listed folders.
@@ -710,7 +717,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
await MailCollection.RemoveAsync(removedMail); await MailCollection.RemoveAsync(removedMail);
if (nextItem != null) if (nextItem != null)
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true)); WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem.UniqueId, ScrollToItem: true));
else if (isDeletedMailSelected) else if (isDeletedMailSelected)
{ {
// There are no next item to select, but we removed the last item which was selected. // 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) void IRecipient<MailItemNavigationRequested>.Receive(MailItemNavigationRequested message)
{ {
Debug.WriteLine($"Mail item navigation requested"); // TODO: Remove this.
// Find mail item and add to selected items.
MailItemViewModel navigatingMailItem = null; WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(message.UniqueMailId, message.ScrollToItem));
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));
} }
#endregion #endregion
@@ -1,8 +1,8 @@
using Wino.Mail.ViewModels.Data; using System;
namespace Wino.Mail.ViewModels.Messages; namespace Wino.Mail.ViewModels.Messages;
/// <summary> /// <summary>
/// When listing view model manipulated the selected mail container in the UI. /// When listing view model manipulated the selected mail container in the UI.
/// </summary> /// </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 System.Text;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.Windows.AppLifecycle; using Microsoft.Windows.AppLifecycle;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.WinUI; using Wino.Core.WinUI;
using Wino.Core.WinUI.Interfaces; using Wino.Core.WinUI.Interfaces;
using Wino.Mail.Services; using Wino.Mail.Services;
using Wino.Mail.ViewModels; using Wino.Mail.ViewModels;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Server; using Wino.Messaging.Server;
using Wino.Services; using Wino.Services;
namespace Wino.Mail.WinUI; namespace Wino.Mail.WinUI;
@@ -22,10 +25,45 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
InitializeComponent(); InitializeComponent();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
ToastNotificationManagerCompat.OnActivated += ToastActivationHandler;
RegisterRecipients(); 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 #region Dependency Injection
@@ -80,9 +118,13 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
} }
private bool IsStartupTaskLaunch() => AppInstance.GetCurrent().GetActivatedEventArgs()?.Kind == ExtendedActivationKind.StartupTask; private bool IsStartupTaskLaunch() => AppInstance.GetCurrent().GetActivatedEventArgs()?.Kind == ExtendedActivationKind.StartupTask;
public bool IsAppRunning() => MainWindow != null;
protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) 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. // TODO: Check app relaunch mutex before loading anything.
// Initialize NewThemeService first to get backdrop settings before creating window // 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) if (innerListViewControl != null)
{ {
innerListView = innerListViewControl; innerListView = innerListViewControl;
// TODO: What if it wasn't realized in the thread?
itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem; 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; break;
+16 -1
View File
@@ -5,8 +5,10 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" 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" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap"> IgnorableNamespaces="uap rescap com">
<!-- Publisher Cache Folders --> <!-- Publisher Cache Folders -->
<Extensions> <Extensions>
@@ -61,6 +63,19 @@
DisplayName="Wino Startup Service" /> DisplayName="Wino Startup Service" />
</uap5:Extension> </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 --> <!-- Protocol activation: mailto -->
<uap:Extension Category="windows.protocol"> <uap:Extension Category="windows.protocol">
<uap:Protocol Name="mailto" /> <uap:Protocol Name="mailto" />
+37 -18
View File
@@ -313,13 +313,18 @@ public sealed partial class MailListPage : MailListPageAbstract,
public async void Receive(SelectMailItemContainerEvent message) 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 () => await DispatcherQueue.EnqueueAsync(async () =>
{ {
// MailListView.ClearSelections(message.SelectedMailViewModel, true); var collectionContainer = await MailListView.GetItemContainersAsync(item);
var collectionContainer = await MailListView.GetItemContainersAsync(message.SelectedMailViewModel);
if (collectionContainer.Item1 == null && collectionContainer.Item2 == null) return; if (collectionContainer.Item1 == null && collectionContainer.Item2 == null) return;
@@ -347,11 +352,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
} }
} }
var listView = collectionContainer.Item3 ?? MailListView; await WinoClickItemInternalAsync(item, true);
var mailItemViewModelContainer = collectionContainer.Item1;
var threadMailItemViewModelContainer = collectionContainer.Item2;
await WinoClickItemInternalAsync(listView, collectionContainer.Item1?.Item ?? null);
}); });
} }
@@ -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; if (clickedItem == null) return;
@@ -621,29 +622,40 @@ public sealed partial class MailListPage : MailListPageAbstract,
if (clickedItem is ThreadMailItemViewModel clickedThread) if (clickedItem is ThreadMailItemViewModel clickedThread)
{ {
bool wasThreadSelected = clickedThread.IsSelected; 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) // Reset everything first (exclusive selection scenario)
await ViewModel.MailCollection.UnselectAllAsync(); await ViewModel.MailCollection.UnselectAllAsync();
await CollapseAllThreadsExceptAsync(clickedThread); await CollapseAllThreadsExceptAsync(clickedThread);
if (wasThreadSelected) if (wasThreadSelected && wasThreadExpanded)
{ {
// Toggle off -> leave nothing selected (all unselected, thread collapsed) // Toggle off -> leave nothing selected (all unselected, thread collapsed)
clickedThread.IsThreadExpanded = false; clickedThread.IsThreadExpanded = false;
return; return;
} }
// Select thread + first child only // Select thread header
clickedThread.IsSelected = true; 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 alreadySelectedChild.IsSelected = true;
foreach (var child in clickedThread.ThreadEmails) }
else
{
var firstChild = clickedThread.ThreadEmails.FirstOrDefault();
if (firstChild != null)
{ {
child.IsSelected = child == firstChild; firstChild.IsSelected = true;
} }
} }
clickedThread.IsThreadExpanded = true; // Show contents of active thread clickedThread.IsThreadExpanded = true; // Show contents of active thread
} }
else if (clickedItem is MailItemViewModel clickedMail) else if (clickedItem is MailItemViewModel clickedMail)
@@ -695,6 +707,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
await ViewModel.MailCollection.UnselectAllAsync(); await ViewModel.MailCollection.UnselectAllAsync();
await ViewModel.MailCollection.CollapseAllThreadsAsync(); 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) if (!wasSelected)
{ {
clickedMail.IsSelected = true; // Toggle on clickedMail.IsSelected = true; // Toggle on
@@ -706,6 +725,6 @@ public sealed partial class MailListPage : MailListPageAbstract,
{ {
if (sender is not WinoListView listView) return; if (sender is not WinoListView listView) return;
await WinoClickItemInternalAsync(listView, e.ClickedItem); await WinoClickItemInternalAsync(e.ClickedItem);
} }
} }