Toast notification navigations and some improvements for list view selection.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user