diff --git a/CLAUDE.md b/CLAUDE.md index d298efea..f8986df3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,3 +129,4 @@ private string searchQuery = string.Empty; - String interpolation over string.Format - Wrap async operations in try-catch - Log errors via IWinoLogger +- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`). diff --git a/Wino.Core.Domain/Interfaces/IContactService.cs b/Wino.Core.Domain/Interfaces/IContactService.cs index a10d5a38..f899d7d0 100644 --- a/Wino.Core.Domain/Interfaces/IContactService.cs +++ b/Wino.Core.Domain/Interfaces/IContactService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using MimeKit; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Models.Contacts; namespace Wino.Core.Domain.Interfaces; @@ -11,11 +12,13 @@ public interface IContactService Task> GetAddressInformationAsync(string queryText); Task GetAddressInformationByAddressAsync(string address); Task SaveAddressInformationAsync(MimeMessage message); + Task SaveAddressInformationAsync(IEnumerable contacts); Task CreateNewContactAsync(string address, string displayName); // New methods for ContactsPage Task> GetAllContactsAsync(); Task> SearchContactsAsync(string searchQuery); + Task GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false); Task UpdateContactAsync(AccountContact contact); Task DeleteContactAsync(string address); Task DeleteContactsAsync(IEnumerable addresses); diff --git a/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs new file mode 100644 index 00000000..38798dea --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel; +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Core.Domain.Interfaces; + +/// +/// Shared display contract for mail list item rendering. +/// Implemented by both single mail and thread mail view models. +/// +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; } +} diff --git a/Wino.Core.Domain/Models/Contacts/PagedContactsResult.cs b/Wino.Core.Domain/Models/Contacts/PagedContactsResult.cs new file mode 100644 index 00000000..20f6530a --- /dev/null +++ b/Wino.Core.Domain/Models/Contacts/PagedContactsResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Core.Domain.Models.Contacts; + +public record PagedContactsResult( + IReadOnlyList Contacts, + int TotalCount, + bool HasMore, + int Offset, + int PageSize); diff --git a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs index f1eec88c..c57b1589 100644 --- a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs +++ b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs @@ -1,6 +1,12 @@ -using MimeKit; +using System.Collections.Generic; +using MimeKit; using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; 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 ExtractedContacts = null); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index b93d0b56..5d553eba 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -137,13 +137,6 @@ "CalendarShowAs_Busy": "Busy", "CalendarShowAs_OutOfOffice": "Out of Office", "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_ViewEventButton": "View event", "CalendarItem_DetailsPopup_ViewSeriesButton": "View series", @@ -153,6 +146,9 @@ "ClipboardTextCopied_Message": "{0} copied to clipboard.", "ClipboardTextCopied_Title": "Copied", "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...", "ComposerAttachmentsDragDropAttach_Message": "Attach", "ComposerAttachmentsDropZone_Message": "Drop your files here", @@ -833,6 +829,7 @@ "Smime_CertificatePassword_Placeholder": "Certificate password for {0} (optional)", "Smime_Confirm_Title": "Confirm", "Buttons_OK": "OK", + "Buttons_Refresh": "Refresh", "SettingsSignatureAndEncryption_Title": "Signature and Encryption", "SettingsSignatureAndEncryption_Description": "Manage S/MIME certificates for signing and encrypting emails.", "SettingsSignatureAndEncryption_MyCertificatesHeader": "My certificates", @@ -909,6 +906,22 @@ "ContactSelection_Clear": "Clear Selection", "ContactsPage_EmptyState": "No contacts to display", "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_Description": "Manage calendar settings for {0}", "CalendarAccountSettings_AccountColor": "Account Color", diff --git a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs index e136bf4d..f9620780 100644 --- a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs +++ b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; @@ -28,10 +31,13 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel public MailCopy DemoPreviewMailCopy { get; } = new MailCopy() { FromName = "Sender Name", + FromAddress = "sender@wino.mail", Subject = "Mail Subject", PreviewText = "Thank you for using Wino Mail. We hope you enjoy the experience.", }; + public IMailItemDisplayInformation DemoPreviewMailItemInformation { get; } + #region Personalization public bool IsSelectedWindowsAccentColor => SelectedAppColor == Colors.LastOrDefault(); @@ -147,6 +153,12 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel StatePersistenceService = statePersistanceService; PreferencesService = preferencesService; + DemoPreviewMailItemInformation = new DemoMailItemDisplayInformation( + DemoPreviewMailCopy.FromName, + DemoPreviewMailCopy.FromAddress, + DemoPreviewMailCopy.Subject, + DemoPreviewMailCopy.PreviewText); + CreateCustomThemeCommand = new AsyncRelayCommand(CreateCustomThemeAsync); } @@ -312,4 +324,31 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel _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 { } + } + } } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 795edbd4..7afdfc14 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -289,6 +289,7 @@ public class GmailSynchronizer : WinoSynchronizer f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID) + .OrderByDescending(f => f.SpecialFolderType == SpecialFolderType.Draft || f.RemoteFolderId == ServiceConstants.DRAFT_LABEL_ID) .ToList(); var totalFolders = syncableFolders.Count; @@ -328,8 +329,9 @@ public class GmailSynchronizer : WinoSynchronizer 0) { - // Download metadata in batches (no raw MIME during initial sync) - await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false); + // Draft folder needs MIME during initial sync so compose can open immediately. + 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) { @@ -1848,6 +1850,88 @@ public class GmailSynchronizer : WinoSynchronizer ExtractContactsFromGmailMessage(Message message, MimeMessage mimeMessage) + { + var contacts = new Dictionary(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 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 + }; + } + } + /// /// Creates new mail packages for the given message. /// AssignedFolder is null since the LabelId is parsed out of the Message. @@ -1887,6 +1971,8 @@ public class GmailSynchronizer : WinoSynchronizer ExtractContactsFromMimeMessage(MimeMessage mimeMessage) + { + if (mimeMessage == null) return []; + + var contacts = new Dictionary(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 SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index a2edd706..c06502e8 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -90,6 +90,11 @@ public class OutlookSynchronizer : WinoSynchronizer 0 && totalProcessed % 50 == 0) + { + var statusMessage = string.Format(Translator.Sync_DownloadedMessages, totalProcessed, folder.FolderName); + UpdateSyncProgress(0, 0, statusMessage); + } } else { @@ -551,7 +578,8 @@ public class OutlookSynchronizer : WinoSynchronizer ExtractContactsFromOutlookMessage(Message message) + { + if (message == null) return []; + + var contacts = new Dictionary(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) => Regex.Split(deltaLink, "deltatoken=")[1]; @@ -1790,7 +1876,8 @@ public class OutlookSynchronizer : WinoSynchronizer _allContacts = new(); + private CancellationTokenSource _searchDebounceCancellationTokenSource; + private int _currentOffset = 0; + private int _currentQueryVersion = 0; [ObservableProperty] public partial string SearchQuery { get; set; } = string.Empty; [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadMoreContactsCommand))] + [NotifyPropertyChangedFor(nameof(IsEmpty))] 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] public partial bool IsSelectionMode { get; set; } = false; [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(DeleteSelectedContactsCommand))] 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 Contacts { get; } = new(); public ObservableCollection SelectedContacts { get; } = new(); @@ -39,7 +64,7 @@ public partial class ContactsPageViewModel : MailBaseViewModel _contactService = contactService; _dialogService = dialogService; - + Contacts.CollectionChanged += ContactsCollectionChanged; } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -49,7 +74,7 @@ public partial class ContactsPageViewModel : MailBaseViewModel SelectedContacts.CollectionChanged -= SelectedContactsChanged; SelectedContacts.CollectionChanged += SelectedContactsChanged; - await LoadContactsAsync(); + await ReloadContactsAsync(); } public override void OnNavigatedFrom(NavigationMode mode, object parameters) @@ -57,58 +82,105 @@ public partial class ContactsPageViewModel : MailBaseViewModel base.OnNavigatedFrom(mode, parameters); SelectedContacts.CollectionChanged -= SelectedContactsChanged; + + _searchDebounceCancellationTokenSource?.Cancel(); + _searchDebounceCancellationTokenSource?.Dispose(); + _searchDebounceCancellationTokenSource = null; } - private void SelectedContactsChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - => SelectedContactsCount = SelectedContacts.Count; + private async void SelectedContactsChanged(object sender, NotifyCollectionChangedEventArgs e) + => await ExecuteUIThread(() => { SelectedContactsCount = SelectedContacts.Count; }); + + private async void ContactsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + => await ExecuteUIThread(() => { OnPropertyChanged(nameof(IsEmpty)); }); [RelayCommand] - private async Task LoadContactsAsync() + private async Task ReloadContactsAsync() { - IsLoading = true; - - 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 filteredContacts; - - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - filteredContacts = _allContacts; - } - else - { - filteredContacts = await _contactService.SearchContactsAsync(SearchQuery); - } + var queryVersion = ++_currentQueryVersion; + _currentOffset = 0; await ExecuteUIThread(() => { + HasMoreContacts = false; Contacts.Clear(); - foreach (var contact in filteredContacts.OrderBy(c => c.Name ?? c.Address)) - { - Contacts.Add(contact); - } + SelectedContacts.Clear(); }); + + 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] @@ -116,27 +188,31 @@ public partial class ContactsPageViewModel : MailBaseViewModel { 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); - - 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); + newContact.Base64ContactPicture = result.Base64ContactPicture; + await _contactService.UpdateContactAsync(newContact); } + + 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); - if (result != null) + if (result == null) return; + + try { - try - { - // Update the contact properties - contact.Name = result.Name; - contact.Base64ContactPicture = result.Base64ContactPicture; - 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 - var index = _allContacts.FindIndex(c => c.Address == contact.Address); - if (index >= 0) - { - _allContacts[index] = contact; - } - - await FilterContactsAsync(); - - _dialogService.InfoBarMessage("Success", "Contact updated successfully", InfoBarMessageType.Success); - } - catch (Exception ex) - { - _dialogService.InfoBarMessage("Error", $"Failed to update contact: {ex.Message}", InfoBarMessageType.Error); - } + _dialogService.InfoBarMessage( + Translator.ContactInfoBar_SuccessTitle, + Translator.ContactInfoBar_ContactUpdated, + InfoBarMessageType.Success); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage( + Translator.ContactInfoBar_ErrorTitle, + string.Format(Translator.ContactInfoBar_FailedToUpdateContact, ex.Message), + InfoBarMessageType.Error); } } @@ -181,40 +253,50 @@ public partial class ContactsPageViewModel : MailBaseViewModel { 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; } - var result = await _dialogService.ShowConfirmationDialogAsync( - $"Are you sure you want to delete the contact '{contact.Name ?? contact.Address}'?", - "Delete Contact", - "Delete"); + var confirmed = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.ContactConfirmDialog_DeleteMessage, contact.Name ?? contact.Address), + Translator.ContactConfirmDialog_DeleteTitle, + Translator.ContactConfirmDialog_DeleteButton); - if (result) + if (confirmed) { await DeleteContactsInternalAsync(new[] { contact }); } } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanDeleteSelectedContacts))] private async Task DeleteSelectedContactsAsync() { 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) { - _dialogService.InfoBarMessage("Cannot Delete", "Root contacts cannot be deleted", InfoBarMessageType.Warning); + _dialogService.InfoBarMessage( + Translator.ContactInfoBar_WarningTitle, + Translator.ContactInfoBar_CannotDeleteRoot, + InfoBarMessageType.Warning); return; } - var result = await _dialogService.ShowConfirmationDialogAsync( - $"Are you sure you want to delete {deletableContacts.Count} contact(s)?", - "Delete Contacts", - "Delete"); + var confirmed = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.ContactConfirmDialog_DeleteMultipleMessage, deletableContacts.Count), + Translator.ContactConfirmDialog_DeleteTitle, + Translator.ContactConfirmDialog_DeleteButton); - if (result) + if (confirmed) { await DeleteContactsInternalAsync(deletableContacts); } @@ -224,51 +306,63 @@ public partial class ContactsPageViewModel : MailBaseViewModel { 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 ReloadContactsAsync(); - // Update local collections - foreach (var contact in contactsToDelete.ToList()) - { - _allContacts.Remove(contact); - SelectedContacts.Remove(contact); - } - - await FilterContactsAsync(); - - _dialogService.InfoBarMessage("Success", "Contacts deleted successfully", InfoBarMessageType.Success); + _dialogService.InfoBarMessage( + Translator.ContactInfoBar_SuccessTitle, + Translator.ContactInfoBar_ContactsDeleted, + InfoBarMessageType.Success); } 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] - 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(); - } + + foreach (var contact in Contacts) + { + SelectedContacts.Add(contact); + } + }); } [RelayCommand] - private void SelectAllContacts() + private async Task ClearSelection() { - SelectedContacts.Clear(); - foreach (var contact in Contacts) - { - SelectedContacts.Add(contact); - } - } - - [RelayCommand] - private void ClearSelection() - { - SelectedContacts.Clear(); + await ExecuteUIThread(() => { SelectedContacts.Clear(); }); } [RelayCommand] @@ -287,20 +381,77 @@ public partial class ContactsPageViewModel : MailBaseViewModel contact.Base64ContactPicture = base64Image; await _contactService.UpdateContactAsync(contact); + await RefreshContactInUiAsync(contact); - await FilterContactsAsync(); - _dialogService.InfoBarMessage("Success", "Contact photo updated successfully", InfoBarMessageType.Success); + _dialogService.InfoBarMessage( + Translator.ContactInfoBar_SuccessTitle, + Translator.ContactInfoBar_ContactPhotoUpdated, + InfoBarMessageType.Success); } } 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 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) { - // Debounce search - implement if needed - SearchContactsCommand.ExecuteAsync(null); + DebounceSearchAndReload(); + } + + 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. + } } } diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 851d6f79..1c4ec5b5 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -2,14 +2,16 @@ using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; namespace Wino.Mail.ViewModels.Data; /// /// Single view model for IMailItem representation. /// -public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem +public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem, IMailItemDisplayInformation { [ObservableProperty] [NotifyPropertyChangedFor(nameof(CreationDate))] @@ -33,6 +35,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, [NotifyPropertyChangedFor(nameof(FolderId))] [NotifyPropertyChangedFor(nameof(UniqueId))] [NotifyPropertyChangedFor(nameof(Base64ContactPicture))] + [NotifyPropertyChangedFor(nameof(SenderContact))] public partial MailCopy MailCopy { get; set; } = mailCopy; [ObservableProperty] @@ -58,6 +61,10 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, [ObservableProperty] public partial bool IsBusy { get; set; } + public bool IsThreadExpanded => false; + + public AccountContact SenderContact => MailCopy.SenderContact; + public DateTime CreationDate { get => MailCopy.CreationDate; @@ -260,6 +267,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, OnPropertyChanged(nameof(FolderId)); OnPropertyChanged(nameof(UniqueId)); OnPropertyChanged(nameof(Base64ContactPicture)); + OnPropertyChanged(nameof(SenderContact)); OnPropertyChanged(nameof(SortingDate)); OnPropertyChanged(nameof(SortingName)); } diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index e8f9c5f6..8bbc1930 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -4,14 +4,16 @@ using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; namespace Wino.Mail.ViewModels.Data; /// /// Thread mail item (multiple IMailItem) view model representation. /// -public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem +public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListItem, IMailItemDisplayInformation { private readonly string _threadId; private readonly HashSet _uniqueIdSet = []; @@ -150,6 +152,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte public bool ThumbnailUpdatedEvent => latestMailViewModel?.ThumbnailUpdatedEvent ?? false; + public AccountContact SenderContact => latestMailViewModel?.MailCopy?.SenderContact; + /// /// Gets all emails in this thread (observable) /// @@ -177,6 +181,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(FolderId))] [NotifyPropertyChangedFor(nameof(UniqueId))] [NotifyPropertyChangedFor(nameof(Base64ContactPicture))] + [NotifyPropertyChangedFor(nameof(SenderContact))] public partial ObservableCollection ThreadEmails { get; set; } = []; private MailItemViewModel latestMailViewModel => _cachedLatestMailViewModel; @@ -260,6 +265,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte OnPropertyChanged(nameof(FolderId)); OnPropertyChanged(nameof(UniqueId)); OnPropertyChanged(nameof(Base64ContactPicture)); + OnPropertyChanged(nameof(SenderContact)); OnPropertyChanged(nameof(ThumbnailUpdatedEvent)); OnPropertyChanged(nameof(SortingDate)); OnPropertyChanged(nameof(SortingName)); diff --git a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs index e9b82571..c0f7ecb8 100644 --- a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs +++ b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs @@ -1,241 +1,402 @@ using System; -using System.Diagnostics; using System.IO; -using System.Text.RegularExpressions; +using System.Net.Mail; using System.Threading; using System.Threading.Tasks; -using Fernandezja.ColorHashSharp; +using CommunityToolkit.WinUI; +using EmailValidation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; -using Microsoft.UI.Xaml.Shapes; -using Windows.UI; +using Wino.Core.Domain.Interfaces; using Wino.Mail.WinUI; namespace Wino.Controls; -public partial class ImagePreviewControl : Control +/// +/// Contact avatar control built on top of PersonPicture. +/// Priority: +/// 1) AccountContact/Base64 picture +/// 2) Gravatar thumbnail (if enabled) +/// 3) Initials from display name fallback +/// +public sealed partial class ImagePreviewControl : PersonPicture { - private const string PART_EllipseInitialsGrid = "EllipseInitialsGrid"; - 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"; + private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture); - #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)); - public static readonly DependencyProperty FromAddressProperty = DependencyProperty.Register(nameof(FromAddress), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, OnInformationChanged)); - 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))); + [GeneratedDependencyProperty] + public partial IMailItemDisplayInformation? MailItemInformation { get; set; } - public bool ThumbnailUpdatedEvent - { - get { return (bool)GetValue(ThumbnailUpdatedEventProperty); } - set { SetValue(ThumbnailUpdatedEventProperty, value); } - } + [GeneratedDependencyProperty] + public partial string? FromName { get; set; } - /// - /// Gets or sets base64 string of the sender contact picture. - /// - public string SenderContactPicture - { - get { return (string)GetValue(SenderContactPictureProperty); } - set { SetValue(SenderContactPictureProperty, value); } - } + [GeneratedDependencyProperty] + public partial string? FromAddress { get; set; } - public string FromName - { - get { return (string)GetValue(FromNameProperty); } - set { SetValue(FromNameProperty, value); } - } + [GeneratedDependencyProperty] + public partial string? SenderContactPicture { get; set; } - public string FromAddress - { - get { return (string)GetValue(FromAddressProperty); } - set { SetValue(FromAddressProperty, value); } - } + [GeneratedDependencyProperty(DefaultValue = false)] + public partial bool ThumbnailUpdatedEvent { get; set; } - #endregion - - private Ellipse Ellipse = null!; - private Grid InitialsGrid = null!; - private TextBlock InitialsTextblock = null!; - private Image KnownHostImage = null!; - private Border FaviconSquircle = null!; - private Image FaviconImage = null!; - private CancellationTokenSource contactPictureLoadingCancellationTokenSource = null!; + private readonly IThumbnailService? _thumbnailService; + private readonly IPreferencesService? _preferencesService; + private CancellationTokenSource? _refreshCancellationTokenSource; + private CancellationTokenSource? _scheduledRefreshCancellationTokenSource; + private long _refreshVersion; public ImagePreviewControl() { - DefaultStyleKey = nameof(ImagePreviewControl); + DefaultStyleKey = typeof(PersonPicture); + + try + { + _thumbnailService = App.Current.Services.GetService(); + _preferencesService = App.Current.Services.GetService(); + } + 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(); - - 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(); + RequestRefresh(); } - 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) - control.UpdateInformation(); + RequestRefresh(); } - 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; - // Cancel active image loading if exists. - if (!contactPictureLoadingCancellationTokenSource?.IsCancellationRequested ?? false) - { - contactPictureLoadingCancellationTokenSource!.Cancel(); - } + CancelScheduledRefresh(); - string contactPicture = SenderContactPicture; + var cts = new CancellationTokenSource(); + _scheduledRefreshCancellationTokenSource = cts; - var isAvatarThumbnail = false; - - 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); - } + _ = DebounceAndRefreshAsync(cts.Token); } - private static async Task GetContactImageBrushAsync(string base64) - { - // 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 GetBitmapImageAsync(string base64) + private async Task DebounceAndRefreshAsync(CancellationToken cancellationToken) { try { - var bitmapImage = new BitmapImage(); - var imageArray = Convert.FromBase64String(base64); - var imageStream = new MemoryStream(imageArray); - var randomAccessImageStream = imageStream.AsRandomAccessStream(); - randomAccessImageStream.Seek(0); - await bitmapImage.SetSourceAsync(randomAccessImageStream); - return bitmapImage; + await Task.Delay(RefreshDebounceDuration, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; } - 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. - if (string.IsNullOrEmpty(name)) + CancelActiveRefresh(); + + 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) - string initials = Regex.Replace(name, @"[\p{P}\p{S}\p{C}\p{N}]+", ""); + cts?.Dispose(); + } - // Replacing all possible whitespace/separator characters (unicode style), with a single, regular ascii space. - initials = Regex.Replace(initials, @"\p{Z}+", " "); + private void CancelActiveRefresh() + { + var cts = _refreshCancellationTokenSource; + _refreshCancellationTokenSource = null; - // Remove all Sr, Jr, I, II, III, IV, V, VI, VII, VIII, IX at the end of names - 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) + if (cts != null && !cts.IsCancellationRequested) { - // Worst case scenario, everything failed, just grab the first two letters of what we have left. - initials = initials.Substring(0, 2); + cts.Cancel(); } - 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 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(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 ExecuteOnUiThreadAsync(Func func) + { + if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess) + { + return func(); + } + + var completion = new TaskCompletionSource(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 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; + } } } diff --git a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml index 784b4cb6..8f6c788d 100644 --- a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml +++ b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml @@ -49,7 +49,7 @@ VerticalAlignment="Top" Canvas.ZIndex="0" Fill="{ThemeResource SystemAccentColor}" - Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(IsRead), Mode=OneWay}" /> + Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(MailItemInformation.IsRead), Mode=OneWay}" /> @@ -73,10 +73,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="14" - FromAddress="{x:Bind FromAddress, Mode=OneWay}" - FromName="{x:Bind FromName, Mode=OneWay}" - SenderContactPicture="{x:Bind Base64ContactPicture, Mode=OneWay}" - ThumbnailUpdatedEvent="{x:Bind IsThumbnailUpdated, Mode=OneWay}" + MailItemInformation="{x:Bind MailItemInformation, Mode=OneWay}" Visibility="{x:Bind IsAvatarVisible, Mode=OneWay}" /> @@ -114,17 +111,17 @@ + Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(MailItemInformation.FromName), Mode=OneWay}" /> + Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityReversedConverter(MailItemInformation.FromName), Mode=OneWay}" /> + Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(MailItemInformation.IsDraft, MailItemInformation.CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" /> @@ -220,10 +217,10 @@ @@ -237,12 +234,12 @@ + IsActive="{x:Bind MailItemInformation.IsBusy, Mode=OneWay}" /> @@ -263,7 +260,7 @@ - + @@ -335,7 +332,7 @@ - + diff --git a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs index 23a35dca..97ba5cbc 100644 --- a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs +++ b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs @@ -51,9 +51,6 @@ public sealed partial class MailItemDisplayInformationControl : UserControl [GeneratedDependencyProperty(DefaultValue = true)] public partial bool IsHoverActionsEnabled { get; set; } - [GeneratedDependencyProperty(DefaultValue = false)] - public partial bool IsBusy { get; set; } - public event EventHandler? HoverActionExecuted; [GeneratedDependencyProperty(DefaultValue = false)] @@ -62,49 +59,12 @@ public sealed partial class MailItemDisplayInformationControl : UserControl [GeneratedDependencyProperty] public partial IMailListItem? ActionItem { get; set; } - #region Display Properties - [GeneratedDependencyProperty] - public partial string? Subject { 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; } + public partial IMailItemDisplayInformation? MailItemInformation { get; set; } [GeneratedDependencyProperty(DefaultValue = false)] 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() { InitializeComponent(); @@ -138,16 +98,14 @@ public sealed partial class MailItemDisplayInformationControl : UserControl _compositor = this.Visual().Compositor; } - partial void OnIsBusyChanged(bool newValue) + partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e) { - if (newValue) + if (ActionItem == null && MailItemInformation is IMailListItem mailListItem) { - StartBusyAnimation(); - } - else - { - StopBusyAnimation(); + ActionItem = mailListItem; } + + UpdateBusyAnimationState(); } private void StartBusyAnimation() @@ -184,9 +142,15 @@ public sealed partial class MailItemDisplayInformationControl : UserControl _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) diff --git a/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml b/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml index ebd9e508..346eb1b1 100644 --- a/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml @@ -5,14 +5,13 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:domain="using:Wino.Core.Domain" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - Title="Edit Contact" HorizontalContentAlignment="Stretch" DefaultButton="Primary" IsPrimaryButtonEnabled="True" PrimaryButtonClick="SaveClicked" - PrimaryButtonText="Save" + PrimaryButtonText="{x:Bind domain:Translator.Buttons_Save, Mode=OneTime}" SecondaryButtonClick="CancelClicked" - SecondaryButtonText="Cancel" + SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel, Mode=OneTime}" Style="{StaticResource WinoDialogStyle}" mc:Ignorable="d"> @@ -24,15 +23,15 @@ @@ -40,7 +39,7 @@ + Text="{x:Bind domain:Translator.ContactEditDialog_PhotoSection, Mode=OneTime}" /> @@ -61,11 +60,12 @@ - - - + + + + + + - + @@ -103,14 +104,13 @@ - - + - + - - - - + Command="{x:Bind ViewModel.AddContactCommand}" + Style="{StaticResource AccentButtonStyle}"> + + + + + - - + + - - - - - - + - - - - - + Command="{x:Bind ViewModel.ReloadContactsCommand}" + Style="{StaticResource SubtleButtonStyle}" + ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Refresh, Mode=OneTime}"> + + + + - - + + + + + + + - - - - + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + x:Load="{x:Bind ViewModel.IsEmpty, Mode=OneWay}" + Spacing="10"> + Text="{x:Bind domain:Translator.ContactsPage_NoContacts, Mode=OneTime}" /> diff --git a/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs b/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs index d2040ab8..58f1a75d 100644 --- a/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Settings/ContactsPage.xaml.cs @@ -1,5 +1,9 @@ +using System; +using System.ComponentModel; +using System.Linq; using Microsoft.UI.Xaml.Controls; using Wino.Core.Domain.Entities.Shared; +using Wino.Mail.ViewModels; using Wino.Views.Abstract; namespace Wino.Views.Settings; @@ -9,6 +13,9 @@ public sealed partial class ContactsPage : ContactsPageAbstract public ContactsPage() { InitializeComponent(); + + ViewModel.PropertyChanged += ViewModelPropertyChanged; + Unloaded += ContactsPageUnloaded; } 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); } } -} \ No newline at end of file + + 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()) + { + 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()) + { + 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(); + } +} diff --git a/Wino.Mail.WinUI/Views/Settings/PersonalizationPage.xaml b/Wino.Mail.WinUI/Views/Settings/PersonalizationPage.xaml index 1a6a7423..a0ac121a 100644 --- a/Wino.Mail.WinUI/Views/Settings/PersonalizationPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/PersonalizationPage.xaml @@ -26,28 +26,25 @@ + /> + /> + /> !string.IsNullOrEmpty(a.Name) && !string.IsNullOrEmpty(a.Address)); + if (message == null) return; - 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 contacts) + { + if (contacts == null) return; + + await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); + } + + private async Task SaveAddressInformationInternalAsync(IEnumerable 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( + $"SELECT * FROM AccountContact WHERE Address IN ({placeholders})", + addresses.Cast().ToArray() + ).ConfigureAwait(false); - try + var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase); + + var toInsert = new List(); + var toUpdate = new List(); + + 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> GetAllContactsAsync() @@ -81,20 +141,61 @@ public class ContactService : BaseDatabaseService, IContactService return Connection.QueryAsync(query, pattern, pattern); } + public async Task 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(); + var parameters = new List(); + + 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(countQuery, parameters.ToArray()).ConfigureAwait(false); + + var pageParameters = new List(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(pageQuery, pageParameters.ToArray()).ConfigureAwait(false); + var hasMore = offset + contacts.Count < totalCount; + + return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize); + } + public async Task UpdateContactAsync(AccountContact contact) { // Mark the contact as overridden when manually updated contact.IsOverridden = true; - + await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); - + return contact; } public async Task DeleteContactAsync(string address) { var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false); - + if (contact != null && !contact.IsRootContact) { await Connection.DeleteAsync(contact.Address).ConfigureAwait(false); @@ -103,9 +204,13 @@ public class ContactService : BaseDatabaseService, IContactService public async Task DeleteContactsAsync(IEnumerable addresses) { - foreach (var address in addresses) - { - await DeleteContactAsync(address).ConfigureAwait(false); - } + var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList(); + if (addressList.Count == 0) return; + + var placeholders = string.Join(",", addressList.Select((_, i) => "?")); + await Connection.ExecuteAsync( + $"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0", + addressList.Cast().ToArray() + ).ConfigureAwait(false); } } diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index 7483c5ba..7e322a62 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -715,11 +715,12 @@ public class MailService : BaseDatabaseService, IMailService mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false); mailCopy.FolderId = mailItemFolder.Id; + await SaveContactsForPackageAsync(package).ConfigureAwait(false); + var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id); - var contactSaveTask = _contactService.SaveAddressInformationAsync(mimeMessage); 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) @@ -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. // 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(); + + 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 CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions) { // This unique id is stored in mime headers for Wino to identify remote message with local copy.