diff --git a/Wino.Authentication/OutlookAuthenticator.cs b/Wino.Authentication/OutlookAuthenticator.cs index 9dfd05b2..156cc321 100644 --- a/Wino.Authentication/OutlookAuthenticator.cs +++ b/Wino.Authentication/OutlookAuthenticator.cs @@ -98,7 +98,8 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator catch (MsalUiRequiredException) { // Somehow MSAL is not able to refresh the token silently. - // Force interactive login. + // Force interactive login which will include calendar scopes. + // The calling code should update account.IsCalendarAccessGranted = true after successful authentication. return await GenerateTokenInformationAsync(account); } diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index 580f2c88..157b243e 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -27,17 +27,13 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, IRecipient, IRecipient, IRecipient, - IRecipient, - IRecipient + IRecipient { public IPreferencesService PreferencesService { get; } public IStatePersistanceService StatePersistenceService { get; } public IAccountCalendarStateService AccountCalendarStateService { get; } public INavigationService NavigationService { get; } - [ObservableProperty] - private bool _isEventDetailsPageActive; - [ObservableProperty] private int _selectedMenuItemIndex = -1; @@ -303,7 +299,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); - Messenger.Register(this); } protected override void UnregisterRecipients() @@ -314,7 +309,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); - Messenger.Unregister(this); } public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange; @@ -369,16 +363,4 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1; public void Receive(CalendarDisplayTypeChangedMessage message) => OnPropertyChanged(nameof(IsVerticalCalendar)); - - public async void Receive(DetailsPageStateChangedMessage message) - { - await ExecuteUIThread(() => - { - IsEventDetailsPageActive = message.IsActivated; - - // TODO: This is for Wino Mail. Generalize this later on. - StatePersistenceService.IsReaderNarrowed = message.IsActivated; - StatePersistenceService.IsReadingMail = message.IsActivated; - }); - } } diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index 7e7034a5..4b3733ea 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -173,14 +173,29 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel { base.OnNavigatedTo(mode, parameters); - Messenger.Send(new DetailsPageStateChangedMessage(true)); - if (parameters == null || parameters is not CalendarItemTarget args) return; await LoadCalendarItemTargetAsync(args); } + protected override async void OnCalendarItemUpdated(CalendarItem calendarItem) + { + base.OnCalendarItemUpdated(calendarItem); + + // If the current event was updated, reload it + if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id) + { + // Refresh the current event data by reloading from service + var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id); + if (refreshedEvent != null) + { + CurrentEvent = new CalendarItemViewModel(refreshedEvent); + await LoadAttendeesAsync(refreshedEvent.EventTrackingId, refreshedEvent); + } + } + } + protected override void OnCalendarItemDeleted(CalendarItem calendarItem) { base.OnCalendarItemDeleted(calendarItem); @@ -205,6 +220,9 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel await LoadAttendeesAsync(currentEventItem.EventTrackingId, currentEventItem); + // Initialize SelectedShowAsOption based on current event's ShowAs + SelectedShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == currentEventItem.ShowAs) ?? ShowAsOptions[2]; + // Load reminders for this calendar item Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId); InitializeReminderOptions(); @@ -221,15 +239,21 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel private async Task LoadAttendeesAsync(Guid eventTrackingId, CalendarItem calendarItem) { CurrentEvent.Attendees.Clear(); - + var attendees = await _calendarService.GetAttendeesAsync(eventTrackingId); - // Check if organizer is in the attendees list - var hasOrganizerInList = attendees.Any(a => a.IsOrganizer); + // Separate organizer from other attendees to ensure organizer is always first + var organizer = attendees.FirstOrDefault(a => a.IsOrganizer); + var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList(); - // If the user is the organizer but not in attendees list, add them - if (!hasOrganizerInList && !string.IsNullOrEmpty(calendarItem.OrganizerEmail)) + // If the organizer is in the list, add them first + if (organizer != null) { + CurrentEvent.Attendees.Add(organizer); + } + else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail)) + { + // If the organizer is not in the attendees list, create and add them first var organizerAttendee = new CalendarEventAttendee { Id = Guid.NewGuid(), @@ -242,7 +266,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel CurrentEvent.Attendees.Add(organizerAttendee); } - foreach (var item in attendees) + // Add all other attendees after the organizer + foreach (var item in nonOrganizerAttendees) { CurrentEvent.Attendees.Add(item); } @@ -321,6 +346,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel try { + // Capture original state BEFORE making any changes for potential revert + var originalItem = await _calendarService.GetCalendarItemAsync(CurrentEvent.CalendarItem.Id); + var originalAttendees = await _calendarService.GetAttendeesAsync(CurrentEvent.CalendarItem.EventTrackingId); + // Get selected reminder options var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList(); @@ -344,12 +373,35 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.EventTrackingId, newReminders); Reminders = newReminders; + // Update ShowAs if changed + if (SelectedShowAsOption != null) + { + CurrentEvent.CalendarItem.ShowAs = SelectedShowAsOption.ShowAs; + } + + // Update the calendar item and attendees in database + await _calendarService.UpdateCalendarItemAsync(CurrentEvent.CalendarItem, CurrentEvent.Attendees.ToList()); + + // Queue the update request to synchronizer with original state for revert capability + var preparationRequest = new CalendarOperationPreparationRequest( + CalendarSynchronizerOperation.UpdateEvent, + CurrentEvent.CalendarItem, + CurrentEvent.Attendees.ToList(), + ResponseMessage: null, + OriginalItem: originalItem, + OriginalAttendees: originalAttendees); + + await _winoRequestDelegator.ExecuteAsync(preparationRequest); + _navigationService.GoBack(); - // TODO: Implement saving other event details } catch (Exception ex) { Debug.WriteLine($"Error saving event: {ex.Message}"); + _dialogService.InfoBarMessage( + Translator.Info_AttachmentSaveFailedTitle, + ex.Message, + InfoBarMessageType.Error); } } diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index 20194b8a..6768ae99 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -140,6 +140,12 @@ public class CalendarItem : ICalendarItem public string HtmlLink { get; set; } public CalendarItemStatus Status { get; set; } public CalendarItemVisibility Visibility { get; set; } + + /// + /// Indicates how the event should be shown in the calendar (Free, Busy, Tentative, etc.). + /// + public CalendarItemShowAs ShowAs { get; set; } = CalendarItemShowAs.Busy; + public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public Guid CalendarId { get; set; } diff --git a/Wino.Core.Domain/Entities/Mail/MailCopy.cs b/Wino.Core.Domain/Entities/Mail/MailCopy.cs index 80ba6b26..ebd71c61 100644 --- a/Wino.Core.Domain/Entities/Mail/MailCopy.cs +++ b/Wino.Core.Domain/Entities/Mail/MailCopy.cs @@ -103,6 +103,11 @@ public class MailCopy /// public bool HasAttachments { get; set; } + /// + /// Type of mail item (regular mail, calendar invitation, calendar response, etc.). + /// + public MailItemType ItemType { get; set; } = MailItemType.Mail; + /// /// Assigned draft id. /// diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index c6935095..7370c268 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -78,6 +78,14 @@ public class MailAccount /// public SpecialImapProvider SpecialImapProvider { get; set; } + /// + /// Gets or sets whether calendar access is granted for this account. + /// When false, synchronizers will not process EventMessages or calendar invitations. + /// Default is false for existing accounts to prevent scope issues. + /// New accounts created after this feature will have this set to true. + /// + public bool IsCalendarAccessGranted { get; set; } + /// /// Contains the merged inbox this account belongs to. /// Ignored for all SQLite operations. diff --git a/Wino.Core.Domain/Enums/MailItemType.cs b/Wino.Core.Domain/Enums/MailItemType.cs new file mode 100644 index 00000000..63fcfa5a --- /dev/null +++ b/Wino.Core.Domain/Enums/MailItemType.cs @@ -0,0 +1,27 @@ +namespace Wino.Core.Domain.Enums; + +/// +/// Represents the type of mail item. +/// +public enum MailItemType +{ + /// + /// Regular mail message. + /// + Mail = 0, + + /// + /// Calendar invitation (meeting request). + /// + CalendarInvitation = 1, + + /// + /// Calendar response (meeting accepted, tentatively accepted, or declined). + /// + CalendarResponse = 2, + + /// + /// Calendar cancellation (meeting cancelled). + /// + CalendarCancellation = 3 +} diff --git a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs index b5bba4e7..f2b0cc93 100644 --- a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs +++ b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs @@ -11,4 +11,12 @@ namespace Wino.Core.Domain.Models.Calendar; /// Calendar item to operate on. /// List of attendees for the calendar event. /// Optional message to include with event responses (Accept, Decline, Tentative). -public record CalendarOperationPreparationRequest(CalendarSynchronizerOperation Operation, CalendarItem CalendarItem, List Attendees, string ResponseMessage = null); +/// Original calendar item state before update (for revert capability). +/// Original attendees list before update (for revert capability). +public record CalendarOperationPreparationRequest( + CalendarSynchronizerOperation Operation, + CalendarItem CalendarItem, + List Attendees, + string ResponseMessage = null, + CalendarItem OriginalItem = null, + List OriginalAttendees = null); diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index ccec151b..5036f2b8 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -60,7 +60,8 @@ public static class OutlookIntegratorExtensions FromName = outlookMessage.From?.EmailAddress?.Name, FromAddress = outlookMessage.From?.EmailAddress?.Address, Subject = outlookMessage.Subject, - FileId = Guid.NewGuid() + FileId = Guid.NewGuid(), + ItemType = MailItemType.Mail // ItemType will be set by caller if calendar access is granted }; if (mailCopy.IsDraft) @@ -69,6 +70,51 @@ public static class OutlookIntegratorExtensions return mailCopy; } + public static MailItemType GetMailItemType(this Message message) + { + // Check if the message is an EventMessage (calendar-related) + if (message is EventMessage eventMessage) + { + // Try to get MeetingMessageType from the property + if (eventMessage.MeetingMessageType.HasValue) + { + return eventMessage.MeetingMessageType.Value switch + { + MeetingMessageType.MeetingRequest => MailItemType.CalendarInvitation, + MeetingMessageType.MeetingCancelled => MailItemType.CalendarCancellation, + MeetingMessageType.MeetingAccepted or + MeetingMessageType.MeetingTenativelyAccepted or + MeetingMessageType.MeetingDeclined => MailItemType.CalendarResponse, + _ => MailItemType.Mail + }; + } + + // Fallback: Check @odata.type in AdditionalData to determine specific type + if (message.AdditionalData?.TryGetValue("@odata.type", out var odataType) == true) + { + var odataTypeString = odataType?.ToString(); + if (odataTypeString != null) + { + // eventMessageRequest -> CalendarInvitation + if (odataTypeString.Contains("eventMessageRequest", StringComparison.OrdinalIgnoreCase)) + return MailItemType.CalendarInvitation; + + // eventMessageResponse -> CalendarResponse + if (odataTypeString.Contains("eventMessageResponse", StringComparison.OrdinalIgnoreCase)) + return MailItemType.CalendarResponse; + + // Generic eventMessage without specific type - assume invitation + if (odataTypeString.Contains("eventMessage", StringComparison.OrdinalIgnoreCase)) + return MailItemType.CalendarInvitation; + } + } + + return MailItemType.CalendarInvitation; + } + + return MailItemType.Mail; + } + public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders, string conversationId = null) { var fromAddress = GetRecipients(mime.From).ElementAt(0); @@ -286,7 +332,7 @@ public static class OutlookIntegratorExtensions public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId, string organizerEmail = null) { // Check if this attendee is the organizer by comparing email addresses - bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) && + bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) && !string.IsNullOrEmpty(attendee?.EmailAddress?.Address) && string.Equals(attendee.EmailAddress.Address, organizerEmail, StringComparison.OrdinalIgnoreCase); diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index 06735274..d42eec75 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -111,6 +111,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary, UpdatedAt = DateTimeOffset.UtcNow, Visibility = string.IsNullOrEmpty(calendarEvent.Visibility) ? parentRecurringEvent.Visibility : GetVisibility(calendarEvent.Visibility), + ShowAs = string.IsNullOrEmpty(calendarEvent.Transparency) ? parentRecurringEvent.ShowAs : GetShowAs(calendarEvent.Transparency), HtmlLink = string.IsNullOrEmpty(calendarEvent.HtmlLink) ? parentRecurringEvent.HtmlLink : calendarEvent.HtmlLink, RemoteEventId = calendarEvent.Id, IsLocked = calendarEvent.Locked.GetValueOrDefault(), @@ -148,6 +149,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso Title = calendarEvent.Summary, UpdatedAt = DateTimeOffset.UtcNow, Visibility = GetVisibility(calendarEvent.Visibility), + ShowAs = GetShowAs(calendarEvent.Transparency), HtmlLink = calendarEvent.HtmlLink, RemoteEventId = calendarEvent.Id, IsLocked = calendarEvent.Locked.GetValueOrDefault(), @@ -429,6 +431,20 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso }; } + private CalendarItemShowAs GetShowAs(string transparency) + { + /// Google Calendar uses "transparent" for free time (event doesn't block time) + /// and "opaque" for busy time (event blocks time on the calendar). + /// If not specified, defaults to opaque (busy). + + return transparency switch + { + "transparent" => CalendarItemShowAs.Free, + "opaque" => CalendarItemShowAs.Busy, + _ => CalendarItemShowAs.Busy + }; + } + public Task HasAccountAnyDraftAsync(Guid accountId) => MailService.HasAccountAnyDraftAsync(accountId); diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 1ce3e3c2..498f21d7 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -135,6 +135,24 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, savingItem.Visibility = CalendarItemVisibility.Public; } + // Set ShowAs status + if (calendarEvent.ShowAs != null) + { + savingItem.ShowAs = calendarEvent.ShowAs.Value switch + { + Microsoft.Graph.Models.FreeBusyStatus.Free => CalendarItemShowAs.Free, + Microsoft.Graph.Models.FreeBusyStatus.Tentative => CalendarItemShowAs.Tentative, + Microsoft.Graph.Models.FreeBusyStatus.Busy => CalendarItemShowAs.Busy, + Microsoft.Graph.Models.FreeBusyStatus.Oof => CalendarItemShowAs.OutOfOffice, + Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere => CalendarItemShowAs.WorkingElsewhere, + _ => CalendarItemShowAs.Busy + }; + } + else + { + savingItem.ShowAs = CalendarItemShowAs.Busy; + } + // Set IsLocked based on whether the user is the organizer // Read-only events are those where the current user is not the organizer savingItem.IsLocked = calendarEvent.IsOrganizer.HasValue && !calendarEvent.IsOrganizer.Value; diff --git a/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs b/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs new file mode 100644 index 00000000..e452dbf8 --- /dev/null +++ b/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.Client.Calendar; + +namespace Wino.Core.Requests.Calendar; + +/// +/// Request to update an existing calendar event on the server. +/// The calendar item should be already updated in the local database before queuing this request. +/// +public record UpdateCalendarEventRequest(CalendarItem Item, List Attendees) : CalendarRequestBase(Item) +{ + /// + /// Original attendees before the update, used for reverting changes if the update fails. + /// + public List OriginalAttendees { get; init; } + + /// + /// Original calendar item state before the update, used for reverting changes if the update fails. + /// + public CalendarItem OriginalItem { get; init; } + + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.UpdateEvent; + + /// + /// After successful update, we need to resync to ensure changes are properly reflected. + /// + public override int ResynchronizationDelay => 2000; + + public override void ApplyUIChanges() + { + // Notify UI that the event was updated locally + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } + + public override void RevertUIChanges() + { + // If update fails, restore the original state + if (OriginalItem != null && OriginalAttendees != null) + { + // Send the original item back to restore UI state + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(OriginalItem)); + } + else + { + // Fallback: just notify with current item to trigger refresh + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item)); + } + } +} diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index fa4a881e..693826b1 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -145,8 +145,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), - // Future support for update operations - // CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees), + CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees) + { + OriginalItem = calendarPreparationRequest.OriginalItem, + OriginalAttendees = calendarPreparationRequest.OriginalAttendees + }, _ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.") }; diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 15b814da..17483356 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1561,6 +1561,9 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""; var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue); + // Detect calendar invitation by checking Content-Type header (only if calendar access granted) + var itemType = Account.IsCalendarAccessGranted ? GetMailItemTypeFromHeaders(gmailMessage.Payload?.Headers) : MailItemType.Mail; + var copy = new MailCopy() { CreationDate = creationDate, @@ -1579,7 +1582,8 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value, MessageId = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value, References = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value, - FileId = Guid.NewGuid() + FileId = Guid.NewGuid(), + ItemType = itemType }; // Set DraftId if this is a draft @@ -1589,6 +1593,47 @@ public class GmailSynchronizer : WinoSynchronizer + /// Determines MailItemType based on Gmail message headers. + /// Gmail doesn't have EventMessage type like Outlook, but calendar invitations can be detected + /// by checking Content-Type header for text/calendar or multipart/alternative with text/calendar part. + /// + private static MailItemType GetMailItemTypeFromHeaders(IList headers) + { + if (headers == null) return MailItemType.Mail; + + // Check Content-Type header for text/calendar + var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value; + + if (!string.IsNullOrEmpty(contentTypeHeader)) + { + // Check if it's a calendar message (text/calendar or multipart with calendar) + if (contentTypeHeader.Contains("text/calendar", StringComparison.OrdinalIgnoreCase)) + { + // Check the METHOD parameter to determine invitation type + var methodMatch = System.Text.RegularExpressions.Regex.Match(contentTypeHeader, @"method=([^;\s]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (methodMatch.Success) + { + var method = methodMatch.Groups[1].Value.Trim('"').ToUpperInvariant(); + + return method switch + { + "REQUEST" => MailItemType.CalendarInvitation, + "CANCEL" => MailItemType.CalendarCancellation, + "REPLY" => MailItemType.CalendarResponse, + _ => MailItemType.Mail + }; + } + + // If no method specified, assume it's an invitation + return MailItemType.CalendarInvitation; + } + } + + return MailItemType.Mail; + } + /// /// Extracts name and email address from a header value like "Name " or "email@domain.com" /// @@ -1849,6 +1894,87 @@ public class GmailSynchronizer : WinoSynchronizer(patchRequest, request)]; } + public override List> UpdateCalendarEvent(UpdateCalendarEventRequest request) + { + var calendarItem = request.Item; + var attendees = request.Attendees; + + // Get the calendar for this event + var calendar = calendarItem.AssignedCalendar; + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot update event without remote event ID"); + } + + // Convert CalendarItem to Google Event for update + var googleEvent = new Event + { + Summary = calendarItem.Title, + Description = calendarItem.Description, + Location = calendarItem.Location, + Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative", + Transparency = calendarItem.ShowAs == CalendarItemShowAs.Free ? "transparent" : "opaque" + }; + + // Set start and end time with proper timezone handling + // CalendarItem stores dates in the event's timezone (StartTimeZone/EndTimeZone) + // When user edits in local timezone, the dates are already converted and stored correctly + if (calendarItem.IsAllDayEvent) + { + // All-day events use Date instead of DateTime + googleEvent.Start = new EventDateTime + { + Date = calendarItem.StartDate.ToString("yyyy-MM-dd") + }; + googleEvent.End = new EventDateTime + { + Date = calendarItem.EndDate.ToString("yyyy-MM-dd") + }; + } + else + { + // Regular events with time + // StartDate and EndDate are stored in the event's timezone + // We preserve the timezone information during update + googleEvent.Start = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero), + TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id + }; + googleEvent.End = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero), + TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id + }; + } + + // Add attendees if any + if (attendees != null && attendees.Count > 0) + { + googleEvent.Attendees = attendees.Select(a => new EventAttendee + { + Email = a.Email, + DisplayName = a.Name, + Optional = a.IsOptionalAttendee + }).ToList(); + } + + // Update the event using Google Calendar API + var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId); + + // Send notifications to attendees if the event has attendees + updateRequest.SendUpdates = (attendees != null && attendees.Count > 0) + ? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All + : Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None; + + return [new HttpRequestBundle(updateRequest, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 137a519e..63e5112b 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -210,6 +210,17 @@ public class OutlookSynchronizer : WinoSynchronizer Regex.Split(deltaLink, "deltatoken=")[1]; + /// + /// Determines MailItemType based on EventMessage's MeetingMessageType. + /// + private static MailItemType GetMailItemType(EventMessage eventMessage) + { + if (eventMessage.MeetingMessageType.HasValue) + { + return eventMessage.MeetingMessageType.Value switch + { + MeetingMessageType.MeetingRequest => MailItemType.CalendarInvitation, + MeetingMessageType.MeetingCancelled => MailItemType.CalendarCancellation, + MeetingMessageType.MeetingAccepted or + MeetingMessageType.MeetingTenativelyAccepted or + MeetingMessageType.MeetingDeclined => MailItemType.CalendarResponse, + _ => MailItemType.Mail + }; + } + + // Fallback to CalendarInvitation if type is unknown + return MailItemType.CalendarInvitation; + } + protected override async Task CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { // Use centralized method @@ -614,10 +663,18 @@ public class OutlookSynchronizer : WinoSynchronizer + var message = await _graphClient.Me.Messages[messageId].GetAsync((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; }, cancellationToken).ConfigureAwait(false); + + // Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted) + if (Account.IsCalendarAccessGranted && message is EventMessage) + { + message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + + return message; } catch (ServiceException serviceException) { @@ -738,6 +795,36 @@ public class OutlookSynchronizer : WinoSynchronizer additionalData) => additionalData != null && additionalData.ContainsKey("@removed"); + /// + /// Fetches an EventMessage with full details including MeetingMessageType from the Messages endpoint. + /// This is necessary because MeetingMessageType is not available when fetching as Message type. + /// + private async Task FetchEventMessageAsync(string messageId, CancellationToken cancellationToken) + { + try + { + var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) => + { + config.QueryParameters.Select = outlookMessageSelectParameters.Concat(["MeetingMessageType"]).ToArray(); + }); + + var eventMessage = await _graphClient.Me.Messages[messageId].GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + var odataType = eventMessage?.AdditionalData?.ContainsKey("@odata.type") == true + ? eventMessage.AdditionalData["@odata.type"]?.ToString() + : "unknown"; + + _logger.Debug("Fetched EventMessage {MessageId} with type {ODataType}", messageId, odataType); + + return eventMessage as EventMessage; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to fetch EventMessage {MessageId}", messageId); + return null; + } + } + private async Task HandleFolderRetrievedAsync(MailFolder folder, OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation, CancellationToken cancellationToken = default) { if (IsResourceDeleted(folder.AdditionalData)) @@ -785,11 +872,12 @@ public class OutlookSynchronizer : WinoSynchronizer /// Retrieved message. /// Whether the item is non-Message type or not. private bool IsNotRealMessageType(Message item) - => item is EventMessage || item.From?.EmailAddress == null; + => item.From?.EmailAddress == null; private async Task HandleItemRetrievedAsync(Message item, MailItemFolder folder, IList downloadedMessageIds, CancellationToken cancellationToken = default) { @@ -802,6 +890,16 @@ public class OutlookSynchronizer : WinoSynchronizer(tentativelyAcceptRequestInfo, request)]; } + public override List> UpdateCalendarEvent(UpdateCalendarEventRequest request) + { + var calendarItem = request.Item; + var attendees = request.Attendees; + + // Get the calendar for this event + var calendar = calendarItem.AssignedCalendar; + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + // Convert CalendarItem to Outlook Event for update + var outlookEvent = new Microsoft.Graph.Models.Event + { + Subject = calendarItem.Title, + Body = new Microsoft.Graph.Models.ItemBody + { + ContentType = Microsoft.Graph.Models.BodyType.Text, + Content = calendarItem.Description + }, + Location = new Microsoft.Graph.Models.Location + { + DisplayName = calendarItem.Location + }, + ShowAs = calendarItem.ShowAs switch + { + CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free, + CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative, + CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy, + CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof, + CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere, + _ => Microsoft.Graph.Models.FreeBusyStatus.Busy + } + }; + + // Set start and end time using DateTimeTimeZone + if (calendarItem.IsAllDayEvent) + { + // All-day events + outlookEvent.IsAllDay = true; + outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone + { + DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"), + TimeZone = "UTC" + }; + outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone + { + DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"), + TimeZone = "UTC" + }; + } + else + { + // Regular events with time + // StartDate and EndDate are stored in the event's timezone + // We preserve the timezone information during update + outlookEvent.IsAllDay = false; + outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone + { + DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id + }; + outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone + { + DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id + }; + } + + // Add attendees if any + if (attendees != null && attendees.Count > 0) + { + outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee + { + EmailAddress = new Microsoft.Graph.Models.EmailAddress + { + Address = a.Email, + Name = a.Name + }, + Type = a.IsOptionalAttendee ? Microsoft.Graph.Models.AttendeeType.Optional : Microsoft.Graph.Models.AttendeeType.Required + }).ToList(); + } + + // Update the event using Graph API + var updateRequest = _graphClient.Me.Events[calendarItem.RemoteEventId].ToPatchRequestInformation(outlookEvent); + + return [new HttpRequestBundle(updateRequest, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index f5d8dd3a..851d6b85 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -381,7 +381,7 @@ public abstract class WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> UpdateCalendarEvent(UpdateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index f15ff5ef..95d81833 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -109,7 +109,8 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel Name = accountCreationDialogResult.AccountName, SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None, Id = Guid.NewGuid(), - AccountColorHex = accountCreationDialogResult.AccountColorHex + AccountColorHex = accountCreationDialogResult.AccountColorHex, + IsCalendarAccessGranted = true // New accounts have calendar scopes }; await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoMailItemTemplateSelector.cs b/Wino.Mail.WinUI/Controls/ListView/WinoMailItemTemplateSelector.cs index d2146b09..a079f1dd 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoMailItemTemplateSelector.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoMailItemTemplateSelector.cs @@ -1,6 +1,7 @@ using System; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; using Wino.Mail.ViewModels.Data; namespace Wino.Mail.WinUI.Controls.ListView; @@ -9,11 +10,18 @@ public partial class WinoMailItemTemplateSelector : DataTemplateSelector { public DataTemplate? SingleMailItemTemplate { get; set; } public DataTemplate? ThreadMailItemTemplate { get; set; } + public DataTemplate? CalendarMailItemTemplate { get; set; } protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) { - if (item is MailItemViewModel) + if (item is MailItemViewModel mailItemViewModel) + { + // Check if it's a calendar-related item + if (mailItemViewModel.MailCopy.ItemType != MailItemType.Mail && CalendarMailItemTemplate != null) + return CalendarMailItemTemplate; + return SingleMailItemTemplate ?? throw new Exception($"Missing template for single mail items."); + } else if (item is ThreadMailItemViewModel) return ThreadMailItemTemplate ?? throw new Exception($"Missing template for thread mail items."); diff --git a/Wino.Mail.WinUI/Services/StatePersistenceService.cs b/Wino.Mail.WinUI/Services/StatePersistenceService.cs index 99f6f801..742ee766 100644 --- a/Wino.Mail.WinUI/Services/StatePersistenceService.cs +++ b/Wino.Mail.WinUI/Services/StatePersistenceService.cs @@ -59,6 +59,9 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic if (SetProperty(ref isEventDetailsVisible, value)) { OnPropertyChanged(nameof(IsBackButtonVisible)); + + IsReaderNarrowed = value; + IsReadingMail = value; } } } diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml index 97cdb33f..d0e03b7e 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml @@ -373,7 +373,7 @@ - + diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index d3c3168a..d610e178 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -121,8 +121,32 @@ + + + + + + + + diff --git a/Wino.Messages/Client/Calendar/DetailsPageStateChangedMessage.cs b/Wino.Messages/Client/Calendar/DetailsPageStateChangedMessage.cs deleted file mode 100644 index a0f22743..00000000 --- a/Wino.Messages/Client/Calendar/DetailsPageStateChangedMessage.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Wino.Messaging.Client.Calendar; - -/// -/// When event details page is activated or deactivated. -/// -public record DetailsPageStateChangedMessage(bool IsActivated); diff --git a/Wino.Services/AccountService.cs b/Wino.Services/AccountService.cs index be003883..e2f4ab14 100644 --- a/Wino.Services/AccountService.cs +++ b/Wino.Services/AccountService.cs @@ -12,7 +12,6 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Messaging.Client.Accounts; using Wino.Messaging.UI; -using Wino.Services.Extensions; namespace Wino.Services; @@ -21,6 +20,7 @@ public class AccountService : BaseDatabaseService, IAccountService public IAuthenticator ExternalAuthenticationAuthenticator { get; set; } private readonly ISignatureService _signatureService; + private readonly IAuthenticationProvider _authenticationProvider; private readonly IMimeFileService _mimeFileService; private readonly IPreferencesService _preferencesService; @@ -28,10 +28,12 @@ public class AccountService : BaseDatabaseService, IAccountService public AccountService(IDatabaseService databaseService, ISignatureService signatureService, + IAuthenticationProvider authenticationProvider, IMimeFileService mimeFileService, IPreferencesService preferencesService) : base(databaseService) { _signatureService = signatureService; + _authenticationProvider = authenticationProvider; _mimeFileService = mimeFileService; _preferencesService = preferencesService; } @@ -59,7 +61,7 @@ public class AccountService : BaseDatabaseService, IAccountService var sql = $"UPDATE MailAccount SET MergedInboxId = ? WHERE Id IN ({placeholders})"; var parameters = new List { mergedInboxId }; parameters.AddRange(accountIdList.Cast()); - + await Connection.ExecuteAsync(sql, parameters.ToArray()); WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested()); @@ -193,13 +195,18 @@ public class AccountService : BaseDatabaseService, IAccountService if (account == null) return; - //var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType); + var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType); - //// This will re-generate token. - //var token = await authenticator.GenerateTokenInformationAsync(account); + // This will re-generate token with interactive authentication + // New authentication will include calendar scopes + var token = await authenticator.GenerateTokenInformationAsync(account); - // TODO: Rest? - // Guard.IsNotNull(token); + Guard.IsNotNull(token); + + // Enable calendar access since new token includes calendar scopes + account.IsCalendarAccessGranted = true; + + await UpdateAccountAsync(account); } private Task GetAccountPreferencesAsync(Guid accountId) diff --git a/Wino.Services/Extensions/MailkitClientExtensions.cs b/Wino.Services/Extensions/MailkitClientExtensions.cs index f8025cbb..a0977beb 100644 --- a/Wino.Services/Extensions/MailkitClientExtensions.cs +++ b/Wino.Services/Extensions/MailkitClientExtensions.cs @@ -111,6 +111,9 @@ public static class MailkitClientExtensions // Use InternalDate (server received date) if available, otherwise fall back to Date header (sent date) var creationDate = messageSummary.InternalDate?.UtcDateTime ?? mime.Date.UtcDateTime; + // Detect calendar invitation based on MIME content type + var itemType = GetMailItemTypeFromMime(mime); + var copy = new MailCopy() { Id = messageUid, @@ -128,12 +131,49 @@ public static class MailkitClientExtensions References = mime.References?.GetReferences(), InReplyTo = mime.GetInReplyTo(), HasAttachments = mime.Attachments.Any(), - FileId = Guid.NewGuid() + FileId = Guid.NewGuid(), + ItemType = itemType }; return copy; } + /// + /// Determines MailItemType based on MIME message content type. + /// Calendar invitations have text/calendar content type with METHOD parameter. + /// + private static MailItemType GetMailItemTypeFromMime(MimeMessage mime) + { + if (mime == null) return MailItemType.Mail; + + // Check if the message contains text/calendar content + var calendarPart = mime.BodyParts.OfType() + .FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true); + + if (calendarPart != null) + { + // Check the METHOD parameter to determine invitation type + var method = calendarPart.ContentType.Parameters + .FirstOrDefault(p => p.Name.Equals("method", StringComparison.OrdinalIgnoreCase))?.Value?.ToUpperInvariant(); + + if (!string.IsNullOrEmpty(method)) + { + return method switch + { + "REQUEST" => MailItemType.CalendarInvitation, + "CANCEL" => MailItemType.CalendarCancellation, + "REPLY" => MailItemType.CalendarResponse, + _ => MailItemType.Mail + }; + } + + // If no method specified, assume it's an invitation + return MailItemType.CalendarInvitation; + } + + return MailItemType.Mail; + } + // TODO: Name and Address parsing should be handled better. // At some point Wino needs better contact management.