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; using Microsoft.UI.Xaml; using Serilog; using Windows.Data.Xml.Dom; using Windows.UI.Notifications; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Messaging.UI; namespace Wino.Mail.WinUI.Services; public class NotificationBuilder : INotificationBuilder { private const string MailApplicationId = "App"; private const string NotificationIconRootUri = "ms-appx:///Assets/NotificationIcons/"; private static readonly int[] SupportedIconScales = [100, 125, 150, 200, 400]; private readonly IAccountService _accountService; private readonly IFolderService _folderService; private readonly IMailService _mailService; private readonly IThumbnailService _thumbnailService; private readonly IPreferencesService _preferencesService; private readonly IUnderlyingThemeService _underlyingThemeService; public NotificationBuilder(IAccountService accountService, IFolderService folderService, IMailService mailService, IThumbnailService thumbnailService, IPreferencesService preferencesService, IUnderlyingThemeService underlyingThemeService) { _accountService = accountService; _folderService = folderService; _mailService = mailService; _thumbnailService = thumbnailService; _preferencesService = preferencesService; _underlyingThemeService = underlyingThemeService; WeakReferenceMessenger.Default.Register(this, (r, msg) => { RemoveNotification(msg.UniqueId); }); } public async Task CreateNotificationsAsync(IEnumerable downloadedMailItems) { 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) { var builder = new ToastContentBuilder(); builder.SetToastScenario(ToastScenario.Default); builder.AddText(Translator.Notifications_MultipleNotificationsTitle); builder.AddText(string.Format(Translator.Notifications_MultipleNotificationsMessage, mailCount)); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddButton(GetDismissButton()); builder.AddAudio(new ToastAudio() { Src = new Uri("ms-winsoundevent:Notification.Mail") }); ShowToast(builder); } else { foreach (var mailItem in inboxMailItems) { await CreateSingleNotificationAsync(mailItem); } await UpdateTaskbarIconBadgeAsync(); } } catch (Exception ex) { Log.Error(ex, "Failed to create notifications."); } } 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.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); 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 ShowToast(builder, mailItem.UniqueId.ToString()); } private ToastButton GetDismissButton() => new ToastButton() .SetDismissActivation() .SetImageUri(GetNotificationIconUri("dismiss")); private ToastButton GetArchiveButton(Guid mailUniqueId) => new ToastButton() .SetContent(Translator.MailOperation_Archive) .SetImageUri(GetNotificationIconUri("mail-archive")) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.Archive) .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail) .SetBackgroundActivation(); private ToastButton GetDeleteButton(Guid mailUniqueId) => new ToastButton() .SetContent(Translator.MailOperation_Delete) .SetImageUri(GetNotificationIconUri("mail-delete")) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete) .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail) .SetBackgroundActivation(); private ToastButton GetMarkAsReadButton(Guid mailUniqueId) => new ToastButton() .SetContent(Translator.MailOperation_MarkAsRead) .SetImageUri(GetNotificationIconUri("mail-markread")) .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) .AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead) .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail) .SetBackgroundActivation(); public async Task UpdateTaskbarIconBadgeAsync() { int totalUnreadCount = 0; try { var badgeUpdater = BadgeUpdateManager.CreateBadgeUpdaterForApplication(MailApplicationId); var accounts = await _accountService.GetAccountsAsync(); foreach (var account in accounts) { if (!account.Preferences.IsTaskbarBadgeEnabled) continue; var accountInbox = await _folderService.GetSpecialFolderByAccountIdAsync(account.Id, SpecialFolderType.Inbox); if (accountInbox == null) continue; var inboxUnreadCount = await _folderService.GetFolderNotificationBadgeAsync(accountInbox.Id); totalUnreadCount += inboxUnreadCount; } if (totalUnreadCount > 0) { // Get the blank badge XML payload for a badge number XmlDocument badgeXml = BadgeUpdateManager.GetTemplateContent(BadgeTemplateType.BadgeNumber); // Set the value of the badge in the XML to our number XmlElement? badgeElement = badgeXml.SelectSingleNode("/badge") as XmlElement; if (badgeElement == null) { badgeUpdater.Clear(); return; } badgeElement.SetAttribute("value", totalUnreadCount.ToString()); // Create the badge notification BadgeNotification badge = new BadgeNotification(badgeXml); // And update the badge badgeUpdater.Update(badge); } else badgeUpdater.Clear(); } catch (Exception ex) { Log.Error(ex, "Error while updating taskbar badge."); } } public void RemoveNotification(Guid mailUniqueId) { try { ToastNotificationManager.History.Remove(mailUniqueId.ToString(), null); } catch (ArgumentException) { // Notification does not exists. Ignore. } catch (Exception ex) { Log.Error(ex, $"Failed to remove notification for mail {mailUniqueId}"); } } public void CreateAttentionRequiredNotification(MailAccount account) { var builder = new ToastContentBuilder(); builder.SetToastScenario(ToastScenario.Default); builder.AddText(Translator.Exception_AccountNeedsAttention_Title); builder.AddText(string.Format(Translator.Exception_AccountNeedsAttention_Message, account.Name)); builder.AddButton(GetDismissButton()); builder.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount)); ShowToast(builder); } public void CreateWebView2RuntimeMissingNotification() { var builder = new ToastContentBuilder(); builder.SetToastScenario(ToastScenario.Default); builder.AddText(Translator.Exception_WebView2RuntimeMissing_Title); builder.AddText(Translator.Exception_WebView2RuntimeMissing_Message); builder.AddButton(GetDismissButton()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); ShowToast(builder); } public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) { if (calendarItem == null) return Task.CompletedTask; var builder = new ToastContentBuilder(); builder.SetToastScenario(ToastScenario.Reminder); var localStart = calendarItem.GetLocalStartDate(); var nowLocal = DateTime.Now; var reminderContext = GetCalendarReminderContext(localStart, nowLocal); builder.AddText(calendarItem.Title); builder.AddText($"{reminderContext} - {localStart:g}"); if (!string.IsNullOrWhiteSpace(calendarItem.Location)) builder.AddText(calendarItem.Location); builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction); builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar); var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( reminderDurationInSeconds, _preferencesService.DefaultReminderDurationInSeconds); if (allowedSnoozeMinutes.Count > 0) { var preferredSnoozeMinutes = _preferencesService.DefaultSnoozeDurationInMinutes; var defaultSnoozeMinutes = allowedSnoozeMinutes.Contains(preferredSnoozeMinutes) ? preferredSnoozeMinutes : allowedSnoozeMinutes[0]; var selectionBox = new ToastSelectionBox(Constants.ToastCalendarSnoozeDurationInputId) { DefaultSelectionBoxItemId = defaultSnoozeMinutes.ToString() }; foreach (var snoozeMinutes in allowedSnoozeMinutes) { selectionBox.Items.Add(new ToastSelectionBoxItem( snoozeMinutes.ToString(), string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes))); } builder.AddToastInput(selectionBox); var snoozeButton = new ToastButton() .SetContent(Translator.CalendarReminder_SnoozeAction) .SetImageUri(GetNotificationIconUri("calendar-snooze")) .SetBackgroundActivation(); builder.AddButton(snoozeButton) .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar); } builder.AddButton(new ToastButton() .SetDismissActivation() .SetImageUri(GetNotificationIconUri("dismiss"))); if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out var joinUri)) { builder.AddButton(new ToastButton() .SetContent(Translator.CalendarEventDetails_JoinOnline) .SetImageUri(GetNotificationIconUri("calendar-join")) .SetProtocolActivation(joinUri)); } builder.AddAudio(new ToastAudio() { Src = new Uri("ms-winsoundevent:Notification.Reminder") }); var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}"; ShowToast(builder, tag); return Task.CompletedTask; } private static string GetCalendarReminderContext(DateTime localStart, DateTime nowLocal) { var delta = localStart - nowLocal; var absDelta = delta.Duration(); if (absDelta < TimeSpan.FromMinutes(1)) return delta.TotalSeconds >= 0 ? Translator.CalendarReminder_StartingNow : Translator.CalendarReminder_StartedNow; if (delta.TotalSeconds > 0) { if (delta.TotalHours >= 1) { var hours = Math.Max(1, (int)Math.Floor(delta.TotalHours)); return string.Format(Translator.CalendarReminder_StartsInHours, hours); } var minutes = Math.Max(1, (int)Math.Floor(delta.TotalMinutes)); return string.Format(Translator.CalendarReminder_StartsInMinutes, minutes); } if (absDelta.TotalHours >= 1) { var hoursAgo = Math.Max(1, (int)Math.Floor(absDelta.TotalHours)); return string.Format(Translator.CalendarReminder_StartedHoursAgo, hoursAgo); } var minutesAgo = Math.Max(1, (int)Math.Floor(absDelta.TotalMinutes)); return string.Format(Translator.CalendarReminder_StartedMinutesAgo, minutesAgo); } private static void ShowToast(ToastContentBuilder builder, string? tag = null) { var toastNotification = new ToastNotification(builder.GetToastContent().GetXml()); if (!string.IsNullOrWhiteSpace(tag)) { toastNotification.Tag = tag; } var notifier = ToastNotificationManager.CreateToastNotifier(); notifier.Show(toastNotification); } private Uri GetNotificationIconUri(string iconName) { var theme = _underlyingThemeService.IsUnderlyingThemeDark() ? "dark" : "light"; var scale = GetClosestAvailableScale(); return new($"{NotificationIconRootUri}{iconName}.theme-{theme}.scale-{scale}.png"); } private static int GetClosestAvailableScale() { var rasterScale = 1.0; if (WinoApplication.MainWindow?.Content is FrameworkElement rootElement && rootElement.XamlRoot != null) { rasterScale = rootElement.XamlRoot.RasterizationScale; } var requestedScale = (int)Math.Round(rasterScale * 100); return SupportedIconScales.OrderBy(s => Math.Abs(s - requestedScale)).First(); } }