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

@@ -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;
}
}