From 870a5e2bf6fd315a369d6ac6220e2059208c37f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 10 Feb 2026 21:35:55 +0100 Subject: [PATCH] Calendar - mail mapping. --- .../Mail/MailInvitationCalendarMapping.cs | 31 ++ .../Models/MailItem/HtmlPreviewVisitor.cs | 19 +- .../CalendarInvitationExtensions.cs | 121 ++++++ .../Processors/DefaultChangeProcessor.cs | 43 ++ Wino.Core/Synchronizers/GmailSynchronizer.cs | 76 ++++ .../Synchronizers/OutlookSynchronizer.cs | 75 +++- ...ndarMailItemDisplayInformationControl.xaml | 69 ++++ ...rMailItemDisplayInformationControl.xaml.cs | 382 ++++++++++++++++++ Wino.Mail.WinUI/Views/Mail/MailListPage.xaml | 20 +- Wino.Services/DatabaseService.cs | 3 +- 10 files changed, 818 insertions(+), 21 deletions(-) create mode 100644 Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs create mode 100644 Wino.Core/Extensions/CalendarInvitationExtensions.cs create mode 100644 Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml create mode 100644 Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs diff --git a/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs b/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs new file mode 100644 index 00000000..d1290062 --- /dev/null +++ b/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs @@ -0,0 +1,31 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Mail; + +/// +/// Maps a calendar invitation mail item to a persisted calendar event. +/// +public class MailInvitationCalendarMapping +{ + [PrimaryKey] + public Guid Id { get; set; } + + public Guid AccountId { get; set; } + + /// + /// MailCopy.Id value of the invitation mail. + /// + public string MailCopyId { get; set; } + + /// + /// iCalendar UID extracted from invitation MIME/ICS content. + /// + public string InvitationUid { get; set; } + + public Guid CalendarId { get; set; } + public Guid CalendarItemId { get; set; } + public string CalendarRemoteEventId { get; set; } + + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs index c62c9a6f..4a8481a4 100644 --- a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs +++ b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs @@ -48,9 +48,22 @@ public class HtmlPreviewVisitor : MimeVisitor protected override void VisitMultipartAlternative(MultipartAlternative alternative) { - // walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful + // Prefer rich body alternatives first, and only fall back to calendar text if nothing else exists. for (int i = alternative.Count - 1; i >= 0 && Body == null; i--) + { + if (IsCalendarText(alternative[i])) + continue; + alternative[i].Accept(this); + } + + for (int i = alternative.Count - 1; i >= 0 && Body == null; i--) + { + if (!IsCalendarText(alternative[i])) + continue; + + alternative[i].Accept(this); + } } protected override void VisitMultipartRelated(MultipartRelated related) @@ -241,6 +254,10 @@ public class HtmlPreviewVisitor : MimeVisitor Body = converter.Convert(entity.Text); } + private static bool IsCalendarText(MimeEntity entity) + => entity is TextPart textPart && + textPart.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true; + protected override void VisitTnefPart(TnefPart entity) { // extract any attachments in the MS-TNEF part diff --git a/Wino.Core/Extensions/CalendarInvitationExtensions.cs b/Wino.Core/Extensions/CalendarInvitationExtensions.cs new file mode 100644 index 00000000..36219272 --- /dev/null +++ b/Wino.Core/Extensions/CalendarInvitationExtensions.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using MimeKit; + +namespace Wino.Core.Extensions; + +public static class CalendarInvitationExtensions +{ + public static string ExtractInvitationUid(this MimeMessage message) + { + if (message == null) + { + return null; + } + + var icsContent = GetCalendarContent(message); + if (string.IsNullOrWhiteSpace(icsContent)) + { + return null; + } + + var unfolded = UnfoldIcs(icsContent); + var veventSection = ExtractFirstVEventSection(unfolded); + if (string.IsNullOrWhiteSpace(veventSection)) + { + return null; + } + + return TryReadIcsProperty(veventSection, "UID", out var uid) + ? uid + : null; + } + + private static string GetCalendarContent(MimeMessage message) + { + var textPart = message.BodyParts + .OfType() + .FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true); + + if (textPart != null) + { + return textPart.Text; + } + + var mimePart = message.BodyParts + .OfType() + .FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true); + + if (mimePart == null) + { + return null; + } + + using var stream = new MemoryStream(); + mimePart.Content.DecodeTo(stream); + var bytes = stream.ToArray(); + if (bytes.Length == 0) + { + return null; + } + + var charset = mimePart.ContentType?.Charset; + var encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset); + return encoding.GetString(bytes); + } + + private static string UnfoldIcs(string content) + => content + .Replace("\r\n ", string.Empty, StringComparison.Ordinal) + .Replace("\r\n\t", string.Empty, StringComparison.Ordinal) + .Replace("\n ", string.Empty, StringComparison.Ordinal) + .Replace("\n\t", string.Empty, StringComparison.Ordinal); + + private static string ExtractFirstVEventSection(string ics) + { + const string beginVevent = "BEGIN:VEVENT"; + const string endVevent = "END:VEVENT"; + + var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase); + if (beginIndex < 0) + { + return string.Empty; + } + + var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase); + if (endIndex < 0) + { + return ics[beginIndex..]; + } + + return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length); + } + + private static bool TryReadIcsProperty(string icsSection, string propertyName, out string value) + { + value = string.Empty; + var lines = icsSection.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var colonIndex = line.IndexOf(':'); + if (colonIndex <= 0 || colonIndex >= line.Length - 1) + { + continue; + } + + value = line[(colonIndex + 1)..].Trim(); + return !string.IsNullOrWhiteSpace(value); + } + + return false; + } +} diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index abf6c21a..d8f0d9d3 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -45,6 +45,7 @@ public interface IDefaultChangeProcessor Task DeleteCalendarItemAsync(Guid calendarItemId); Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId); + Task GetCalendarItemAsync(Guid calendarId, string remoteEventId); Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar); Task InsertAccountCalendarAsync(AccountCalendar accountCalendar); @@ -54,6 +55,8 @@ public interface IDefaultChangeProcessor Task> GetMailCopiesAsync(IEnumerable mailCopyIds); Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package); Task DeleteUserMailCacheAsync(Guid accountId); + Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping); + Task GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId); /// /// Checks whether the mail exists in the folder. @@ -199,6 +202,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) => CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId); + public Task GetCalendarItemAsync(Guid calendarId, string remoteEventId) + => CalendarService.GetCalendarItemAsync(calendarId, remoteEventId); + public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar) => CalendarService.DeleteAccountCalendarAsync(accountCalendar); @@ -217,6 +223,43 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false); } + public async Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping) + { + if (mapping == null || mapping.AccountId == Guid.Empty || string.IsNullOrWhiteSpace(mapping.MailCopyId)) + return; + + var existing = await Connection.Table() + .FirstOrDefaultAsync(x => x.AccountId == mapping.AccountId && x.MailCopyId == mapping.MailCopyId) + .ConfigureAwait(false); + + if (existing == null) + { + if (mapping.Id == Guid.Empty) + mapping.Id = Guid.NewGuid(); + + mapping.UpdatedAtUtc = DateTime.UtcNow; + await Connection.InsertAsync(mapping, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false); + return; + } + + existing.InvitationUid = mapping.InvitationUid; + existing.CalendarId = mapping.CalendarId; + existing.CalendarItemId = mapping.CalendarItemId; + existing.CalendarRemoteEventId = mapping.CalendarRemoteEventId; + existing.UpdatedAtUtc = DateTime.UtcNow; + + await Connection.UpdateAsync(existing, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false); + } + + public Task GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId) + { + if (accountId == Guid.Empty || string.IsNullOrWhiteSpace(mailCopyId)) + return Task.FromResult(null); + + return Connection.Table() + .FirstOrDefaultAsync(x => x.AccountId == accountId && x.MailCopyId == mailCopyId); + } + public Task IsMailExistsInFolderAsync(string messageId, Guid folderId) => MailService.IsMailExistsAsync(messageId, folderId); } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 7afdfc14..c5a615c0 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1965,12 +1965,35 @@ public class GmailSynchronizer : WinoSynchronizer + { + requestConfiguration.QueryParameters.Filter = $"iCalUId eq '{escapedUid}'"; + requestConfiguration.QueryParameters.Select = ["id"]; + requestConfiguration.QueryParameters.Top = 1; + }, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + var matchedEvent = eventsResponse?.Value?.FirstOrDefault(); + if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id)) + continue; + + var fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[matchedEvent.Id] + .GetAsync(requestConfiguration => + { + requestConfiguration.QueryParameters.Expand = ["attachments($select=id,name,contentType,size,isInline)"]; + }, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (fullEvent == null) + continue; + + await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false); + + var localCalendarItem = await _outlookChangeProcessor.GetCalendarItemAsync(calendar.Id, fullEvent.Id).ConfigureAwait(false); + if (localCalendarItem == null) + return; + + await _outlookChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping() + { + Id = Guid.NewGuid(), + AccountId = Account.Id, + MailCopyId = mailCopy.Id, + InvitationUid = invitationUid, + CalendarId = calendar.Id, + CalendarItemId = localCalendarItem.Id, + CalendarRemoteEventId = fullEvent.Id + }).ConfigureAwait(false); + + return; + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}", mailCopy.Id, calendar.Id); + } + } + } + protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) { _logger.Information("Internal calendar synchronization started for {Name}", Account.Name); diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml new file mode 100644 index 00000000..3fc1132a --- /dev/null +++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs new file mode 100644 index 00000000..1d7f8eb3 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.WinUI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using MimeKit; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; +using Wino.Helpers; +using Wino.Mail.ViewModels.Data; +using Wino.Mail.WinUI; + +namespace Wino.Controls; + +public sealed partial class CalendarMailItemDisplayInformationControl : UserControl +{ + private static readonly ConcurrentDictionary EventDateRangeCache = []; + + private readonly IMimeFileService _mimeFileService; + private readonly IPreferencesService _preferencesService; + private CancellationTokenSource? _loadingCts; + + [GeneratedDependencyProperty] + public partial MailItemViewModel? MailItem { get; set; } + + [GeneratedDependencyProperty(DefaultValue = MailListDisplayMode.Spacious)] + public partial MailListDisplayMode DisplayMode { get; set; } + + [GeneratedDependencyProperty(DefaultValue = false)] + public partial bool Prefer24HourTimeFormat { get; set; } + + [GeneratedDependencyProperty(DefaultValue = "")] + public partial string EventDateRangeText { get; set; } + + public event EventHandler? HoverActionExecuted; + + public CalendarMailItemDisplayInformationControl() + { + InitializeComponent(); + + _mimeFileService = App.Current.Services.GetRequiredService(); + _preferencesService = App.Current.Services.GetRequiredService(); + + DisplayMode = _preferencesService.MailItemDisplayMode; + Prefer24HourTimeFormat = _preferencesService.Prefer24HourTimeFormat; + + Unloaded += OnControlUnloaded; + } + + partial void OnMailItemPropertyChanged(DependencyPropertyChangedEventArgs e) + { + _ = LoadEventDateRangeAsync(); + } + + private async Task LoadEventDateRangeAsync() + { + _loadingCts?.Cancel(); + _loadingCts?.Dispose(); + _loadingCts = new CancellationTokenSource(); + var token = _loadingCts.Token; + + if (MailItem?.MailCopy == null) + { + EventDateRangeText = string.Empty; + return; + } + + if (EventDateRangeCache.TryGetValue(MailItem.MailCopy.FileId, out var cachedValue)) + { + EventDateRangeText = cachedValue; + return; + } + + try + { + var accountId = MailItem.MailCopy.AssignedAccount?.Id; + if (accountId == null || accountId == Guid.Empty) + { + EventDateRangeText = Translator.UnknownDateHeader; + return; + } + + var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId.Value, MailItem.MailCopy.FileId); + if (!isMimeExists) + { + EventDateRangeText = Translator.UnknownDateHeader; + return; + } + + var mimeInfo = await _mimeFileService.GetMimeMessageInformationAsync(MailItem.MailCopy.FileId, accountId.Value, token); + if (mimeInfo == null) + { + EventDateRangeText = Translator.UnknownDateHeader; + return; + } + + var renderedDateRange = ExtractCalendarDateRange(mimeInfo.MimeMessage); + + EventDateRangeText = string.IsNullOrWhiteSpace(renderedDateRange) ? Translator.UnknownDateHeader : renderedDateRange; + EventDateRangeCache.TryAdd(MailItem.MailCopy.FileId, EventDateRangeText); + } + catch (OperationCanceledException) + { + // Ignore; newer bind request superseded this one. + } + catch + { + EventDateRangeText = Translator.UnknownDateHeader; + } + } + + private void BaseMailControlHoverActionExecuted(object sender, MailOperationPreperationRequest e) + => HoverActionExecuted?.Invoke(this, e); + + private string ExtractCalendarDateRange(MimeMessage message) + { + var calendarContent = GetCalendarContent(message); + if (string.IsNullOrWhiteSpace(calendarContent)) + { + return string.Empty; + } + + var unfoldedIcs = UnfoldIcs(calendarContent); + var eventSection = ExtractFirstVEventSection(unfoldedIcs); + + if (!TryReadIcsDateValue(eventSection, "DTSTART", out var dtStartValue, out var dtStartTzId)) + { + return string.Empty; + } + + if (!TryParseIcsDateTime(dtStartValue, dtStartTzId, out var startLocal, out var isAllDay)) + { + return string.Empty; + } + + DateTime endLocal = startLocal; + if (TryReadIcsDateValue(eventSection, "DTEND", out var dtEndValue, out var dtEndTzId)) + { + if (!TryParseIcsDateTime(dtEndValue, dtEndTzId, out endLocal, out var endIsAllDay)) + { + endLocal = startLocal; + } + + isAllDay = isAllDay && endIsAllDay; + } + + return FormatDisplayDateRange(startLocal, endLocal, isAllDay); + } + + private static string GetCalendarContent(MimeMessage message) + { + var calendarTextPart = message.BodyParts + .OfType() + .FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true); + + if (calendarTextPart != null) + { + return calendarTextPart.Text ?? string.Empty; + } + + var calendarMimePart = message.BodyParts + .OfType() + .FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true); + + if (calendarMimePart == null) + { + return string.Empty; + } + + using var stream = new MemoryStream(); + calendarMimePart.Content.DecodeTo(stream); + var contentBytes = stream.ToArray(); + + if (contentBytes.Length == 0) + { + return string.Empty; + } + + var charset = calendarMimePart.ContentType?.Charset; + var encoding = string.IsNullOrWhiteSpace(charset) ? System.Text.Encoding.UTF8 : System.Text.Encoding.GetEncoding(charset); + return encoding.GetString(contentBytes); + } + + private static string UnfoldIcs(string content) + => content + .Replace("\r\n ", string.Empty, StringComparison.Ordinal) + .Replace("\r\n\t", string.Empty, StringComparison.Ordinal) + .Replace("\n ", string.Empty, StringComparison.Ordinal) + .Replace("\n\t", string.Empty, StringComparison.Ordinal); + + private static string ExtractFirstVEventSection(string ics) + { + if (string.IsNullOrWhiteSpace(ics)) + { + return string.Empty; + } + + const string beginVevent = "BEGIN:VEVENT"; + const string endVevent = "END:VEVENT"; + + var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase); + if (beginIndex < 0) + { + return ics; + } + + var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase); + if (endIndex < 0) + { + return ics[beginIndex..]; + } + + return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length); + } + + private static bool TryReadIcsDateValue(string ics, string propertyName, out string value, out string timeZoneId) + { + value = string.Empty; + timeZoneId = string.Empty; + + var lines = ics.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var colonIndex = line.IndexOf(':'); + if (colonIndex <= 0 || colonIndex == line.Length - 1) + { + continue; + } + + var parameterSection = line[..colonIndex]; + value = line[(colonIndex + 1)..].Trim(); + + var paramsSplit = parameterSection.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var parameter in paramsSplit.Skip(1)) + { + var eqIndex = parameter.IndexOf('='); + if (eqIndex <= 0 || eqIndex == parameter.Length - 1) + { + continue; + } + + var name = parameter[..eqIndex].Trim(); + if (!name.Equals("TZID", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + timeZoneId = parameter[(eqIndex + 1)..].Trim().Trim('"'); + break; + } + + return true; + } + + return false; + } + + private static bool TryParseIcsDateTime(string rawValue, string timeZoneId, out DateTime localDateTime, out bool isAllDay) + { + localDateTime = default; + isAllDay = false; + + if (string.IsNullOrWhiteSpace(rawValue)) + { + return false; + } + + var value = rawValue.Trim(); + + if (value.Length == 8 && DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) + { + localDateTime = dateOnly.Date; + isAllDay = true; + return true; + } + + var isUtc = value.EndsWith("Z", StringComparison.OrdinalIgnoreCase); + if (isUtc) + { + value = value[..^1]; + } + + var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" }; + if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) + { + return false; + } + + if (isUtc) + { + localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Utc).ToLocalTime(); + return true; + } + + if (!string.IsNullOrWhiteSpace(timeZoneId) && TryFindTimeZone(timeZoneId, out var sourceZone)) + { + var unspecifiedTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified); + localDateTime = TimeZoneInfo.ConvertTime(unspecifiedTime, sourceZone, TimeZoneInfo.Local); + return true; + } + + localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Local); + return true; + } + + private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone) + { + timeZone = null!; + + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + return true; + } + catch + { + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId)) + { + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(windowsId); + return true; + } + catch + { + return false; + } + } + } + + return false; + } + + private string FormatDisplayDateRange(DateTime startLocal, DateTime endLocal, bool isAllDay) + { + if (endLocal < startLocal) + { + endLocal = startLocal; + } + + var culture = CultureInfo.DefaultThreadCurrentUICulture; + if (isAllDay) + { + var adjustedEnd = endLocal.Date > startLocal.Date ? endLocal.Date.AddDays(-1) : startLocal.Date; + + if (adjustedEnd.Date > startLocal.Date) + { + return $"{startLocal.ToString("d", culture)} - {adjustedEnd.ToString("d", culture)} ({Translator.CalendarItemAllDay})"; + } + + return $"{startLocal.ToString("d", culture)} ({Translator.CalendarItemAllDay})"; + } + + var timeFormat = Prefer24HourTimeFormat ? "HH:mm" : "h:mm tt"; + + if (startLocal.Date == endLocal.Date) + { + return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString(timeFormat, culture)}"; + } + + return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString($"ddd, MMM d {timeFormat}", culture)}"; + } + + private void OnControlUnloaded(object sender, RoutedEventArgs e) + { + _loadingCts?.Cancel(); + } +} diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index ddb22069..4594e38d 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -100,27 +100,13 @@ - + - - - - + MailItem="{x:Bind}" /> (), Connection.CreateTableAsync(), Connection.CreateTableAsync(), - Connection.CreateTableAsync() + Connection.CreateTableAsync(), + Connection.CreateTableAsync() ); } }