Calendar attachments.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Extensions;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Data;
|
||||
|
||||
public partial class CalendarAttachmentViewModel : ObservableObject
|
||||
{
|
||||
public CalendarAttachment Attachment { get; }
|
||||
|
||||
public Guid Id => Attachment.Id;
|
||||
public string FileName => Attachment.FileName;
|
||||
public string ReadableSize { get; }
|
||||
public MailAttachmentType AttachmentType { get; }
|
||||
public bool IsDownloaded => Attachment.IsDownloaded;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
public CalendarAttachmentViewModel(CalendarAttachment attachment)
|
||||
{
|
||||
Attachment = attachment;
|
||||
ReadableSize = attachment.Size.GetBytesReadable();
|
||||
|
||||
var extension = Path.GetExtension(FileName);
|
||||
AttachmentType = GetAttachmentType(extension);
|
||||
}
|
||||
|
||||
private MailAttachmentType GetAttachmentType(string extension)
|
||||
{
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
return MailAttachmentType.None;
|
||||
|
||||
switch (extension.ToLower())
|
||||
{
|
||||
case ".exe":
|
||||
return MailAttachmentType.Executable;
|
||||
case ".rar":
|
||||
return MailAttachmentType.RarArchive;
|
||||
case ".zip":
|
||||
return MailAttachmentType.Archive;
|
||||
case ".ogg":
|
||||
case ".mp3":
|
||||
case ".wav":
|
||||
case ".aac":
|
||||
case ".alac":
|
||||
return MailAttachmentType.Audio;
|
||||
case ".mp4":
|
||||
case ".wmv":
|
||||
case ".avi":
|
||||
case ".flv":
|
||||
return MailAttachmentType.Video;
|
||||
case ".pdf":
|
||||
return MailAttachmentType.PDF;
|
||||
case ".htm":
|
||||
case ".html":
|
||||
return MailAttachmentType.HTML;
|
||||
case ".png":
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
case ".gif":
|
||||
case ".jiff":
|
||||
return MailAttachmentType.Image;
|
||||
default:
|
||||
return MailAttachmentType.Other;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
@@ -14,6 +16,7 @@ using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Core.ViewModels;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
@@ -35,11 +38,12 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
[ObservableProperty]
|
||||
public partial bool IsDarkWebviewRenderer { get; set; }
|
||||
|
||||
public ObservableCollection<CalendarAttachmentViewModel> Attachments { get; } = new ObservableCollection<CalendarAttachmentViewModel>();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the current event has attachments.
|
||||
/// Currently always returns false until attachments are implemented.
|
||||
/// </summary>
|
||||
public bool HasAttachments => false; // TODO: Implement when CalendarItem attachments are added
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
|
||||
#region Details
|
||||
|
||||
@@ -200,6 +204,24 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
|
||||
var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.EventTrackingId);
|
||||
|
||||
// Check if organizer is in the attendees list
|
||||
var hasOrganizerInList = attendees.Any(a => a.IsOrganizer);
|
||||
|
||||
// If the user is the organizer but not in attendees list, add them
|
||||
if (!hasOrganizerInList && !string.IsNullOrEmpty(currentEventItem.OrganizerEmail))
|
||||
{
|
||||
var organizerAttendee = new CalendarEventAttendee
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = currentEventItem.Id,
|
||||
Name = currentEventItem.OrganizerDisplayName ?? currentEventItem.OrganizerEmail,
|
||||
Email = currentEventItem.OrganizerEmail,
|
||||
IsOrganizer = true,
|
||||
AttendenceStatus = AttendeeStatus.Accepted
|
||||
};
|
||||
CurrentEvent.Attendees.Add(organizerAttendee);
|
||||
}
|
||||
|
||||
foreach (var item in attendees)
|
||||
{
|
||||
CurrentEvent.Attendees.Add(item);
|
||||
@@ -208,6 +230,9 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
// Load reminders for this calendar item
|
||||
Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId);
|
||||
InitializeReminderOptions();
|
||||
|
||||
// Load attachments
|
||||
await LoadAttachmentsAsync(currentEventItem.EventTrackingId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -215,6 +240,27 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAttachmentsAsync(Guid calendarItemId)
|
||||
{
|
||||
Attachments.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var attachments = await _calendarService.GetAttachmentsAsync(calendarItemId);
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
Attachments.Add(new CalendarAttachmentViewModel(attachment));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasAttachments));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Error loading attachments: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeReminderOptions()
|
||||
{
|
||||
ReminderOptions.Clear();
|
||||
@@ -429,6 +475,117 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
Debug.WriteLine($"Error loading series: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||
{
|
||||
if (attachmentViewModel == null || CurrentEvent?.CalendarItem == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
attachmentViewModel.IsBusy = true;
|
||||
|
||||
// If not downloaded, download it first
|
||||
if (!attachmentViewModel.IsDownloaded)
|
||||
{
|
||||
await DownloadAttachmentAsync(attachmentViewModel);
|
||||
}
|
||||
|
||||
// Launch the file
|
||||
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
|
||||
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
|
||||
{
|
||||
await _nativeAppService.LaunchFileAsync(attachmentViewModel.Attachment.LocalFilePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to open calendar attachment.");
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_AttachmentOpenFailedTitle,
|
||||
Translator.Info_AttachmentOpenFailedMessage,
|
||||
InfoBarMessageType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
attachmentViewModel.IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||
{
|
||||
if (attachmentViewModel == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
attachmentViewModel.IsBusy = true;
|
||||
|
||||
var pickedPath = await _dialogService.PickWindowsFolderAsync();
|
||||
if (string.IsNullOrEmpty(pickedPath)) return;
|
||||
|
||||
// Download if not already downloaded
|
||||
if (!attachmentViewModel.IsDownloaded)
|
||||
{
|
||||
await DownloadAttachmentAsync(attachmentViewModel);
|
||||
}
|
||||
|
||||
// Copy to selected location
|
||||
if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) &&
|
||||
File.Exists(attachmentViewModel.Attachment.LocalFilePath))
|
||||
{
|
||||
var destinationPath = Path.Combine(pickedPath, attachmentViewModel.FileName);
|
||||
File.Copy(attachmentViewModel.Attachment.LocalFilePath, destinationPath, overwrite: true);
|
||||
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_AttachmentSaveSuccessTitle,
|
||||
Translator.Info_AttachmentSaveSuccessMessage,
|
||||
InfoBarMessageType.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to save calendar attachment.");
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_AttachmentSaveFailedTitle,
|
||||
Translator.Info_AttachmentSaveFailedMessage,
|
||||
InfoBarMessageType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
attachmentViewModel.IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel)
|
||||
{
|
||||
if (CurrentEvent?.CalendarItem == null) return;
|
||||
|
||||
// Create attachments folder for this calendar item
|
||||
var attachmentsFolder = Path.Combine(
|
||||
_nativeAppService.GetCalendarAttachmentsFolderPath(),
|
||||
CurrentEvent.CalendarItem.Id.ToString());
|
||||
|
||||
Directory.CreateDirectory(attachmentsFolder);
|
||||
|
||||
var localFilePath = Path.Combine(attachmentsFolder, attachmentViewModel.FileName);
|
||||
|
||||
// Download attachment using synchronizer
|
||||
await SynchronizationManager.Instance.DownloadCalendarAttachmentAsync(
|
||||
CurrentEvent.CalendarItem,
|
||||
attachmentViewModel.Attachment,
|
||||
localFilePath);
|
||||
|
||||
// Mark as downloaded
|
||||
await _calendarService.MarkAttachmentDownloadedAsync(
|
||||
attachmentViewModel.Id,
|
||||
localFilePath);
|
||||
|
||||
// Update view model
|
||||
attachmentViewModel.Attachment.IsDownloaded = true;
|
||||
attachmentViewModel.Attachment.LocalFilePath = localFilePath;
|
||||
OnPropertyChanged(nameof(attachmentViewModel.IsDownloaded));
|
||||
}
|
||||
}
|
||||
|
||||
public partial class ReminderOption : ObservableObject
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata for calendar event attachments.
|
||||
/// Actual file content is downloaded on-demand.
|
||||
/// </summary>
|
||||
public class CalendarAttachment
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The calendar item this attachment belongs to.
|
||||
/// </summary>
|
||||
public Guid CalendarItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote identifier for the attachment from the provider (Outlook, Gmail, etc.).
|
||||
/// </summary>
|
||||
public string RemoteAttachmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File name of the attachment.
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the attachment in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME content type (e.g., "application/pdf", "image/png").
|
||||
/// </summary>
|
||||
public string ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attachment has been downloaded to local storage.
|
||||
/// </summary>
|
||||
public bool IsDownloaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local file path where the attachment is stored (if downloaded).
|
||||
/// </summary>
|
||||
public string LocalFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attachment was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastModified { get; set; }
|
||||
}
|
||||
@@ -54,4 +54,28 @@ public interface ICalendarService
|
||||
/// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min).
|
||||
/// </summary>
|
||||
int[] GetPredefinedReminderMinutes();
|
||||
|
||||
#region Attachments
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attachments for a calendar event.
|
||||
/// </summary>
|
||||
Task<List<CalendarAttachment>> GetAttachmentsAsync(Guid calendarItemId);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or updates calendar attachments.
|
||||
/// </summary>
|
||||
Task InsertOrReplaceAttachmentsAsync(List<CalendarAttachment> attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an attachment as downloaded and updates its local file path.
|
||||
/// </summary>
|
||||
Task MarkAttachmentDownloadedAsync(Guid attachmentId, string localFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all attachments for a calendar item.
|
||||
/// </summary>
|
||||
Task DeleteAttachmentsAsync(Guid calendarItemId);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -22,4 +22,9 @@ public interface INativeAppService
|
||||
/// This is used to display WAM broker dialog on running UWP app called by a windowless server code.
|
||||
/// </summary>
|
||||
Func<IntPtr> GetCoreWindowHwnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the folder path where calendar attachments are stored.
|
||||
/// </summary>
|
||||
string GetCalendarAttachmentsFolderPath();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -46,4 +47,5 @@ public interface IWinoSynchronizerBase : IBaseSynchronizer
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Search results after downloading missing mail copies from server.</returns>
|
||||
Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default);
|
||||
Task DownloadCalendarAttachmentAsync(CalendarItem calendarItem, CalendarAttachment attachment, string localFilePath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@
|
||||
"CalendarEventDetails_ReadOnlyEvent": "Read-only event",
|
||||
"CalendarEventDetails_Reminder": "Reminder",
|
||||
"CalendarEventDetails_ShowAs": "Show as",
|
||||
"CalendarEventResponse_Accept": "Accept",
|
||||
"CalendarEventResponse_AcceptedResponse": "Accepted",
|
||||
"CalendarEventResponse_Decline": "Decline",
|
||||
"CalendarEventResponse_DeclinedResponse": "Declined",
|
||||
"CalendarEventResponse_NotResponded": "Not Responded",
|
||||
"CalendarEventResponse_Tentative": "Tentative",
|
||||
"CalendarEventResponse_TentativeResponse": "Tentatively Accepted",
|
||||
"CalendarItem_DetailsPopup_JoinOnline": "Join online",
|
||||
"CalendarItem_DetailsPopup_ViewEventButton": "View event",
|
||||
"CalendarItem_DetailsPopup_ViewSeriesButton": "View series",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Apis.Calendar.v3.Data;
|
||||
using Serilog;
|
||||
@@ -250,10 +251,37 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare attachments metadata from Gmail event
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Title))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = calendarItem.Id,
|
||||
RemoteAttachmentId = a.FileId ?? a.FileUrl, // Gmail uses FileId or FileUrl
|
||||
FileName = a.Title,
|
||||
Size = 0, // Gmail API doesn't provide size in Event.Attachment
|
||||
ContentType = a.MimeType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
await CalendarService.CreateNewCalendarItemAsync(calendarItem, attendees);
|
||||
|
||||
// Save reminders separately
|
||||
await CalendarService.SaveRemindersAsync(calendarItem.Id, reminders).ConfigureAwait(false);
|
||||
|
||||
// Save attachments metadata separately
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -315,6 +343,33 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
|
||||
// Save reminders
|
||||
await CalendarService.SaveRemindersAsync(existingCalendarItem.Id, reminders).ConfigureAwait(false);
|
||||
|
||||
// Prepare attachments metadata from Gmail event for update
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Title))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = existingCalendarItem.Id,
|
||||
RemoteAttachmentId = a.FileId ?? a.FileUrl,
|
||||
FileName = a.Title,
|
||||
Size = 0,
|
||||
ContentType = a.MimeType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Save attachments metadata
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert the event.
|
||||
|
||||
@@ -195,6 +195,27 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare attachments metadata from Outlook event
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.HasAttachments.GetValueOrDefault() && calendarEvent.Attachments != null)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Name))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
RemoteAttachmentId = a.Id,
|
||||
FileName = a.Name,
|
||||
Size = a.Size ?? 0,
|
||||
ContentType = a.ContentType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = calendarEvent.LastModifiedDateTime ?? DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Use CalendarService to create or update the event
|
||||
if (isNewItem)
|
||||
{
|
||||
@@ -209,5 +230,11 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
|
||||
// Save reminders separately
|
||||
await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false);
|
||||
|
||||
// Save attachments metadata separately
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +412,53 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a calendar attachment using the appropriate synchronizer.
|
||||
/// </summary>
|
||||
public async Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (calendarItem == null)
|
||||
throw new ArgumentNullException(nameof(calendarItem));
|
||||
|
||||
if (attachment == null)
|
||||
throw new ArgumentNullException(nameof(attachment));
|
||||
|
||||
var accountId = calendarItem.AssignedCalendar?.AccountId ?? Guid.Empty;
|
||||
if (accountId == Guid.Empty)
|
||||
throw new InvalidOperationException("Calendar item does not have an assigned account.");
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(accountId);
|
||||
|
||||
if (synchronizer == null)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId} to download calendar attachment", accountId);
|
||||
throw new InvalidOperationException("No synchronizer available for downloading calendar attachment.");
|
||||
}
|
||||
|
||||
_logger.Debug("Downloading calendar attachment {AttachmentId} for calendar item {CalendarItemId}",
|
||||
attachment.Id, calendarItem.Id);
|
||||
|
||||
try
|
||||
{
|
||||
await synchronizer.DownloadCalendarAttachmentAsync(
|
||||
calendarItem,
|
||||
attachment,
|
||||
localFilePath,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to download calendar attachment {AttachmentId}", attachment.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new synchronizer for a newly added account.
|
||||
/// </summary>
|
||||
|
||||
@@ -1242,6 +1242,39 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Gmail calendar attachments are stored in Google Drive
|
||||
// RemoteAttachmentId contains either FileId or FileUrl
|
||||
// For simplicity, we'll try to download from the FileId/FileUrl
|
||||
|
||||
if (string.IsNullOrEmpty(attachment.RemoteAttachmentId))
|
||||
{
|
||||
_logger.Error("RemoteAttachmentId is empty for attachment {AttachmentId}", attachment.Id);
|
||||
throw new InvalidOperationException("RemoteAttachmentId is required to download Gmail calendar attachment.");
|
||||
}
|
||||
|
||||
// Gmail calendar attachments are links to Google Drive files
|
||||
// The attachment.RemoteAttachmentId is either a FileId or FileUrl
|
||||
// Since we can't directly download from Calendar API, this would require Drive API access
|
||||
// For now, throw NotSupportedException as Gmail attachments require additional Drive API setup
|
||||
|
||||
_logger.Warning("Gmail calendar attachment download requires Google Drive API access. FileId/URL: {RemoteId}", attachment.RemoteAttachmentId);
|
||||
throw new NotSupportedException("Gmail calendar attachments are stored in Google Drive and require additional API configuration to download.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Error downloading Gmail calendar attachment {AttachmentId}", attachment.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<IClientServiceRequest>> RenameFolder(RenameFolderRequest request)
|
||||
{
|
||||
var label = new Label()
|
||||
|
||||
@@ -243,6 +243,18 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
_clientPool.Release(client);
|
||||
}
|
||||
|
||||
public override Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// IMAP protocol doesn't support calendar operations natively
|
||||
// Calendar functionality would require CalDAV protocol
|
||||
_logger.Warning("IMAP protocol does not support calendar attachments. CalDAV would be required.");
|
||||
throw new NotSupportedException("IMAP does not support calendar attachments. Use Outlook or Gmail for calendar functionality.");
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<ImapRequest>> RenameFolder(RenameFolderRequest request)
|
||||
{
|
||||
return CreateSingleTaskBundle(async (client, item) =>
|
||||
|
||||
@@ -1337,6 +1337,61 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var calendar = calendarItem.AssignedCalendar;
|
||||
|
||||
// First, get the attachment metadata to retrieve contentBytes for FileAttachment
|
||||
var attachmentItem = await _graphClient.Me
|
||||
.Calendars[calendar.RemoteCalendarId]
|
||||
.Events[calendarItem.RemoteEventId]
|
||||
.Attachments[attachment.RemoteAttachmentId]
|
||||
.GetAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (attachmentItem == null)
|
||||
{
|
||||
_logger.Error("Failed to retrieve attachment {AttachmentId} for event {EventId}", attachment.RemoteAttachmentId, calendarItem.RemoteEventId);
|
||||
throw new InvalidOperationException("Failed to retrieve attachment.");
|
||||
}
|
||||
|
||||
byte[] contentBytes = null;
|
||||
|
||||
// Handle FileAttachment (has ContentBytes property)
|
||||
if (attachmentItem is FileAttachment fileAttachment && fileAttachment.ContentBytes != null)
|
||||
{
|
||||
contentBytes = fileAttachment.ContentBytes;
|
||||
}
|
||||
// Handle ItemAttachment (embedded items like emails)
|
||||
else if (attachmentItem is ItemAttachment)
|
||||
{
|
||||
_logger.Warning("ItemAttachment type not supported for download. AttachmentId: {AttachmentId}", attachment.RemoteAttachmentId);
|
||||
throw new NotSupportedException("ItemAttachment downloads are not currently supported.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error("Unknown attachment type or missing content for {AttachmentId}", attachment.RemoteAttachmentId);
|
||||
throw new InvalidOperationException("Attachment content is not available.");
|
||||
}
|
||||
|
||||
// Save to local file
|
||||
await System.IO.File.WriteAllBytesAsync(localFilePath, contentBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.Information("Downloaded calendar attachment {FileName} to {LocalPath}", attachment.FileName, localFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Error downloading calendar attachment {AttachmentId}", attachment.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<RequestInformation>> RenameFolder(RenameFolderRequest request)
|
||||
{
|
||||
var requestBody = new MailFolder
|
||||
@@ -1693,9 +1748,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
await _handleCalendarEventRetrievalSemaphore.WaitAsync();
|
||||
|
||||
// Check if the event has complete information
|
||||
// Sometimes delta sync returns events with only Id available
|
||||
Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id].GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); ;
|
||||
Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id]
|
||||
.GetAsync(requestConfiguration =>
|
||||
{
|
||||
// Expand attachments but only get metadata, not the full content
|
||||
requestConfiguration.QueryParameters.Expand = new[] { "attachments($select=id,name,contentType,size,isInline)" };
|
||||
}, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -526,6 +526,19 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public virtual Task DownloadMissingMimeMessageAsync(MailCopy mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a calendar attachment from the provider.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">Calendar item the attachment belongs to.</param>
|
||||
/// <param name="attachment">Attachment metadata to download.</param>
|
||||
/// <param name="localFilePath">Local file path to save the attachment to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public virtual Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
/// <summary>
|
||||
/// Performs an online search for the given query text in the given folders.
|
||||
/// Downloads the missing messages from the server.
|
||||
|
||||
@@ -128,13 +128,11 @@ public static class CalendarXamlHelpers
|
||||
|
||||
/// <summary>
|
||||
/// Returns visibility for attendee status badge.
|
||||
/// Only shows status for non-organizers and when status is not NeedsAction.
|
||||
/// Always shows status for all attendees.
|
||||
/// </summary>
|
||||
public static Microsoft.UI.Xaml.Visibility GetAttendeeStatusVisibility(AttendeeStatus status)
|
||||
{
|
||||
// Don't show "Needs Action" status as it's the default
|
||||
return status == AttendeeStatus.NeedsAction
|
||||
? Microsoft.UI.Xaml.Visibility.Collapsed
|
||||
: Microsoft.UI.Xaml.Visibility.Visible;
|
||||
// Always show status
|
||||
return Microsoft.UI.Xaml.Visibility.Visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,4 +103,14 @@ public class NativeAppService : INativeAppService
|
||||
|
||||
//await taskbarManager.RequestPinCurrentAppAsync();
|
||||
}
|
||||
|
||||
public bool IsAppRunningInBackground()
|
||||
=> !Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread().HasThreadAccess;
|
||||
|
||||
public string GetCalendarAttachmentsFolderPath()
|
||||
{
|
||||
var attachmentsFolder = System.IO.Path.Combine(ApplicationData.Current.LocalFolder.Path, "CalendarAttachments");
|
||||
System.IO.Directory.CreateDirectory(attachmentsFolder);
|
||||
return attachmentsFolder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
|
||||
xmlns:ctControls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:data="using:Wino.Calendar.ViewModels.Data"
|
||||
xmlns:domain="using:Wino.Core.Domain"
|
||||
xmlns:enums="using:Wino.Core.Domain.Enums"
|
||||
xmlns:helpers="using:Wino.Helpers"
|
||||
@@ -444,6 +445,7 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<PersonPicture
|
||||
@@ -464,7 +466,10 @@
|
||||
FontSize="13"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind Email}" />
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6">
|
||||
<StackPanel
|
||||
Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<Border
|
||||
Padding="6,2"
|
||||
Background="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
@@ -476,18 +481,22 @@
|
||||
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
||||
Text="{x:Bind domain:Translator.CalendarEventDetails_Organizer}" />
|
||||
</Border>
|
||||
<Border
|
||||
Padding="6,2"
|
||||
Background="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
CornerRadius="4"
|
||||
Visibility="{x:Bind calendarHelpers:CalendarXamlHelpers.GetAttendeeStatusVisibility(AttendenceStatus)}">
|
||||
<TextBlock
|
||||
FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind calendarHelpers:CalendarXamlHelpers.GetAttendeeStatusText(AttendenceStatus)}" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border
|
||||
Grid.Column="2"
|
||||
Padding="6,2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Background="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
CornerRadius="4"
|
||||
Visibility="{x:Bind calendarHelpers:CalendarXamlHelpers.GetAttendeeStatusVisibility(AttendenceStatus)}">
|
||||
<TextBlock
|
||||
FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind calendarHelpers:CalendarXamlHelpers.GetAttendeeStatusText(AttendenceStatus)}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
@@ -508,6 +517,100 @@
|
||||
|
||||
<TextBlock Style="{StaticResource EventDetailsPanelTitleStyle}" Text="{x:Bind domain:Translator.CalendarEventDetails_Attachments}" />
|
||||
|
||||
<ListView
|
||||
Grid.Row="1"
|
||||
Margin="-12,0"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="AttachmentClicked"
|
||||
ItemsSource="{x:Bind ViewModel.Attachments, Mode=OneWay}"
|
||||
SelectionMode="None">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="data:CalendarAttachmentViewModel">
|
||||
<Grid Height="51">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="50" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid
|
||||
Grid.Row="0"
|
||||
Height="50"
|
||||
Margin="-8,0,0,0"
|
||||
Background="Transparent"
|
||||
ColumnSpacing="3">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip Content="{x:Bind FileName}" />
|
||||
</ToolTipService.ToolTip>
|
||||
<Grid.ContextFlyout>
|
||||
<MenuFlyout Placement="Right">
|
||||
<MenuFlyoutItem
|
||||
Click="OpenCalendarAttachment_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Text="{x:Bind domain:Translator.Buttons_Open}">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<PathIcon Data="{StaticResource OpenFilePathIcon}" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
Click="SaveCalendarAttachment_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Text="{x:Bind domain:Translator.Buttons_Save}">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<PathIcon Data="{StaticResource SaveAttachmentPathIcon}" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
</MenuFlyout>
|
||||
</Grid.ContextFlyout>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Icon -->
|
||||
<ContentControl
|
||||
VerticalAlignment="Center"
|
||||
Content="{x:Bind AttachmentType}"
|
||||
ContentTemplateSelector="{StaticResource FileTypeIconSelector}" />
|
||||
|
||||
<!-- Name && Size -->
|
||||
<Grid
|
||||
Grid.Column="1"
|
||||
MaxWidth="200"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock
|
||||
FontSize="13"
|
||||
MaxLines="1"
|
||||
Text="{x:Bind FileName}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
FontSize="11"
|
||||
Foreground="Gray"
|
||||
Text="{x:Bind ReadableSize}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<ProgressBar
|
||||
Grid.Row="1"
|
||||
Margin="0,-5,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top"
|
||||
IsIndeterminate="{x:Bind IsBusy, Mode=OneWay}"
|
||||
ShowError="False"
|
||||
ShowPaused="False"
|
||||
Visibility="{x:Bind IsBusy, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Windows.System;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Mail.WinUI;
|
||||
@@ -203,4 +204,28 @@ public sealed partial class EventDetailsPage : EventDetailsPageAbstract,
|
||||
WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<CalendarDescriptionRenderingRequested>(this);
|
||||
}
|
||||
|
||||
private void AttachmentClicked(object sender, ItemClickEventArgs e)
|
||||
{
|
||||
if (e.ClickedItem is CalendarAttachmentViewModel attachmentViewModel)
|
||||
{
|
||||
ViewModel?.OpenAttachmentCommand.Execute(attachmentViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCalendarAttachment_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuFlyoutItem item && item.CommandParameter is CalendarAttachmentViewModel attachment)
|
||||
{
|
||||
ViewModel?.OpenAttachmentCommand.Execute(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCalendarAttachment_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuFlyoutItem item && item.CommandParameter is CalendarAttachmentViewModel attachment)
|
||||
{
|
||||
ViewModel?.SaveAttachmentCommand.Execute(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,4 +480,38 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#region Attachments
|
||||
|
||||
public Task<List<CalendarAttachment>> GetAttachmentsAsync(Guid calendarItemId)
|
||||
=> Connection.Table<CalendarAttachment>().Where(x => x.CalendarItemId == calendarItemId).ToListAsync();
|
||||
|
||||
public async Task InsertOrReplaceAttachmentsAsync(List<CalendarAttachment> attachments)
|
||||
{
|
||||
if (attachments == null || attachments.Count == 0) return;
|
||||
|
||||
foreach (var item in attachments)
|
||||
{
|
||||
await Connection.InsertOrReplaceAsync(item, typeof(CalendarAttachment));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkAttachmentDownloadedAsync(Guid attachmentId, string localFilePath)
|
||||
{
|
||||
var attachment = await Connection.Table<CalendarAttachment>().FirstOrDefaultAsync(x => x.Id == attachmentId);
|
||||
|
||||
if (attachment == null) return;
|
||||
|
||||
attachment.IsDownloaded = true;
|
||||
attachment.LocalFilePath = localFilePath;
|
||||
|
||||
await Connection.UpdateAsync(attachment, typeof(CalendarAttachment));
|
||||
}
|
||||
|
||||
public async Task DeleteAttachmentsAsync(Guid calendarItemId)
|
||||
{
|
||||
await Connection.ExecuteAsync("DELETE FROM CalendarAttachment WHERE CalendarItemId = ?", calendarItemId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class DatabaseService : IDatabaseService
|
||||
Connection.CreateTableAsync<AccountCalendar>(),
|
||||
Connection.CreateTableAsync<CalendarEventAttendee>(),
|
||||
Connection.CreateTableAsync<CalendarItem>(),
|
||||
Connection.CreateTableAsync<CalendarAttachment>(),
|
||||
Connection.CreateTableAsync<Reminder>()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user