feat: Enhanced sender avatars with gravatar and favicons integration (#685)

* feat: Enhanced sender avatars with gravatar and favicons integration

* chore: Remove unused known companies thumbnails

* feat(thumbnail): add IThumbnailService and refactor usage

- Introduced a new interface `IThumbnailService` for handling thumbnail-related functionalities.
- Registered `IThumbnailService` with its implementation `ThumbnailService` in the service container.
- Updated `NotificationBuilder` to use an instance of `IThumbnailService` instead of static methods.
- Refactored `ThumbnailService` from a static class to a regular class with instance methods and variables.
- Modified `ImagePreviewControl` to utilize the new `IThumbnailService` instance.
- Completed integration of `IThumbnailService` in the application by registering it in `App.xaml.cs`.

* style: Show favicons as squares

- Changed `hintCrop` in `NotificationBuilder` to `None` for app logo display.
- Added `FaviconSquircle`, `FaviconImage`, and `isFavicon` to `ImagePreviewControl` for favicon handling.
- Updated `UpdateInformation` method to manage favicon visibility.
- Introduced `GetBitmapImageAsync` for converting Base64 to Bitmap images.
- Enhanced XAML to include `FaviconSquircle` for improved UI appearance.

* refactor thumbnail service

* Removed old code and added clear method

* added prefetch function

* Change key from host to email

* Remove redundant code

* Test event

* Fixed an issue with the thumbnail updated event.

* Fix cutted favicons

* exclude some domain from favicons

* add yandex.ru

* fix buttons in settings

* remove prefetch method

* Added thumbnails propagation to mailRenderingPage

* Revert MailItemViewModel to object

* Remove redundant code

* spaces

* await load parameter added

* fix spaces

* fix case sensativity for mail list thumbnails

* change duckdns to google

* Some cleanup.

---------

Co-authored-by: Aleh Khantsevich <aleh.khantsevich@gmail.com>
Co-authored-by: Burak Kaan Köse <bkaankose@outlook.com>
This commit is contained in:
Maicol Battistini
2025-06-21 01:40:25 +02:00
committed by GitHub
parent a8cb332232
commit 256fd1cce2
38 changed files with 489 additions and 172 deletions

View File

@@ -25,7 +25,7 @@ public static class CoreUWPContainerSetup
services.AddSingleton<IPreferencesService, PreferencesService>();
services.AddSingleton<IThemeService, ThemeService>();
services.AddSingleton<IStatePersistanceService, StatePersistenceService>();
services.AddSingleton<IThumbnailService, ThumbnailService>();
services.AddSingleton<IDialogServiceBase, DialogServiceBase>();
services.AddTransient<IConfigurationService, ConfigurationService>();
services.AddTransient<IFileService, FileService>();

View File

@@ -45,7 +45,6 @@ public class NativeAppService : INativeAppService
return _mimeMessagesFolder;
}
public async Task<string> GetEditorBundlePathAsync()
{
if (string.IsNullOrEmpty(_editorBundlePath))

View File

@@ -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<IMailItem> 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.

View File

@@ -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<string> 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<Guid?>(nameof(StartupEntityId), null);

View File

@@ -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<string, (string graviton, string favicon)> _cache;
private readonly ConcurrentDictionary<string, Task> _requests = [];
private static readonly List<string> _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<string> 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<Thumbnail>().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<string, (string graviton, string favicon)>(
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<bool, string> CheckIsKnown(string host)
public async Task ClearCache()
{
// Check known hosts.
// Apply company logo if available.
_cache?.Clear();
_requests.Clear();
await _databaseService.Connection.DeleteAllAsync<Thumbnail>();
}
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<string> 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<bool, string>(false, host);
}
return new Tuple<bool, string>(IsKnown(host), host);
catch { }
return null;
}
public static string GetKnownHostImage(string host)
=> $"ms-appx:///Assets/Thumbnails/{host}.png";
private static async Task<string> 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;
}
}

View File

@@ -85,6 +85,7 @@
<Content Include="BackgroundImages\Snowflake.jpg" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="gravatar-dotnet" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.UI.Xaml" />
<PackageReference Include="CommunityToolkit.Common" />

View File

@@ -40,6 +40,7 @@ public abstract class WinoApplication : Application, IRecipient<LanguageChanged>
protected IWinoServerConnectionManager<AppServiceConnection> 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<LanguageChanged>
DatabaseService = Services.GetService<IDatabaseService>();
TranslationService = Services.GetService<ITranslationService>();
UnderlyingThemeService = Services.GetService<IUnderlyingThemeService>();
ThumbnailService = Services.GetService<IThumbnailService>();
// Make sure the paths are setup on app start.
AppConfiguration.ApplicationDataFolderPath = ApplicationData.Current.LocalFolder.Path;