diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 4703131a..d5754b4d 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -147,6 +147,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private readonly ICalendarService _calendarService; private readonly INavigationService _navigationService; private readonly INativeAppService _nativeAppService; + private readonly INotificationBuilder _notificationBuilder; private readonly IPreferencesService _preferencesService; private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IMailDialogService _dialogService; @@ -157,6 +158,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private bool _subscriptionsAttached; private CancellationTokenSource _pageLifetimeCts = new(); private long _pageLifetimeVersion; + private bool _isCalendarBadgeClearedForPageLifetime; private Dictionary _loadedCalendarItems = new(); [ObservableProperty] @@ -172,6 +174,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, IKeyPressService keyPressService, INativeAppService nativeAppService, IAccountCalendarStateService accountCalendarStateService, + INotificationBuilder notificationBuilder, IPreferencesService preferencesService, IWinoRequestDelegator winoRequestDelegator, IMailDialogService dialogService, @@ -183,6 +186,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, _calendarService = calendarService; _navigationService = navigationService; _nativeAppService = nativeAppService; + _notificationBuilder = notificationBuilder; _preferencesService = preferencesService; _winoRequestDelegator = winoRequestDelegator; _dialogService = dialogService; @@ -360,6 +364,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { CancelPendingOperations(); _pageLifetimeCts = new CancellationTokenSource(); + _isCalendarBadgeClearedForPageLifetime = false; Interlocked.Increment(ref _pageLifetimeVersion); } @@ -614,6 +619,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { var lifetimeVersion = CurrentPageLifetimeVersion; var hasLoadingLock = await WaitForCalendarLoadingLockAsync(lifetimeVersion).ConfigureAwait(false); + var loadSucceeded = false; if (!hasLoadingLock) return; @@ -666,6 +672,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, DisplayDetailsCalendarItemViewModel = null; } }).ConfigureAwait(false); + + loadSucceeded = true; } catch (OperationCanceledException) { @@ -685,6 +693,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, ReleaseCalendarLoadingLock(); await ExecuteUIThreadIfActiveAsync(lifetimeVersion, () => IsCalendarEnabled = true).ConfigureAwait(false); } + + if (loadSucceeded && !_isCalendarBadgeClearedForPageLifetime && IsPageActive(lifetimeVersion)) + { + await _notificationBuilder.ClearCalendarTaskbarBadgeAsync().ConfigureAwait(false); + _isCalendarBadgeClearedForPageLifetime = true; + } } public Task ReloadCurrentVisibleRangeAsync() @@ -692,6 +706,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (CurrentVisibleRange == null) return Task.CompletedTask; + RefreshSettings(); return ApplyDisplayRequestAsync(new CalendarDisplayRequest(CurrentVisibleRange.DisplayType, CurrentVisibleRange.AnchorDate), forceReload: true); } diff --git a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs index ed41e474..ad3fdea2 100644 --- a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs +++ b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs @@ -20,6 +20,16 @@ public interface INotificationBuilder /// Task UpdateTaskbarIconBadgeAsync(); + /// + /// Adds to the calendar app-entry badge count for newly downloaded events. + /// + Task AddCalendarTaskbarBadgeCountAsync(int newlyDownloadedCount); + + /// + /// Clears the calendar app-entry badge. + /// + Task ClearCalendarTaskbarBadgeAsync(); + /// /// Removes the toast notification for a specific mail by unique id. /// diff --git a/Wino.Core.Tests/AppModeActivationResolverTests.cs b/Wino.Core.Tests/AppModeActivationResolverTests.cs new file mode 100644 index 00000000..aa7202c6 --- /dev/null +++ b/Wino.Core.Tests/AppModeActivationResolverTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using Wino.Core.Activation; +using Wino.Core.Domain.Enums; +using Xunit; + +namespace Wino.Core.Tests; + +public class AppModeActivationResolverTests +{ + [Theory] + [InlineData("--wino-mail", WinoApplicationMode.Calendar, WinoApplicationMode.Mail)] + [InlineData("--wino-calendar", WinoApplicationMode.Mail, WinoApplicationMode.Calendar)] + [InlineData("--mode=mail", WinoApplicationMode.Calendar, WinoApplicationMode.Mail)] + [InlineData("--mode=calendar", WinoApplicationMode.Mail, WinoApplicationMode.Calendar)] + [InlineData("CalendarApp", WinoApplicationMode.Mail, WinoApplicationMode.Calendar)] + [InlineData("App", WinoApplicationMode.Calendar, WinoApplicationMode.Mail)] + public void Resolve_PrefersKnownMailCalendarSignals(string source, WinoApplicationMode defaultMode, WinoApplicationMode expectedMode) + { + var resolvedMode = AppModeActivationResolver.Resolve(source, null, null, defaultMode); + + resolvedMode.Should().Be(expectedMode); + } + + [Fact] + public void Resolve_ToggleDefaultArgumentFlipsBetweenMailAndCalendar() + { + AppModeActivationResolver.Resolve("--mode=toggle-default", null, null, WinoApplicationMode.Mail) + .Should().Be(WinoApplicationMode.Calendar); + + AppModeActivationResolver.Resolve("--mode=toggle-default", null, null, WinoApplicationMode.Calendar) + .Should().Be(WinoApplicationMode.Mail); + } +} diff --git a/Wino.Core.Tests/CalendarPageViewModelTests.cs b/Wino.Core.Tests/CalendarPageViewModelTests.cs index 8e437cc1..98f57137 100644 --- a/Wino.Core.Tests/CalendarPageViewModelTests.cs +++ b/Wino.Core.Tests/CalendarPageViewModelTests.cs @@ -47,7 +47,7 @@ public class CalendarPageViewModelTests viewModel.CurrentVisibleRange.EndDate.Should().Be(new DateOnly(2026, 3, 22)); viewModel.LoadedDateWindow.StartDate.Should().Be(new DateTime(2026, 3, 9)); viewModel.LoadedDateWindow.EndDate.Should().Be(new DateTime(2026, 3, 30)); - viewModel.VisibleDateRangeText.Should().Be("March 16 - March 22"); + viewModel.VisibleDateRangeText.Should().Be("March 16 - 22"); requestedPeriod.Should().NotBeNull(); requestedPeriod!.Start.Should().Be(new DateTime(2026, 3, 9)); @@ -242,6 +242,45 @@ public class CalendarPageViewModelTests } } + [Fact] + public async Task ApplyDisplayRequestAsync_ClearsCalendarBadgeOnlyOncePerPageLifetime() + { + var settings = CreateSettings(); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + var notificationBuilder = new Mock(); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + notificationBuilder + .Setup(builder => builder.ClearCalendarTaskbarBadgeAsync()) + .Returns(Task.CompletedTask); + + var viewModel = CreateViewModel( + calendarService.Object, + preferencesService.Object, + new DateOnly(2026, 3, 20), + notificationBuilder: notificationBuilder.Object); + + viewModel.OnNavigatedTo(NavigationMode.New, null!); + + try + { + var request = new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20)); + + await viewModel.ApplyDisplayRequestAsync(request); + await viewModel.ApplyDisplayRequestAsync(request, forceReload: true); + + notificationBuilder.Verify(builder => builder.ClearCalendarTaskbarBadgeAsync(), Times.Once); + } + finally + { + viewModel.OnNavigatedFrom(NavigationMode.Back, null!); + } + } + [Fact] public async Task CalendarItemAddedMessage_ReconcilesTrackedLocalPreviewInPlace() { @@ -332,6 +371,15 @@ public class CalendarPageViewModelTests ICalendarService calendarService, IPreferencesService preferencesService, DateOnly today) + { + return CreateViewModel(calendarService, preferencesService, today, notificationBuilder: null); + } + + private static CalendarPageViewModel CreateViewModel( + ICalendarService calendarService, + IPreferencesService preferencesService, + DateOnly today, + INotificationBuilder? notificationBuilder) { var account = CreateAccount(); @@ -339,7 +387,7 @@ public class CalendarPageViewModelTests var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); var accountCalendarStateService = new FakeAccountCalendarStateService([accountCalendarViewModel]); - return CreateViewModel(calendarService, preferencesService, today, accountCalendarStateService); + return CreateViewModel(calendarService, preferencesService, today, accountCalendarStateService, notificationBuilder: notificationBuilder); } private static CalendarPageViewModel CreateViewModel( @@ -356,6 +404,7 @@ public class CalendarPageViewModelTests IAccountCalendarStateService accountCalendarStateService, INavigationService? navigationService = null, INativeAppService? nativeAppService = null, + INotificationBuilder? notificationBuilder = null, IWinoRequestDelegator? requestDelegator = null, IMailDialogService? dialogService = null) { @@ -371,6 +420,7 @@ public class CalendarPageViewModelTests Mock.Of(), nativeAppService ?? Mock.Of(), accountCalendarStateService, + notificationBuilder ?? Mock.Of(), preferencesService, requestDelegator ?? Mock.Of(), dialogService ?? Mock.Of(), diff --git a/Wino.Core.Tests/CalendarRangeResolverTests.cs b/Wino.Core.Tests/CalendarRangeResolverTests.cs index 20b8e3fe..bfb94e52 100644 --- a/Wino.Core.Tests/CalendarRangeResolverTests.cs +++ b/Wino.Core.Tests/CalendarRangeResolverTests.cs @@ -159,7 +159,7 @@ public class CalendarRangeResolverTests var text = formatter.Format(range, new TestDateContextProvider("de-DE", today: new DateOnly(2026, 3, 20))); - text.Should().Be("16. März - 22. März"); + text.Should().Be("16. März - 22"); } private static CalendarSettings CreateSettings( diff --git a/Wino.Core/Activation/AppModeActivationResolver.cs b/Wino.Core/Activation/AppModeActivationResolver.cs new file mode 100644 index 00000000..e9e979ff --- /dev/null +++ b/Wino.Core/Activation/AppModeActivationResolver.cs @@ -0,0 +1,97 @@ +#nullable enable +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Activation; + +public static class AppModeActivationResolver +{ + public static WinoApplicationMode Resolve(string? launchArguments, string? tileId, string? appId, WinoApplicationMode defaultMode = WinoApplicationMode.Mail) + { + if (TryResolveFromText(launchArguments, defaultMode, out var mode)) + return mode; + + if (TryResolveFromText(tileId, defaultMode, out mode)) + return mode; + + if (TryResolveFromText(appId, defaultMode, out mode)) + return mode; + + return defaultMode; + } + + private static bool TryResolveFromText(string? value, WinoApplicationMode defaultMode, out WinoApplicationMode mode) + { + mode = defaultMode; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (Contains(value, "--mode=toggle-default") || + Contains(value, "mode=toggle-default")) + { + mode = GetOpposite(defaultMode); + return true; + } + + if (Contains(value, "--wino-calendar") || + Contains(value, "wino-calendar") || + Contains(value, "--mode=calendar") || + Contains(value, "mode=calendar") || + Contains(value, "calendarapp") || + EqualsToken(value, "calendar")) + { + mode = WinoApplicationMode.Calendar; + return true; + } + + if (Contains(value, "wino-contacts") || + Contains(value, "--mode=contacts") || + Contains(value, "mode=contacts") || + Contains(value, "contactsapp") || + EqualsToken(value, "contacts")) + { + mode = WinoApplicationMode.Contacts; + return true; + } + + if (Contains(value, "wino-settings") || + Contains(value, "--mode=settings") || + Contains(value, "mode=settings") || + Contains(value, "settingsapp") || + EqualsToken(value, "settings")) + { + mode = WinoApplicationMode.Settings; + return true; + } + + if (Contains(value, "--wino-mail") || + Contains(value, "wino-mail") || + Contains(value, "--mode=mail") || + Contains(value, "mode=mail") || + Contains(value, "!app") || + Contains(value, "mailapp") || + EqualsToken(value, "app") || + EqualsToken(value, "mail")) + { + mode = WinoApplicationMode.Mail; + return true; + } + + return false; + } + + private static bool Contains(string source, string token) + => source.Contains(token, StringComparison.OrdinalIgnoreCase); + + private static bool EqualsToken(string source, string token) + => string.Equals(source.Trim(), token, StringComparison.OrdinalIgnoreCase); + + private static WinoApplicationMode GetOpposite(WinoApplicationMode defaultMode) + => defaultMode switch + { + WinoApplicationMode.Mail => WinoApplicationMode.Calendar, + WinoApplicationMode.Calendar => WinoApplicationMode.Mail, + _ => WinoApplicationMode.Mail + }; +} diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 3a2c3746..c9edc33b 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -465,13 +465,15 @@ public class SynchronizationManager : ISynchronizationManager try { var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token); + var downloadedEventCount = result.DownloadedEvents?.Count() ?? 0; _logger.Information("Calendar synchronization completed for account {AccountId} with state {State}", options.AccountId, result.CompletedState); - // TODO: Create notifications for new calendar events when INotificationBuilder supports it - // if (result.DownloadedEvents?.Any() ?? false) - // await _notificationBuilder.CreateCalendarNotificationsAsync(result.DownloadedEvents); + if (downloadedEventCount > 0) + { + await _notificationBuilder.AddCalendarTaskbarBadgeCountAsync(downloadedEventCount).ConfigureAwait(false); + } return result; } diff --git a/Wino.Mail.WinUI/Activation/AppEntryConstants.cs b/Wino.Mail.WinUI/Activation/AppEntryConstants.cs new file mode 100644 index 00000000..2e9b4f79 --- /dev/null +++ b/Wino.Mail.WinUI/Activation/AppEntryConstants.cs @@ -0,0 +1,35 @@ +using Windows.ApplicationModel; +using Wino.Core.Domain.Enums; + +namespace Wino.Mail.WinUI.Activation; + +internal static class AppEntryConstants +{ + public const string MailApplicationId = "App"; + public const string CalendarApplicationId = "CalendarApp"; + public const string MailLaunchArgument = "--wino-mail"; + public const string CalendarLaunchArgument = "--wino-calendar"; + + public static string GetModeLaunchArgument(WinoApplicationMode mode) + => mode switch + { + WinoApplicationMode.Calendar => CalendarLaunchArgument, + WinoApplicationMode.Contacts => "--mode=contacts", + WinoApplicationMode.Settings => "--mode=settings", + _ => MailLaunchArgument + }; + + public static string? GetPackagedApplicationId(WinoApplicationMode mode) + => mode switch + { + WinoApplicationMode.Calendar => CalendarApplicationId, + WinoApplicationMode.Mail => MailApplicationId, + _ => null + }; + + public static string GetAppUserModelId(string packageFamilyName, WinoApplicationMode mode) + => $"{packageFamilyName}!{GetPackagedApplicationId(mode) ?? MailApplicationId}"; + + public static string GetAppUserModelId(WinoApplicationMode mode) + => GetAppUserModelId(Package.Current.Id.FamilyName, mode); +} diff --git a/Wino.Mail.WinUI/Activation/AppModeActivationResolver.cs b/Wino.Mail.WinUI/Activation/AppModeActivationResolver.cs index 965da52a..2406c352 100644 --- a/Wino.Mail.WinUI/Activation/AppModeActivationResolver.cs +++ b/Wino.Mail.WinUI/Activation/AppModeActivationResolver.cs @@ -1,4 +1,3 @@ -using System; using Wino.Core.Domain.Enums; namespace Wino.Mail.WinUI.Activation; @@ -6,87 +5,5 @@ namespace Wino.Mail.WinUI.Activation; internal static class AppModeActivationResolver { public static WinoApplicationMode Resolve(string? launchArguments, string? tileId, string? appId, WinoApplicationMode defaultMode = WinoApplicationMode.Mail) - { - if (TryResolveFromText(launchArguments, defaultMode, out var mode)) - return mode; - - if (TryResolveFromText(tileId, defaultMode, out mode)) - return mode; - - if (TryResolveFromText(appId, defaultMode, out mode)) - return mode; - - return defaultMode; - } - - private static bool TryResolveFromText(string? value, WinoApplicationMode defaultMode, out WinoApplicationMode mode) - { - mode = defaultMode; - - if (string.IsNullOrWhiteSpace(value)) - return false; - - if (Contains(value, "--mode=toggle-default") || - Contains(value, "mode=toggle-default")) - { - mode = GetOpposite(defaultMode); - return true; - } - - if (Contains(value, "wino-calendar") || - Contains(value, "--mode=calendar") || - Contains(value, "mode=calendar") || - Contains(value, "calendarapp") || - EqualsToken(value, "calendar")) - { - mode = WinoApplicationMode.Calendar; - return true; - } - - if (Contains(value, "wino-contacts") || - Contains(value, "--mode=contacts") || - Contains(value, "mode=contacts") || - Contains(value, "contactsapp") || - EqualsToken(value, "contacts")) - { - mode = WinoApplicationMode.Contacts; - return true; - } - - if (Contains(value, "wino-settings") || - Contains(value, "--mode=settings") || - Contains(value, "mode=settings") || - Contains(value, "settingsapp") || - EqualsToken(value, "settings")) - { - mode = WinoApplicationMode.Settings; - return true; - } - - if (Contains(value, "wino-mail") || - Contains(value, "--mode=mail") || - Contains(value, "mode=mail") || - Contains(value, "mailapp") || - EqualsToken(value, "mail")) - { - mode = WinoApplicationMode.Mail; - return true; - } - - return false; - } - - private static bool Contains(string source, string token) - => source.Contains(token, StringComparison.OrdinalIgnoreCase); - - private static bool EqualsToken(string source, string token) - => string.Equals(source.Trim(), token, StringComparison.OrdinalIgnoreCase); - - private static WinoApplicationMode GetOpposite(WinoApplicationMode defaultMode) - => defaultMode switch - { - WinoApplicationMode.Mail => WinoApplicationMode.Calendar, - WinoApplicationMode.Calendar => WinoApplicationMode.Mail, - _ => WinoApplicationMode.Mail - }; + => Wino.Core.Activation.AppModeActivationResolver.Resolve(launchArguments, tileId, appId, defaultMode); } diff --git a/Wino.Mail.WinUI/Activation/ToastActivationResolver.cs b/Wino.Mail.WinUI/Activation/ToastActivationResolver.cs new file mode 100644 index 00000000..bbbf829d --- /dev/null +++ b/Wino.Mail.WinUI/Activation/ToastActivationResolver.cs @@ -0,0 +1,56 @@ +using Microsoft.Windows.AppNotifications; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; + +namespace Wino.Mail.WinUI.Activation; + +internal static class ToastActivationResolver +{ + public static bool TryParse(string? argument, out NotificationArguments toastArguments) + { + toastArguments = default!; + + if (string.IsNullOrWhiteSpace(argument)) + return false; + + try + { + var parsedArguments = NotificationArguments.Parse(argument); + if (!ContainsKnownToastKey(parsedArguments)) + return false; + + toastArguments = parsedArguments; + return true; + } + catch + { + return false; + } + } + + public static bool ShouldBringToForeground(NotificationArguments toastArguments) + { + if (toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string storeUpdateAction) && + storeUpdateAction == Constants.ToastStoreUpdateActionInstall) + { + return true; + } + + if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction)) + { + return calendarAction == Constants.ToastCalendarNavigateAction; + } + + if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation mailAction)) + { + return mailAction == MailOperation.Navigate; + } + + return true; + } + + private static bool ContainsKnownToastKey(NotificationArguments toastArguments) + => toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string _) || + toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string _) || + toastArguments.TryGetValue(Constants.ToastActionKey, out string _); +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 88778d93..92fb2664 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -167,19 +167,31 @@ public partial class App : WinoApplication, if (!_hasConfiguredAccounts) return ActivateWelcomeWindowAsync(); - return ActivateShellWindowAsync(_preferencesService?.DefaultApplicationMode); + return LaunchEntryOrActivateShellAsync(_preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail); } private Task OpenMailFromTrayAsync() => _hasConfiguredAccounts - ? ActivateShellWindowAsync(WinoApplicationMode.Mail) + ? LaunchEntryOrActivateShellAsync(WinoApplicationMode.Mail) : ActivateWelcomeWindowAsync(); private Task OpenCalendarFromTrayAsync() => _hasConfiguredAccounts - ? ActivateShellWindowAsync(WinoApplicationMode.Calendar) + ? LaunchEntryOrActivateShellAsync(WinoApplicationMode.Calendar) : ActivateWelcomeWindowAsync(); + private async Task LaunchEntryOrActivateShellAsync(WinoApplicationMode mode) + { + if (AppEntryConstants.GetPackagedApplicationId(mode) != null) + { + var appEntryLauncher = Services.GetRequiredService(); + if (await appEntryLauncher.LaunchAsync(mode)) + return; + } + + await ActivateShellWindowAsync(mode); + } + private async Task ActivateWelcomeWindowAsync() { var windowManager = Services.GetRequiredService(); @@ -218,7 +230,7 @@ public partial class App : WinoApplication, return; if (mode.HasValue) - shellWindow.HandleAppActivation(GetModeLaunchArgument(mode.Value)); + shellWindow.HandleAppActivation(AppEntryConstants.GetModeLaunchArgument(mode.Value)); CloseWelcomeWindowIfPresent(); await ActivateWindowAsync((WindowEx)shellWindow); @@ -455,7 +467,15 @@ public partial class App : WinoApplication, TryMarkInitialNotificationActivationHandled()) { LogActivation($"Processing notification activation from OnLaunched. Arguments: {toastArgs.Argument}"); - await HandleToastActivationAsync(toastArgs); + await HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput); + return; + } + + if (ToastActivationResolver.TryParse(args.Arguments, out var launchToastArguments) && + TryMarkInitialNotificationActivationHandled()) + { + LogActivation($"Processing toast launch activation from OnLaunched. Arguments: {args.Arguments}"); + await HandleToastActivationAsync(launchToastArguments); return; } @@ -512,18 +532,18 @@ public partial class App : WinoApplication, LogActivation($"Processing initial notification activation from application startup. Arguments: {toastArgs.Argument}"); await EnsureActivationInfrastructureAsync(); - await HandleToastActivationAsync(toastArgs); + await HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput); } private void AppNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args) { // AppNotification callbacks are not guaranteed to run on the UI thread. // Marshal toast handling to the window dispatcher before touching window APIs. - if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args)) == true) + if (MainWindow?.DispatcherQueue?.TryEnqueue(() => _ = HandleToastActivationAsync(args.Argument, args.UserInput)) == true) return; LogActivation($"Processing notification activation from NotificationInvoked. Arguments: {args.Argument}"); - _ = HandleToastActivationAsync(args); + _ = HandleToastActivationAsync(args.Argument, args.UserInput); } private void TryRegisterAppNotifications() @@ -546,11 +566,9 @@ public partial class App : WinoApplication, /// /// Handles toast notification activation scenarios. /// - private async Task HandleToastActivationAsync(AppNotificationActivatedEventArgs toastArgs) + private async Task HandleToastActivationAsync(NotificationArguments toastArguments, IDictionary? userInput = null) { - LogActivation($"Handling app notification activation. Arguments: {toastArgs.Argument}"); - - var toastArguments = NotificationArguments.Parse(toastArgs.Argument); + LogActivation("Handling app notification activation."); if (toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string storeUpdateAction) && storeUpdateAction == Constants.ToastStoreUpdateActionInstall) @@ -572,7 +590,7 @@ public partial class App : WinoApplication, if (calendarAction == Constants.ToastCalendarSnoozeAction) { - await HandleCalendarToastSnoozeAsync(toastArgs, calendarItemId); + await HandleCalendarToastSnoozeAsync(userInput, calendarItemId); return; } } @@ -600,6 +618,17 @@ public partial class App : WinoApplication, LogActivation("App notification activation did not match any known handler."); } + private Task HandleToastActivationAsync(string toastArgument, IDictionary? userInput = null) + { + if (!ToastActivationResolver.TryParse(toastArgument, out var toastArguments)) + { + LogActivation($"Ignoring non-toast launch argument: {toastArgument}"); + return Task.CompletedTask; + } + + return HandleToastActivationAsync(toastArguments, userInput); + } + private async Task EnsureShellWindowAsync(WinoApplicationMode mode, bool activateWindow, bool suppressStartupFlows = true) { var windowManager = Services.GetRequiredService(); @@ -612,7 +641,7 @@ public partial class App : WinoApplication, CreateWindow( null, - GetModeLaunchArgument(mode), + AppEntryConstants.GetModeLaunchArgument(mode), new ShellModeActivationContext { SuppressStartupFlows = suppressStartupFlows @@ -671,9 +700,9 @@ public partial class App : WinoApplication, navigationService.Navigate(WinoPage.EventDetailsPage, target); } - private async Task HandleCalendarToastSnoozeAsync(AppNotificationActivatedEventArgs toastArgs, Guid calendarItemId) + private async Task HandleCalendarToastSnoozeAsync(IDictionary? userInput, Guid calendarItemId) { - if (!TryGetSnoozeDurationMinutes(toastArgs, out var snoozeDurationMinutes)) + if (!TryGetSnoozeDurationMinutes(userInput, out var snoozeDurationMinutes)) return; var calendarService = Services.GetRequiredService(); @@ -682,15 +711,15 @@ public partial class App : WinoApplication, await calendarService.SnoozeCalendarItemAsync(calendarItemId, snoozedUntilLocal); } - private static bool TryGetSnoozeDurationMinutes(AppNotificationActivatedEventArgs toastArgs, out int snoozeDurationMinutes) + private bool TryGetSnoozeDurationMinutes(IDictionary? userInput, out int snoozeDurationMinutes) { - snoozeDurationMinutes = 0; + snoozeDurationMinutes = _preferencesService?.DefaultSnoozeDurationInMinutes ?? 0; - if (toastArgs.UserInput == null || - !toastArgs.UserInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) || + if (userInput == null || + !userInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) || selectedValue == null) { - return false; + return snoozeDurationMinutes > 0; } var selectedText = selectedValue.ToString(); @@ -910,7 +939,7 @@ public partial class App : WinoApplication, if (TryResolveActivationMode(activationArgs, defaultMode, out var activationMode)) { - shellWindow.HandleAppActivation(GetModeLaunchArgument(activationMode)); + shellWindow.HandleAppActivation(AppEntryConstants.GetModeLaunchArgument(activationMode)); return; } @@ -1049,7 +1078,7 @@ public partial class App : WinoApplication, MainWindow?.DispatcherQueue?.TryEnqueue(async () => { // Create and activate ShellWindow — ActiveWindowChanged fires and rebinds the dispatcher. - CreateWindow(null, GetModeLaunchArgument(WinoApplicationMode.Mail)); + CreateWindow(null, AppEntryConstants.GetModeLaunchArgument(WinoApplicationMode.Mail)); CloseWelcomeWindowIfPresent(); if (MainWindow != null) await ActivateWindowAsync(MainWindow); @@ -1085,7 +1114,7 @@ public partial class App : WinoApplication, CreateWindow( null, - GetModeLaunchArgument(WinoApplicationMode.Mail), + AppEntryConstants.GetModeLaunchArgument(WinoApplicationMode.Mail), new ShellModeActivationContext { SuppressStartupFlows = true @@ -1394,33 +1423,44 @@ public partial class App : WinoApplication, // Handle toast notification activation var toastArgs = (AppNotificationActivatedEventArgs)args.Data; LogActivation($"Processing redirected notification activation. Arguments: {toastArgs.Argument}"); - _ = HandleToastActivationAsync(toastArgs); + _ = HandleToastActivationAsync(toastArgs.Argument, toastArgs.UserInput); } else { + var shouldActivateWindow = true; + if (MainWindow is IWinoShellWindow shellWindow) { if (args.Kind == ExtendedActivationKind.Launch && args.Data is ILaunchActivatedEventArgs launchArgs) { - var launchArguments = launchArgs.Arguments; - - if (Program.TryConsumeRedirectedAlternateModeOverride()) + if (ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments)) { - launchArguments = AppendLaunchArgument(launchArguments, ToggleDefaultModeLaunchArgument); + shouldActivateWindow = ToastActivationResolver.ShouldBringToForeground(launchToastArguments); + LogActivation($"Processing redirected toast launch activation. Arguments: {launchArgs.Arguments}"); + _ = HandleToastActivationAsync(launchToastArguments); } + else + { + var launchArguments = launchArgs.Arguments; - shellWindow.HandleAppActivation(launchArguments, launchArgs.TileId); + if (Program.TryConsumeRedirectedAlternateModeOverride()) + { + launchArguments = AppendLaunchArgument(launchArguments, ToggleDefaultModeLaunchArgument); + } + + shellWindow.HandleAppActivation(launchArguments, launchArgs.TileId); + } } else if (TryResolveActivationMode(args, _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail, out var redirectedMode)) { - shellWindow.HandleAppActivation(GetModeLaunchArgument(redirectedMode)); + shellWindow.HandleAppActivation(AppEntryConstants.GetModeLaunchArgument(redirectedMode)); } } // Redirected launches can target a shell window that is currently hidden in the tray. // Restore it through the window manager so Show/BringToFront/Activate happen together. - if (MainWindow is WindowEx mainWindow) + if (shouldActivateWindow && MainWindow is WindowEx mainWindow) { Services.GetRequiredService().ActivateWindow(mainWindow); } @@ -1434,15 +1474,6 @@ public partial class App : WinoApplication, _ = HandleRedirectedActivationAsync(); } - private static string GetModeLaunchArgument(WinoApplicationMode mode) - => mode switch - { - WinoApplicationMode.Calendar => "--mode=calendar", - WinoApplicationMode.Contacts => "--mode=contacts", - WinoApplicationMode.Settings => "--mode=settings", - _ => "--mode=mail" - }; - private static string AppendLaunchArgument(string? launchArguments, string launchArgument) { return string.IsNullOrWhiteSpace(launchArguments) diff --git a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs index 0c1b42b2..048013e8 100644 --- a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs +++ b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs @@ -36,6 +36,7 @@ public static class CoreUWPContainerSetup services.AddTransient(); services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddSingleton(); diff --git a/Wino.Mail.WinUI/Helpers/WindowAppUserModelIdHelper.cs b/Wino.Mail.WinUI/Helpers/WindowAppUserModelIdHelper.cs new file mode 100644 index 00000000..be130f97 --- /dev/null +++ b/Wino.Mail.WinUI/Helpers/WindowAppUserModelIdHelper.cs @@ -0,0 +1,100 @@ +using System; +using System.Runtime.InteropServices; +using WinUIEx; + +namespace Wino.Mail.WinUI.Helpers; + +internal static class WindowAppUserModelIdHelper +{ + private static readonly Guid PropertyStoreGuid = new("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"); + private static readonly PropertyKey AppUserModelIdPropertyKey = new(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 5); + + public static void TrySet(WindowEx window, string appUserModelId) + { + ArgumentNullException.ThrowIfNull(window); + + if (string.IsNullOrWhiteSpace(appUserModelId)) + return; + + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window); + if (hwnd == IntPtr.Zero) + return; + + var propertyStoreGuid = PropertyStoreGuid; + var appUserModelIdPropertyKey = AppUserModelIdPropertyKey; + var hr = SHGetPropertyStoreForWindow(hwnd, ref propertyStoreGuid, out var propertyStore); + if (hr < 0 || propertyStore == null) + return; + + using (propertyStore) + { + using var value = PropVariant.FromString(appUserModelId); + propertyStore.SetValue(ref appUserModelIdPropertyKey, value); + propertyStore.Commit(); + } + } + catch + { + // Best effort only. Some Windows builds may keep the original taskbar identity. + } + } + + [DllImport("shell32.dll")] + private static extern int SHGetPropertyStoreForWindow( + IntPtr hwnd, + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IPropertyStore propertyStore); + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private readonly struct PropertyKey(Guid fmtid, uint pid) + { + public Guid FormatId { get; } = fmtid; + public uint PropertyId { get; } = pid; + } + + [ComImport] + [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPropertyStore : IDisposable + { + uint GetCount(); + void GetAt(uint propertyIndex, out PropertyKey key); + void GetValue(ref PropertyKey key, out PropVariant pv); + void SetValue(ref PropertyKey key, PropVariant pv); + void Commit(); + } + + [StructLayout(LayoutKind.Explicit)] + private sealed class PropVariant : IDisposable + { + [FieldOffset(0)] + private ushort _valueType; + + [FieldOffset(8)] + private IntPtr _pointerValue; + + private PropVariant(string value) + { + _valueType = 31; + _pointerValue = Marshal.StringToCoTaskMemUni(value); + } + + public static PropVariant FromString(string value) => new(value); + + public void Dispose() + { + PropVariantClear(this); + GC.SuppressFinalize(this); + } + + ~PropVariant() + { + Dispose(); + } + + [DllImport("ole32.dll")] + private static extern int PropVariantClear([In, Out] PropVariant propvar); + } +} diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index 843427f6..af6072a8 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -46,7 +46,7 @@ + uap10:Parameters="--wino-mail"> - - - - Calendar Protocol - - - - - - Calendar Protocol (Secure) - - - @@ -119,18 +106,58 @@ - - - - - Assets\AppEntries\CalendarAssets\Square44x44Logo.png - - .ics - - - + + + + + + + + + + + + + + + + + + + + + + + Calendar Protocol + + + + + + Calendar Protocol (Secure) + + + + + + Assets\AppEntries\CalendarAssets\Square44x44Logo.png + + .ics + + + + + diff --git a/Wino.Mail.WinUI/Program.cs b/Wino.Mail.WinUI/Program.cs index 4763d251..5444cb3c 100644 --- a/Wino.Mail.WinUI/Program.cs +++ b/Wino.Mail.WinUI/Program.cs @@ -7,8 +7,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppLifecycle; -using Wino.Core.Domain; -using Wino.Core.Domain.Enums; +using Wino.Mail.WinUI.Activation; namespace Wino.Mail.WinUI; @@ -200,28 +199,19 @@ public class Program private static bool ShouldBringWindowToForegroundAfterRedirection(AppActivationArguments args) { - if (args.Kind != ExtendedActivationKind.AppNotification || - args.Data is not AppNotificationActivatedEventArgs toastArgs) + if (args.Kind == ExtendedActivationKind.AppNotification && + args.Data is AppNotificationActivatedEventArgs toastArgs) { - return true; + return ToastActivationResolver.TryParse(toastArgs.Argument, out var toastArguments) + ? ToastActivationResolver.ShouldBringToForeground(toastArguments) + : true; } - var toastArguments = NotificationArguments.Parse(toastArgs.Argument); - - if (toastArguments.TryGetValue(Constants.ToastStoreUpdateActionKey, out string storeUpdateAction) && - storeUpdateAction == Constants.ToastStoreUpdateActionInstall) + if (args.Kind == ExtendedActivationKind.Launch && + args.Data is Windows.ApplicationModel.Activation.ILaunchActivatedEventArgs launchArgs && + ToastActivationResolver.TryParse(launchArgs.Arguments, out var launchToastArguments)) { - return true; - } - - if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction)) - { - return calendarAction == Constants.ToastCalendarNavigateAction; - } - - if (toastArguments.TryGetValue(Constants.ToastActionKey, out MailOperation mailAction)) - { - return mailAction == MailOperation.Navigate; + return ToastActivationResolver.ShouldBringToForeground(launchToastArguments); } return true; diff --git a/Wino.Mail.WinUI/Services/NotificationBuilder.cs b/Wino.Mail.WinUI/Services/NotificationBuilder.cs index 9a958127..e07ae586 100644 --- a/Wino.Mail.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Mail.WinUI/Services/NotificationBuilder.cs @@ -2,12 +2,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.Windows.AppNotifications; -using Microsoft.Windows.AppNotifications.Builder; +using CommunityToolkit.WinUI.Notifications; using Serilog; using Windows.Data.Xml.Dom; +using Windows.Storage; using Windows.UI.Notifications; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; @@ -16,14 +17,15 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; +using Wino.Mail.WinUI.Activation; 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 int _calendarTaskbarBadgeCount; private readonly IAccountService _accountService; private readonly IFolderService _folderService; @@ -53,50 +55,33 @@ public class NotificationBuilder : INotificationBuilder { 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); + if (mailItem != null) + { + inboxMailItems.Add(mailItem); + } } var mailCount = inboxMailItems.Count; - if (mailCount == 0) return; - // If there are more than 3 mails, just display 1 general notification. if (mailCount > 3) { - var builder = CreateBuilder(); + var builder = new ToastContentBuilder() + .AddText(Translator.Notifications_MultipleNotificationsTitle) + .AddText(string.Format(Translator.Notifications_MultipleNotificationsMessage, mailCount)) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail) + .AddAudio(new ToastAudio + { + Src = new Uri("ms-winsoundevent:Notification.Mail") + }); - builder.AddText(Translator.Notifications_MultipleNotificationsTitle); - builder.AddText(string.Format(Translator.Notifications_MultipleNotificationsMessage, mailCount)); - builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Mail")); - - ShowNotification(builder); + ShowToast(builder, ToastTargetApp.Mail); } else { @@ -114,104 +99,28 @@ public class NotificationBuilder : INotificationBuilder } } - private async Task CreateSingleNotificationAsync(MailCopy mailItem) - { - var builder = CreateBuilder(); - - 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.SetAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), AppNotificationImageCrop.Default); - } - - builder.SetTimeStamp(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.ToString()); - builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - - builder.AddButton(GetMarkAsReadButton(mailItem.UniqueId)); - builder.AddButton(GetDeleteButton(mailItem.UniqueId)); - builder.AddButton(GetArchiveButton(mailItem.UniqueId)); - builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Mail")); - - ShowNotification(builder, mailItem.UniqueId.ToString()); - } - - private AppNotificationButton GetArchiveButton(Guid mailUniqueId) - => new AppNotificationButton(Translator.MailOperation_Archive) - .SetIcon(GetNotificationIconUri("mail-archive")) - .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) - .AddArgument(Constants.ToastActionKey, MailOperation.Archive.ToString()) - .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - - private AppNotificationButton GetDeleteButton(Guid mailUniqueId) - => new AppNotificationButton(Translator.MailOperation_Delete) - .SetIcon(GetNotificationIconUri("mail-delete")) - .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) - .AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete.ToString()) - .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - - private AppNotificationButton GetMarkAsReadButton(Guid mailUniqueId) - => new AppNotificationButton(Translator.MailOperation_MarkAsRead) - .SetIcon(GetNotificationIconUri("mail-markread")) - .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) - .AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead.ToString()) - .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - public async Task UpdateTaskbarIconBadgeAsync() { - int totalUnreadCount = 0; + var totalUnreadCount = 0; try { - var badgeUpdater = BadgeUpdateManager.CreateBadgeUpdaterForApplication(MailApplicationId); var accounts = await _accountService.GetAccountsAsync(); foreach (var account in accounts) { - if (!account.Preferences.IsTaskbarBadgeEnabled) continue; + 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) - { - XmlDocument badgeXml = BadgeUpdateManager.GetTemplateContent(BadgeTemplateType.BadgeNumber); - - XmlElement? badgeElement = badgeXml.SelectSingleNode("/badge") as XmlElement; - if (badgeElement == null) - { - badgeUpdater.Clear(); - return; - } - - badgeElement.SetAttribute("value", totalUnreadCount.ToString()); - - BadgeNotification badge = new(badgeXml); - badgeUpdater.Update(badge); - } - else - { - badgeUpdater.Clear(); - } + UpdateBadge(AppEntryConstants.MailApplicationId, totalUnreadCount > 0 ? totalUnreadCount : null); } catch (Exception ex) { @@ -219,15 +128,34 @@ public class NotificationBuilder : INotificationBuilder } } + public Task AddCalendarTaskbarBadgeCountAsync(int newlyDownloadedCount) + { + if (newlyDownloadedCount <= 0) + return Task.CompletedTask; + + var badgeCount = Interlocked.Add(ref _calendarTaskbarBadgeCount, newlyDownloadedCount); + UpdateBadge(AppEntryConstants.CalendarApplicationId, badgeCount > 0 ? badgeCount : null); + return Task.CompletedTask; + } + + public Task ClearCalendarTaskbarBadgeAsync() + { + Interlocked.Exchange(ref _calendarTaskbarBadgeCount, 0); + UpdateBadge(AppEntryConstants.CalendarApplicationId, null); + return Task.CompletedTask; + } + public void RemoveNotification(Guid mailUniqueId) { try { - AppNotificationManager.Default.RemoveByTagAsync(mailUniqueId.ToString()).AsTask().GetAwaiter().GetResult(); + ToastNotificationManager.History.Remove( + mailUniqueId.ToString(), + null, + AppEntryConstants.GetAppUserModelId(WinoApplicationMode.Mail)); } catch (ArgumentException) { - // Notification does not exists. Ignore. } catch (Exception ex) { @@ -237,39 +165,38 @@ public class NotificationBuilder : INotificationBuilder public void CreateAttentionRequiredNotification(MailAccount account) { - var builder = CreateBuilder(); - - builder.AddText(Translator.Exception_AccountNeedsAttention_Title); - builder.AddText(string.Format(Translator.Exception_AccountNeedsAttention_Message, account.Name)); - builder.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()); - builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - builder.AddButton(new AppNotificationButton(Translator.Buttons_FixAccount) + var builder = new ToastContentBuilder() + .AddText(Translator.Exception_AccountNeedsAttention_Title) + .AddText(string.Format(Translator.Exception_AccountNeedsAttention_Message, account.Name)) .AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()) - .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)); + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail) + .AddButton(new ToastButton() + .SetContent(Translator.Buttons_FixAccount) + .AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail)); - ShowNotification(builder); + ShowToast(builder, ToastTargetApp.Mail); } public void CreateWebView2RuntimeMissingNotification() { - var builder = CreateBuilder(); + var builder = new ToastContentBuilder() + .AddText(Translator.Exception_WebView2RuntimeMissing_Title) + .AddText(Translator.Exception_WebView2RuntimeMissing_Message) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - builder.AddText(Translator.Exception_WebView2RuntimeMissing_Title); - builder.AddText(Translator.Exception_WebView2RuntimeMissing_Message); - builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - - ShowNotification(builder); + ShowToast(builder, ToastTargetApp.Mail); } public void CreateStoreUpdateNotification() { - var builder = CreateBuilder(); + var builder = new ToastContentBuilder() + .AddText(Translator.Notifications_StoreUpdateAvailableTitle) + .AddText(Translator.Notifications_StoreUpdateAvailableMessage) + .AddArgument(Constants.ToastStoreUpdateActionKey, Constants.ToastStoreUpdateActionInstall) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - builder.AddText(Translator.Notifications_StoreUpdateAvailableTitle); - builder.AddText(Translator.Notifications_StoreUpdateAvailableMessage); - builder.AddArgument(Constants.ToastStoreUpdateActionKey, Constants.ToastStoreUpdateActionInstall); - - ShowNotification(builder, "store-update-available"); + ShowToast(builder, ToastTargetApp.Mail, "store-update-available"); } public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) @@ -277,11 +204,10 @@ public class NotificationBuilder : INotificationBuilder if (calendarItem == null) return Task.CompletedTask; - var builder = CreateBuilder(AppNotificationScenario.Reminder); - + var builder = new ToastContentBuilder() + .SetToastScenario(ToastScenario.Reminder); var localStart = calendarItem.GetLocalStartDate(); - var nowLocal = DateTime.Now; - var reminderContext = GetCalendarReminderContext(localStart, nowLocal); + var reminderContext = GetCalendarReminderContext(localStart, DateTime.Now); builder.AddText(calendarItem.Title); builder.AddText($"{reminderContext} - {localStart:g}"); @@ -292,6 +218,10 @@ public class NotificationBuilder : INotificationBuilder builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction); builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar); + builder.AddAudio(new ToastAudio + { + Src = new Uri("ms-winsoundevent:Notification.Reminder") + }); var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( reminderDurationInSeconds, @@ -304,42 +234,108 @@ public class NotificationBuilder : INotificationBuilder ? preferredSnoozeMinutes : allowedSnoozeMinutes[0]; - var selectionBox = new AppNotificationComboBox(Constants.ToastCalendarSnoozeDurationInputId) - .SetSelectedItem(defaultSnoozeMinutes.ToString()); + var selectionBox = new ToastSelectionBox(Constants.ToastCalendarSnoozeDurationInputId) + { + DefaultSelectionBoxItemId = defaultSnoozeMinutes.ToString() + }; foreach (var snoozeMinutes in allowedSnoozeMinutes) { - selectionBox.AddItem( + selectionBox.Items.Add(new ToastSelectionBoxItem( snoozeMinutes.ToString(), - string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes)); + string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes))); } - builder.AddComboBox(selectionBox); - builder.AddButton(new AppNotificationButton(Translator.CalendarReminder_SnoozeAction) + builder.AddToastInput(selectionBox); + builder.AddButton(new ToastButton() + .SetContent(Translator.CalendarReminder_SnoozeAction) + .SetImageUri(GetNotificationIconUri("calendar-snooze")) + .SetBackgroundActivation() .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); } - builder.AddButton(new AppNotificationButton(Translator.Buttons_Open) + builder.AddButton(new ToastButton() + .SetContent(Translator.Buttons_Open) + .SetBackgroundActivation() .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction) .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out var joinUri)) { - builder.AddButton(new AppNotificationButton(Translator.CalendarEventDetails_JoinOnline) - .SetInvokeUri(joinUri)); + builder.AddButton(new ToastButton() + .SetContent(Translator.CalendarEventDetails_JoinOnline) + .SetImageUri(GetNotificationIconUri("calendar-join")) + .SetProtocolActivation(joinUri)); } - builder.SetAudioUri(new Uri("ms-winsoundevent:Notification.Reminder")); - var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}"; - ShowNotification(builder, tag); + ShowToast(builder, ToastTargetApp.Calendar, tag); return Task.CompletedTask; } + private async Task CreateSingleNotificationAsync(MailCopy mailItem) + { + var builder = new ToastContentBuilder(); + + 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}"), ToastGenericAppLogoCrop.Default); + } + + 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") + }); + + ShowToast(builder, ToastTargetApp.Mail, mailItem.UniqueId.ToString()); + } + + private void UpdateBadge(string applicationId, int? badgeCount) + { + var badgeUpdater = BadgeUpdateManager.CreateBadgeUpdaterForApplication(applicationId); + + if (!badgeCount.HasValue || badgeCount.Value <= 0) + { + badgeUpdater.Clear(); + return; + } + + XmlDocument badgeXml = BadgeUpdateManager.GetTemplateContent(BadgeTemplateType.BadgeNumber); + if (badgeXml.SelectSingleNode("/badge") is not XmlElement badgeElement) + { + badgeUpdater.Clear(); + return; + } + + badgeElement.SetAttribute("value", badgeCount.Value.ToString()); + badgeUpdater.Update(new BadgeNotification(badgeXml)); + } + private static string GetCalendarReminderContext(DateTime localStart, DateTime nowLocal) { var delta = localStart - nowLocal; @@ -370,22 +366,55 @@ public class NotificationBuilder : INotificationBuilder return string.Format(Translator.CalendarReminder_StartedMinutesAgo, minutesAgo); } - private static AppNotificationBuilder CreateBuilder(AppNotificationScenario scenario = AppNotificationScenario.Default) - => new AppNotificationBuilder().SetScenario(scenario); + private ToastButton GetArchiveButton(Guid mailUniqueId) + => new ToastButton() + .SetContent(Translator.MailOperation_Archive) + .SetImageUri(GetNotificationIconUri("mail-archive")) + .SetBackgroundActivation() + .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) + .AddArgument(Constants.ToastActionKey, MailOperation.Archive) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - private static void ShowNotification(AppNotificationBuilder builder, string? tag = null) + private ToastButton GetDeleteButton(Guid mailUniqueId) + => new ToastButton() + .SetContent(Translator.MailOperation_Delete) + .SetImageUri(GetNotificationIconUri("mail-delete")) + .SetBackgroundActivation() + .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) + .AddArgument(Constants.ToastActionKey, MailOperation.SoftDelete) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); + + private ToastButton GetMarkAsReadButton(Guid mailUniqueId) + => new ToastButton() + .SetContent(Translator.MailOperation_MarkAsRead) + .SetImageUri(GetNotificationIconUri("mail-markread")) + .SetBackgroundActivation() + .AddArgument(Constants.ToastMailUniqueIdKey, mailUniqueId.ToString()) + .AddArgument(Constants.ToastActionKey, MailOperation.MarkAsRead) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); + + private static void ShowToast(ToastContentBuilder builder, ToastTargetApp targetApp, string? tag = null) { - var notification = builder.BuildNotification(); + var toastNotification = new ToastNotification(builder.GetToastContent().GetXml()); if (!string.IsNullOrWhiteSpace(tag)) - notification.Tag = tag; + { + toastNotification.Tag = tag; + } - AppNotificationManager.Default.Show(notification); + ToastNotificationManager + .CreateToastNotifier(AppEntryConstants.GetAppUserModelId(targetApp == ToastTargetApp.Calendar + ? WinoApplicationMode.Calendar + : WinoApplicationMode.Mail)) + .Show(toastNotification); } - private Uri GetNotificationIconUri(string iconName) + private static Uri GetNotificationIconUri(string iconName) + => new($"{NotificationIconRootUri}{iconName}.png"); + + private enum ToastTargetApp { - // Keep the URI unqualified so Windows resolves the best matching theme/scale asset from the package. - return new($"{NotificationIconRootUri}{iconName}.png"); + Mail, + Calendar } } diff --git a/Wino.Mail.WinUI/Services/PackagedAppEntryLauncher.cs b/Wino.Mail.WinUI/Services/PackagedAppEntryLauncher.cs new file mode 100644 index 00000000..f8f3f18a --- /dev/null +++ b/Wino.Mail.WinUI/Services/PackagedAppEntryLauncher.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Windows.ApplicationModel; +using Wino.Core.Domain.Enums; +using Wino.Mail.WinUI.Activation; + +namespace Wino.Mail.WinUI.Services; + +internal sealed class PackagedAppEntryLauncher +{ + public async Task LaunchAsync(WinoApplicationMode mode) + { + var targetApplicationId = AppEntryConstants.GetPackagedApplicationId(mode); + if (string.IsNullOrWhiteSpace(targetApplicationId)) + return false; + + var targetAppUserModelId = AppEntryConstants.GetAppUserModelId(mode); + var appEntries = await Package.Current.GetAppListEntriesAsync(); + var appEntry = appEntries.FirstOrDefault(entry => + string.Equals(entry.AppUserModelId, targetAppUserModelId, StringComparison.OrdinalIgnoreCase)); + + return appEntry != null && await appEntry.LaunchAsync(); + } +} diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs index ac1a756a..755687ab 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml.cs +++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs @@ -117,6 +117,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, public void HandleAppActivation(string? launchArguments, string? tileId = null, string? appId = null) { var targetMode = AppModeActivationResolver.Resolve(launchArguments, tileId, appId, PreferencesService.DefaultApplicationMode); + WindowAppUserModelIdHelper.TrySet(this, AppEntryConstants.GetAppUserModelId(targetMode)); NavigationService.ChangeApplicationMode(targetMode); } diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index 217e9d54..813c9993 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -174,6 +174,7 @@ +