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
+ /// 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);
}
}