Contacts, thread animation and image preview control improvements.
This commit is contained in:
@@ -129,3 +129,4 @@ private string searchQuery = string.Empty;
|
|||||||
- String interpolation over string.Format
|
- String interpolation over string.Format
|
||||||
- Wrap async operations in try-catch
|
- Wrap async operations in try-catch
|
||||||
- Log errors via IWinoLogger
|
- Log errors via IWinoLogger
|
||||||
|
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Models.Contacts;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Interfaces;
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ public interface IContactService
|
|||||||
Task<List<AccountContact>> GetAddressInformationAsync(string queryText);
|
Task<List<AccountContact>> GetAddressInformationAsync(string queryText);
|
||||||
Task<AccountContact> GetAddressInformationByAddressAsync(string address);
|
Task<AccountContact> GetAddressInformationByAddressAsync(string address);
|
||||||
Task SaveAddressInformationAsync(MimeMessage message);
|
Task SaveAddressInformationAsync(MimeMessage message);
|
||||||
|
Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts);
|
||||||
Task<AccountContact> CreateNewContactAsync(string address, string displayName);
|
Task<AccountContact> CreateNewContactAsync(string address, string displayName);
|
||||||
|
|
||||||
// New methods for ContactsPage
|
// New methods for ContactsPage
|
||||||
Task<List<AccountContact>> GetAllContactsAsync();
|
Task<List<AccountContact>> GetAllContactsAsync();
|
||||||
Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
|
Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
|
||||||
|
Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false);
|
||||||
Task<AccountContact> UpdateContactAsync(AccountContact contact);
|
Task<AccountContact> UpdateContactAsync(AccountContact contact);
|
||||||
Task DeleteContactAsync(string address);
|
Task DeleteContactAsync(string address);
|
||||||
Task DeleteContactsAsync(IEnumerable<string> addresses);
|
Task DeleteContactsAsync(IEnumerable<string> addresses);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared display contract for mail list item rendering.
|
||||||
|
/// Implemented by both single mail and thread mail view models.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMailItemDisplayInformation : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
string Subject { get; }
|
||||||
|
string FromName { get; }
|
||||||
|
string FromAddress { get; }
|
||||||
|
string PreviewText { get; }
|
||||||
|
bool IsRead { get; }
|
||||||
|
bool IsDraft { get; }
|
||||||
|
bool HasAttachments { get; }
|
||||||
|
bool IsFlagged { get; }
|
||||||
|
DateTime CreationDate { get; }
|
||||||
|
string Base64ContactPicture { get; }
|
||||||
|
bool ThumbnailUpdatedEvent { get; }
|
||||||
|
bool IsBusy { get; }
|
||||||
|
bool IsThreadExpanded { get; }
|
||||||
|
AccountContact SenderContact { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.Contacts;
|
||||||
|
|
||||||
|
public record PagedContactsResult(
|
||||||
|
IReadOnlyList<AccountContact> Contacts,
|
||||||
|
int TotalCount,
|
||||||
|
bool HasMore,
|
||||||
|
int Offset,
|
||||||
|
int PageSize);
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
using MimeKit;
|
using System.Collections.Generic;
|
||||||
|
using MimeKit;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Models.MailItem;
|
namespace Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
public record NewMailItemPackage(MailCopy Copy, MimeMessage Mime, string AssignedRemoteFolderId);
|
public record NewMailItemPackage(
|
||||||
|
MailCopy Copy,
|
||||||
|
MimeMessage Mime,
|
||||||
|
string AssignedRemoteFolderId,
|
||||||
|
IReadOnlyList<AccountContact> ExtractedContacts = null);
|
||||||
|
|||||||
@@ -137,13 +137,6 @@
|
|||||||
"CalendarShowAs_Busy": "Busy",
|
"CalendarShowAs_Busy": "Busy",
|
||||||
"CalendarShowAs_OutOfOffice": "Out of Office",
|
"CalendarShowAs_OutOfOffice": "Out of Office",
|
||||||
"CalendarShowAs_WorkingElsewhere": "Working Elsewhere",
|
"CalendarShowAs_WorkingElsewhere": "Working Elsewhere",
|
||||||
"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_JoinOnline": "Join online",
|
||||||
"CalendarItem_DetailsPopup_ViewEventButton": "View event",
|
"CalendarItem_DetailsPopup_ViewEventButton": "View event",
|
||||||
"CalendarItem_DetailsPopup_ViewSeriesButton": "View series",
|
"CalendarItem_DetailsPopup_ViewSeriesButton": "View series",
|
||||||
@@ -153,6 +146,9 @@
|
|||||||
"ClipboardTextCopied_Message": "{0} copied to clipboard.",
|
"ClipboardTextCopied_Message": "{0} copied to clipboard.",
|
||||||
"ClipboardTextCopied_Title": "Copied",
|
"ClipboardTextCopied_Title": "Copied",
|
||||||
"ClipboardTextCopyFailed_Message": "Failed to copy {0} to clipboard.",
|
"ClipboardTextCopyFailed_Message": "Failed to copy {0} to clipboard.",
|
||||||
|
"ContactInfoBar_ErrorTitle": "Failed to load contact information",
|
||||||
|
"ContactInfoBar_SuccessTitle": "Contact information loaded",
|
||||||
|
"ContactInfoBar_WarningTitle": "Contact information might be incomplete",
|
||||||
"ComingSoon": "Coming soon...",
|
"ComingSoon": "Coming soon...",
|
||||||
"ComposerAttachmentsDragDropAttach_Message": "Attach",
|
"ComposerAttachmentsDragDropAttach_Message": "Attach",
|
||||||
"ComposerAttachmentsDropZone_Message": "Drop your files here",
|
"ComposerAttachmentsDropZone_Message": "Drop your files here",
|
||||||
@@ -833,6 +829,7 @@
|
|||||||
"Smime_CertificatePassword_Placeholder": "Certificate password for {0} (optional)",
|
"Smime_CertificatePassword_Placeholder": "Certificate password for {0} (optional)",
|
||||||
"Smime_Confirm_Title": "Confirm",
|
"Smime_Confirm_Title": "Confirm",
|
||||||
"Buttons_OK": "OK",
|
"Buttons_OK": "OK",
|
||||||
|
"Buttons_Refresh": "Refresh",
|
||||||
"SettingsSignatureAndEncryption_Title": "Signature and Encryption",
|
"SettingsSignatureAndEncryption_Title": "Signature and Encryption",
|
||||||
"SettingsSignatureAndEncryption_Description": "Manage S/MIME certificates for signing and encrypting emails.",
|
"SettingsSignatureAndEncryption_Description": "Manage S/MIME certificates for signing and encrypting emails.",
|
||||||
"SettingsSignatureAndEncryption_MyCertificatesHeader": "My certificates",
|
"SettingsSignatureAndEncryption_MyCertificatesHeader": "My certificates",
|
||||||
@@ -909,6 +906,22 @@
|
|||||||
"ContactSelection_Clear": "Clear Selection",
|
"ContactSelection_Clear": "Clear Selection",
|
||||||
"ContactsPage_EmptyState": "No contacts to display",
|
"ContactsPage_EmptyState": "No contacts to display",
|
||||||
"ContactsPage_AddFirstContact": "Add your first contact",
|
"ContactsPage_AddFirstContact": "Add your first contact",
|
||||||
|
"ContactsPage_ContactsCountSuffix": "contacts",
|
||||||
|
"ContactEditDialog_AddTitle": "Add Contact",
|
||||||
|
"ContactInfoBar_ContactAdded": "Contact added successfully.",
|
||||||
|
"ContactInfoBar_ContactUpdated": "Contact updated successfully.",
|
||||||
|
"ContactInfoBar_ContactsDeleted": "Contacts deleted successfully.",
|
||||||
|
"ContactInfoBar_ContactPhotoUpdated": "Contact photo updated successfully.",
|
||||||
|
"ContactInfoBar_FailedToLoadContacts": "Failed to load contacts: {0}",
|
||||||
|
"ContactInfoBar_FailedToAddContact": "Failed to add contact: {0}",
|
||||||
|
"ContactInfoBar_FailedToUpdateContact": "Failed to update contact: {0}",
|
||||||
|
"ContactInfoBar_FailedToDeleteContacts": "Failed to delete contacts: {0}",
|
||||||
|
"ContactInfoBar_FailedToUpdatePhoto": "Failed to update photo: {0}",
|
||||||
|
"ContactInfoBar_CannotDeleteRoot": "Root contacts cannot be deleted.",
|
||||||
|
"ContactConfirmDialog_DeleteTitle": "Delete Contact",
|
||||||
|
"ContactConfirmDialog_DeleteMessage": "Are you sure you want to delete the contact '{0}'?",
|
||||||
|
"ContactConfirmDialog_DeleteMultipleMessage": "Are you sure you want to delete {0} contact(s)?",
|
||||||
|
"ContactConfirmDialog_DeleteButton": "Delete",
|
||||||
"CalendarAccountSettings_Title": "Calendar Account Settings",
|
"CalendarAccountSettings_Title": "Calendar Account Settings",
|
||||||
"CalendarAccountSettings_Description": "Manage calendar settings for {0}",
|
"CalendarAccountSettings_Description": "Manage calendar settings for {0}",
|
||||||
"CalendarAccountSettings_AccountColor": "Account Color",
|
"CalendarAccountSettings_AccountColor": "Account Color",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
@@ -28,10 +31,13 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel
|
|||||||
public MailCopy DemoPreviewMailCopy { get; } = new MailCopy()
|
public MailCopy DemoPreviewMailCopy { get; } = new MailCopy()
|
||||||
{
|
{
|
||||||
FromName = "Sender Name",
|
FromName = "Sender Name",
|
||||||
|
FromAddress = "sender@wino.mail",
|
||||||
Subject = "Mail Subject",
|
Subject = "Mail Subject",
|
||||||
PreviewText = "Thank you for using Wino Mail. We hope you enjoy the experience.",
|
PreviewText = "Thank you for using Wino Mail. We hope you enjoy the experience.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; }
|
||||||
|
|
||||||
#region Personalization
|
#region Personalization
|
||||||
|
|
||||||
public bool IsSelectedWindowsAccentColor => SelectedAppColor == Colors.LastOrDefault();
|
public bool IsSelectedWindowsAccentColor => SelectedAppColor == Colors.LastOrDefault();
|
||||||
@@ -147,6 +153,12 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel
|
|||||||
StatePersistenceService = statePersistanceService;
|
StatePersistenceService = statePersistanceService;
|
||||||
PreferencesService = preferencesService;
|
PreferencesService = preferencesService;
|
||||||
|
|
||||||
|
DemoPreviewMailItemInformation = new DemoMailItemDisplayInformation(
|
||||||
|
DemoPreviewMailCopy.FromName,
|
||||||
|
DemoPreviewMailCopy.FromAddress,
|
||||||
|
DemoPreviewMailCopy.Subject,
|
||||||
|
DemoPreviewMailCopy.PreviewText);
|
||||||
|
|
||||||
CreateCustomThemeCommand = new AsyncRelayCommand(CreateCustomThemeAsync);
|
CreateCustomThemeCommand = new AsyncRelayCommand(CreateCustomThemeAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,4 +324,31 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel
|
|||||||
_newThemeService.AccentColor = SelectedAppColor.Hex;
|
_newThemeService.AccentColor = SelectedAppColor.Hex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class DemoMailItemDisplayInformation(
|
||||||
|
string fromName,
|
||||||
|
string fromAddress,
|
||||||
|
string subject,
|
||||||
|
string previewText) : IMailItemDisplayInformation
|
||||||
|
{
|
||||||
|
public string Subject { get; } = subject;
|
||||||
|
public string FromName { get; } = fromName;
|
||||||
|
public string FromAddress { get; } = fromAddress;
|
||||||
|
public string PreviewText { get; } = previewText;
|
||||||
|
public bool IsRead { get; } = false;
|
||||||
|
public bool IsDraft { get; } = false;
|
||||||
|
public bool HasAttachments { get; } = false;
|
||||||
|
public bool IsFlagged { get; } = false;
|
||||||
|
public DateTime CreationDate { get; } = DateTime.Now;
|
||||||
|
public string Base64ContactPicture { get; } = string.Empty;
|
||||||
|
public bool ThumbnailUpdatedEvent { get; } = false;
|
||||||
|
public bool IsBusy { get; } = false;
|
||||||
|
public bool IsThreadExpanded { get; } = false;
|
||||||
|
public AccountContact SenderContact { get; } = null;
|
||||||
|
event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
||||||
var syncableFolders = folders
|
var syncableFolders = folders
|
||||||
.Where(f => f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID)
|
.Where(f => f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID)
|
||||||
|
.OrderByDescending(f => f.SpecialFolderType == SpecialFolderType.Draft || f.RemoteFolderId == ServiceConstants.DRAFT_LABEL_ID)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var totalFolders = syncableFolders.Count;
|
var totalFolders = syncableFolders.Count;
|
||||||
@@ -328,8 +329,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
if (newMessageIds.Count > 0)
|
if (newMessageIds.Count > 0)
|
||||||
{
|
{
|
||||||
// Download metadata in batches (no raw MIME during initial sync)
|
// Draft folder needs MIME during initial sync so compose can open immediately.
|
||||||
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
|
bool shouldDownloadRawMime = folder.SpecialFolderType == SpecialFolderType.Draft || folder.RemoteFolderId == ServiceConstants.DRAFT_LABEL_ID;
|
||||||
|
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: shouldDownloadRawMime, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var id in newMessageIds)
|
foreach (var id in newMessageIds)
|
||||||
{
|
{
|
||||||
@@ -1848,6 +1850,88 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
return ("", emailOnly);
|
return ("", emailOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AccountContact> ExtractContactsFromGmailMessage(Message message, MimeMessage mimeMessage)
|
||||||
|
{
|
||||||
|
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
AddFromHeaders(message?.Payload?.Headers);
|
||||||
|
|
||||||
|
if (mimeMessage != null)
|
||||||
|
{
|
||||||
|
AddFromInternetAddressList(mimeMessage.From);
|
||||||
|
AddFromInternetAddressList(mimeMessage.To);
|
||||||
|
AddFromInternetAddressList(mimeMessage.Cc);
|
||||||
|
AddFromInternetAddressList(mimeMessage.Bcc);
|
||||||
|
AddFromInternetAddressList(mimeMessage.ReplyTo);
|
||||||
|
|
||||||
|
if (mimeMessage.Sender is MailboxAddress senderMailbox)
|
||||||
|
{
|
||||||
|
AddContact(senderMailbox.Address, senderMailbox.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.Values.ToList();
|
||||||
|
|
||||||
|
void AddFromHeaders(IList<MessagePartHeader> headers)
|
||||||
|
{
|
||||||
|
if (headers == null || headers.Count == 0) return;
|
||||||
|
|
||||||
|
AddFromHeader("From");
|
||||||
|
AddFromHeader("Sender");
|
||||||
|
AddFromHeader("To");
|
||||||
|
AddFromHeader("Cc");
|
||||||
|
AddFromHeader("Bcc");
|
||||||
|
AddFromHeader("Reply-To");
|
||||||
|
|
||||||
|
void AddFromHeader(string headerName)
|
||||||
|
{
|
||||||
|
var headerValue = headers
|
||||||
|
.FirstOrDefault(h => h.Name.Equals(headerName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(headerValue)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var addresses = InternetAddressList.Parse(headerValue);
|
||||||
|
foreach (var mailbox in addresses.Mailboxes)
|
||||||
|
{
|
||||||
|
AddContact(mailbox.Address, mailbox.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
var (name, email) = ExtractNameAndEmailFromHeader(headerValue);
|
||||||
|
AddContact(email, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddFromInternetAddressList(InternetAddressList addresses)
|
||||||
|
{
|
||||||
|
if (addresses == null) return;
|
||||||
|
|
||||||
|
foreach (var mailbox in addresses.Mailboxes)
|
||||||
|
{
|
||||||
|
AddContact(mailbox.Address, mailbox.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddContact(string address, string name)
|
||||||
|
{
|
||||||
|
var trimmedAddress = address?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
|
||||||
|
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
|
||||||
|
|
||||||
|
contacts[trimmedAddress] = new AccountContact
|
||||||
|
{
|
||||||
|
Address = trimmedAddress,
|
||||||
|
Name = displayName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates new mail packages for the given message.
|
/// Creates new mail packages for the given message.
|
||||||
/// AssignedFolder is null since the LabelId is parsed out of the Message.
|
/// AssignedFolder is null since the LabelId is parsed out of the Message.
|
||||||
@@ -1887,6 +1971,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var extractedContacts = ExtractContactsFromGmailMessage(message, mimeMessage);
|
||||||
|
|
||||||
// Check for local draft mapping using X-Wino-Draft-Id header.
|
// Check for local draft mapping using X-Wino-Draft-Id header.
|
||||||
// For Metadata format we read from Payload.Headers.
|
// For Metadata format we read from Payload.Headers.
|
||||||
// For Raw format (Payload is null), we read from parsed MIME headers.
|
// For Raw format (Payload is null), we read from parsed MIME headers.
|
||||||
@@ -1962,7 +2048,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
mailCopyForLabel.Id = sharedId;
|
mailCopyForLabel.Id = sharedId;
|
||||||
mailCopyForLabel.FileId = sharedFileId;
|
mailCopyForLabel.FileId = sharedFileId;
|
||||||
|
|
||||||
packageList.Add(new NewMailItemPackage(mailCopyForLabel, mimeMessage, labelId));
|
packageList.Add(new NewMailItemPackage(mailCopyForLabel, mimeMessage, labelId, extractedContacts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.Messaging;
|
|||||||
using MailKit;
|
using MailKit;
|
||||||
using MailKit.Net.Imap;
|
using MailKit.Net.Imap;
|
||||||
using MailKit.Search;
|
using MailKit.Search;
|
||||||
|
using MimeKit;
|
||||||
using MoreLinq;
|
using MoreLinq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
@@ -334,7 +335,8 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
// Local copy doesn't exists. Continue execution to insert mail copy.
|
// Local copy doesn't exists. Continue execution to insert mail copy.
|
||||||
}
|
}
|
||||||
|
|
||||||
var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId);
|
var contacts = ExtractContactsFromMimeMessage(message.MimeMessage);
|
||||||
|
var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId, contacts);
|
||||||
|
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -342,6 +344,50 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AccountContact> ExtractContactsFromMimeMessage(MimeMessage mimeMessage)
|
||||||
|
{
|
||||||
|
if (mimeMessage == null) return [];
|
||||||
|
|
||||||
|
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
AddFromInternetAddressList(mimeMessage.From);
|
||||||
|
AddFromInternetAddressList(mimeMessage.To);
|
||||||
|
AddFromInternetAddressList(mimeMessage.Cc);
|
||||||
|
AddFromInternetAddressList(mimeMessage.Bcc);
|
||||||
|
AddFromInternetAddressList(mimeMessage.ReplyTo);
|
||||||
|
|
||||||
|
if (mimeMessage.Sender is MailboxAddress senderMailbox)
|
||||||
|
{
|
||||||
|
AddContact(senderMailbox.Address, senderMailbox.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.Values.ToList();
|
||||||
|
|
||||||
|
void AddFromInternetAddressList(InternetAddressList addresses)
|
||||||
|
{
|
||||||
|
if (addresses == null) return;
|
||||||
|
|
||||||
|
foreach (var mailbox in addresses.Mailboxes)
|
||||||
|
{
|
||||||
|
AddContact(mailbox.Address, mailbox.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddContact(string address, string name)
|
||||||
|
{
|
||||||
|
var trimmedAddress = address?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
|
||||||
|
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
|
||||||
|
|
||||||
|
contacts[trimmedAddress] = new AccountContact
|
||||||
|
{
|
||||||
|
Address = trimmedAddress,
|
||||||
|
Name = displayName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var downloadedMessageIds = new List<string>();
|
var downloadedMessageIds = new List<string>();
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
"Id",
|
"Id",
|
||||||
"ConversationId",
|
"ConversationId",
|
||||||
"From",
|
"From",
|
||||||
|
"Sender",
|
||||||
|
"ToRecipients",
|
||||||
|
"CcRecipients",
|
||||||
|
"BccRecipients",
|
||||||
|
"ReplyTo",
|
||||||
"Subject",
|
"Subject",
|
||||||
"ParentFolderId",
|
"ParentFolderId",
|
||||||
"InternetMessageId",
|
"InternetMessageId",
|
||||||
@@ -375,28 +380,50 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
if (!mailExists)
|
if (!mailExists)
|
||||||
{
|
{
|
||||||
// Create MailCopy from metadata
|
// For drafts, download MIME during initial sync like delta sync.
|
||||||
var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
|
if (folder.SpecialFolderType == SpecialFolderType.Draft)
|
||||||
|
|
||||||
if (mailCopy != null)
|
|
||||||
{
|
{
|
||||||
// Create package without MIME
|
var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
||||||
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
|
|
||||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (isInserted)
|
if (draftPackages != null)
|
||||||
{
|
{
|
||||||
downloadedMessageIds.Add(mailCopy.Id);
|
foreach (var package in draftPackages)
|
||||||
totalProcessed++;
|
|
||||||
|
|
||||||
// Update progress periodically
|
|
||||||
if (totalProcessed % 50 == 0)
|
|
||||||
{
|
{
|
||||||
var statusMessage = string.Format(Translator.Sync_DownloadedMessages, totalProcessed, folder.FolderName);
|
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
UpdateSyncProgress(0, 0, statusMessage);
|
if (isInserted)
|
||||||
|
{
|
||||||
|
downloadedMessageIds.Add(package.Copy.Id);
|
||||||
|
totalProcessed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create MailCopy from metadata
|
||||||
|
var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (mailCopy != null)
|
||||||
|
{
|
||||||
|
// Create package without MIME
|
||||||
|
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||||
|
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts);
|
||||||
|
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (isInserted)
|
||||||
|
{
|
||||||
|
downloadedMessageIds.Add(mailCopy.Id);
|
||||||
|
totalProcessed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress periodically
|
||||||
|
if (totalProcessed > 0 && totalProcessed % 50 == 0)
|
||||||
|
{
|
||||||
|
var statusMessage = string.Format(Translator.Sync_DownloadedMessages, totalProcessed, folder.FolderName);
|
||||||
|
UpdateSyncProgress(0, 0, statusMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -551,7 +578,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
if (mailCopy != null)
|
if (mailCopy != null)
|
||||||
{
|
{
|
||||||
// Create package without MIME
|
// Create package without MIME
|
||||||
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
|
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||||
|
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts);
|
||||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
|
||||||
if (isInserted)
|
if (isInserted)
|
||||||
@@ -686,6 +714,64 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
return mailCopy;
|
return mailCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AccountContact> ExtractContactsFromOutlookMessage(Message message)
|
||||||
|
{
|
||||||
|
if (message == null) return [];
|
||||||
|
|
||||||
|
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
AddRecipient(message.From?.EmailAddress);
|
||||||
|
AddRecipient(message.Sender?.EmailAddress);
|
||||||
|
|
||||||
|
if (message.ToRecipients != null)
|
||||||
|
{
|
||||||
|
foreach (var recipient in message.ToRecipients)
|
||||||
|
{
|
||||||
|
AddRecipient(recipient?.EmailAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.CcRecipients != null)
|
||||||
|
{
|
||||||
|
foreach (var recipient in message.CcRecipients)
|
||||||
|
{
|
||||||
|
AddRecipient(recipient?.EmailAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.BccRecipients != null)
|
||||||
|
{
|
||||||
|
foreach (var recipient in message.BccRecipients)
|
||||||
|
{
|
||||||
|
AddRecipient(recipient?.EmailAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ReplyTo != null)
|
||||||
|
{
|
||||||
|
foreach (var recipient in message.ReplyTo)
|
||||||
|
{
|
||||||
|
AddRecipient(recipient?.EmailAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.Values.ToList();
|
||||||
|
|
||||||
|
void AddRecipient(EmailAddress emailAddress)
|
||||||
|
{
|
||||||
|
var address = emailAddress?.Address?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(address)) return;
|
||||||
|
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(emailAddress.Name) ? address : emailAddress.Name.Trim();
|
||||||
|
|
||||||
|
contacts[address] = new AccountContact
|
||||||
|
{
|
||||||
|
Address = address,
|
||||||
|
Name = displayName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
||||||
=> Regex.Split(deltaLink, "deltatoken=")[1];
|
=> Regex.Split(deltaLink, "deltatoken=")[1];
|
||||||
|
|
||||||
@@ -1790,7 +1876,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
// Outlook messages can only be assigned to 1 folder at a time.
|
// Outlook messages can only be assigned to 1 folder at a time.
|
||||||
// Therefore we don't need to create multiple copies of the same message for different folders.
|
// Therefore we don't need to create multiple copies of the same message for different folders.
|
||||||
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId);
|
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||||
|
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts);
|
||||||
|
|
||||||
return [package];
|
return [package];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
@@ -14,23 +17,45 @@ namespace Wino.Mail.ViewModels;
|
|||||||
|
|
||||||
public partial class ContactsPageViewModel : MailBaseViewModel
|
public partial class ContactsPageViewModel : MailBaseViewModel
|
||||||
{
|
{
|
||||||
|
private const int ContactPageSize = 50;
|
||||||
|
|
||||||
private readonly IContactService _contactService;
|
private readonly IContactService _contactService;
|
||||||
private readonly IMailDialogService _dialogService;
|
private readonly IMailDialogService _dialogService;
|
||||||
|
|
||||||
private List<AccountContact> _allContacts = new();
|
private CancellationTokenSource _searchDebounceCancellationTokenSource;
|
||||||
|
private int _currentOffset = 0;
|
||||||
|
private int _currentQueryVersion = 0;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string SearchQuery { get; set; } = string.Empty;
|
public partial string SearchQuery { get; set; } = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsEmpty))]
|
||||||
public partial bool IsLoading { get; set; } = false;
|
public partial bool IsLoading { get; set; } = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
|
||||||
|
public partial bool IsLoadingMore { get; set; } = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))]
|
||||||
|
public partial bool HasMoreContacts { get; set; } = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsSelectionMode { get; set; } = false;
|
public partial bool IsSelectionMode { get; set; } = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedContactsCommand))]
|
||||||
public partial int SelectedContactsCount { get; set; } = 0;
|
public partial int SelectedContactsCount { get; set; } = 0;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial int TotalContactsCount { get; set; } = 0;
|
||||||
|
|
||||||
|
public bool IsEmpty => !IsLoading && Contacts.Count == 0;
|
||||||
|
public bool CanLoadMoreContacts => HasMoreContacts && !IsLoading && !IsLoadingMore;
|
||||||
|
public bool CanDeleteSelectedContacts => SelectedContactsCount > 0;
|
||||||
|
|
||||||
public ObservableCollection<AccountContact> Contacts { get; } = new();
|
public ObservableCollection<AccountContact> Contacts { get; } = new();
|
||||||
public ObservableCollection<AccountContact> SelectedContacts { get; } = new();
|
public ObservableCollection<AccountContact> SelectedContacts { get; } = new();
|
||||||
|
|
||||||
@@ -39,7 +64,7 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
_contactService = contactService;
|
_contactService = contactService;
|
||||||
_dialogService = dialogService;
|
_dialogService = dialogService;
|
||||||
|
|
||||||
|
Contacts.CollectionChanged += ContactsCollectionChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
@@ -49,7 +74,7 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
|
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
|
||||||
SelectedContacts.CollectionChanged += SelectedContactsChanged;
|
SelectedContacts.CollectionChanged += SelectedContactsChanged;
|
||||||
|
|
||||||
await LoadContactsAsync();
|
await ReloadContactsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
||||||
@@ -57,58 +82,105 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
base.OnNavigatedFrom(mode, parameters);
|
base.OnNavigatedFrom(mode, parameters);
|
||||||
|
|
||||||
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
|
SelectedContacts.CollectionChanged -= SelectedContactsChanged;
|
||||||
|
|
||||||
|
_searchDebounceCancellationTokenSource?.Cancel();
|
||||||
|
_searchDebounceCancellationTokenSource?.Dispose();
|
||||||
|
_searchDebounceCancellationTokenSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectedContactsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
private async void SelectedContactsChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
=> SelectedContactsCount = SelectedContacts.Count;
|
=> await ExecuteUIThread(() => { SelectedContactsCount = SelectedContacts.Count; });
|
||||||
|
|
||||||
|
private async void ContactsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
=> await ExecuteUIThread(() => { OnPropertyChanged(nameof(IsEmpty)); });
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoadContactsAsync()
|
private async Task ReloadContactsAsync()
|
||||||
{
|
{
|
||||||
IsLoading = true;
|
var queryVersion = ++_currentQueryVersion;
|
||||||
|
_currentOffset = 0;
|
||||||
try
|
|
||||||
{
|
|
||||||
_allContacts = await _contactService.GetAllContactsAsync();
|
|
||||||
await FilterContactsAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_dialogService.InfoBarMessage("Error", $"Failed to load contacts: {ex.Message}", InfoBarMessageType.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task SearchContactsAsync()
|
|
||||||
{
|
|
||||||
await FilterContactsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task FilterContactsAsync()
|
|
||||||
{
|
|
||||||
List<AccountContact> filteredContacts;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(SearchQuery))
|
|
||||||
{
|
|
||||||
filteredContacts = _allContacts;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
filteredContacts = await _contactService.SearchContactsAsync(SearchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
|
HasMoreContacts = false;
|
||||||
Contacts.Clear();
|
Contacts.Clear();
|
||||||
foreach (var contact in filteredContacts.OrderBy(c => c.Name ?? c.Address))
|
SelectedContacts.Clear();
|
||||||
{
|
|
||||||
Contacts.Add(contact);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await LoadContactsPageAsync(queryVersion, reset: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanLoadMoreContacts))]
|
||||||
|
private async Task LoadMoreContactsAsync()
|
||||||
|
{
|
||||||
|
await LoadContactsPageAsync(_currentQueryVersion, reset: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadContactsPageAsync(int queryVersion, bool reset)
|
||||||
|
{
|
||||||
|
if (IsLoading || IsLoadingMore)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
if (reset)
|
||||||
|
IsLoading = true;
|
||||||
|
else
|
||||||
|
IsLoadingMore = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var searchQuery = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery.Trim();
|
||||||
|
var page = await _contactService.GetContactsPageAsync(
|
||||||
|
_currentOffset,
|
||||||
|
ContactPageSize,
|
||||||
|
searchQuery,
|
||||||
|
excludeRootContacts: true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (queryVersion != _currentQueryVersion)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
if (reset)
|
||||||
|
{
|
||||||
|
Contacts.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var contact in page.Contacts)
|
||||||
|
{
|
||||||
|
Contacts.Add(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
TotalContactsCount = page.TotalCount;
|
||||||
|
HasMoreContacts = page.HasMore;
|
||||||
|
_currentOffset = Contacts.Count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (queryVersion != _currentQueryVersion)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_ErrorTitle,
|
||||||
|
string.Format(Translator.ContactInfoBar_FailedToLoadContacts, ex.Message),
|
||||||
|
InfoBarMessageType.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (queryVersion == _currentQueryVersion)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
if (reset)
|
||||||
|
IsLoading = false;
|
||||||
|
else
|
||||||
|
IsLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -116,27 +188,31 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
var result = await _dialogService.ShowEditContactDialogAsync(null);
|
var result = await _dialogService.ShowEditContactDialogAsync(null);
|
||||||
|
|
||||||
if (result != null)
|
if (result == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
var newContact = await _contactService.CreateNewContactAsync(result.Address, result.Name);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(result.Base64ContactPicture))
|
||||||
{
|
{
|
||||||
var newContact = await _contactService.CreateNewContactAsync(result.Address, result.Name);
|
newContact.Base64ContactPicture = result.Base64ContactPicture;
|
||||||
|
await _contactService.UpdateContactAsync(newContact);
|
||||||
if (!string.IsNullOrEmpty(result.Base64ContactPicture))
|
|
||||||
{
|
|
||||||
newContact.Base64ContactPicture = result.Base64ContactPicture;
|
|
||||||
await _contactService.UpdateContactAsync(newContact);
|
|
||||||
}
|
|
||||||
|
|
||||||
_allContacts.Add(newContact);
|
|
||||||
await FilterContactsAsync();
|
|
||||||
|
|
||||||
_dialogService.InfoBarMessage("Success", "Contact added successfully", InfoBarMessageType.Success);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_dialogService.InfoBarMessage("Error", $"Failed to add contact: {ex.Message}", InfoBarMessageType.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ReloadContactsAsync();
|
||||||
|
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_SuccessTitle,
|
||||||
|
Translator.ContactInfoBar_ContactAdded,
|
||||||
|
InfoBarMessageType.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_ErrorTitle,
|
||||||
|
string.Format(Translator.ContactInfoBar_FailedToAddContact, ex.Message),
|
||||||
|
InfoBarMessageType.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,32 +223,28 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
|
|
||||||
var result = await _dialogService.ShowEditContactDialogAsync(contact);
|
var result = await _dialogService.ShowEditContactDialogAsync(contact);
|
||||||
|
|
||||||
if (result != null)
|
if (result == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
contact.Name = result.Name;
|
||||||
{
|
contact.Base64ContactPicture = result.Base64ContactPicture;
|
||||||
// Update the contact properties
|
contact.IsOverridden = result.IsOverridden;
|
||||||
contact.Name = result.Name;
|
|
||||||
contact.Base64ContactPicture = result.Base64ContactPicture;
|
|
||||||
contact.IsOverridden = result.IsOverridden;
|
|
||||||
|
|
||||||
await _contactService.UpdateContactAsync(contact);
|
await _contactService.UpdateContactAsync(contact);
|
||||||
|
await ReloadContactsAsync();
|
||||||
|
|
||||||
// Update the UI
|
_dialogService.InfoBarMessage(
|
||||||
var index = _allContacts.FindIndex(c => c.Address == contact.Address);
|
Translator.ContactInfoBar_SuccessTitle,
|
||||||
if (index >= 0)
|
Translator.ContactInfoBar_ContactUpdated,
|
||||||
{
|
InfoBarMessageType.Success);
|
||||||
_allContacts[index] = contact;
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
await FilterContactsAsync();
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_ErrorTitle,
|
||||||
_dialogService.InfoBarMessage("Success", "Contact updated successfully", InfoBarMessageType.Success);
|
string.Format(Translator.ContactInfoBar_FailedToUpdateContact, ex.Message),
|
||||||
}
|
InfoBarMessageType.Error);
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_dialogService.InfoBarMessage("Error", $"Failed to update contact: {ex.Message}", InfoBarMessageType.Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,40 +253,50 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
if (contact == null || contact.IsRootContact)
|
if (contact == null || contact.IsRootContact)
|
||||||
{
|
{
|
||||||
_dialogService.InfoBarMessage("Cannot Delete", "Root contacts cannot be deleted", InfoBarMessageType.Warning);
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_WarningTitle,
|
||||||
|
Translator.ContactInfoBar_CannotDeleteRoot,
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _dialogService.ShowConfirmationDialogAsync(
|
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
$"Are you sure you want to delete the contact '{contact.Name ?? contact.Address}'?",
|
string.Format(Translator.ContactConfirmDialog_DeleteMessage, contact.Name ?? contact.Address),
|
||||||
"Delete Contact",
|
Translator.ContactConfirmDialog_DeleteTitle,
|
||||||
"Delete");
|
Translator.ContactConfirmDialog_DeleteButton);
|
||||||
|
|
||||||
if (result)
|
if (confirmed)
|
||||||
{
|
{
|
||||||
await DeleteContactsInternalAsync(new[] { contact });
|
await DeleteContactsInternalAsync(new[] { contact });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanDeleteSelectedContacts))]
|
||||||
private async Task DeleteSelectedContactsAsync()
|
private async Task DeleteSelectedContactsAsync()
|
||||||
{
|
{
|
||||||
if (SelectedContacts.Count == 0) return;
|
if (SelectedContacts.Count == 0) return;
|
||||||
|
|
||||||
var deletableContacts = SelectedContacts.Where(c => !c.IsRootContact).ToList();
|
var deletableContacts = SelectedContacts
|
||||||
|
.Where(c => c != null && !c.IsRootContact)
|
||||||
|
.GroupBy(c => c.Address, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (deletableContacts.Count == 0)
|
if (deletableContacts.Count == 0)
|
||||||
{
|
{
|
||||||
_dialogService.InfoBarMessage("Cannot Delete", "Root contacts cannot be deleted", InfoBarMessageType.Warning);
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_WarningTitle,
|
||||||
|
Translator.ContactInfoBar_CannotDeleteRoot,
|
||||||
|
InfoBarMessageType.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _dialogService.ShowConfirmationDialogAsync(
|
var confirmed = await _dialogService.ShowConfirmationDialogAsync(
|
||||||
$"Are you sure you want to delete {deletableContacts.Count} contact(s)?",
|
string.Format(Translator.ContactConfirmDialog_DeleteMultipleMessage, deletableContacts.Count),
|
||||||
"Delete Contacts",
|
Translator.ContactConfirmDialog_DeleteTitle,
|
||||||
"Delete");
|
Translator.ContactConfirmDialog_DeleteButton);
|
||||||
|
|
||||||
if (result)
|
if (confirmed)
|
||||||
{
|
{
|
||||||
await DeleteContactsInternalAsync(deletableContacts);
|
await DeleteContactsInternalAsync(deletableContacts);
|
||||||
}
|
}
|
||||||
@@ -224,51 +306,63 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var addresses = contactsToDelete.Select(c => c.Address);
|
var addresses = contactsToDelete
|
||||||
|
.Select(c => c.Address)
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (addresses.Count == 0) return;
|
||||||
|
|
||||||
await _contactService.DeleteContactsAsync(addresses);
|
await _contactService.DeleteContactsAsync(addresses);
|
||||||
|
await ReloadContactsAsync();
|
||||||
|
|
||||||
// Update local collections
|
_dialogService.InfoBarMessage(
|
||||||
foreach (var contact in contactsToDelete.ToList())
|
Translator.ContactInfoBar_SuccessTitle,
|
||||||
{
|
Translator.ContactInfoBar_ContactsDeleted,
|
||||||
_allContacts.Remove(contact);
|
InfoBarMessageType.Success);
|
||||||
SelectedContacts.Remove(contact);
|
|
||||||
}
|
|
||||||
|
|
||||||
await FilterContactsAsync();
|
|
||||||
|
|
||||||
_dialogService.InfoBarMessage("Success", "Contacts deleted successfully", InfoBarMessageType.Success);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_dialogService.InfoBarMessage("Error", $"Failed to delete contacts: {ex.Message}", InfoBarMessageType.Error);
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_ErrorTitle,
|
||||||
|
string.Format(Translator.ContactInfoBar_FailedToDeleteContacts, ex.Message),
|
||||||
|
InfoBarMessageType.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleSelection()
|
private async Task ToggleSelection()
|
||||||
{
|
{
|
||||||
IsSelectionMode = !IsSelectionMode;
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
IsSelectionMode = !IsSelectionMode;
|
||||||
|
|
||||||
if (!IsSelectionMode)
|
if (!IsSelectionMode)
|
||||||
|
{
|
||||||
|
SelectedContacts.Clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SelectAllContacts()
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
SelectedContacts.Clear();
|
SelectedContacts.Clear();
|
||||||
}
|
|
||||||
|
foreach (var contact in Contacts)
|
||||||
|
{
|
||||||
|
SelectedContacts.Add(contact);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectAllContacts()
|
private async Task ClearSelection()
|
||||||
{
|
{
|
||||||
SelectedContacts.Clear();
|
await ExecuteUIThread(() => { SelectedContacts.Clear(); });
|
||||||
foreach (var contact in Contacts)
|
|
||||||
{
|
|
||||||
SelectedContacts.Add(contact);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ClearSelection()
|
|
||||||
{
|
|
||||||
SelectedContacts.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -287,20 +381,77 @@ public partial class ContactsPageViewModel : MailBaseViewModel
|
|||||||
|
|
||||||
contact.Base64ContactPicture = base64Image;
|
contact.Base64ContactPicture = base64Image;
|
||||||
await _contactService.UpdateContactAsync(contact);
|
await _contactService.UpdateContactAsync(contact);
|
||||||
|
await RefreshContactInUiAsync(contact);
|
||||||
|
|
||||||
await FilterContactsAsync();
|
_dialogService.InfoBarMessage(
|
||||||
_dialogService.InfoBarMessage("Success", "Contact photo updated successfully", InfoBarMessageType.Success);
|
Translator.ContactInfoBar_SuccessTitle,
|
||||||
|
Translator.ContactInfoBar_ContactPhotoUpdated,
|
||||||
|
InfoBarMessageType.Success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_dialogService.InfoBarMessage("Error", $"Failed to update photo: {ex.Message}", InfoBarMessageType.Error);
|
_dialogService.InfoBarMessage(
|
||||||
|
Translator.ContactInfoBar_ErrorTitle,
|
||||||
|
string.Format(Translator.ContactInfoBar_FailedToUpdatePhoto, ex.Message),
|
||||||
|
InfoBarMessageType.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RefreshContactInUiAsync(AccountContact contact)
|
||||||
|
{
|
||||||
|
if (contact == null || string.IsNullOrWhiteSpace(contact.Address))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
ReplaceContactByAddress(Contacts, contact);
|
||||||
|
ReplaceContactByAddress(SelectedContacts, contact);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReplaceContactByAddress(ObservableCollection<AccountContact> source, AccountContact updatedContact)
|
||||||
|
{
|
||||||
|
var index = source
|
||||||
|
.Select((item, i) => new { item, i })
|
||||||
|
.FirstOrDefault(x => string.Equals(x.item.Address, updatedContact.Address, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.i ?? -1;
|
||||||
|
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
source[index] = CloneContact(updatedContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AccountContact CloneContact(AccountContact contact)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Address = contact.Address,
|
||||||
|
Name = contact.Name,
|
||||||
|
Base64ContactPicture = contact.Base64ContactPicture,
|
||||||
|
IsRootContact = contact.IsRootContact,
|
||||||
|
IsOverridden = contact.IsOverridden
|
||||||
|
};
|
||||||
|
|
||||||
partial void OnSearchQueryChanged(string value)
|
partial void OnSearchQueryChanged(string value)
|
||||||
{
|
{
|
||||||
// Debounce search - implement if needed
|
DebounceSearchAndReload();
|
||||||
SearchContactsCommand.ExecuteAsync(null);
|
}
|
||||||
|
|
||||||
|
private async void DebounceSearchAndReload()
|
||||||
|
{
|
||||||
|
_searchDebounceCancellationTokenSource?.Cancel();
|
||||||
|
_searchDebounceCancellationTokenSource?.Dispose();
|
||||||
|
|
||||||
|
_searchDebounceCancellationTokenSource = new CancellationTokenSource();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(250, _searchDebounceCancellationTokenSource.Token);
|
||||||
|
await ReloadContactsAsync();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ignore stale search input.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Wino.Mail.ViewModels.Data;
|
namespace Wino.Mail.ViewModels.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single view model for IMailItem representation.
|
/// Single view model for IMailItem representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem
|
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem, IMailItemDisplayInformation
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(CreationDate))]
|
[NotifyPropertyChangedFor(nameof(CreationDate))]
|
||||||
@@ -33,6 +35,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
[NotifyPropertyChangedFor(nameof(FolderId))]
|
[NotifyPropertyChangedFor(nameof(FolderId))]
|
||||||
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
||||||
[NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
|
[NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
||||||
public partial MailCopy MailCopy { get; set; } = mailCopy;
|
public partial MailCopy MailCopy { get; set; } = mailCopy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -58,6 +61,10 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
public bool IsThreadExpanded => false;
|
||||||
|
|
||||||
|
public AccountContact SenderContact => MailCopy.SenderContact;
|
||||||
|
|
||||||
public DateTime CreationDate
|
public DateTime CreationDate
|
||||||
{
|
{
|
||||||
get => MailCopy.CreationDate;
|
get => MailCopy.CreationDate;
|
||||||
@@ -260,6 +267,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
OnPropertyChanged(nameof(FolderId));
|
OnPropertyChanged(nameof(FolderId));
|
||||||
OnPropertyChanged(nameof(UniqueId));
|
OnPropertyChanged(nameof(UniqueId));
|
||||||
OnPropertyChanged(nameof(Base64ContactPicture));
|
OnPropertyChanged(nameof(Base64ContactPicture));
|
||||||
|
OnPropertyChanged(nameof(SenderContact));
|
||||||
OnPropertyChanged(nameof(SortingDate));
|
OnPropertyChanged(nameof(SortingDate));
|
||||||
OnPropertyChanged(nameof(SortingName));
|
OnPropertyChanged(nameof(SortingName));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ using System.Collections.ObjectModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Wino.Mail.ViewModels.Data;
|
namespace Wino.Mail.ViewModels.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thread mail item (multiple IMailItem) view model representation.
|
/// Thread mail item (multiple IMailItem) view model representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem
|
public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation
|
||||||
{
|
{
|
||||||
private readonly string _threadId;
|
private readonly string _threadId;
|
||||||
private readonly HashSet<Guid> _uniqueIdSet = [];
|
private readonly HashSet<Guid> _uniqueIdSet = [];
|
||||||
@@ -150,6 +152,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
|
|
||||||
public bool ThumbnailUpdatedEvent => latestMailViewModel?.ThumbnailUpdatedEvent ?? false;
|
public bool ThumbnailUpdatedEvent => latestMailViewModel?.ThumbnailUpdatedEvent ?? false;
|
||||||
|
|
||||||
|
public AccountContact SenderContact => latestMailViewModel?.MailCopy?.SenderContact;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all emails in this thread (observable)
|
/// Gets all emails in this thread (observable)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -177,6 +181,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
[NotifyPropertyChangedFor(nameof(FolderId))]
|
[NotifyPropertyChangedFor(nameof(FolderId))]
|
||||||
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
||||||
[NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
|
[NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
||||||
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
|
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
|
||||||
|
|
||||||
private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel;
|
private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel;
|
||||||
@@ -260,6 +265,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
OnPropertyChanged(nameof(FolderId));
|
OnPropertyChanged(nameof(FolderId));
|
||||||
OnPropertyChanged(nameof(UniqueId));
|
OnPropertyChanged(nameof(UniqueId));
|
||||||
OnPropertyChanged(nameof(Base64ContactPicture));
|
OnPropertyChanged(nameof(Base64ContactPicture));
|
||||||
|
OnPropertyChanged(nameof(SenderContact));
|
||||||
OnPropertyChanged(nameof(ThumbnailUpdatedEvent));
|
OnPropertyChanged(nameof(ThumbnailUpdatedEvent));
|
||||||
OnPropertyChanged(nameof(SortingDate));
|
OnPropertyChanged(nameof(SortingDate));
|
||||||
OnPropertyChanged(nameof(SortingName));
|
OnPropertyChanged(nameof(SortingName));
|
||||||
|
|||||||
@@ -1,241 +1,402 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.RegularExpressions;
|
using System.Net.Mail;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Fernandezja.ColorHashSharp;
|
using CommunityToolkit.WinUI;
|
||||||
|
using EmailValidation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Microsoft.UI.Xaml.Media;
|
|
||||||
using Microsoft.UI.Xaml.Media.Imaging;
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
using Microsoft.UI.Xaml.Shapes;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Windows.UI;
|
|
||||||
using Wino.Mail.WinUI;
|
using Wino.Mail.WinUI;
|
||||||
|
|
||||||
namespace Wino.Controls;
|
namespace Wino.Controls;
|
||||||
|
|
||||||
public partial class ImagePreviewControl : Control
|
/// <summary>
|
||||||
|
/// Contact avatar control built on top of PersonPicture.
|
||||||
|
/// Priority:
|
||||||
|
/// 1) AccountContact/Base64 picture
|
||||||
|
/// 2) Gravatar thumbnail (if enabled)
|
||||||
|
/// 3) Initials from display name fallback
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ImagePreviewControl : PersonPicture
|
||||||
{
|
{
|
||||||
private const string PART_EllipseInitialsGrid = "EllipseInitialsGrid";
|
private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture);
|
||||||
private const string PART_InitialsTextBlock = "InitialsTextBlock";
|
|
||||||
private const string PART_KnownHostImage = "KnownHostImage";
|
|
||||||
private const string PART_Ellipse = "Ellipse";
|
|
||||||
private const string PART_FaviconSquircle = "FaviconSquircle";
|
|
||||||
private const string PART_FaviconImage = "FaviconImage";
|
|
||||||
|
|
||||||
#region Dependency Properties
|
private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40);
|
||||||
|
|
||||||
public static readonly DependencyProperty FromNameProperty = DependencyProperty.Register(nameof(FromName), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, OnInformationChanged));
|
[GeneratedDependencyProperty]
|
||||||
public static readonly DependencyProperty FromAddressProperty = DependencyProperty.Register(nameof(FromAddress), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, OnInformationChanged));
|
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
|
||||||
public static readonly DependencyProperty SenderContactPictureProperty = DependencyProperty.Register(nameof(SenderContactPicture), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, new PropertyChangedCallback(OnInformationChanged)));
|
|
||||||
public static readonly DependencyProperty ThumbnailUpdatedEventProperty = DependencyProperty.Register(nameof(ThumbnailUpdatedEvent), typeof(bool), typeof(ImagePreviewControl), new PropertyMetadata(false, new PropertyChangedCallback(OnInformationChanged)));
|
|
||||||
|
|
||||||
public bool ThumbnailUpdatedEvent
|
[GeneratedDependencyProperty]
|
||||||
{
|
public partial string? FromName { get; set; }
|
||||||
get { return (bool)GetValue(ThumbnailUpdatedEventProperty); }
|
|
||||||
set { SetValue(ThumbnailUpdatedEventProperty, value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
[GeneratedDependencyProperty]
|
||||||
/// Gets or sets base64 string of the sender contact picture.
|
public partial string? FromAddress { get; set; }
|
||||||
/// </summary>
|
|
||||||
public string SenderContactPicture
|
|
||||||
{
|
|
||||||
get { return (string)GetValue(SenderContactPictureProperty); }
|
|
||||||
set { SetValue(SenderContactPictureProperty, value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FromName
|
[GeneratedDependencyProperty]
|
||||||
{
|
public partial string? SenderContactPicture { get; set; }
|
||||||
get { return (string)GetValue(FromNameProperty); }
|
|
||||||
set { SetValue(FromNameProperty, value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FromAddress
|
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||||
{
|
public partial bool ThumbnailUpdatedEvent { get; set; }
|
||||||
get { return (string)GetValue(FromAddressProperty); }
|
|
||||||
set { SetValue(FromAddressProperty, value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
private readonly IThumbnailService? _thumbnailService;
|
||||||
|
private readonly IPreferencesService? _preferencesService;
|
||||||
private Ellipse Ellipse = null!;
|
private CancellationTokenSource? _refreshCancellationTokenSource;
|
||||||
private Grid InitialsGrid = null!;
|
private CancellationTokenSource? _scheduledRefreshCancellationTokenSource;
|
||||||
private TextBlock InitialsTextblock = null!;
|
private long _refreshVersion;
|
||||||
private Image KnownHostImage = null!;
|
|
||||||
private Border FaviconSquircle = null!;
|
|
||||||
private Image FaviconImage = null!;
|
|
||||||
private CancellationTokenSource contactPictureLoadingCancellationTokenSource = null!;
|
|
||||||
|
|
||||||
public ImagePreviewControl()
|
public ImagePreviewControl()
|
||||||
{
|
{
|
||||||
DefaultStyleKey = nameof(ImagePreviewControl);
|
DefaultStyleKey = typeof(PersonPicture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_thumbnailService = App.Current.Services.GetService<IThumbnailService>();
|
||||||
|
_preferencesService = App.Current.Services.GetService<IPreferencesService>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep control functional in design-time/test contexts without service provider.
|
||||||
|
}
|
||||||
|
|
||||||
|
Loaded += OnLoaded;
|
||||||
|
Unloaded += OnUnloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnApplyTemplate();
|
RequestRefresh();
|
||||||
|
|
||||||
InitialsGrid = (GetTemplateChild(PART_EllipseInitialsGrid) as Grid)!;
|
|
||||||
InitialsTextblock = (GetTemplateChild(PART_InitialsTextBlock) as TextBlock)!;
|
|
||||||
KnownHostImage = (GetTemplateChild(PART_KnownHostImage) as Image)!;
|
|
||||||
Ellipse = (GetTemplateChild(PART_Ellipse) as Ellipse)!;
|
|
||||||
FaviconSquircle = (GetTemplateChild(PART_FaviconSquircle) as Border)!;
|
|
||||||
FaviconImage = (GetTemplateChild(PART_FaviconImage) as Image)!;
|
|
||||||
|
|
||||||
UpdateInformation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnInformationChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
|
partial void OnFromNameChanged(string? newValue) => RequestRefresh();
|
||||||
|
|
||||||
|
partial void OnFromAddressChanged(string? newValue) => RequestRefresh();
|
||||||
|
|
||||||
|
partial void OnSenderContactPictureChanged(string? newValue) => RequestRefresh();
|
||||||
|
|
||||||
|
partial void OnThumbnailUpdatedEventChanged(bool newValue) => RequestRefresh();
|
||||||
|
|
||||||
|
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (obj is ImagePreviewControl control)
|
RequestRefresh();
|
||||||
control.UpdateInformation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void UpdateInformation()
|
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if ((KnownHostImage == null && FaviconSquircle == null) || InitialsGrid == null || InitialsTextblock == null || (string.IsNullOrEmpty(FromName) && string.IsNullOrEmpty(FromAddress)))
|
CancelScheduledRefresh();
|
||||||
|
CancelActiveRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestRefresh()
|
||||||
|
{
|
||||||
|
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
|
||||||
|
{
|
||||||
|
QueueRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatcherQueue.TryEnqueue(QueueRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueRefresh()
|
||||||
|
{
|
||||||
|
if (!IsLoaded)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Cancel active image loading if exists.
|
CancelScheduledRefresh();
|
||||||
if (!contactPictureLoadingCancellationTokenSource?.IsCancellationRequested ?? false)
|
|
||||||
{
|
|
||||||
contactPictureLoadingCancellationTokenSource!.Cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
string contactPicture = SenderContactPicture;
|
var cts = new CancellationTokenSource();
|
||||||
|
_scheduledRefreshCancellationTokenSource = cts;
|
||||||
|
|
||||||
var isAvatarThumbnail = false;
|
_ = DebounceAndRefreshAsync(cts.Token);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(contactPicture) && !string.IsNullOrEmpty(FromAddress))
|
|
||||||
{
|
|
||||||
contactPicture = await App.Current.ThumbnailService.GetThumbnailAsync(FromAddress);
|
|
||||||
isAvatarThumbnail = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(contactPicture))
|
|
||||||
{
|
|
||||||
if (isAvatarThumbnail && FaviconSquircle != null && FaviconImage != null)
|
|
||||||
{
|
|
||||||
// Show favicon in squircle
|
|
||||||
FaviconSquircle.Visibility = Visibility.Visible;
|
|
||||||
if (InitialsGrid != null)
|
|
||||||
InitialsGrid.Visibility = Visibility.Collapsed;
|
|
||||||
if (KnownHostImage != null)
|
|
||||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
var bitmapImage = await GetBitmapImageAsync(contactPicture);
|
|
||||||
|
|
||||||
if (bitmapImage != null)
|
|
||||||
{
|
|
||||||
FaviconImage.Source = bitmapImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Show normal avatar (tondo)
|
|
||||||
if (FaviconSquircle != null)
|
|
||||||
FaviconSquircle.Visibility = Visibility.Collapsed;
|
|
||||||
if (KnownHostImage != null)
|
|
||||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
|
||||||
if (InitialsGrid != null)
|
|
||||||
InitialsGrid.Visibility = Visibility.Visible;
|
|
||||||
contactPictureLoadingCancellationTokenSource = new CancellationTokenSource();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var brush = await GetContactImageBrushAsync(contactPicture);
|
|
||||||
|
|
||||||
if (brush != null)
|
|
||||||
{
|
|
||||||
if (!contactPictureLoadingCancellationTokenSource?.Token.IsCancellationRequested ?? false)
|
|
||||||
{
|
|
||||||
if (Ellipse != null)
|
|
||||||
Ellipse.Fill = brush;
|
|
||||||
if (InitialsTextblock != null)
|
|
||||||
InitialsTextblock.Text = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
Debugger.Break();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (FaviconSquircle != null)
|
|
||||||
FaviconSquircle.Visibility = Visibility.Collapsed;
|
|
||||||
if (KnownHostImage != null)
|
|
||||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
|
||||||
if (InitialsGrid != null)
|
|
||||||
InitialsGrid.Visibility = Visibility.Visible;
|
|
||||||
|
|
||||||
var colorHash = new ColorHash();
|
|
||||||
var rgb = colorHash.Rgb(FromAddress);
|
|
||||||
|
|
||||||
if (Ellipse != null)
|
|
||||||
Ellipse.Fill = new SolidColorBrush(Color.FromArgb(rgb.A, rgb.R, rgb.G, rgb.B));
|
|
||||||
if (InitialsTextblock != null)
|
|
||||||
InitialsTextblock.Text = ExtractInitialsFromName(FromName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ImageBrush?> GetContactImageBrushAsync(string base64)
|
private async Task DebounceAndRefreshAsync(CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
// Load the image from base64 string.
|
|
||||||
|
|
||||||
var bitmapImage = await GetBitmapImageAsync(base64);
|
|
||||||
|
|
||||||
if (bitmapImage == null) return null;
|
|
||||||
|
|
||||||
return new ImageBrush() { ImageSource = bitmapImage };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<BitmapImage?> GetBitmapImageAsync(string base64)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bitmapImage = new BitmapImage();
|
await Task.Delay(RefreshDebounceDuration, cancellationToken).ConfigureAwait(false);
|
||||||
var imageArray = Convert.FromBase64String(base64);
|
}
|
||||||
var imageStream = new MemoryStream(imageArray);
|
catch (OperationCanceledException)
|
||||||
var randomAccessImageStream = imageStream.AsRandomAccessStream();
|
{
|
||||||
randomAccessImageStream.Seek(0);
|
return;
|
||||||
await bitmapImage.SetSourceAsync(randomAccessImageStream);
|
|
||||||
return bitmapImage;
|
|
||||||
}
|
}
|
||||||
catch (Exception) { }
|
|
||||||
|
|
||||||
return null;
|
StartRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ExtractInitialsFromName(string name)
|
private void StartRefresh()
|
||||||
{
|
{
|
||||||
// Change from name to from address in case of name doesn't exists.
|
CancelActiveRefresh();
|
||||||
if (string.IsNullOrEmpty(name))
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_refreshCancellationTokenSource = cts;
|
||||||
|
var refreshVersion = Interlocked.Increment(ref _refreshVersion);
|
||||||
|
_ = RefreshAsync(refreshVersion, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelScheduledRefresh()
|
||||||
|
{
|
||||||
|
var cts = _scheduledRefreshCancellationTokenSource;
|
||||||
|
_scheduledRefreshCancellationTokenSource = null;
|
||||||
|
|
||||||
|
if (cts != null && !cts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
name = FromAddress;
|
cts.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// first remove all: punctuation, separator chars, control chars, and numbers (unicode style regexes)
|
cts?.Dispose();
|
||||||
string initials = Regex.Replace(name, @"[\p{P}\p{S}\p{C}\p{N}]+", "");
|
}
|
||||||
|
|
||||||
// Replacing all possible whitespace/separator characters (unicode style), with a single, regular ascii space.
|
private void CancelActiveRefresh()
|
||||||
initials = Regex.Replace(initials, @"\p{Z}+", " ");
|
{
|
||||||
|
var cts = _refreshCancellationTokenSource;
|
||||||
|
_refreshCancellationTokenSource = null;
|
||||||
|
|
||||||
// Remove all Sr, Jr, I, II, III, IV, V, VI, VII, VIII, IX at the end of names
|
if (cts != null && !cts.IsCancellationRequested)
|
||||||
initials = Regex.Replace(initials.Trim(), @"\s+(?:[JS]R|I{1,3}|I[VX]|VI{0,3})$", "", RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
// Extract up to 2 initials from the remaining cleaned name.
|
|
||||||
initials = Regex.Replace(initials, @"^(\p{L})[^\s]*(?:\s+(?:\p{L}+\s+(?=\p{L}))?(?:(\p{L})\p{L}*)?)?$", "$1$2").Trim();
|
|
||||||
|
|
||||||
if (initials.Length > 2)
|
|
||||||
{
|
{
|
||||||
// Worst case scenario, everything failed, just grab the first two letters of what we have left.
|
cts.Cancel();
|
||||||
initials = initials.Substring(0, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initials.ToUpperInvariant();
|
cts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAsync(long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await CaptureSnapshotAsync(refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (snapshot == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// 1) Explicit contact picture.
|
||||||
|
if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture))
|
||||||
|
{
|
||||||
|
var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (localBitmap != null)
|
||||||
|
{
|
||||||
|
await ApplyProfilePictureAsync(localBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Gravatar lookup through thumbnail service (if enabled).
|
||||||
|
if (_preferencesService?.IsGravatarEnabled == true &&
|
||||||
|
_thumbnailService != null &&
|
||||||
|
!string.IsNullOrWhiteSpace(snapshot.Address) &&
|
||||||
|
EmailValidator.Validate(snapshot.Address))
|
||||||
|
{
|
||||||
|
var thumbnailBase64 = await _thumbnailService
|
||||||
|
.GetThumbnailAsync(snapshot.Address.Trim().ToLowerInvariant(), awaitLoad: true)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(thumbnailBase64))
|
||||||
|
{
|
||||||
|
var thumbnailBitmap = await CreateBitmapFromBase64Async(thumbnailBase64, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (thumbnailBitmap != null)
|
||||||
|
{
|
||||||
|
await ApplyProfilePictureAsync(thumbnailBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Initials fallback is already in place via DisplayName + ProfilePicture = null.
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected during virtualization/recycling.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep fallback initials if decoding/network fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DependencyProperty-backed values must be read on UI thread once, then used off-thread.
|
||||||
|
private async Task<RefreshSnapshot?> CaptureSnapshotAsync(long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
if (!IsActiveRefresh(refreshVersion, cancellationToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var address = ResolveAddress();
|
||||||
|
var displayName = ResolveDisplayName(address);
|
||||||
|
var base64Picture = ResolveBase64Picture();
|
||||||
|
|
||||||
|
return new RefreshSnapshot(displayName, address, base64Picture);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveAddress()
|
||||||
|
{
|
||||||
|
var contactAddress = MailItemInformation?.SenderContact?.Address;
|
||||||
|
if (!string.IsNullOrWhiteSpace(contactAddress))
|
||||||
|
return contactAddress.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(MailItemInformation?.FromAddress))
|
||||||
|
return MailItemInformation.FromAddress.Trim();
|
||||||
|
|
||||||
|
return FromAddress?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveDisplayName(string resolvedAddress)
|
||||||
|
{
|
||||||
|
var contactName = MailItemInformation?.SenderContact?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(contactName))
|
||||||
|
return contactName.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(MailItemInformation?.FromName))
|
||||||
|
return MailItemInformation.FromName.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(FromName))
|
||||||
|
return FromName.Trim();
|
||||||
|
|
||||||
|
return resolvedAddress.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveBase64Picture()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(MailItemInformation?.SenderContact?.Base64ContactPicture))
|
||||||
|
return MailItemInformation.SenderContact.Base64ContactPicture;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(MailItemInformation?.Base64ContactPicture))
|
||||||
|
return MailItemInformation.Base64ContactPicture;
|
||||||
|
|
||||||
|
return SenderContactPicture ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyInitialVisualStateAsync(string displayName, long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ExecuteOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
if (!IsActiveRefresh(refreshVersion, cancellationToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
DisplayName = displayName;
|
||||||
|
ProfilePicture = null;
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyProfilePictureAsync(BitmapImage bitmapImage, long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ExecuteOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
if (!IsActiveRefresh(refreshVersion, cancellationToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ProfilePicture = bitmapImage;
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsActiveRefresh(long refreshVersion, CancellationToken cancellationToken)
|
||||||
|
=> !cancellationToken.IsCancellationRequested && refreshVersion == _refreshVersion;
|
||||||
|
|
||||||
|
private async Task ExecuteOnUiThreadAsync(Action action)
|
||||||
|
{
|
||||||
|
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var completion = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var enqueued = DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
completion.TrySetResult(null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
completion.TrySetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enqueued)
|
||||||
|
{
|
||||||
|
completion.TrySetException(new InvalidOperationException("Failed to dispatch UI update."));
|
||||||
|
}
|
||||||
|
|
||||||
|
await completion.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteOnUiThreadAsync<T>(Func<T> func)
|
||||||
|
{
|
||||||
|
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
|
||||||
|
{
|
||||||
|
return func();
|
||||||
|
}
|
||||||
|
|
||||||
|
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var enqueued = DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
completion.TrySetResult(func());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
completion.TrySetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enqueued)
|
||||||
|
{
|
||||||
|
completion.TrySetException(new InvalidOperationException("Failed to dispatch UI update."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await completion.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BitmapImage?> CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(base64))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = await Task.Run(() => Convert.FromBase64String(base64), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
return await ExecuteOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream(bytes);
|
||||||
|
var bitmapImage = new BitmapImage();
|
||||||
|
bitmapImage.SetSource(memoryStream.AsRandomAccessStream());
|
||||||
|
return bitmapImage;
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidEmail(string email)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new MailAddress(email);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Canvas.ZIndex="0"
|
Canvas.ZIndex="0"
|
||||||
Fill="{ThemeResource SystemAccentColor}"
|
Fill="{ThemeResource SystemAccentColor}"
|
||||||
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(IsRead), Mode=OneWay}" />
|
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(MailItemInformation.IsRead), Mode=OneWay}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
@@ -73,10 +73,7 @@
|
|||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
FromAddress="{x:Bind FromAddress, Mode=OneWay}"
|
MailItemInformation="{x:Bind MailItemInformation, Mode=OneWay}"
|
||||||
FromName="{x:Bind FromName, Mode=OneWay}"
|
|
||||||
SenderContactPicture="{x:Bind Base64ContactPicture, Mode=OneWay}"
|
|
||||||
ThumbnailUpdatedEvent="{x:Bind IsThumbnailUpdated, Mode=OneWay}"
|
|
||||||
Visibility="{x:Bind IsAvatarVisible, Mode=OneWay}" />
|
Visibility="{x:Bind IsAvatarVisible, Mode=OneWay}" />
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
@@ -104,7 +101,7 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
x:Name="DraftTitle"
|
x:Name="DraftTitle"
|
||||||
Margin="0,0,4,0"
|
Margin="0,0,4,0"
|
||||||
x:Load="{x:Bind IsDraft, Mode=OneWay}"
|
x:Load="{x:Bind MailItemInformation.IsDraft, Mode=OneWay}"
|
||||||
Foreground="{StaticResource DeleteBrush}">
|
Foreground="{StaticResource DeleteBrush}">
|
||||||
|
|
||||||
<Run Text="[" /><Run Text="{x:Bind domain:Translator.Draft}" /><Run Text="]" /> <Run Text=" " />
|
<Run Text="[" /><Run Text="{x:Bind domain:Translator.Draft}" /><Run Text="]" /> <Run Text=" " />
|
||||||
@@ -114,17 +111,17 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
x:Name="SenderTextFromName"
|
x:Name="SenderTextFromName"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{x:Bind FromName, Mode=OneWay}"
|
Text="{x:Bind MailItemInformation.FromName, Mode=OneWay}"
|
||||||
TextTrimming="WordEllipsis"
|
TextTrimming="WordEllipsis"
|
||||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(FromName), Mode=OneWay}" />
|
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(MailItemInformation.FromName), Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
x:Name="SenderTextFromAddress"
|
x:Name="SenderTextFromAddress"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{x:Bind FromAddress}"
|
Text="{x:Bind MailItemInformation.FromAddress}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityReversedConverter(FromName), Mode=OneWay}" />
|
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityReversedConverter(MailItemInformation.FromName), Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Hover button -->
|
<!-- Hover button -->
|
||||||
<StackPanel
|
<StackPanel
|
||||||
@@ -194,7 +191,7 @@
|
|||||||
x:Name="TitleText"
|
x:Name="TitleText"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
MaxLines="1"
|
MaxLines="1"
|
||||||
Text="{x:Bind Subject, Mode=OneWay}"
|
Text="{x:Bind MailItemInformation.Subject, Mode=OneWay}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
@@ -204,7 +201,7 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.7"
|
Opacity="0.7"
|
||||||
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(IsDraft, CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(MailItemInformation.IsDraft, MailItemInformation.CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Message -->
|
<!-- Message -->
|
||||||
@@ -220,10 +217,10 @@
|
|||||||
<Grid x:Name="PreviewTextContainer">
|
<Grid x:Name="PreviewTextContainer">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
x:Name="PreviewTextblock"
|
x:Name="PreviewTextblock"
|
||||||
x:Load="{x:Bind helpers:XamlHelpers.ShouldDisplayPreview(PreviewText), Mode=OneWay}"
|
x:Load="{x:Bind helpers:XamlHelpers.ShouldDisplayPreview(MailItemInformation.PreviewText), Mode=OneWay}"
|
||||||
MaxLines="1"
|
MaxLines="1"
|
||||||
Opacity="0.7"
|
Opacity="0.7"
|
||||||
Text="{x:Bind PreviewText, Mode=OneWay}"
|
Text="{x:Bind MailItemInformation.PreviewText, Mode=OneWay}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -237,12 +234,12 @@
|
|||||||
|
|
||||||
<ContentPresenter
|
<ContentPresenter
|
||||||
x:Name="HasAttachmentContent"
|
x:Name="HasAttachmentContent"
|
||||||
x:Load="{x:Bind HasAttachments, Mode=OneWay}"
|
x:Load="{x:Bind MailItemInformation.HasAttachments, Mode=OneWay}"
|
||||||
ContentTemplate="{StaticResource AttachmentSymbolControlTemplate}" />
|
ContentTemplate="{StaticResource AttachmentSymbolControlTemplate}" />
|
||||||
|
|
||||||
<ContentPresenter
|
<ContentPresenter
|
||||||
x:Name="IsFlaggedContent"
|
x:Name="IsFlaggedContent"
|
||||||
x:Load="{x:Bind IsFlagged, Mode=OneWay}"
|
x:Load="{x:Bind MailItemInformation.IsFlagged, Mode=OneWay}"
|
||||||
ContentTemplate="{StaticResource FlaggedSymbolControlTemplate}" />
|
ContentTemplate="{StaticResource FlaggedSymbolControlTemplate}" />
|
||||||
|
|
||||||
<ProgressRing
|
<ProgressRing
|
||||||
@@ -250,7 +247,7 @@
|
|||||||
Height="3"
|
Height="3"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
IsActive="{x:Bind IsBusy, Mode=OneWay}" />
|
IsActive="{x:Bind MailItemInformation.IsBusy, Mode=OneWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -263,7 +260,7 @@
|
|||||||
<VisualStateGroup x:Name="ReadStates">
|
<VisualStateGroup x:Name="ReadStates">
|
||||||
<VisualState x:Name="Unread">
|
<VisualState x:Name="Unread">
|
||||||
<VisualState.StateTriggers>
|
<VisualState.StateTriggers>
|
||||||
<StateTrigger IsActive="{x:Bind IsRead, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}" />
|
<StateTrigger IsActive="{x:Bind MailItemInformation.IsRead, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}" />
|
||||||
</VisualState.StateTriggers>
|
</VisualState.StateTriggers>
|
||||||
|
|
||||||
<VisualState.Setters>
|
<VisualState.Setters>
|
||||||
@@ -335,7 +332,7 @@
|
|||||||
<Setter Target="ExpandCollapseChevron.(controls:AnimatedIcon.State)" Value="NormalOn" />
|
<Setter Target="ExpandCollapseChevron.(controls:AnimatedIcon.State)" Value="NormalOn" />
|
||||||
</VisualState.Setters>
|
</VisualState.Setters>
|
||||||
<VisualState.StateTriggers>
|
<VisualState.StateTriggers>
|
||||||
<StateTrigger IsActive="{x:Bind IsThreadExpanded, Mode=OneWay}" />
|
<StateTrigger IsActive="{x:Bind MailItemInformation.IsThreadExpanded, Mode=OneWay}" />
|
||||||
</VisualState.StateTriggers>
|
</VisualState.StateTriggers>
|
||||||
</VisualState>
|
</VisualState>
|
||||||
</VisualStateGroup>
|
</VisualStateGroup>
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
|||||||
[GeneratedDependencyProperty(DefaultValue = true)]
|
[GeneratedDependencyProperty(DefaultValue = true)]
|
||||||
public partial bool IsHoverActionsEnabled { get; set; }
|
public partial bool IsHoverActionsEnabled { get; set; }
|
||||||
|
|
||||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
|
||||||
public partial bool IsBusy { get; set; }
|
|
||||||
|
|
||||||
public event EventHandler<MailOperationPreperationRequest>? HoverActionExecuted;
|
public event EventHandler<MailOperationPreperationRequest>? HoverActionExecuted;
|
||||||
|
|
||||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||||
@@ -62,49 +59,12 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
|||||||
[GeneratedDependencyProperty]
|
[GeneratedDependencyProperty]
|
||||||
public partial IMailListItem? ActionItem { get; set; }
|
public partial IMailListItem? ActionItem { get; set; }
|
||||||
|
|
||||||
#region Display Properties
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
[GeneratedDependencyProperty]
|
||||||
public partial string? Subject { get; set; }
|
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial string? FromName { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial string? FromAddress { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial string? PreviewText { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial bool IsRead { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial bool IsDraft { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial bool HasAttachments { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial bool IsFlagged { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial DateTime CreationDate { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
|
||||||
public partial string? Base64ContactPicture { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||||
public partial bool IsThreadExpanderVisible { get; set; }
|
public partial bool IsThreadExpanderVisible { get; set; }
|
||||||
|
|
||||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
|
||||||
public partial bool IsThreadExpanded { get; set; }
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
|
||||||
public partial bool IsThumbnailUpdated { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public MailItemDisplayInformationControl()
|
public MailItemDisplayInformationControl()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -138,16 +98,14 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
|||||||
_compositor = this.Visual().Compositor;
|
_compositor = this.Visual().Compositor;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsBusyChanged(bool newValue)
|
partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (newValue)
|
if (ActionItem == null && MailItemInformation is IMailListItem mailListItem)
|
||||||
{
|
{
|
||||||
StartBusyAnimation();
|
ActionItem = mailListItem;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StopBusyAnimation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateBusyAnimationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartBusyAnimation()
|
private void StartBusyAnimation()
|
||||||
@@ -184,9 +142,15 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
|||||||
_opacityAnimation = null;
|
_opacityAnimation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsFlaggedChanged(bool newValue)
|
private void UpdateBusyAnimationState()
|
||||||
{
|
{
|
||||||
|
if (MailItemInformation?.IsBusy == true)
|
||||||
|
{
|
||||||
|
StartBusyAnimation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopBusyAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ControlPointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
private void ControlPointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||||
|
|||||||
@@ -5,14 +5,13 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:domain="using:Wino.Core.Domain"
|
xmlns:domain="using:Wino.Core.Domain"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
Title="Edit Contact"
|
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
DefaultButton="Primary"
|
DefaultButton="Primary"
|
||||||
IsPrimaryButtonEnabled="True"
|
IsPrimaryButtonEnabled="True"
|
||||||
PrimaryButtonClick="SaveClicked"
|
PrimaryButtonClick="SaveClicked"
|
||||||
PrimaryButtonText="Save"
|
PrimaryButtonText="{x:Bind domain:Translator.Buttons_Save, Mode=OneTime}"
|
||||||
SecondaryButtonClick="CancelClicked"
|
SecondaryButtonClick="CancelClicked"
|
||||||
SecondaryButtonText="Cancel"
|
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel, Mode=OneTime}"
|
||||||
Style="{StaticResource WinoDialogStyle}"
|
Style="{StaticResource WinoDialogStyle}"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
@@ -24,15 +23,15 @@
|
|||||||
<!-- Contact Name -->
|
<!-- Contact Name -->
|
||||||
<TextBox
|
<TextBox
|
||||||
x:Name="ContactNameTextBox"
|
x:Name="ContactNameTextBox"
|
||||||
Header="Name"
|
Header="{x:Bind domain:Translator.ContactEditDialog_NameHeader, Mode=OneTime}"
|
||||||
PlaceholderText="Contact name"
|
PlaceholderText="{x:Bind domain:Translator.ContactEditDialog_NamePlaceholder, Mode=OneTime}"
|
||||||
TextChanged="ValidateInput" />
|
TextChanged="ValidateInput" />
|
||||||
|
|
||||||
<!-- Email Address -->
|
<!-- Email Address -->
|
||||||
<TextBox
|
<TextBox
|
||||||
x:Name="EmailAddressTextBox"
|
x:Name="EmailAddressTextBox"
|
||||||
Header="Email Address"
|
Header="{x:Bind domain:Translator.ContactEditDialog_EmailHeader, Mode=OneTime}"
|
||||||
PlaceholderText="contact@example.com"
|
PlaceholderText="{x:Bind domain:Translator.ContactEditDialog_EmailPlaceholder, Mode=OneTime}"
|
||||||
TextChanged="ValidateInput" />
|
TextChanged="ValidateInput" />
|
||||||
|
|
||||||
<!-- Contact Photo -->
|
<!-- Contact Photo -->
|
||||||
@@ -40,7 +39,7 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="0,0,0,8"
|
Margin="0,0,0,8"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="Photo" />
|
Text="{x:Bind domain:Translator.ContactEditDialog_PhotoSection, Mode=OneTime}" />
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -61,11 +60,12 @@
|
|||||||
<Button
|
<Button
|
||||||
x:Name="ChoosePhotoButton"
|
x:Name="ChoosePhotoButton"
|
||||||
Click="ChoosePhotoClicked"
|
Click="ChoosePhotoClicked"
|
||||||
Content="Choose Photo" />
|
Content="{x:Bind domain:Translator.ContactEditDialog_ChoosePhoto, Mode=OneTime}" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="RemovePhotoButton"
|
x:Name="RemovePhotoButton"
|
||||||
Click="RemovePhotoClicked"
|
Click="RemovePhotoClicked"
|
||||||
Content="Remove Photo" />
|
Content="{x:Bind domain:Translator.ContactEditDialog_RemovePhoto, Mode=OneTime}"
|
||||||
|
Visibility="Collapsed" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
Visibility="Collapsed">
|
Visibility="Collapsed">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
||||||
Text="This is a root contact and cannot be deleted."
|
Text="{x:Bind domain:Translator.ContactEditDialog_RootContactInfo, Mode=OneTime}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
Visibility="Collapsed">
|
Visibility="Collapsed">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
||||||
Text="This contact has been manually modified."
|
Text="{x:Bind domain:Translator.ContactEditDialog_OverriddenContactInfo, Mode=OneTime}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ public sealed partial class ContactEditDialog : ContentDialog
|
|||||||
{
|
{
|
||||||
private AccountContact _contact;
|
private AccountContact _contact;
|
||||||
private IDialogServiceBase? _dialogService;
|
private IDialogServiceBase? _dialogService;
|
||||||
|
private bool _isEditMode;
|
||||||
|
|
||||||
public AccountContact Contact => _contact;
|
public AccountContact Contact => _contact;
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@ public sealed partial class ContactEditDialog : ContentDialog
|
|||||||
|
|
||||||
_contact = contact ?? new AccountContact();
|
_contact = contact ?? new AccountContact();
|
||||||
_dialogService = dialogService;
|
_dialogService = dialogService;
|
||||||
|
_isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address);
|
||||||
|
|
||||||
|
Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle;
|
||||||
|
|
||||||
LoadContactData();
|
LoadContactData();
|
||||||
ValidateInput();
|
ValidateInput();
|
||||||
@@ -31,67 +38,109 @@ public sealed partial class ContactEditDialog : ContentDialog
|
|||||||
ContactNameTextBox.Text = _contact.Name ?? string.Empty;
|
ContactNameTextBox.Text = _contact.Name ?? string.Empty;
|
||||||
EmailAddressTextBox.Text = _contact.Address ?? string.Empty;
|
EmailAddressTextBox.Text = _contact.Address ?? string.Empty;
|
||||||
|
|
||||||
// Show info badges
|
// Disable email editing for existing contacts (Address is PK).
|
||||||
|
EmailAddressTextBox.IsEnabled = !_isEditMode;
|
||||||
|
|
||||||
if (_contact.IsRootContact)
|
if (_contact.IsRootContact)
|
||||||
{
|
|
||||||
RootContactInfoBorder.Visibility = Visibility.Visible;
|
RootContactInfoBorder.Visibility = Visibility.Visible;
|
||||||
}
|
|
||||||
|
|
||||||
if (_contact.IsOverridden)
|
if (_contact.IsOverridden)
|
||||||
{
|
|
||||||
OverriddenContactInfoBorder.Visibility = Visibility.Visible;
|
OverriddenContactInfoBorder.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
// Load existing photo.
|
||||||
|
if (!string.IsNullOrEmpty(_contact.Base64ContactPicture))
|
||||||
|
{
|
||||||
|
LoadContactPhoto(_contact.Base64ContactPicture);
|
||||||
|
RemovePhotoButton.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ContactPhotoPersonPicture.DisplayName = _contact.Name ?? string.Empty;
|
||||||
|
RemovePhotoButton.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ChoosePhotoClicked(object sender, RoutedEventArgs e)
|
private async void ChoosePhotoClicked(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// TODO: Implement photo picker
|
if (_dialogService == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = await _dialogService.PickFilesAsync(".png", ".jpg", ".jpeg");
|
||||||
|
|
||||||
|
if (files?.Count > 0)
|
||||||
|
{
|
||||||
|
var file = files[0];
|
||||||
|
var base64 = Convert.ToBase64String(file.Data);
|
||||||
|
_contact.Base64ContactPicture = base64;
|
||||||
|
LoadContactPhoto(base64);
|
||||||
|
RemovePhotoButton.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Failed to pick photo, ignore.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemovePhotoClicked(object sender, RoutedEventArgs e)
|
private void RemovePhotoClicked(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
_contact.Base64ContactPicture = null;
|
||||||
ContactPhotoPersonPicture.ProfilePicture = null;
|
ContactPhotoPersonPicture.ProfilePicture = null;
|
||||||
|
ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text;
|
||||||
RemovePhotoButton.Visibility = Visibility.Collapsed;
|
RemovePhotoButton.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadContactPhoto(string base64String)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var imageBytes = Convert.FromBase64String(base64String);
|
||||||
|
using var stream = new MemoryStream(imageBytes);
|
||||||
|
var bitmap = new BitmapImage();
|
||||||
|
bitmap.SetSource(stream.AsRandomAccessStream());
|
||||||
|
ContactPhotoPersonPicture.ProfilePicture = bitmap;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Failed to load image, ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ValidateInput(object? sender = null, TextChangedEventArgs? e = null)
|
private void ValidateInput(object? sender = null, TextChangedEventArgs? e = null)
|
||||||
{
|
{
|
||||||
var hasName = !string.IsNullOrWhiteSpace(ContactNameTextBox.Text);
|
var hasName = !string.IsNullOrWhiteSpace(ContactNameTextBox.Text);
|
||||||
var hasEmail = !string.IsNullOrWhiteSpace(EmailAddressTextBox.Text);
|
var hasEmail = !string.IsNullOrWhiteSpace(EmailAddressTextBox.Text);
|
||||||
var isValidEmail = hasEmail && IsValidEmail(EmailAddressTextBox.Text);
|
|
||||||
|
|
||||||
IsPrimaryButtonEnabled = hasName && isValidEmail;
|
var isValidEmail = hasEmail && EmailValidation.EmailValidator.Validate(EmailAddressTextBox.Text);
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsValidEmail(string email)
|
if (_isEditMode)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var addr = new System.Net.Mail.MailAddress(email);
|
// In edit mode, only name is required (email is locked).
|
||||||
return addr.Address == email;
|
IsPrimaryButtonEnabled = hasName;
|
||||||
}
|
}
|
||||||
catch
|
else
|
||||||
{
|
{
|
||||||
return false;
|
// In create mode, both name and valid email are required.
|
||||||
|
IsPrimaryButtonEnabled = hasName && isValidEmail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||||
{
|
{
|
||||||
// Update contact data
|
|
||||||
_contact.Name = ContactNameTextBox.Text?.Trim();
|
_contact.Name = ContactNameTextBox.Text?.Trim();
|
||||||
_contact.Address = EmailAddressTextBox.Text?.Trim();
|
|
||||||
|
|
||||||
// Mark as overridden if this was a user edit
|
if (!_isEditMode)
|
||||||
if (!string.IsNullOrEmpty(_contact.Address))
|
_contact.Address = EmailAddressTextBox.Text?.Trim();
|
||||||
{
|
|
||||||
|
// Mark as overridden if this was a user edit of an existing contact.
|
||||||
|
if (_isEditMode)
|
||||||
_contact.IsOverridden = true;
|
_contact.IsOverridden = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||||
{
|
{
|
||||||
// Nothing to do, dialog will close
|
// Nothing to do, dialog will close.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,54 +5,8 @@
|
|||||||
|
|
||||||
<Style TargetType="controls:ImagePreviewControl">
|
<Style TargetType="controls:ImagePreviewControl">
|
||||||
<Style.Setters>
|
<Style.Setters>
|
||||||
<Setter Property="Width" Value="34" />
|
<Setter Property="Width" Value="44" />
|
||||||
<Setter Property="Height" Value="34" />
|
<Setter Property="Height" Value="44" />
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="controls:ImagePreviewControl">
|
|
||||||
<Grid>
|
|
||||||
<!-- Ellipse Initials -->
|
|
||||||
<Grid x:Name="EllipseInitialsGrid">
|
|
||||||
<Ellipse
|
|
||||||
x:Name="Ellipse"
|
|
||||||
Grid.RowSpan="2"
|
|
||||||
Width="{TemplateBinding Width}"
|
|
||||||
Height="{TemplateBinding Height}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
<TextBlock
|
|
||||||
x:Name="InitialsTextBlock"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
Foreground="White" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Squircle for favicon -->
|
|
||||||
<Border
|
|
||||||
x:Name="FaviconSquircle"
|
|
||||||
Width="{TemplateBinding Width}"
|
|
||||||
Height="{TemplateBinding Height}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Background="Transparent"
|
|
||||||
CornerRadius="6"
|
|
||||||
Visibility="Collapsed">
|
|
||||||
<Image x:Name="FaviconImage" Stretch="Fill" />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
x:Name="KnownHostImage"
|
|
||||||
Width="{TemplateBinding Width}"
|
|
||||||
Height="{TemplateBinding Height}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Stretch="UniformToFill"
|
|
||||||
Visibility="Collapsed" />
|
|
||||||
</Grid>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style.Setters>
|
</Style.Setters>
|
||||||
</Style>
|
</Style>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -55,20 +55,9 @@
|
|||||||
<controls:MailItemDisplayInformationControl
|
<controls:MailItemDisplayInformationControl
|
||||||
x:DefaultBindMode="OneWay"
|
x:DefaultBindMode="OneWay"
|
||||||
ActionItem="{x:Bind}"
|
ActionItem="{x:Bind}"
|
||||||
Base64ContactPicture="{x:Bind MailCopy.SenderContact.Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
|
|
||||||
ContextRequested="MailItemContextRequested"
|
ContextRequested="MailItemContextRequested"
|
||||||
CreationDate="{x:Bind CreationDate}"
|
|
||||||
FromAddress="{x:Bind FromAddress}"
|
|
||||||
FromName="{x:Bind FromName}"
|
|
||||||
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
|
|
||||||
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
||||||
IsBusy="{x:Bind IsBusy, Mode=OneWay}"
|
MailItemInformation="{x:Bind}" />
|
||||||
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
|
|
||||||
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
|
|
||||||
IsRead="{x:Bind IsRead, Mode=OneWay}"
|
|
||||||
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
|
|
||||||
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
|
|
||||||
Subject="{x:Bind Subject, Mode=OneWay}" />
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<DataTemplate x:Key="ThreadMailItemTemplate" x:DataType="viewModelData:ThreadMailItemViewModel">
|
<DataTemplate x:Key="ThreadMailItemTemplate" x:DataType="viewModelData:ThreadMailItemViewModel">
|
||||||
@@ -80,22 +69,10 @@
|
|||||||
<controls:MailItemDisplayInformationControl
|
<controls:MailItemDisplayInformationControl
|
||||||
x:DefaultBindMode="OneWay"
|
x:DefaultBindMode="OneWay"
|
||||||
ActionItem="{x:Bind}"
|
ActionItem="{x:Bind}"
|
||||||
Base64ContactPicture="{x:Bind Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
|
|
||||||
ContextRequested="MailItemContextRequested"
|
ContextRequested="MailItemContextRequested"
|
||||||
CreationDate="{x:Bind CreationDate}"
|
|
||||||
FromAddress="{x:Bind FromAddress, Mode=OneWay}"
|
|
||||||
FromName="{x:Bind FromName, Mode=OneWay}"
|
|
||||||
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
|
|
||||||
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
||||||
IsBusy="{x:Bind IsBusy, Mode=OneWay}"
|
|
||||||
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
|
|
||||||
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
|
|
||||||
IsRead="{x:Bind IsRead, Mode=OneWay}"
|
|
||||||
IsThreadExpanded="{x:Bind IsThreadExpanded, Mode=OneWay}"
|
|
||||||
IsThreadExpanderVisible="True"
|
IsThreadExpanderVisible="True"
|
||||||
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
|
MailItemInformation="{x:Bind}" />
|
||||||
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
|
|
||||||
Subject="{x:Bind Subject, Mode=OneWay}" />
|
|
||||||
</controls:WinoExpander.Header>
|
</controls:WinoExpander.Header>
|
||||||
<controls:WinoExpander.Content>
|
<controls:WinoExpander.Content>
|
||||||
<listview:WinoListView
|
<listview:WinoListView
|
||||||
|
|||||||
@@ -633,7 +633,8 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
// * Clicking a single (non-thread) item OR a child item: collapse & unselect all others then toggle that item's selection.
|
// * Clicking a single (non-thread) item OR a child item: collapse & unselect all others then toggle that item's selection.
|
||||||
// If it was selected, result is nothing selected.
|
// If it was selected, result is nothing selected.
|
||||||
|
|
||||||
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed();
|
// Treat toolbar multi-select mode the same as holding CTRL for click selection behavior.
|
||||||
|
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed() || ViewModel.IsMultiSelectionModeEnabled;
|
||||||
|
|
||||||
// Helper local to collapse all other threads (we always collapse ALL then possibly re-expand the active thread per rules)
|
// Helper local to collapse all other threads (we always collapse ALL then possibly re-expand the active thread per rules)
|
||||||
async Task CollapseAllThreadsExceptAsync(ThreadMailItemViewModel? except)
|
async Task CollapseAllThreadsExceptAsync(ThreadMailItemViewModel? except)
|
||||||
@@ -648,6 +649,36 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail)
|
||||||
|
{
|
||||||
|
foreach (var group in ViewModel.MailCollection.MailItems)
|
||||||
|
{
|
||||||
|
foreach (var item in group)
|
||||||
|
{
|
||||||
|
if (item is ThreadMailItemViewModel thread && thread.ThreadEmails.Contains(mail))
|
||||||
|
{
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SyncThreadSelectionFromChildren(ThreadMailItemViewModel? thread)
|
||||||
|
{
|
||||||
|
if (thread == null) return;
|
||||||
|
|
||||||
|
bool hasSelectedChildren = thread.ThreadEmails.Any(child => child.IsSelected);
|
||||||
|
thread.IsSelected = hasSelectedChildren;
|
||||||
|
|
||||||
|
// Keep thread open while it has selected children.
|
||||||
|
if (hasSelectedChildren)
|
||||||
|
{
|
||||||
|
thread.IsThreadExpanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isCtrlPressed)
|
if (isCtrlPressed)
|
||||||
{
|
{
|
||||||
switch (clickedItem)
|
switch (clickedItem)
|
||||||
@@ -678,6 +709,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
{
|
{
|
||||||
// Toggle just this item; no collapse/unselect of others in multi-select mode.
|
// Toggle just this item; no collapse/unselect of others in multi-select mode.
|
||||||
mail.IsSelected = !mail.IsSelected;
|
mail.IsSelected = !mail.IsSelected;
|
||||||
|
SyncThreadSelectionFromChildren(FindParentThread(mail));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,20 +762,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
|
|
||||||
// Determine if this mail belongs to an already selected & expanded thread.
|
// Determine if this mail belongs to an already selected & expanded thread.
|
||||||
// If so, we only want to switch the selection inside that thread without collapsing or unselecting the thread header.
|
// If so, we only want to switch the selection inside that thread without collapsing or unselecting the thread header.
|
||||||
ThreadMailItemViewModel? parentThread = null;
|
ThreadMailItemViewModel? parentThread = FindParentThread(clickedMail);
|
||||||
|
|
||||||
foreach (var group in ViewModel.MailCollection.MailItems)
|
|
||||||
{
|
|
||||||
foreach (var item in group)
|
|
||||||
{
|
|
||||||
if (item is ThreadMailItemViewModel thread && thread.ThreadEmails.Contains(clickedMail))
|
|
||||||
{
|
|
||||||
parentThread = thread;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parentThread != null) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded;
|
bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded;
|
||||||
|
|
||||||
@@ -758,20 +777,31 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasSelected && parentThread != null)
|
SyncThreadSelectionFromChildren(parentThread);
|
||||||
{
|
|
||||||
// Clicking the already selected child should leave the thread header selected (canonical state: thread + first child previously).
|
|
||||||
// Decide whether to keep a child selected; spec wants toggle off allowed, so leave no child selected.
|
|
||||||
// Ensure parent thread stays selected & expanded.
|
|
||||||
parentThread.IsSelected = true;
|
|
||||||
parentThread.IsThreadExpanded = true;
|
|
||||||
}
|
|
||||||
return; // Done.
|
return; // Done.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal single-item (non-thread or entering a thread via child) behavior.
|
// Normal single-item (non-thread or entering a thread via child) behavior.
|
||||||
await ViewModel.MailCollection.UnselectAllAsync();
|
await ViewModel.MailCollection.UnselectAllAsync();
|
||||||
await ViewModel.MailCollection.CollapseAllThreadsAsync();
|
|
||||||
|
// If parent thread is already expanded, keep it as-is to avoid collapse/expand animation.
|
||||||
|
if (parentThread != null && parentThread.IsThreadExpanded)
|
||||||
|
{
|
||||||
|
foreach (var group in ViewModel.MailCollection.MailItems)
|
||||||
|
{
|
||||||
|
foreach (var item in group)
|
||||||
|
{
|
||||||
|
if (item is ThreadMailItemViewModel thread && !ReferenceEquals(thread, parentThread))
|
||||||
|
{
|
||||||
|
thread.IsThreadExpanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ViewModel.MailCollection.CollapseAllThreadsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (parentThread != null && selectExpandThread)
|
if (parentThread != null && selectExpandThread)
|
||||||
{
|
{
|
||||||
@@ -784,6 +814,8 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
{
|
{
|
||||||
clickedMail.IsSelected = true; // Toggle on
|
clickedMail.IsSelected = true; // Toggle on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SyncThreadSelectionFromChildren(parentThread);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,99 +3,100 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:abstract="using:Wino.Views.Abstract"
|
xmlns:abstract="using:Wino.Views.Abstract"
|
||||||
xmlns:controls="using:Wino.Controls"
|
|
||||||
xmlns:controls1="using:CommunityToolkit.WinUI.Controls"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:domain="using:Wino.Core.Domain"
|
xmlns:domain="using:Wino.Core.Domain"
|
||||||
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
|
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
|
||||||
xmlns:helpers="using:Wino.Helpers"
|
xmlns:helpers="using:Wino.Helpers"
|
||||||
|
xmlns:listview="using:Wino.Mail.WinUI.Controls.ListView"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
|
xmlns:toolkitExt="using:CommunityToolkit.WinUI"
|
||||||
x:Name="root"
|
x:Name="root"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<Page.Resources>
|
<Page.Resources>
|
||||||
<DataTemplate x:Key="ContactTemplate" x:DataType="entities:AccountContact">
|
<DataTemplate x:Key="ContactTemplate" x:DataType="entities:AccountContact">
|
||||||
<Grid Margin="0,4" Padding="16,12">
|
<Grid Margin="0,0,0,8" Padding="0,4">
|
||||||
<Grid.ColumnDefinitions>
|
<Border
|
||||||
<ColumnDefinition Width="Auto" />
|
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
|
||||||
<ColumnDefinition Width="*" />
|
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||||
<ColumnDefinition Width="Auto" />
|
BorderThickness="1"
|
||||||
</Grid.ColumnDefinitions>
|
CornerRadius="8" />
|
||||||
|
|
||||||
<!-- Contact Picture -->
|
<Grid Padding="12" ColumnSpacing="12">
|
||||||
<PersonPicture
|
<Grid.ColumnDefinitions>
|
||||||
Grid.Column="0"
|
<ColumnDefinition Width="Auto" />
|
||||||
Width="48"
|
<ColumnDefinition Width="*" />
|
||||||
Height="48"
|
<ColumnDefinition Width="Auto" />
|
||||||
Margin="0,0,16,0"
|
</Grid.ColumnDefinitions>
|
||||||
DisplayName="{x:Bind Name}"
|
|
||||||
ProfilePicture="{x:Bind helpers:XamlHelpers.Base64ToBitmapImage(Base64ContactPicture)}" />
|
|
||||||
|
|
||||||
<!-- Contact Info -->
|
<PersonPicture
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
Grid.Column="0"
|
||||||
<TextBlock
|
Width="40"
|
||||||
FontWeight="SemiBold"
|
Height="40"
|
||||||
Text="{x:Bind Name}"
|
DisplayName="{x:Bind Name, Mode=OneTime, TargetNullValue=''}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
ProfilePicture="{x:Bind helpers:XamlHelpers.Base64ToBitmapImage(Base64ContactPicture), Mode=OneWay}" />
|
||||||
<TextBlock
|
|
||||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
<StackPanel
|
||||||
Style="{StaticResource CaptionTextBlockStyle}"
|
Grid.Column="1"
|
||||||
Text="{x:Bind Address}"
|
VerticalAlignment="Center"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Spacing="2">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<TextBlock
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{x:Bind Name, Mode=OneTime, TargetNullValue=''}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Text="{x:Bind Address, Mode=OneTime}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
<Border
|
<Border
|
||||||
Padding="4,2"
|
x:Name="ModifiedBorder"
|
||||||
Background="{ThemeResource AccentFillColorDefaultBrush}"
|
Padding="6,2"
|
||||||
CornerRadius="2"
|
HorizontalAlignment="Left"
|
||||||
Visibility="{x:Bind IsRootContact}">
|
x:Load="{x:Bind IsOverridden, Mode=OneTime}"
|
||||||
<TextBlock
|
Background="{ThemeResource SubtleFillColorSecondaryBrush}"
|
||||||
FontSize="10"
|
CornerRadius="4">
|
||||||
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.ContactStatus_Modified, Mode=OneTime}" />
|
||||||
Text="{x:Bind domain:Translator.ContactStatus_Account, Mode=OneTime}" />
|
|
||||||
</Border>
|
|
||||||
<Border
|
|
||||||
Padding="4,2"
|
|
||||||
CornerRadius="2"
|
|
||||||
Visibility="{x:Bind IsOverridden}">
|
|
||||||
<TextBlock FontSize="10" Text="{x:Bind domain:Translator.ContactStatus_Modified, Mode=OneTime}" />
|
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<StackPanel
|
||||||
<StackPanel
|
Grid.Column="2"
|
||||||
Grid.Column="2"
|
VerticalAlignment="Center"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
Spacing="8">
|
Spacing="4">
|
||||||
<Button
|
<Button
|
||||||
Click="EditContact_Click"
|
Click="EditContact_Click"
|
||||||
CommandParameter="{x:Bind}"
|
CommandParameter="{x:Bind}"
|
||||||
Style="{StaticResource SubtleButtonStyle}"
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Edit, Mode=OneTime}">
|
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Edit, Mode=OneTime}">
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
Click="PickContactPhoto_Click"
|
Click="PickContactPhoto_Click"
|
||||||
CommandParameter="{x:Bind}"
|
CommandParameter="{x:Bind}"
|
||||||
Style="{StaticResource SubtleButtonStyle}"
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_ChangePhoto, Mode=OneTime}">
|
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_ChangePhoto, Mode=OneTime}">
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
Click="DeleteContact_Click"
|
Click="DeleteContact_Click"
|
||||||
CommandParameter="{x:Bind}"
|
CommandParameter="{x:Bind}"
|
||||||
IsEnabled="{x:Bind helpers:XamlHelpers.ReverseBoolConverter(IsRootContact)}"
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
Style="{StaticResource SubtleButtonStyle}"
|
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}">
|
||||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}">
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
</Button>
|
||||||
</Button>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</Page.Resources>
|
</Page.Resources>
|
||||||
|
|
||||||
<Grid MaxWidth="700">
|
<Grid
|
||||||
|
MaxWidth="980"
|
||||||
|
Padding="24,20,24,16"
|
||||||
|
RowSpacing="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
@@ -103,14 +104,13 @@
|
|||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Header -->
|
<Grid Grid.Row="0">
|
||||||
<Grid Grid.Row="0" Padding="24,16">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<StackPanel Grid.Column="0">
|
<StackPanel>
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="28"
|
FontSize="28"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
@@ -118,122 +118,175 @@
|
|||||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind domain:Translator.ContactsPage_Subtitle, Mode=OneTime}" />
|
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind domain:Translator.ContactsPage_Subtitle, Mode=OneTime}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel
|
<Button
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Orientation="Horizontal"
|
Command="{x:Bind ViewModel.AddContactCommand}"
|
||||||
Spacing="8">
|
Style="{StaticResource AccentButtonStyle}">
|
||||||
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Add, Mode=OneTime}" />
|
||||||
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Add, Mode=OneTime}" />
|
</StackPanel>
|
||||||
</StackPanel>
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button Command="{x:Bind ViewModel.ToggleSelectionCommand}" Style="{StaticResource DefaultButtonStyle}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
|
||||||
<TextBlock Text="{x:Bind helpers:XamlHelpers.BoolToSelectionModeText(ViewModel.IsSelectionMode), Mode=OneWay}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Search and Selection Bar -->
|
<Grid Grid.Row="1" ColumnSpacing="8">
|
||||||
<Grid
|
|
||||||
Grid.Row="2"
|
|
||||||
Padding="24,0,24,16"
|
|
||||||
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
<AutoSuggestBox
|
||||||
<TextBlock
|
PlaceholderText="{x:Bind domain:Translator.ContactsPage_SearchPlaceholder, Mode=OneTime}"
|
||||||
VerticalAlignment="Center"
|
QueryIcon="Find"
|
||||||
Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}"
|
Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay}" />
|
||||||
TextWrapping="Wrap">
|
|
||||||
<Run Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}" />
|
|
||||||
<Run Text="{x:Bind domain:Translator.ContactSelection_Selected, Mode=OneTime}" />
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel
|
<Button
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Orientation="Horizontal"
|
Command="{x:Bind ViewModel.ReloadContactsCommand}"
|
||||||
Spacing="8">
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
<Button Command="{x:Bind ViewModel.SelectAllContactsCommand}" Style="{StaticResource SubtleButtonStyle}">
|
ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Refresh, Mode=OneTime}">
|
||||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_SelectAll, Mode=OneTime}" />
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Command="{x:Bind ViewModel.ClearSelectionCommand}" Style="{StaticResource SubtleButtonStyle}">
|
|
||||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_Clear, Mode=OneTime}" />
|
<Button
|
||||||
</Button>
|
Grid.Column="2"
|
||||||
<Button
|
Command="{x:Bind ViewModel.ToggleSelectionCommand}"
|
||||||
Command="{x:Bind ViewModel.DeleteSelectedContactsCommand}"
|
Style="{StaticResource SubtleButtonStyle}">
|
||||||
IsEnabled="{x:Bind helpers:XamlHelpers.CountToBooleanConverter(ViewModel.SelectedContactsCount), Mode=OneWay}"
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
Style="{StaticResource SubtleButtonStyle}">
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<TextBlock Text="{x:Bind helpers:XamlHelpers.BoolToSelectionModeText(ViewModel.IsSelectionMode), Mode=OneWay}" />
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
</StackPanel>
|
||||||
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}" />
|
</Button>
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Search Box -->
|
<Grid
|
||||||
<AutoSuggestBox
|
x:Name="SelectionModeGrid"
|
||||||
Grid.Row="1"
|
Grid.Row="2"
|
||||||
Margin="24,0,24,16"
|
x:Load="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}"
|
||||||
PlaceholderText="{x:Bind domain:Translator.ContactsPage_SearchPlaceholder, Mode=OneTime}"
|
ColumnSpacing="8">
|
||||||
QueryIcon="Find"
|
<Grid.ColumnDefinitions>
|
||||||
Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay}"
|
<ColumnDefinition Width="*" />
|
||||||
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Content -->
|
<TextBlock VerticalAlignment="Center">
|
||||||
<Grid Grid.Row="3" Padding="24,0">
|
<Run Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}" />
|
||||||
<!-- Loading Indicator -->
|
<Run Text=" " />
|
||||||
<ProgressRing
|
<Run Text="{x:Bind domain:Translator.ContactSelection_Selected, Mode=OneTime}" />
|
||||||
Width="48"
|
</TextBlock>
|
||||||
Height="48"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
|
|
||||||
Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />
|
|
||||||
|
|
||||||
<!-- Contacts List -->
|
<Button
|
||||||
<ListView
|
Grid.Column="1"
|
||||||
ItemTemplate="{StaticResource ContactTemplate}"
|
Click="SelectAllContacts_Click"
|
||||||
ItemsSource="{x:Bind ViewModel.Contacts, Mode=OneWay}"
|
Style="{StaticResource SubtleButtonStyle}">
|
||||||
SelectionMode="{x:Bind helpers:XamlHelpers.BoolToSelectionMode(ViewModel.IsSelectionMode), Mode=OneWay}">
|
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_SelectAll, Mode=OneTime}" />
|
||||||
<ListView.ItemContainerTransitions>
|
</Button>
|
||||||
<TransitionCollection>
|
|
||||||
<AddDeleteThemeTransition />
|
<Button
|
||||||
<ContentThemeTransition />
|
Grid.Column="2"
|
||||||
<ReorderThemeTransition />
|
Click="ClearSelection_Click"
|
||||||
<EntranceThemeTransition IsStaggeringEnabled="True" />
|
Style="{StaticResource SubtleButtonStyle}">
|
||||||
</TransitionCollection>
|
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_Clear, Mode=OneTime}" />
|
||||||
</ListView.ItemContainerTransitions>
|
</Button>
|
||||||
</ListView>
|
|
||||||
|
<Button
|
||||||
|
Grid.Column="3"
|
||||||
|
Command="{x:Bind ViewModel.DeleteSelectedContactsCommand}"
|
||||||
|
Style="{StaticResource SubtleButtonStyle}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
|
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_DeleteSelectedContacts, Mode=OneTime}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3">
|
||||||
|
<Border
|
||||||
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="10">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
Grid.Row="0"
|
||||||
|
Padding="12,10"
|
||||||
|
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}">
|
||||||
|
<Run Text="{x:Bind ViewModel.TotalContactsCount, Mode=OneWay}" />
|
||||||
|
<Run Text=" " />
|
||||||
|
<Run Text="{x:Bind domain:Translator.ContactsPage_ContactsCountSuffix, Mode=OneTime}" />
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<listview:WinoListView
|
||||||
|
x:Name="ContactsListView"
|
||||||
|
Grid.Row="1"
|
||||||
|
Margin="6"
|
||||||
|
toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal"
|
||||||
|
IsMultiSelectCheckBoxEnabled="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}"
|
||||||
|
ItemTemplate="{StaticResource ContactTemplate}"
|
||||||
|
ItemsSource="{x:Bind ViewModel.Contacts, Mode=OneWay}"
|
||||||
|
LoadMoreCommand="{x:Bind ViewModel.LoadMoreContactsCommand}"
|
||||||
|
SelectionChanged="ContactsListView_SelectionChanged"
|
||||||
|
SelectionMode="{x:Bind helpers:XamlHelpers.BoolToSelectionMode(ViewModel.IsSelectionMode), Mode=OneWay}">
|
||||||
|
<listview:WinoListView.ItemContainerTransitions>
|
||||||
|
<TransitionCollection>
|
||||||
|
<AddDeleteThemeTransition />
|
||||||
|
<ContentThemeTransition />
|
||||||
|
<EntranceThemeTransition IsStaggeringEnabled="True" />
|
||||||
|
</TransitionCollection>
|
||||||
|
</listview:WinoListView.ItemContainerTransitions>
|
||||||
|
</listview:WinoListView>
|
||||||
|
|
||||||
|
<ProgressRing
|
||||||
|
Grid.Row="2"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="0,8,0,10"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsActive="{x:Bind ViewModel.IsLoadingMore, Mode=OneWay}"
|
||||||
|
Visibility="{x:Bind ViewModel.IsLoadingMore, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
x:Name="LoadingGrid"
|
||||||
|
x:Load="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
|
||||||
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
||||||
|
<ProgressRing
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsActive="True" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<StackPanel
|
<StackPanel
|
||||||
|
x:Name="IsEmptyPanel"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Spacing="16"
|
x:Load="{x:Bind ViewModel.IsEmpty, Mode=OneWay}"
|
||||||
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(ViewModel.SelectedContactsCount), Mode=OneWay}">
|
Spacing="10">
|
||||||
<FontIcon
|
<FontIcon
|
||||||
FontSize="48"
|
FontSize="40"
|
||||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
Glyph="" />
|
Glyph="" />
|
||||||
<TextBlock
|
<TextBlock
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
Text="{x:Bind domain:Translator.ContactsPage_EmptyState, Mode=OneTime}"
|
Text="{x:Bind domain:Translator.ContactsPage_NoContacts, Mode=OneTime}" />
|
||||||
TextAlignment="Center" />
|
|
||||||
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
|
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_AddFirstContact, Mode=OneTime}" />
|
||||||
<FontIcon FontSize="16" Glyph="" />
|
|
||||||
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_AddFirstContact, Mode=OneTime}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Mail.ViewModels;
|
||||||
using Wino.Views.Abstract;
|
using Wino.Views.Abstract;
|
||||||
|
|
||||||
namespace Wino.Views.Settings;
|
namespace Wino.Views.Settings;
|
||||||
@@ -9,6 +13,9 @@ public sealed partial class ContactsPage : ContactsPageAbstract
|
|||||||
public ContactsPage()
|
public ContactsPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
ViewModel.PropertyChanged += ViewModelPropertyChanged;
|
||||||
|
Unloaded += ContactsPageUnloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EditContact_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
private void EditContact_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
@@ -34,4 +41,73 @@ public sealed partial class ContactsPage : ContactsPageAbstract
|
|||||||
ViewModel.DeleteContactCommand.Execute(contact);
|
ViewModel.DeleteContactCommand.Execute(contact);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ContactsListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not ListView)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!ViewModel.IsSelectionMode)
|
||||||
|
{
|
||||||
|
ClearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var removedItem in e.RemovedItems.OfType<AccountContact>())
|
||||||
|
{
|
||||||
|
var selectedContact = ViewModel.SelectedContacts.FirstOrDefault(c =>
|
||||||
|
string.Equals(c.Address, removedItem.Address, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (selectedContact != null)
|
||||||
|
{
|
||||||
|
ViewModel.SelectedContacts.Remove(selectedContact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var addedItem in e.AddedItems.OfType<AccountContact>())
|
||||||
|
{
|
||||||
|
var alreadySelected = ViewModel.SelectedContacts.Any(c =>
|
||||||
|
string.Equals(c.Address, addedItem.Address, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!alreadySelected)
|
||||||
|
{
|
||||||
|
ViewModel.SelectedContacts.Add(addedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectAllContacts_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!ViewModel.IsSelectionMode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ContactsListView.SelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSelection_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(ContactsPageViewModel.IsSelectionMode) && !ViewModel.IsSelectionMode)
|
||||||
|
{
|
||||||
|
ClearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ContactsPageUnloaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ViewModel.PropertyChanged -= ViewModelPropertyChanged;
|
||||||
|
Unloaded -= ContactsPageUnloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSelection()
|
||||||
|
{
|
||||||
|
ContactsListView.SelectionChanged -= ContactsListView_SelectionChanged;
|
||||||
|
ContactsListView.SelectedItems.Clear();
|
||||||
|
ContactsListView.SelectionChanged += ContactsListView_SelectionChanged;
|
||||||
|
ViewModel.SelectedContacts.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -26,28 +26,25 @@
|
|||||||
<DataTemplate x:Key="CompactDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
<DataTemplate x:Key="CompactDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||||
<controls1:MailItemDisplayInformationControl
|
<controls1:MailItemDisplayInformationControl
|
||||||
DisplayMode="Compact"
|
DisplayMode="Compact"
|
||||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
|
||||||
ShowPreviewText="False"
|
ShowPreviewText="False"
|
||||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<DataTemplate x:Key="MediumDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
<DataTemplate x:Key="MediumDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||||
<controls1:MailItemDisplayInformationControl
|
<controls1:MailItemDisplayInformationControl
|
||||||
DisplayMode="Medium"
|
DisplayMode="Medium"
|
||||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
|
||||||
ShowPreviewText="True"
|
ShowPreviewText="True"
|
||||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<DataTemplate x:Key="SpaciousDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
<DataTemplate x:Key="SpaciousDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||||
<controls1:MailItemDisplayInformationControl
|
<controls1:MailItemDisplayInformationControl
|
||||||
DisplayMode="Spacious"
|
DisplayMode="Spacious"
|
||||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
|
||||||
ShowPreviewText="True"
|
ShowPreviewText="True"
|
||||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<mailSelectors:MailItemDisplayModePreviewTemplateSelector
|
<mailSelectors:MailItemDisplayModePreviewTemplateSelector
|
||||||
|
|||||||
+122
-17
@@ -6,6 +6,7 @@ using MimeKit;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Contacts;
|
||||||
using Wino.Services.Extensions;
|
using Wino.Services.Extensions;
|
||||||
|
|
||||||
namespace Wino.Services;
|
namespace Wino.Services;
|
||||||
@@ -38,32 +39,91 @@ public class ContactService : BaseDatabaseService, IContactService
|
|||||||
|
|
||||||
public async Task SaveAddressInformationAsync(MimeMessage message)
|
public async Task SaveAddressInformationAsync(MimeMessage message)
|
||||||
{
|
{
|
||||||
var recipients = message
|
if (message == null) return;
|
||||||
.GetRecipients(true)
|
|
||||||
.Where(a => !string.IsNullOrEmpty(a.Name) && !string.IsNullOrEmpty(a.Address));
|
|
||||||
|
|
||||||
var addressInformations = recipients.Select(a => new AccountContact() { Name = a.Name, Address = a.Address });
|
var contacts = message
|
||||||
|
.GetRecipients(true)
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a.Address))
|
||||||
|
.Select(a => new AccountContact
|
||||||
|
{
|
||||||
|
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address : a.Name,
|
||||||
|
Address = a.Address
|
||||||
|
});
|
||||||
|
|
||||||
foreach (var info in addressInformations)
|
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts)
|
||||||
|
{
|
||||||
|
if (contacts == null) return;
|
||||||
|
|
||||||
|
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> contacts)
|
||||||
|
{
|
||||||
|
var addressInformations = contacts
|
||||||
|
.Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address))
|
||||||
|
.Select(a => new AccountContact
|
||||||
|
{
|
||||||
|
Address = a.Address.Trim(),
|
||||||
|
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address.Trim() : a.Name.Trim()
|
||||||
|
})
|
||||||
|
.GroupBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (addressInformations.Count == 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var currentContact = await GetAddressInformationByAddressAsync(info.Address).ConfigureAwait(false);
|
// Batch-fetch all existing contacts in one query.
|
||||||
|
var addresses = addressInformations.Select(a => a.Address).ToList();
|
||||||
|
var placeholders = string.Join(",", addresses.Select((_, i) => "?"));
|
||||||
|
var existingContacts = await Connection.QueryAsync<AccountContact>(
|
||||||
|
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
|
||||||
|
addresses.Cast<object>().ToArray()
|
||||||
|
).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var toInsert = new List<AccountContact>();
|
||||||
|
var toUpdate = new List<AccountContact>();
|
||||||
|
|
||||||
|
foreach (var info in addressInformations)
|
||||||
{
|
{
|
||||||
if (currentContact == null)
|
if (!existingLookup.TryGetValue(info.Address, out var existing))
|
||||||
{
|
{
|
||||||
await Connection.InsertAsync(info, typeof(AccountContact)).ConfigureAwait(false);
|
toInsert.Add(info);
|
||||||
}
|
}
|
||||||
else if (!currentContact.IsRootContact && !currentContact.IsOverridden) // Don't update root contacts or overridden contacts.
|
else if (!existing.IsRootContact && !existing.IsOverridden)
|
||||||
{
|
{
|
||||||
await Connection.InsertOrReplaceAsync(info, typeof(AccountContact)).ConfigureAwait(false);
|
// Only update if the new name is more informative (not just the email address)
|
||||||
|
// and actually different from the current name.
|
||||||
|
if (info.Name != info.Address && existing.Name != info.Name)
|
||||||
|
{
|
||||||
|
existing.Name = info.Name;
|
||||||
|
toUpdate.Add(existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
if (toInsert.Count > 0 || toUpdate.Count > 0)
|
||||||
{
|
{
|
||||||
Log.Error("Failed to add contact information to the database.", ex);
|
await Connection.RunInTransactionAsync(conn =>
|
||||||
|
{
|
||||||
|
if (toInsert.Count > 0)
|
||||||
|
conn.InsertAll(toInsert, typeof(AccountContact));
|
||||||
|
|
||||||
|
foreach (var contact in toUpdate)
|
||||||
|
conn.Update(contact, typeof(AccountContact));
|
||||||
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to batch save contact information to the database.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<AccountContact>> GetAllContactsAsync()
|
public Task<List<AccountContact>> GetAllContactsAsync()
|
||||||
@@ -81,6 +141,47 @@ public class ContactService : BaseDatabaseService, IContactService
|
|||||||
return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
|
return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false)
|
||||||
|
{
|
||||||
|
offset = Math.Max(0, offset);
|
||||||
|
pageSize = Math.Max(1, pageSize);
|
||||||
|
|
||||||
|
var whereClauses = new List<string>();
|
||||||
|
var parameters = new List<object>();
|
||||||
|
|
||||||
|
if (excludeRootContacts)
|
||||||
|
{
|
||||||
|
whereClauses.Add("IsRootContact = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||||
|
{
|
||||||
|
var pattern = $"%{searchQuery.Trim()}%";
|
||||||
|
whereClauses.Add("(Address LIKE ? OR Name LIKE ?)");
|
||||||
|
parameters.Add(pattern);
|
||||||
|
parameters.Add(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
var whereSql = whereClauses.Count > 0
|
||||||
|
? $" WHERE {string.Join(" AND ", whereClauses)}"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var countQuery = $"SELECT COUNT(*) FROM AccountContact{whereSql}";
|
||||||
|
var totalCount = await Connection.ExecuteScalarAsync<int>(countQuery, parameters.ToArray()).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pageParameters = new List<object>(parameters)
|
||||||
|
{
|
||||||
|
pageSize,
|
||||||
|
offset
|
||||||
|
};
|
||||||
|
|
||||||
|
var pageQuery = $"SELECT * FROM AccountContact{whereSql} ORDER BY COALESCE(Name, Address) COLLATE NOCASE, Address COLLATE NOCASE LIMIT ? OFFSET ?";
|
||||||
|
var contacts = await Connection.QueryAsync<AccountContact>(pageQuery, pageParameters.ToArray()).ConfigureAwait(false);
|
||||||
|
var hasMore = offset + contacts.Count < totalCount;
|
||||||
|
|
||||||
|
return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AccountContact> UpdateContactAsync(AccountContact contact)
|
public async Task<AccountContact> UpdateContactAsync(AccountContact contact)
|
||||||
{
|
{
|
||||||
// Mark the contact as overridden when manually updated
|
// Mark the contact as overridden when manually updated
|
||||||
@@ -103,9 +204,13 @@ public class ContactService : BaseDatabaseService, IContactService
|
|||||||
|
|
||||||
public async Task DeleteContactsAsync(IEnumerable<string> addresses)
|
public async Task DeleteContactsAsync(IEnumerable<string> addresses)
|
||||||
{
|
{
|
||||||
foreach (var address in addresses)
|
var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList();
|
||||||
{
|
if (addressList.Count == 0) return;
|
||||||
await DeleteContactAsync(address).ConfigureAwait(false);
|
|
||||||
}
|
var placeholders = string.Join(",", addressList.Select((_, i) => "?"));
|
||||||
|
await Connection.ExecuteAsync(
|
||||||
|
$"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0",
|
||||||
|
addressList.Cast<object>().ToArray()
|
||||||
|
).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -715,11 +715,12 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
|
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
|
||||||
mailCopy.FolderId = mailItemFolder.Id;
|
mailCopy.FolderId = mailItemFolder.Id;
|
||||||
|
|
||||||
|
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
|
||||||
|
|
||||||
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
|
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
|
||||||
var contactSaveTask = _contactService.SaveAddressInformationAsync(mimeMessage);
|
|
||||||
var insertMailTask = InsertMailAsync(mailCopy);
|
var insertMailTask = InsertMailAsync(mailCopy);
|
||||||
|
|
||||||
await Task.WhenAll(mimeSaveTask, contactSaveTask, insertMailTask).ConfigureAwait(false);
|
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMailAsyncEx(Guid accountId, NewMailItemPackage package)
|
public async Task CreateMailAsyncEx(Guid accountId, NewMailItemPackage package)
|
||||||
@@ -780,10 +781,11 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save contact information.
|
|
||||||
await _contactService.SaveAddressInformationAsync(mimeMessage).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save contact information extracted from provider API or MIME before insert/update.
|
||||||
|
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
|
||||||
|
|
||||||
// Create mail copy in the database.
|
// Create mail copy in the database.
|
||||||
// Update if exists.
|
// Update if exists.
|
||||||
|
|
||||||
@@ -814,6 +816,35 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SaveContactsForPackageAsync(NewMailItemPackage package)
|
||||||
|
{
|
||||||
|
if (package == null) return;
|
||||||
|
|
||||||
|
if (package.Mime != null)
|
||||||
|
{
|
||||||
|
await _contactService.SaveAddressInformationAsync(package.Mime).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contacts = package.ExtractedContacts?
|
||||||
|
.Where(c => c != null && !string.IsNullOrWhiteSpace(c.Address))
|
||||||
|
.ToList() ?? new List<AccountContact>();
|
||||||
|
|
||||||
|
var senderAddress = package.Copy?.FromAddress;
|
||||||
|
if (!string.IsNullOrWhiteSpace(senderAddress))
|
||||||
|
{
|
||||||
|
contacts.Add(new AccountContact
|
||||||
|
{
|
||||||
|
Address = senderAddress,
|
||||||
|
Name = string.IsNullOrWhiteSpace(package.Copy?.FromName) ? senderAddress : package.Copy.FromName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts.Count == 0) return;
|
||||||
|
|
||||||
|
await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
|
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
|
||||||
{
|
{
|
||||||
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
|
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
|
||||||
|
|||||||
Reference in New Issue
Block a user