diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b347682..f97ada3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/Wino.Core.Domain/Entities/Shared/Thumbnail.cs b/Wino.Core.Domain/Entities/Shared/Thumbnail.cs new file mode 100644 index 00000000..3fd68c0f --- /dev/null +++ b/Wino.Core.Domain/Entities/Shared/Thumbnail.cs @@ -0,0 +1,14 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Shared; + +public class Thumbnail +{ + [PrimaryKey] + public string Domain { get; set; } + + public string Gravatar { get; set; } + public string Favicon { get; set; } + public DateTime LastUpdated { get; set; } +} diff --git a/Wino.Core.Domain/Interfaces/IPreferencesService.cs b/Wino.Core.Domain/Interfaces/IPreferencesService.cs index 11575f8f..345eccb1 100644 --- a/Wino.Core.Domain/Interfaces/IPreferencesService.cs +++ b/Wino.Core.Domain/Interfaces/IPreferencesService.cs @@ -1,11 +1,12 @@ using System; +using System.ComponentModel; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Reader; namespace Wino.Core.Domain.Interfaces; -public interface IPreferencesService +public interface IPreferencesService: INotifyPropertyChanged { /// /// When any of the preferences are changed. @@ -193,6 +194,16 @@ public interface IPreferencesService /// bool IsShowActionLabelsEnabled { get; set; } + /// + /// Setting: Enable/disable Gravatar for sender avatars. + /// + bool IsGravatarEnabled { get; set; } + + /// + /// Setting: Enable/disable Favicon for sender avatars. + /// + bool IsFaviconEnabled { get; set; } + #endregion #region Calendar diff --git a/Wino.Core.Domain/Interfaces/IThumbnailService.cs b/Wino.Core.Domain/Interfaces/IThumbnailService.cs new file mode 100644 index 00000000..7c95a2f8 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IThumbnailService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Interfaces; + +public interface IThumbnailService +{ + /// + /// Clears the thumbnail cache. + /// + Task ClearCache(); + + /// + /// Gets thumbnail + /// + /// Address for thumbnail + /// Force to wait for thumbnail loading. + /// Should be used in non-UI threads or where delay is acceptable + /// + ValueTask GetThumbnailAsync(string email, bool awaitLoad = false); +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 7b67d5eb..3fc21dca 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -623,6 +623,11 @@ "SettingsShowPreviewText_Title": "Show Preview Text", "SettingsShowSenderPictures_Description": "Hide/show the thumbnail sender pictures.", "SettingsShowSenderPictures_Title": "Show Sender Avatars", + "SettingsEnableGravatarAvatars_Title": "Gravatar", + "SettingsEnableGravatarAvatars_Description": "Use gravatar (if available) as sender picture", + "SettingsEnableFavicons_Title": "Domain icons (Favicons)", + "SettingsEnableFavicons_Description": "Use domain favicons (if available) as sender picture", + "SettingsMailList_ClearAvatarsCache_Button": "Clear cached avatars", "SettingsSignature_AddCustomSignature_Button": "Add signature", "SettingsSignature_AddCustomSignature_Title": "Add custom signature", "SettingsSignature_DeleteSignature_Title": "Delete signature", diff --git a/Wino.Core.UWP/CoreUWPContainerSetup.cs b/Wino.Core.UWP/CoreUWPContainerSetup.cs index ddb230b6..5869d67e 100644 --- a/Wino.Core.UWP/CoreUWPContainerSetup.cs +++ b/Wino.Core.UWP/CoreUWPContainerSetup.cs @@ -25,7 +25,7 @@ public static class CoreUWPContainerSetup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/Wino.Core.UWP/Services/NativeAppService.cs b/Wino.Core.UWP/Services/NativeAppService.cs index 63666cc3..eb38b19e 100644 --- a/Wino.Core.UWP/Services/NativeAppService.cs +++ b/Wino.Core.UWP/Services/NativeAppService.cs @@ -45,7 +45,6 @@ public class NativeAppService : INativeAppService return _mimeMessagesFolder; } - public async Task GetEditorBundlePathAsync() { if (string.IsNullOrEmpty(_editorBundlePath)) diff --git a/Wino.Core.UWP/Services/NotificationBuilder.cs b/Wino.Core.UWP/Services/NotificationBuilder.cs index 3504869e..6770c297 100644 --- a/Wino.Core.UWP/Services/NotificationBuilder.cs +++ b/Wino.Core.UWP/Services/NotificationBuilder.cs @@ -11,7 +11,7 @@ using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; -using Wino.Core.Services; +using System.IO; namespace Wino.Core.UWP.Services; @@ -23,16 +23,19 @@ public class NotificationBuilder : INotificationBuilder private readonly IAccountService _accountService; private readonly IFolderService _folderService; private readonly IMailService _mailService; + private readonly IThumbnailService _thumbnailService; public NotificationBuilder(IUnderlyingThemeService underlyingThemeService, IAccountService accountService, IFolderService folderService, - IMailService mailService) + IMailService mailService, + IThumbnailService thumbnailService) { _underlyingThemeService = underlyingThemeService; _accountService = accountService; _folderService = folderService; _mailService = mailService; + _thumbnailService = thumbnailService; } public async Task CreateNotificationsAsync(Guid inboxFolderId, IEnumerable downloadedMailItems) @@ -83,24 +86,16 @@ public class NotificationBuilder : INotificationBuilder var builder = new ToastContentBuilder(); builder.SetToastScenario(ToastScenario.Default); - var host = ThumbnailService.GetHost(mailItem.FromAddress); - - var knownTuple = ThumbnailService.CheckIsKnown(host); - - bool isKnown = knownTuple.Item1; - host = knownTuple.Item2; - - if (isKnown) - builder.AddAppLogoOverride(new System.Uri(ThumbnailService.GetKnownHostImage(host)), hintCrop: ToastGenericAppLogoCrop.Default); - else + var avatarThumbnail = await _thumbnailService.GetThumbnailAsync(mailItem.FromAddress, awaitLoad: true); + if (!string.IsNullOrEmpty(avatarThumbnail)) { - // TODO: https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=toolkit - // Follow official guides for icons/theme. - - bool isOSDarkTheme = _underlyingThemeService.IsUnderlyingThemeDark(); - string profileLogoName = isOSDarkTheme ? "profile-dark.png" : "profile-light.png"; - - builder.AddAppLogoOverride(new System.Uri($"ms-appx:///Assets/NotificationIcons/{profileLogoName}"), hintCrop: ToastGenericAppLogoCrop.Circle); + var tempFile = await Windows.Storage.ApplicationData.Current.TemporaryFolder.CreateFileAsync($"{Guid.NewGuid()}.png", Windows.Storage.CreationCollisionOption.ReplaceExisting); + await using (var stream = await tempFile.OpenStreamForWriteAsync()) + { + var bytes = Convert.FromBase64String(avatarThumbnail); + await stream.WriteAsync(bytes); + } + builder.AddAppLogoOverride(new Uri($"ms-appdata:///temp/{tempFile.Name}"), hintCrop: ToastGenericAppLogoCrop.Default); } // Override system notification timetamp with received date of the mail. diff --git a/Wino.Core.UWP/Services/PreferencesService.cs b/Wino.Core.UWP/Services/PreferencesService.cs index bdeffed2..ea88910e 100644 --- a/Wino.Core.UWP/Services/PreferencesService.cs +++ b/Wino.Core.UWP/Services/PreferencesService.cs @@ -13,17 +13,12 @@ using Wino.Services; namespace Wino.Core.UWP.Services; -public class PreferencesService : ObservableObject, IPreferencesService +public class PreferencesService(IConfigurationService configurationService) : ObservableObject, IPreferencesService { - private readonly IConfigurationService _configurationService; + private readonly IConfigurationService _configurationService = configurationService; public event EventHandler PreferenceChanged; - public PreferencesService(IConfigurationService configurationService) - { - _configurationService = configurationService; - } - protected override void OnPropertyChanged(PropertyChangedEventArgs e) { base.OnPropertyChanged(e); @@ -181,6 +176,18 @@ public class PreferencesService : ObservableObject, IPreferencesService set => SetPropertyAndSave(nameof(IsMailkitProtocolLoggerEnabled), value); } + public bool IsGravatarEnabled + { + get => _configurationService.Get(nameof(IsGravatarEnabled), true); + set => SetPropertyAndSave(nameof(IsGravatarEnabled), value); + } + + public bool IsFaviconEnabled + { + get => _configurationService.Get(nameof(IsFaviconEnabled), true); + set => SetPropertyAndSave(nameof(IsFaviconEnabled), value); + } + public Guid? StartupEntityId { get => _configurationService.Get(nameof(StartupEntityId), null); diff --git a/Wino.Core.UWP/Services/ThumbnailService.cs b/Wino.Core.UWP/Services/ThumbnailService.cs index dd042b79..7598252a 100644 --- a/Wino.Core.UWP/Services/ThumbnailService.cs +++ b/Wino.Core.UWP/Services/ThumbnailService.cs @@ -1,63 +1,198 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Net.Mail; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Gravatar; +using Windows.Networking.Connectivity; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Messaging.UI; +using Wino.Services; namespace Wino.Core.UWP.Services; -public static class ThumbnailService +public class ThumbnailService(IPreferencesService preferencesService, IDatabaseService databaseService) : IThumbnailService { - private static string[] knownCompanies = new string[] + private readonly IPreferencesService _preferencesService = preferencesService; + private readonly IDatabaseService _databaseService = databaseService; + private static readonly HttpClient _httpClient = new(); + private bool _isInitialized = false; + + private ConcurrentDictionary _cache; + private readonly ConcurrentDictionary _requests = []; + + private static readonly List _excludedFaviconDomains = [ + "gmail.com", + "outlook.com", + "hotmail.com", + "live.com", + "yahoo.com", + "icloud.com", + "aol.com", + "protonmail.com", + "zoho.com", + "mail.com", + "gmx.com", + "yandex.com", + "yandex.ru", + "tutanota.com", + "mail.ru", + "rediffmail.com" + ]; + + public async ValueTask GetThumbnailAsync(string email, bool awaitLoad = false) { - "microsoft.com", "apple.com", "google.com", "steampowered.com", "airbnb.com", "youtube.com", "uber.com" - }; + if (string.IsNullOrWhiteSpace(email)) + return null; - public static bool IsKnown(string mailHost) => !string.IsNullOrEmpty(mailHost) && knownCompanies.Contains(mailHost); + if (!_preferencesService.IsShowSenderPicturesEnabled) + return null; - public static string GetHost(string address) - { - if (string.IsNullOrEmpty(address)) - return string.Empty; - - if (address.Contains('@')) + if (!_isInitialized) { - var splitted = address.Split('@'); + var thumbnailsList = await _databaseService.Connection.Table().ToListAsync(); - if (splitted.Length >= 2 && !string.IsNullOrEmpty(splitted[1])) - { - try - { - return new MailAddress(address).Host; - } - catch (Exception) - { - // TODO: Exceptions are ignored for now. - } - } + _cache = new ConcurrentDictionary( + thumbnailsList.ToDictionary(x => x.Domain, x => (x.Gravatar, x.Favicon))); + _isInitialized = true; } - return string.Empty; + var sanitizedEmail = email.Trim().ToLowerInvariant(); + + var (gravatar, favicon) = await GetThumbnailInternal(sanitizedEmail, awaitLoad); + + if (_preferencesService.IsGravatarEnabled && !string.IsNullOrEmpty(gravatar)) + { + return gravatar; + } + + if (_preferencesService.IsFaviconEnabled && !string.IsNullOrEmpty(favicon)) + { + return favicon; + } + + return null; } - public static Tuple CheckIsKnown(string host) + public async Task ClearCache() { - // Check known hosts. - // Apply company logo if available. + _cache?.Clear(); + _requests.Clear(); + await _databaseService.Connection.DeleteAllAsync(); + } + private async ValueTask<(string gravatar, string favicon)> GetThumbnailInternal(string email, bool awaitLoad) + { + if (_cache.TryGetValue(email, out var cached)) + return cached; + + // No network available, skip fetching Gravatar + // Do not cache it, since network can be available later + bool isInternetAvailable = GetIsInternetAvailable(); + + if (!isInternetAvailable) + return default; + + if (!_requests.TryGetValue(email, out var request)) + { + request = Task.Run(() => RequestNewThumbnail(email)); + _requests[email] = request; + } + + if (awaitLoad) + { + await request; + _cache.TryGetValue(email, out cached); + return cached; + } + + return default; + + static bool GetIsInternetAvailable() + { + var connection = NetworkInformation.GetInternetConnectionProfile(); + return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess; + } + } + + private async Task RequestNewThumbnail(string email) + { + var gravatarBase64 = await GetGravatarBase64(email); + var faviconBase64 = await GetFaviconBase64(email); + + await _databaseService.Connection.InsertOrReplaceAsync(new Thumbnail + { + Domain = email, + Gravatar = gravatarBase64, + Favicon = faviconBase64, + LastUpdated = DateTime.UtcNow + }); + _ = _cache.TryAdd(email, (gravatarBase64, faviconBase64)); + + WeakReferenceMessenger.Default.Send(new ThumbnailAdded(email)); + } + + private static async Task GetGravatarBase64(string email) + { try { - var last = host.Split('.'); - - if (last.Length > 2) - host = $"{last[last.Length - 2]}.{last[last.Length - 1]}"; + var gravatarUrl = GravatarHelper.GetAvatarUrl( + email, + size: 128, + defaultValue: GravatarAvatarDefault.Blank, + withFileExtension: false).ToString().Replace("d=blank", "d=404"); + var response = await _httpClient.GetAsync(gravatarUrl); + if (response.IsSuccessStatusCode) + { + var bytes = response.Content.ReadAsByteArrayAsync().Result; + return Convert.ToBase64String(bytes); + } } - catch (Exception) - { - return new Tuple(false, host); - } - - return new Tuple(IsKnown(host), host); + catch { } + return null; } - public static string GetKnownHostImage(string host) - => $"ms-appx:///Assets/Thumbnails/{host}.png"; + private static async Task GetFaviconBase64(string email) + { + try + { + var host = GetHost(email); + + if (string.IsNullOrEmpty(host)) + return null; + + // Do not fetch favicon for specific default domains of major platforms + if (_excludedFaviconDomains.Contains(host, StringComparer.OrdinalIgnoreCase)) + return null; + + var primaryDomain = string.Join('.', host.Split('.')[^2..]); + + var googleFaviconUrl = $"https://www.google.com/s2/favicons?sz=128&domain_url={primaryDomain}"; + var response = await _httpClient.GetAsync(googleFaviconUrl); + if (response.IsSuccessStatusCode) + { + var bytes = response.Content.ReadAsByteArrayAsync().Result; + return Convert.ToBase64String(bytes); + } + } + catch { } + return null; + } + + private static string GetHost(string email) + { + if (!string.IsNullOrEmpty(email) && email.Contains('@')) + { + var split = email.Split('@'); + if (split.Length >= 2 && !string.IsNullOrEmpty(split[1])) + { + try { return new MailAddress(email).Host; } catch { } + } + } + return string.Empty; + } } diff --git a/Wino.Core.UWP/Wino.Core.UWP.csproj b/Wino.Core.UWP/Wino.Core.UWP.csproj index 1bb7391b..e6b2d5af 100644 --- a/Wino.Core.UWP/Wino.Core.UWP.csproj +++ b/Wino.Core.UWP/Wino.Core.UWP.csproj @@ -85,6 +85,7 @@ + diff --git a/Wino.Core.UWP/WinoApplication.cs b/Wino.Core.UWP/WinoApplication.cs index 4eae38cd..6cdaa9dc 100644 --- a/Wino.Core.UWP/WinoApplication.cs +++ b/Wino.Core.UWP/WinoApplication.cs @@ -40,6 +40,7 @@ public abstract class WinoApplication : Application, IRecipient protected IWinoServerConnectionManager AppServiceConnectionManager { get; } public IThemeService ThemeService { get; } public IUnderlyingThemeService UnderlyingThemeService { get; } + public IThumbnailService ThumbnailService { get; } protected IDatabaseService DatabaseService { get; } protected ITranslationService TranslationService { get; } @@ -64,6 +65,7 @@ public abstract class WinoApplication : Application, IRecipient DatabaseService = Services.GetService(); TranslationService = Services.GetService(); UnderlyingThemeService = Services.GetService(); + ThumbnailService = Services.GetService(); // Make sure the paths are setup on app start. AppConfiguration.ApplicationDataFolderPath = ApplicationData.Current.LocalFolder.Path; diff --git a/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs b/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs index f80f0ee3..41dcd0e4 100644 --- a/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs +++ b/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs @@ -18,7 +18,6 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel [ObservableProperty] private List _appTerminationBehavior; - [ObservableProperty] public partial List SearchModes { get; set; } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 9f2a132c..3425bcfa 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -129,11 +129,11 @@ public class WinoMailCollection private async Task HandleExistingThreadAsync(ObservableGroup group, ThreadMailItemViewModel threadViewModel, MailCopy addedItem) { var existingGroupKey = GetGroupingKey(threadViewModel); - + await ExecuteUIThread(() => { threadViewModel.AddMailItemViewModel(addedItem); }); var newGroupKey = GetGroupingKey(threadViewModel); - + if (!existingGroupKey.Equals(newGroupKey)) { await MoveThreadToNewGroupAsync(group, threadViewModel, newGroupKey); @@ -294,6 +294,25 @@ public class WinoMailCollection return null; } + public void UpdateThumbnails(string address) + { + if (CoreDispatcher == null) return; + + CoreDispatcher.ExecuteOnUIThread(() => + { + foreach (var group in _mailItemSource) + { + foreach (var item in group) + { + if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.FromAddress.Equals(address, StringComparison.OrdinalIgnoreCase)) + { + mailItemViewModel.ThumbnailUpdatedEvent = !mailItemViewModel.ThumbnailUpdatedEvent; + } + } + } + }); + } + /// /// Fins the item container that updated mail copy belongs to and updates it. /// diff --git a/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs b/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs index 262d68a8..cc9ff979 100644 --- a/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs +++ b/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs @@ -1,10 +1,17 @@ -using Wino.Core.Domain; +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; namespace Wino.Mail.ViewModels.Data; -public class AccountContactViewModel : AccountContact +public partial class AccountContactViewModel : ObservableObject { + public string Address { get; set; } + public string Name { get; set; } + public string Base64ContactPicture { get; set; } + public bool IsRootContact { get; set; } + public AccountContactViewModel(AccountContact contact) { Address = contact.Address; @@ -39,4 +46,7 @@ public class AccountContactViewModel : AccountContact /// Display name of the contact in a format: Name
. /// public string DisplayName => Address == Name || string.IsNullOrWhiteSpace(Name) ? Address.ToLowerInvariant() : $"{Name} <{Address.ToLowerInvariant()}>"; + + [ObservableProperty] + public partial bool ThumbnailUpdatedEvent { get; set; } } diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 5d28029c..66344587 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -13,7 +13,7 @@ namespace Wino.Mail.ViewModels.Data; public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IMailItem { [ObservableProperty] - private MailCopy mailCopy = mailCopy; + public partial MailCopy MailCopy { get; set; } = mailCopy; public Guid UniqueId => ((IMailItem)MailCopy).UniqueId; public string ThreadId => ((IMailItem)MailCopy).ThreadId; @@ -23,10 +23,13 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableObject, IM public string InReplyTo => ((IMailItem)MailCopy).InReplyTo; [ObservableProperty] - private bool isCustomFocused; + public partial bool ThumbnailUpdatedEvent { get; set; } = false; [ObservableProperty] - private bool isSelected; + public partial bool IsCustomFocused { get; set; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } public bool IsFlagged { diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index bc825ea8..b4fd27ad 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -41,7 +41,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private bool isChangingFolder = false; @@ -1140,4 +1141,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, }); } } + + public void Receive(ThumbnailAdded message) => MailCollection.UpdateThumbnails(message.Email); } diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index 5a2141a7..3a2818c6 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -25,12 +25,14 @@ using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Messages; using Wino.Messaging.Client.Mails; using Wino.Messaging.Server; +using Wino.Messaging.UI; using IMailService = Wino.Core.Domain.Interfaces.IMailService; namespace Wino.Mail.ViewModels; public partial class MailRenderingPageViewModel : MailBaseViewModel, IRecipient, + IRecipient, ITransferProgress // For listening IMAP message download progress. { private readonly IMailDialogService _dialogService; @@ -788,4 +790,27 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, Log.Error(ex, "Failed to render mail."); } } + + public void Receive(ThumbnailAdded message) + { + UpdateThumbnails(ToItems, message.Email); + UpdateThumbnails(CcItems, message.Email); + UpdateThumbnails(BccItems, message.Email); + } + + private void UpdateThumbnails(ObservableCollection items, string email) + { + if (Dispatcher == null || items.Count == 0) return; + + Dispatcher.ExecuteOnUIThread(() => + { + foreach (var item in items) + { + if (item.Address.Equals(email, StringComparison.OrdinalIgnoreCase)) + { + item.ThumbnailUpdatedEvent = !item.ThumbnailUpdatedEvent; + } + } + }); + } } diff --git a/Wino.Mail.ViewModels/MessageListPageViewModel.cs b/Wino.Mail.ViewModels/MessageListPageViewModel.cs index daaf04ae..b1bf22bb 100644 --- a/Wino.Mail.ViewModels/MessageListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MessageListPageViewModel.cs @@ -1,17 +1,19 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; namespace Wino.Mail.ViewModels; -public class MessageListPageViewModel : MailBaseViewModel +public partial class MessageListPageViewModel : MailBaseViewModel { public IPreferencesService PreferencesService { get; } + private readonly IThumbnailService _thumbnailService; private int selectedMarkAsOptionIndex; - public int SelectedMarkAsOptionIndex { get => selectedMarkAsOptionIndex; @@ -46,9 +48,7 @@ public class MessageListPageViewModel : MailBaseViewModel ]; #region Properties - private int leftHoverActionIndex; - public int LeftHoverActionIndex { get => leftHoverActionIndex; @@ -61,9 +61,7 @@ public class MessageListPageViewModel : MailBaseViewModel } } - private int centerHoverActionIndex; - public int CenterHoverActionIndex { get => centerHoverActionIndex; @@ -77,7 +75,6 @@ public class MessageListPageViewModel : MailBaseViewModel } private int rightHoverActionIndex; - public int RightHoverActionIndex { get => rightHoverActionIndex; @@ -89,18 +86,21 @@ public class MessageListPageViewModel : MailBaseViewModel } } } - #endregion - public MessageListPageViewModel(IMailDialogService dialogService, - IPreferencesService preferencesService) + public MessageListPageViewModel(IPreferencesService preferencesService, IThumbnailService thumbnailService) { PreferencesService = preferencesService; - + _thumbnailService = thumbnailService; leftHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.LeftHoverAction); centerHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.CenterHoverAction); rightHoverActionIndex = availableHoverActions.IndexOf(PreferencesService.RightHoverAction); - SelectedMarkAsOptionIndex = Array.IndexOf(Enum.GetValues(), PreferencesService.MarkAsPreference); } + + [RelayCommand] + private async Task ClearAvatarsCacheAsync() + { + await _thumbnailService.ClearCache(); + } } diff --git a/Wino.Mail/Assets/Thumbnails/airbnb.com.png b/Wino.Mail/Assets/Thumbnails/airbnb.com.png deleted file mode 100644 index 9713a7af..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/airbnb.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/apple.com.png b/Wino.Mail/Assets/Thumbnails/apple.com.png deleted file mode 100644 index 18621ea7..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/apple.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/google.com.png b/Wino.Mail/Assets/Thumbnails/google.com.png deleted file mode 100644 index 5af3ede9..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/google.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/microsoft.com.png b/Wino.Mail/Assets/Thumbnails/microsoft.com.png deleted file mode 100644 index e0a0d837..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/microsoft.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/steampowered.com.png b/Wino.Mail/Assets/Thumbnails/steampowered.com.png deleted file mode 100644 index a5d6087f..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/steampowered.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/uber.com.png b/Wino.Mail/Assets/Thumbnails/uber.com.png deleted file mode 100644 index 5dbb4ea8..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/uber.com.png and /dev/null differ diff --git a/Wino.Mail/Assets/Thumbnails/youtube.com.png b/Wino.Mail/Assets/Thumbnails/youtube.com.png deleted file mode 100644 index 0df5bd9b..00000000 Binary files a/Wino.Mail/Assets/Thumbnails/youtube.com.png and /dev/null differ diff --git a/Wino.Mail/Controls/ImagePreviewControl.cs b/Wino.Mail/Controls/ImagePreviewControl.cs index f3a0df2c..6037ecb9 100644 --- a/Wino.Mail/Controls/ImagePreviewControl.cs +++ b/Wino.Mail/Controls/ImagePreviewControl.cs @@ -5,13 +5,14 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Fernandezja.ColorHashSharp; +using Microsoft.Extensions.DependencyInjection; using Windows.UI; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Media; using Windows.UI.Xaml.Media.Imaging; using Windows.UI.Xaml.Shapes; -using Wino.Core.UWP.Services; +using Wino.Core.Domain.Interfaces; namespace Wino.Controls; @@ -21,12 +22,21 @@ public partial class ImagePreviewControl : Control 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 - public static readonly DependencyProperty FromNameProperty = DependencyProperty.Register(nameof(FromName), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, OnAddressInformationChanged)); - public static readonly DependencyProperty FromAddressProperty = DependencyProperty.Register(nameof(FromAddress), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, OnAddressInformationChanged)); - public static readonly DependencyProperty SenderContactPictureProperty = DependencyProperty.Register(nameof(SenderContactPicture), typeof(string), typeof(ImagePreviewControl), new PropertyMetadata(string.Empty, new PropertyChangedCallback(OnAddressInformationChanged))); + 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))); + + public bool ThumbnailUpdatedEvent + { + get { return (bool)GetValue(ThumbnailUpdatedEventProperty); } + set { SetValue(ThumbnailUpdatedEventProperty, value); } + } /// /// Gets or sets base64 string of the sender contact picture. @@ -55,6 +65,8 @@ public partial class ImagePreviewControl : Control private Grid InitialsGrid; private TextBlock InitialsTextblock; private Image KnownHostImage; + private Border FaviconSquircle; + private Image FaviconImage; private CancellationTokenSource contactPictureLoadingCancellationTokenSource; public ImagePreviewControl() @@ -70,11 +82,13 @@ public partial class ImagePreviewControl : Control 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 OnAddressInformationChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + private static void OnInformationChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { if (obj is ImagePreviewControl control) control.UpdateInformation(); @@ -82,7 +96,7 @@ public partial class ImagePreviewControl : Control private async void UpdateInformation() { - if (KnownHostImage == null || InitialsGrid == null || InitialsTextblock == null || (string.IsNullOrEmpty(FromName) && string.IsNullOrEmpty(FromAddress))) + if ((KnownHostImage == null && FaviconSquircle == null) || InitialsGrid == null || InitialsTextblock == null || (string.IsNullOrEmpty(FromName) && string.IsNullOrEmpty(FromAddress))) return; // Cancel active image loading if exists. @@ -91,81 +105,100 @@ public partial class ImagePreviewControl : Control contactPictureLoadingCancellationTokenSource.Cancel(); } - var host = ThumbnailService.GetHost(FromAddress); + string contactPicture = SenderContactPicture; - bool isKnownHost = false; + var isAvatarThumbnail = false; - if (!string.IsNullOrEmpty(host)) + if (string.IsNullOrEmpty(contactPicture) && !string.IsNullOrEmpty(FromAddress)) { - var tuple = ThumbnailService.CheckIsKnown(host); - - isKnownHost = tuple.Item1; - host = tuple.Item2; + contactPicture = await App.Current.ThumbnailService.GetThumbnailAsync(FromAddress); + isAvatarThumbnail = true; } - if (isKnownHost) + if (!string.IsNullOrEmpty(contactPicture)) { - // Unrealize others. - - KnownHostImage.Visibility = Visibility.Visible; - InitialsGrid.Visibility = Visibility.Collapsed; - - // Apply company logo. - KnownHostImage.Source = new BitmapImage(new Uri(ThumbnailService.GetKnownHostImage(host))); - } - else - { - KnownHostImage.Visibility = Visibility.Collapsed; - InitialsGrid.Visibility = Visibility.Visible; - - if (!string.IsNullOrEmpty(SenderContactPicture)) + if (isAvatarThumbnail && FaviconSquircle != null && FaviconImage != null) { - contactPictureLoadingCancellationTokenSource = new CancellationTokenSource(); + // Show favicon in squircle + FaviconSquircle.Visibility = Visibility.Visible; + InitialsGrid.Visibility = Visibility.Collapsed; + KnownHostImage.Visibility = Visibility.Collapsed; - try - { - var brush = await GetContactImageBrushAsync(); + var bitmapImage = await GetBitmapImageAsync(contactPicture); - if (!contactPictureLoadingCancellationTokenSource?.Token.IsCancellationRequested ?? false) - { - Ellipse.Fill = brush; - InitialsTextblock.Text = string.Empty; - } - } - catch (Exception) + if (bitmapImage != null) { - // Log exception. - Debugger.Break(); + FaviconImage.Source = bitmapImage; } } else { - var colorHash = new ColorHash(); - var rgb = colorHash.Rgb(FromAddress); + // Show normal avatar (tondo) + FaviconSquircle.Visibility = Visibility.Collapsed; + KnownHostImage.Visibility = Visibility.Collapsed; + InitialsGrid.Visibility = Visibility.Visible; + contactPictureLoadingCancellationTokenSource = new CancellationTokenSource(); + try + { + var brush = await GetContactImageBrushAsync(contactPicture); - Ellipse.Fill = new SolidColorBrush(Color.FromArgb(rgb.A, rgb.R, rgb.G, rgb.B)); - InitialsTextblock.Text = ExtractInitialsFromName(FromName); + if (brush != null) + { + if (!contactPictureLoadingCancellationTokenSource?.Token.IsCancellationRequested ?? false) + { + Ellipse.Fill = brush; + InitialsTextblock.Text = string.Empty; + } + } + } + catch (Exception) + { + Debugger.Break(); + } } } + else + { + FaviconSquircle.Visibility = Visibility.Collapsed; + KnownHostImage.Visibility = Visibility.Collapsed; + InitialsGrid.Visibility = Visibility.Visible; + + var colorHash = new ColorHash(); + var rgb = colorHash.Rgb(FromAddress); + + Ellipse.Fill = new SolidColorBrush(Color.FromArgb(rgb.A, rgb.R, rgb.G, rgb.B)); + InitialsTextblock.Text = ExtractInitialsFromName(FromName); + } } - private async Task GetContactImageBrushAsync() + private static async Task GetContactImageBrushAsync(string base64) { // Load the image from base64 string. - var bitmapImage = new BitmapImage(); + + var bitmapImage = await GetBitmapImageAsync(base64); - var imageArray = Convert.FromBase64String(SenderContactPicture); - var imageStream = new MemoryStream(imageArray); - var randomAccessImageStream = imageStream.AsRandomAccessStream(); - - randomAccessImageStream.Seek(0); - - - await bitmapImage.SetSourceAsync(randomAccessImageStream); + if (bitmapImage == null) return null; return new ImageBrush() { ImageSource = bitmapImage }; } + private static async Task GetBitmapImageAsync(string base64) + { + 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; + } + catch (Exception) { } + + return null; + } + public string ExtractInitialsFromName(string name) { // Change from name to from address in case of name doesn't exists. diff --git a/Wino.Mail/Controls/MailItemDisplayInformationControl.xaml b/Wino.Mail/Controls/MailItemDisplayInformationControl.xaml index ff0c6ceb..92d3332a 100644 --- a/Wino.Mail/Controls/MailItemDisplayInformationControl.xaml +++ b/Wino.Mail/Controls/MailItemDisplayInformationControl.xaml @@ -76,6 +76,7 @@ FromAddress="{x:Bind MailItem.FromAddress, Mode=OneWay}" FromName="{x:Bind MailItem.FromName, Mode=OneWay}" SenderContactPicture="{x:Bind MailItem.SenderContact.Base64ContactPicture}" + ThumbnailUpdatedEvent="{x:Bind IsThumbnailUpdated, Mode=OneWay}" Visibility="{x:Bind IsAvatarVisible, Mode=OneWay}" /> + + + + + + Stretch="UniformToFill" + Visibility="Collapsed" /> diff --git a/Wino.Mail/Views/MailListPage.xaml b/Wino.Mail/Views/MailListPage.xaml index f9053698..8befa449 100644 --- a/Wino.Mail/Views/MailListPage.xaml +++ b/Wino.Mail/Views/MailListPage.xaml @@ -113,6 +113,7 @@ IsAvatarVisible="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsShowSenderPicturesEnabled, Mode=OneWay}" IsCustomFocused="{x:Bind IsCustomFocused, Mode=OneWay}" IsHoverActionsEnabled="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsHoverActionsEnabled, Mode=OneWay}" + IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}" LeftHoverAction="{Binding ElementName=root, Path=ViewModel.PreferencesService.LeftHoverAction, Mode=OneWay}" MailItem="{x:Bind MailCopy, Mode=OneWay}" Prefer24HourTimeFormat="{Binding ElementName=root, Path=ViewModel.PreferencesService.Prefer24HourTimeFormat, Mode=OneWay}" @@ -136,6 +137,7 @@ IsAvatarVisible="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsShowSenderPicturesEnabled, Mode=OneWay}" IsCustomFocused="{x:Bind IsCustomFocused, Mode=OneWay}" IsHoverActionsEnabled="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsHoverActionsEnabled, Mode=OneWay}" + IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}" LeftHoverAction="{Binding ElementName=root, Path=ViewModel.PreferencesService.LeftHoverAction, Mode=OneWay}" MailItem="{x:Bind MailCopy, Mode=OneWay}" Prefer24HourTimeFormat="{Binding ElementName=root, Path=ViewModel.PreferencesService.Prefer24HourTimeFormat, Mode=OneWay}" diff --git a/Wino.Mail/Views/MailRenderingPage.xaml b/Wino.Mail/Views/MailRenderingPage.xaml index a5fd3e83..c6a8a93a 100644 --- a/Wino.Mail/Views/MailRenderingPage.xaml +++ b/Wino.Mail/Views/MailRenderingPage.xaml @@ -46,7 +46,8 @@ Height="36" FromAddress="{x:Bind Address}" FromName="{x:Bind Name}" - SenderContactPicture="{x:Bind Base64ContactPicture}" /> + SenderContactPicture="{x:Bind Base64ContactPicture}" + ThumbnailUpdatedEvent="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}" /> diff --git a/Wino.Mail/Views/Settings/MessageListPage.xaml b/Wino.Mail/Views/Settings/MessageListPage.xaml index 91a492d6..0e67e487 100644 --- a/Wino.Mail/Views/Settings/MessageListPage.xaml +++ b/Wino.Mail/Views/Settings/MessageListPage.xaml @@ -68,14 +68,30 @@ - - - + + - - + - + + + + + + + + + + + + + + +