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
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs
index b2bcf03f..2d316a26 100644
--- a/Wino.Services/DatabaseService.cs
+++ b/Wino.Services/DatabaseService.cs
@@ -57,7 +57,8 @@ public class DatabaseService : IDatabaseService
typeof(AccountCalendar),
typeof(CalendarEventAttendee),
typeof(CalendarItem),
- typeof(Reminder)
+ typeof(Reminder),
+ typeof(Thumbnail)
);
}
}