diff --git a/Wino.Core.Domain/CalendarReminderSnoozeOptions.cs b/Wino.Core.Domain/CalendarReminderSnoozeOptions.cs new file mode 100644 index 00000000..dbff15fb --- /dev/null +++ b/Wino.Core.Domain/CalendarReminderSnoozeOptions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Wino.Core.Domain; + +public static class CalendarReminderSnoozeOptions +{ + private static readonly int[] SupportedSnoozeMinutes = [5, 10, 15, 30]; + + public static IReadOnlyList GetAllowedSnoozeMinutes(long reminderDurationInSeconds, long defaultReminderDurationInSeconds) + { + var reminderMinutes = (int)Math.Max(0, reminderDurationInSeconds / 60); + + if (reminderMinutes <= 0) + return []; + + var maxSnoozeMinutes = reminderMinutes; + var defaultReminderMinutes = (int)Math.Max(0, defaultReminderDurationInSeconds / 60); + + if (defaultReminderMinutes > 0) + maxSnoozeMinutes = Math.Min(maxSnoozeMinutes, defaultReminderMinutes); + + return SupportedSnoozeMinutes.Where(minutes => minutes <= maxSnoozeMinutes).ToArray(); + } +} diff --git a/Wino.Core.Domain/Constants.cs b/Wino.Core.Domain/Constants.cs index 6c57207b..6165b628 100644 --- a/Wino.Core.Domain/Constants.cs +++ b/Wino.Core.Domain/Constants.cs @@ -16,6 +16,8 @@ public static class Constants public const string ToastCalendarItemIdKey = nameof(ToastCalendarItemIdKey); public const string ToastCalendarActionKey = nameof(ToastCalendarActionKey); public const string ToastCalendarNavigateAction = nameof(ToastCalendarNavigateAction); + public const string ToastCalendarSnoozeAction = nameof(ToastCalendarSnoozeAction); + public const string ToastCalendarSnoozeDurationInputId = nameof(ToastCalendarSnoozeDurationInputId); public const string ToastModeKey = nameof(ToastModeKey); public const string ToastModeMail = nameof(ToastModeMail); public const string ToastModeCalendar = nameof(ToastModeCalendar); diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index 86d6235a..a04576f9 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -138,6 +138,7 @@ public class CalendarItem : ICalendarItem // TODO public string CustomEventColorHex { get; set; } public string HtmlLink { get; set; } + public DateTime? SnoozedUntil { get; set; } public CalendarItemStatus Status { get; set; } public CalendarItemVisibility Visibility { get; set; } diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs index 6e2ba3c1..d41f6dc0 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs @@ -42,6 +42,7 @@ public interface ICalendarService Task UpdateCalendarItemAsync(CalendarItem calendarItem, List attendees); Task> GetRemindersAsync(Guid calendarItemId); Task SaveRemindersAsync(Guid calendarItemId, List reminders); + Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal); /// /// Checks due reminder windows and returns reminder notifications that should trigger now. diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index ba2f5ee1..8bf3b921 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -133,6 +133,7 @@ "CalendarEventDetails_People": "People", "CalendarEventDetails_ReadOnlyEvent": "Read-only event", "CalendarEventDetails_Reminder": "Reminder", + "CalendarReminder_SnoozeMinutesOption": "{0} minutes", "CalendarEventDetails_ShowAs": "Show as", "CalendarShowAs_Free": "Free", "CalendarShowAs_Tentative": "Tentative", diff --git a/Wino.Core.Tests/CalendarReminderSnoozeOptionsTests.cs b/Wino.Core.Tests/CalendarReminderSnoozeOptionsTests.cs new file mode 100644 index 00000000..561e3024 --- /dev/null +++ b/Wino.Core.Tests/CalendarReminderSnoozeOptionsTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Wino.Core.Domain; +using Xunit; + +namespace Wino.Core.Tests; + +public class CalendarReminderSnoozeOptionsTests +{ + [Fact] + public void GetAllowedSnoozeMinutes_WhenDefaultIs15AndReminderIs15_Excludes30() + { + var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( + reminderDurationInSeconds: 15 * 60, + defaultReminderDurationInSeconds: 15 * 60); + + options.Should().Equal(5, 10, 15); + } + + [Fact] + public void GetAllowedSnoozeMinutes_WhenReminderIs5AndDefaultIs15_DoesNotPassEventStart() + { + var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( + reminderDurationInSeconds: 5 * 60, + defaultReminderDurationInSeconds: 15 * 60); + + options.Should().Equal(5); + } + + [Fact] + public void GetAllowedSnoozeMinutes_WhenDefaultReminderIsNone_UsesReminderDurationOnly() + { + var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( + reminderDurationInSeconds: 30 * 60, + defaultReminderDurationInSeconds: 0); + + options.Should().Equal(5, 10, 15, 30); + } + + [Fact] + public void GetAllowedSnoozeMinutes_WhenReminderIsUnderFiveMinutes_ReturnsNoOptions() + { + var options = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( + reminderDurationInSeconds: 60, + defaultReminderDurationInSeconds: 15 * 60); + + options.Should().BeEmpty(); + } +} diff --git a/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs b/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs index 00261663..23e9b62b 100644 --- a/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs +++ b/Wino.Core.Tests/Services/CalendarReminderServiceTests.cs @@ -68,8 +68,8 @@ public class CalendarReminderServiceTests : IAsyncLifetime due.Should().HaveCount(1); due[0].CalendarItem.Id.Should().Be(calendarItem.Id); due[0].ReminderDurationInSeconds.Should().Be(5 * 60); - due[0].ReminderKey.Should().Be($"{calendarItem.Id:N}:{5 * 60}"); - sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}"); + due[0].ReminderKey.Should().StartWith($"{calendarItem.Id:N}:{5 * 60}:"); + sentReminderKeys.Should().ContainSingle(k => k.StartsWith($"{calendarItem.Id:N}:{5 * 60}:")); } [Fact] @@ -108,7 +108,7 @@ public class CalendarReminderServiceTests : IAsyncLifetime firstRun.Should().HaveCount(1); secondRun.Should().BeEmpty(); - sentReminderKeys.Should().Contain($"{calendarItem.Id:N}:{5 * 60}"); + sentReminderKeys.Should().ContainSingle(k => k.StartsWith($"{calendarItem.Id:N}:{5 * 60}:")); } [Fact] @@ -189,6 +189,35 @@ public class CalendarReminderServiceTests : IAsyncLifetime due.Should().BeEmpty(); } + + [Fact] + public async Task CheckAndNotifyAsync_WhenItemIsSnoozed_TriggersAtSnoozedTime() + { + var nowLocal = new DateTime(2026, 1, 1, 10, 0, 0); + var lastCheckLocal = nowLocal.AddSeconds(-30); + + var calendarItem = await CreateCalendarItemWithReminderAsync( + startDate: nowLocal.AddMinutes(5), + reminderDurationInSeconds: 5 * 60, + reminderType: CalendarItemReminderType.Popup); + + await _calendarService.SnoozeCalendarItemAsync(calendarItem.Id, nowLocal.AddMinutes(10)); + + HashSet sentReminderKeys = []; + + var dueAtOriginalTrigger = await _calendarService.CheckAndNotifyAsync(lastCheckLocal, nowLocal, sentReminderKeys); + dueAtOriginalTrigger.Should().BeEmpty(); + + var snoozeTriggerWindowStart = nowLocal.AddMinutes(10).AddSeconds(-30); + var snoozeTriggerWindowEnd = nowLocal.AddMinutes(10); + + var dueAtSnoozeTime = await _calendarService.CheckAndNotifyAsync(snoozeTriggerWindowStart, snoozeTriggerWindowEnd, sentReminderKeys); + + dueAtSnoozeTime.Should().HaveCount(1); + dueAtSnoozeTime[0].CalendarItem.Id.Should().Be(calendarItem.Id); + dueAtSnoozeTime[0].ReminderKey.Should().StartWith($"{calendarItem.Id:N}:{5 * 60}:"); + } + private async Task CreateCalendarItemWithReminderAsync( DateTime startDate, long reminderDurationInSeconds, diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index b303ec45..0a206ed9 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -215,12 +215,20 @@ public partial class App : WinoApplication, // Check calendar reminder toast activation first. if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) && - calendarAction == Constants.ToastCalendarNavigateAction && toastArguments.TryGetValue(Constants.ToastCalendarItemIdKey, out string calendarItemIdString) && Guid.TryParse(calendarItemIdString, out Guid calendarItemId)) { - await HandleCalendarToastNavigationAsync(calendarItemId); - return; + if (calendarAction == Constants.ToastCalendarNavigateAction) + { + await HandleCalendarToastNavigationAsync(calendarItemId); + return; + } + + if (calendarAction == Constants.ToastCalendarSnoozeAction) + { + await HandleCalendarToastSnoozeAsync(toastArgs, calendarItemId); + return; + } } // Check if this is a navigation toast (user clicked the notification). @@ -267,6 +275,33 @@ public partial class App : WinoApplication, navigationService.Navigate(WinoPage.EventDetailsPage, target); } + private async Task HandleCalendarToastSnoozeAsync(AppNotificationActivatedEventArgs toastArgs, Guid calendarItemId) + { + if (!TryGetSnoozeDurationMinutes(toastArgs, out var snoozeDurationMinutes)) + return; + + var calendarService = Services.GetRequiredService(); + var snoozedUntilLocal = DateTime.Now.AddMinutes(snoozeDurationMinutes); + + await calendarService.SnoozeCalendarItemAsync(calendarItemId, snoozedUntilLocal).ConfigureAwait(false); + } + + private static bool TryGetSnoozeDurationMinutes(AppNotificationActivatedEventArgs toastArgs, out int snoozeDurationMinutes) + { + snoozeDurationMinutes = 0; + + if (toastArgs.UserInput == null || + !toastArgs.UserInput.TryGetValue(Constants.ToastCalendarSnoozeDurationInputId, out var selectedValue) || + selectedValue == null) + { + return false; + } + + var selectedText = selectedValue.ToString(); + + return int.TryParse(selectedText, out snoozeDurationMinutes) && snoozeDurationMinutes > 0; + } + /// /// Handles toast notification click for navigation. /// Creates window if not running, sets up navigation parameter. diff --git a/Wino.Mail.WinUI/Services/NotificationBuilder.cs b/Wino.Mail.WinUI/Services/NotificationBuilder.cs index 3221452d..7e9d6054 100644 --- a/Wino.Mail.WinUI/Services/NotificationBuilder.cs +++ b/Wino.Mail.WinUI/Services/NotificationBuilder.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Toolkit.Uwp.Notifications; using Serilog; -using Windows.ApplicationModel; using Windows.Data.Xml.Dom; using Windows.UI.Notifications; using Wino.Core.Domain.Entities.Calendar; @@ -26,16 +25,19 @@ public class NotificationBuilder : INotificationBuilder private readonly IFolderService _folderService; private readonly IMailService _mailService; private readonly IThumbnailService _thumbnailService; + private readonly IPreferencesService _preferencesService; public NotificationBuilder(IAccountService accountService, IFolderService folderService, IMailService mailService, - IThumbnailService thumbnailService) + IThumbnailService thumbnailService, + IPreferencesService preferencesService) { _accountService = accountService; _folderService = folderService; _mailService = mailService; _thumbnailService = thumbnailService; + _preferencesService = preferencesService; WeakReferenceMessenger.Default.Register(this, (r, msg) => { @@ -96,7 +98,7 @@ public class NotificationBuilder : INotificationBuilder Src = new Uri("ms-winsoundevent:Notification.Mail") }); - ShowToast(builder, ToastTargetApp.Mail); + ShowToast(builder); } else { @@ -151,7 +153,7 @@ public class NotificationBuilder : INotificationBuilder }); // Use UniqueId as tag to allow removal - ShowToast(builder, ToastTargetApp.Mail, mailItem.UniqueId.ToString()); + ShowToast(builder, mailItem.UniqueId.ToString()); } private ToastButton GetDismissButton() @@ -242,7 +244,7 @@ public class NotificationBuilder : INotificationBuilder { try { - ToastNotificationManager.History.Remove(mailUniqueId.ToString(), null, GetAppUserModelId(ToastTargetApp.Mail)); + ToastNotificationManager.History.Remove(mailUniqueId.ToString()); } catch (Exception ex) { @@ -263,7 +265,7 @@ public class NotificationBuilder : INotificationBuilder builder.AddArgument(Constants.ToastMailAccountIdKey, account.Id.ToString()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); builder.AddButton(new ToastButton().SetContent(Translator.Buttons_FixAccount)); - ShowToast(builder, ToastTargetApp.Mail); + ShowToast(builder); } public void CreateWebView2RuntimeMissingNotification() @@ -276,7 +278,7 @@ public class NotificationBuilder : INotificationBuilder builder.AddButton(GetDismissButton()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail); - ShowToast(builder, ToastTargetApp.Mail); + ShowToast(builder); } public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) @@ -302,19 +304,54 @@ public class NotificationBuilder : INotificationBuilder builder.AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarNavigateAction); builder.AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()); builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar); - builder.AddButton(GetDismissButton()); + + var allowedSnoozeMinutes = CalendarReminderSnoozeOptions.GetAllowedSnoozeMinutes( + reminderDurationInSeconds, + _preferencesService.DefaultReminderDurationInSeconds); + + if (allowedSnoozeMinutes.Count > 0) + { + var selectionBox = new ToastSelectionBox(Constants.ToastCalendarSnoozeDurationInputId) + { + DefaultSelectionBoxItemId = allowedSnoozeMinutes[0].ToString() + }; + + foreach (var snoozeMinutes in allowedSnoozeMinutes) + { + selectionBox.Items.Add(new ToastSelectionBoxItem( + snoozeMinutes.ToString(), + string.Format(Translator.CalendarReminder_SnoozeMinutesOption, snoozeMinutes))); + } + + builder.AddInput(selectionBox); + builder.AddButton(new ToastButtonSnooze() + .SetSelectionBoxId(Constants.ToastCalendarSnoozeDurationInputId) + .AddArgument(Constants.ToastCalendarActionKey, Constants.ToastCalendarSnoozeAction) + .AddArgument(Constants.ToastCalendarItemIdKey, calendarItem.Id.ToString()) + .AddArgument(Constants.ToastModeKey, Constants.ToastModeCalendar)); + } + + builder.AddButton(new ToastButtonDismiss()); + + if (Uri.TryCreate(calendarItem.HtmlLink, UriKind.Absolute, out var joinUri)) + { + builder.AddButton(new ToastButton() + .SetContent(Translator.CalendarEventDetails_JoinOnline) + .SetProtocolActivation(joinUri)); + } + builder.AddAudio(new ToastAudio() { Src = new Uri("ms-winsoundevent:Notification.Reminder") }); var tag = $"calendar-reminder-{calendarItem.Id:N}-{reminderDurationInSeconds}"; - ShowToast(builder, ToastTargetApp.Calendar, tag); + ShowToast(builder, tag); return Task.CompletedTask; } - private static void ShowToast(ToastContentBuilder builder, ToastTargetApp targetApp, string? tag = null) + private static void ShowToast(ToastContentBuilder builder, string? tag = null) { var toastNotification = new ToastNotification(builder.GetToastContent().GetXml()); @@ -323,20 +360,7 @@ public class NotificationBuilder : INotificationBuilder toastNotification.Tag = tag; } - var appUserModelId = GetAppUserModelId(targetApp); - var notifier = ToastNotificationManager.CreateToastNotifier(appUserModelId); + var notifier = ToastNotificationManager.CreateToastNotifier(); notifier.Show(toastNotification); } - - private static string GetAppUserModelId(ToastTargetApp targetApp) - { - _ = targetApp; - return $"{Package.Current.Id.FamilyName}!{MailApplicationId}"; - } - - private enum ToastTargetApp - { - Mail, - Calendar - } } diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 9ec43d34..2c46ff2c 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -336,6 +336,12 @@ public class CalendarService : BaseDatabaseService, ICalendarService }); } + public Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal) + => Connection.ExecuteAsync( + $"UPDATE {nameof(CalendarItem)} SET {nameof(CalendarItem.SnoozedUntil)} = ? WHERE {nameof(CalendarItem.Id)} = ?", + snoozedUntilLocal, + calendarItemId); + public async Task> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet sentReminderKeys, CancellationToken cancellationToken = default) { if (sentReminderKeys == null) @@ -347,7 +353,8 @@ public class CalendarService : BaseDatabaseService, ICalendarService c.Id AS CalendarItemId, c.StartDate, c.StartTimeZone, - r.DurationInSeconds AS ReminderDurationInSeconds + r.DurationInSeconds AS ReminderDurationInSeconds, + c.SnoozedUntil FROM CalendarItem c INNER JOIN Reminder r ON r.CalendarItemId = c.Id INNER JOIN AccountCalendar ac ON ac.Id = c.CalendarId @@ -367,11 +374,14 @@ public class CalendarService : BaseDatabaseService, ICalendarService var eventStartLocal = candidate.StartDate.ToLocalTimeFromTimeZone(candidate.StartTimeZone); var triggerTimeLocal = eventStartLocal.AddSeconds(-candidate.ReminderDurationInSeconds); + var effectiveTriggerTimeLocal = candidate.SnoozedUntil.HasValue + ? MaxDateTime(triggerTimeLocal, candidate.SnoozedUntil.Value) + : triggerTimeLocal; - if (triggerTimeLocal <= lastCheckLocal || triggerTimeLocal > nowLocal) + if (effectiveTriggerTimeLocal <= lastCheckLocal || effectiveTriggerTimeLocal > nowLocal) continue; - var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}"; + var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}:{effectiveTriggerTimeLocal.Ticks}"; if (!sentReminderKeys.Add(reminderKey)) continue; @@ -438,11 +448,15 @@ public class CalendarService : BaseDatabaseService, ICalendarService #endregion + private static DateTime MaxDateTime(DateTime first, DateTime second) + => first >= second ? first : second; + private sealed class CalendarReminderCandidate { public Guid CalendarItemId { get; set; } public DateTime StartDate { get; set; } public string StartTimeZone { get; set; } public long ReminderDurationInSeconds { get; set; } + public DateTime? SnoozedUntil { get; set; } } } diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index fe4a40f7..a7b19c1e 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -117,6 +117,15 @@ public class DatabaseService : IDatabaseService .ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalendarSupportMode)} INTEGER NOT NULL DEFAULT 0") .ConfigureAwait(false); } + + var calendarItemColumns = await Connection.GetTableInfoAsync(nameof(CalendarItem)).ConfigureAwait(false); + + if (!calendarItemColumns.Any(c => c.Name == nameof(CalendarItem.SnoozedUntil))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(CalendarItem)} ADD COLUMN {nameof(CalendarItem.SnoozedUntil)} TEXT NULL") + .ConfigureAwait(false); + } } private async Task EnsureIndexesAsync()