diff --git a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs index aa85e8a6..82d4c21b 100644 --- a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs +++ b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs @@ -10,7 +10,7 @@ public interface INotificationBuilder /// /// Creates toast notifications for new mails. /// - Task CreateNotificationsAsync(Guid inboxFolderId, IEnumerable newMailItems); + Task CreateNotificationsAsync(IEnumerable newMailItems); /// /// Gets the unread Inbox messages for each account and updates the taskbar icon. @@ -18,11 +18,6 @@ public interface INotificationBuilder /// Task UpdateTaskbarIconBadgeAsync(); - /// - /// Creates test notification for test purposes. - /// - Task CreateTestNotificationAsync(string title, string message); - /// /// Removes the toast notification for a specific mail by unique id. /// diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs index 0bcdb657..23f50df3 100644 --- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs +++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs @@ -19,9 +19,10 @@ public interface ISynchronizationManager /// /// Initializes the SynchronizationManager with required dependencies. /// - Task InitializeAsync(ISynchronizerFactory synchronizerFactory, + Task InitializeAsync(ISynchronizerFactory synchronizerFactory, IImapTestService imapTestService, IAccountService accountService, + INotificationBuilder notificationBuilder, IAuthenticationProvider authenticationProvider); /// @@ -32,7 +33,7 @@ public interface ISynchronizationManager /// /// Starts a new mail synchronization for the given account. /// - Task SynchronizeMailAsync(MailSynchronizationOptions options, + Task SynchronizeMailAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default); /// @@ -53,25 +54,25 @@ public interface ISynchronizationManager /// /// Handles folder synchronization for the given account. /// - Task SynchronizeFoldersAsync(Guid accountId, + Task SynchronizeFoldersAsync(Guid accountId, CancellationToken cancellationToken = default); /// /// Handles alias synchronization for the given account. /// - Task SynchronizeAliasesAsync(Guid accountId, + Task SynchronizeAliasesAsync(Guid accountId, CancellationToken cancellationToken = default); /// /// Handles profile synchronization for the given account. /// - Task SynchronizeProfileAsync(Guid accountId, + Task SynchronizeProfileAsync(Guid accountId, CancellationToken cancellationToken = default); /// /// Downloads a MIME message for the given mail item. /// - Task DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId, + Task DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId, CancellationToken cancellationToken = default); /// @@ -97,7 +98,7 @@ public interface ISynchronizationManager /// /// Handles OAuth authentication for the specified provider. /// - Task HandleAuthorizationAsync(MailProviderType providerType, - MailAccount account = null, + Task HandleAuthorizationAsync(MailProviderType providerType, + MailAccount account = null, bool proposeCopyAuthorizationURL = false); -} \ No newline at end of file +} diff --git a/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs b/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs index 59b8a3f6..50aecc8a 100644 --- a/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs +++ b/Wino.Core.Domain/Models/Comparers/ListItemComparer.cs @@ -10,7 +10,7 @@ public class ListItemComparer : IComparer 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) diff --git a/Wino.Core.WinUI/Services/NotificationBuilder.cs b/Wino.Core.WinUI/Services/NotificationBuilder.cs index 77bb844d..33c3dd84 100644 --- a/Wino.Core.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Core.WinUI/Services/NotificationBuilder.cs @@ -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 downloadedMailItems) + public async Task CreateNotificationsAsync(IEnumerable downloadedMailItems) { - var mailCount = downloadedMailItems.Count(); - try { + // Filter mails to only include Inbox folder items + var inboxMailItems = new List(); + + 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(); - - // 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() - //{ - // 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 diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 3c50c939..24e3d89f 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -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(); - 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 /// Service for testing IMAP connectivity /// Service for account operations /// Provider for OAuth authentication - 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 /// Mail synchronization options /// Cancellation token /// Synchronization result - public async Task SynchronizeMailAsync(MailSynchronizationOptions options, + public async Task 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 /// Account ID to synchronize folders for /// Cancellation token /// Synchronization result - public async Task SynchronizeFoldersAsync(Guid accountId, + public async Task SynchronizeFoldersAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); @@ -273,7 +279,7 @@ public class SynchronizationManager : ISynchronizationManager /// Account ID to synchronize aliases for /// Cancellation token /// Synchronization result - public async Task SynchronizeAliasesAsync(Guid accountId, + public async Task SynchronizeAliasesAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); @@ -293,7 +299,7 @@ public class SynchronizationManager : ISynchronizationManager /// Account ID to synchronize profile for /// Cancellation token /// Synchronization result - public async Task SynchronizeProfileAsync(Guid accountId, + public async Task SynchronizeProfileAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); @@ -314,7 +320,7 @@ public class SynchronizationManager : ISynchronizationManager /// Account ID that owns the mail item /// Cancellation token /// Downloaded MIME content path - public async Task DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId, + public async Task 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(null); } @@ -434,8 +440,8 @@ public class SynchronizationManager : ISynchronizationManager /// Optional account to authenticate (null for initial authentication) /// Whether to propose copying auth URL for Gmail /// Token information containing access token and username - public async Task HandleAuthorizationAsync(MailProviderType providerType, - MailAccount account = null, + public async Task 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."); } } -} \ No newline at end of file +} diff --git a/Wino.Core/Services/SynchronizationManagerInitializer.cs b/Wino.Core/Services/SynchronizationManagerInitializer.cs index ddae076f..71b22e8e 100644 --- a/Wino.Core/Services/SynchronizationManagerInitializer.cs +++ b/Wino.Core/Services/SynchronizationManagerInitializer.cs @@ -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(); var accountService = _serviceProvider.GetRequiredService(); var authenticationProvider = _serviceProvider.GetRequiredService(); + var notificationBuilder = _serviceProvider.GetRequiredService(); // 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); } -} \ No newline at end of file +} diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 71e5a482..948de029 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -217,6 +217,42 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + /// Finds a MailItemViewModel by its UniqueId, searching through all items including those inside threads. + /// + /// The UniqueId of the mail item to find. + /// The MailItemViewModel if found, otherwise null. + 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); diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 87a0e795..6987d94a 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -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; } diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 24319fde..eb47a6bc 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -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.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 diff --git a/Wino.Mail.ViewModels/Messages/SelectMailItemContainerEvent.cs b/Wino.Mail.ViewModels/Messages/SelectMailItemContainerEvent.cs index 814d75a6..ec6797ec 100644 --- a/Wino.Mail.ViewModels/Messages/SelectMailItemContainerEvent.cs +++ b/Wino.Mail.ViewModels/Messages/SelectMailItemContainerEvent.cs @@ -1,8 +1,8 @@ -using Wino.Mail.ViewModels.Data; +using System; namespace Wino.Mail.ViewModels.Messages; /// /// When listing view model manipulated the selected mail container in the UI. /// -public record SelectMailItemContainerEvent(MailItemViewModel SelectedMailViewModel, bool ScrollToItem = false); +public record SelectMailItemContainerEvent(Guid MailUniqueId, bool ScrollToItem = false); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 65466623..7eb5387d 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -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(); + var accountService = Services.GetRequiredService(); + + 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(); + 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 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 diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs index dba93f81..0f70bfc9 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs @@ -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().FirstOrDefault(a => a.UniqueId == mailItemViewModel.MailCopy.UniqueId); + + if (realThreadItem != null) + { + itemContainer = innerListViewControl.ContainerFromItem(realThreadItem) as WinoMailItemViewModelListViewItem; + } + } } } break; diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index 660b7214..a8a18784 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -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"> @@ -60,6 +62,19 @@ Enabled="true" DisplayName="Wino Startup Service" /> + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs index ceb711f6..aa990e2a 100644 --- a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs @@ -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); } }