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:
committed by
GitHub
parent
a8cb332232
commit
256fd1cce2
@@ -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>();
|
||||
|
||||
@@ -45,7 +45,6 @@ public class NativeAppService : INativeAppService
|
||||
return _mimeMessagesFolder;
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> GetEditorBundlePathAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_editorBundlePath))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user