Restore dual mail and calendar app entries

This commit is contained in:
Burak Kaan Köse
2026-04-11 01:28:19 +02:00
parent 4cb08f0a98
commit fdb340549d
19 changed files with 756 additions and 336 deletions
@@ -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<Guid, CalendarItemViewModel> _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);
}
@@ -20,6 +20,16 @@ public interface INotificationBuilder
/// <returns></returns>
Task UpdateTaskbarIconBadgeAsync();
/// <summary>
/// Adds to the calendar app-entry badge count for newly downloaded events.
/// </summary>
Task AddCalendarTaskbarBadgeCountAsync(int newlyDownloadedCount);
/// <summary>
/// Clears the calendar app-entry badge.
/// </summary>
Task ClearCalendarTaskbarBadgeAsync();
/// <summary>
/// Removes the toast notification for a specific mail by unique id.
/// </summary>
@@ -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);
}
}
+52 -2
View File
@@ -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<ICalendarService>();
var notificationBuilder = new Mock<INotificationBuilder>();
calendarService
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.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<IKeyPressService>(),
nativeAppService ?? Mock.Of<INativeAppService>(),
accountCalendarStateService,
notificationBuilder ?? Mock.Of<INotificationBuilder>(),
preferencesService,
requestDelegator ?? Mock.Of<IWinoRequestDelegator>(),
dialogService ?? Mock.Of<IMailDialogService>(),
@@ -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(
@@ -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
};
}
+5 -3
View File
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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 _);
}
+72 -41
View File
@@ -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<PackagedAppEntryLauncher>();
if (await appEntryLauncher.LaunchAsync(mode))
return;
}
await ActivateShellWindowAsync(mode);
}
private async Task ActivateWelcomeWindowAsync()
{
var windowManager = Services.GetRequiredService<IWinoWindowManager>();
@@ -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,
/// <summary>
/// Handles toast notification activation scenarios.
/// </summary>
private async Task HandleToastActivationAsync(AppNotificationActivatedEventArgs toastArgs)
private async Task HandleToastActivationAsync(NotificationArguments toastArguments, IDictionary<string, string>? 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<string, string>? 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<IWinoShellWindow?> EnsureShellWindowAsync(WinoApplicationMode mode, bool activateWindow, bool suppressStartupFlows = true)
{
var windowManager = Services.GetRequiredService<IWinoWindowManager>();
@@ -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<string, string>? userInput, Guid calendarItemId)
{
if (!TryGetSnoozeDurationMinutes(toastArgs, out var snoozeDurationMinutes))
if (!TryGetSnoozeDurationMinutes(userInput, out var snoozeDurationMinutes))
return;
var calendarService = Services.GetRequiredService<ICalendarService>();
@@ -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<string, string>? 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<IWinoWindowManager>().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)
+1
View File
@@ -36,6 +36,7 @@ public static class CoreUWPContainerSetup
services.AddTransient<IWebView2RuntimeValidatorService, WebView2RuntimeValidatorService>();
services.AddTransient<INotificationBuilder, NotificationBuilder>();
services.AddSingleton<ICalendarReminderServer, CalendarReminderServer>();
services.AddSingleton<PackagedAppEntryLauncher>();
services.AddTransient<IClipboardService, ClipboardService>();
services.AddTransient<IStartupBehaviorService, StartupBehaviorService>();
services.AddSingleton<IPrintService, PrintService>();
@@ -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);
}
}
+51 -24
View File
@@ -46,7 +46,7 @@
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$"
uap10:Parameters="--mode=mail">
uap10:Parameters="--wino-mail">
<uap:VisualElements
DisplayName="Wino Mail"
Description="Wino.Mail.WinUI"
@@ -97,19 +97,6 @@
</uap:Protocol>
</uap:Extension>
<!-- Protocol activation: webcal -->
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="webcal">
<uap:DisplayName>Calendar Protocol</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="webcals">
<uap:DisplayName>Calendar Protocol (Secure)</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<!-- File Assosication: EML -->
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="eml">
@@ -119,18 +106,58 @@
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
<!-- File Association: ICS -->
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="ics">
<uap:Logo>Assets\AppEntries\CalendarAssets\Square44x44Logo.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.ics</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
<Application Id="CalendarApp"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$"
uap10:Parameters="--wino-calendar">
<uap:VisualElements
DisplayName="Wino Calendar"
Description="Wino.Mail.WinUI.Calendar"
BackgroundColor="transparent"
Square150x150Logo="Assets\AppEntries\CalendarAssets\Square150x150Logo.png"
Square44x44Logo="Assets\AppEntries\CalendarAssets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\AppEntries\CalendarAssets\Wide310x150Logo.png" Square71x71Logo="Assets\AppEntries\CalendarAssets\SmallTile.png" Square310x310Logo="Assets\AppEntries\CalendarAssets\LargeTile.png"/>
<uap:SplashScreen Image="Assets\AppEntries\CalendarAssets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.toastNotificationActivation">
<desktop:ToastNotificationActivation ToastActivatorCLSID="44c05d2b-aa1d-4e59-9d7d-8b4c8607cb8d" />
</desktop:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Wino.Mail.WinUI.exe" Arguments="----AppNotificationActivated:" DisplayName="Calendar toast activator">
<com:Class Id="44c05d2b-aa1d-4e59-9d7d-8b4c8607cb8d" DisplayName="Calendar toast activator"/>
</com:ExeServer>
</com:ComServer>
</com:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="webcal">
<uap:DisplayName>Calendar Protocol</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="webcals">
<uap:DisplayName>Calendar Protocol (Secure)</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="ics">
<uap:Logo>Assets\AppEntries\CalendarAssets\Square44x44Logo.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.ics</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
+10 -20
View File
@@ -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;
+190 -161
View File
@@ -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<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);
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
}
}
@@ -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<bool> 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();
}
}
+1
View File
@@ -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);
}
+1
View File
@@ -174,6 +174,7 @@
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.Labs.WinUI.DependencyPropertyGenerator" />
<PackageReference Include="CommunityToolkit.WinUI.Notifications" />
<PackageReference Include="CommunityToolkit.WinUI.Animations" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" />