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

@@ -19,6 +19,7 @@
<PackageVersion Include="CommunityToolkit.Uwp.Extensions" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.Uwp.Controls.Primitives" Version="8.2.250129-preview2" />
<PackageVersion Include="EmailValidation" Version="1.3.0" />
<PackageVersion Include="gravatar-dotnet" Version="0.1.3" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.0" />
<PackageVersion Include="Ical.Net" Version="4.3.1" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" />

View File

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

View File

@@ -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
{
/// <summary>
/// When any of the preferences are changed.
@@ -193,6 +194,16 @@ public interface IPreferencesService
/// </summary>
bool IsShowActionLabelsEnabled { get; set; }
/// <summary>
/// Setting: Enable/disable Gravatar for sender avatars.
/// </summary>
bool IsGravatarEnabled { get; set; }
/// <summary>
/// Setting: Enable/disable Favicon for sender avatars.
/// </summary>
bool IsFaviconEnabled { get; set; }
#endregion
#region Calendar

View File

@@ -0,0 +1,20 @@
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
public interface IThumbnailService
{
/// <summary>
/// Clears the thumbnail cache.
/// </summary>
Task ClearCache();
/// <summary>
/// Gets thumbnail
/// </summary>
/// <param name="email">Address for thumbnail</param>
/// <param name="awaitLoad">Force to wait for thumbnail loading.
/// Should be used in non-UI threads or where delay is acceptable
/// </param>
ValueTask<string> GetThumbnailAsync(string email, bool awaitLoad = false);
}

View File

@@ -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",

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;

View File

@@ -18,7 +18,6 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel
[ObservableProperty]
private List<string> _appTerminationBehavior;
[ObservableProperty]
public partial List<string> SearchModes { get; set; }

View File

@@ -129,11 +129,11 @@ public class WinoMailCollection
private async Task HandleExistingThreadAsync(ObservableGroup<object, IMailItem> 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;
}
}
}
});
}
/// <summary>
/// Fins the item container that updated mail copy belongs to and updates it.
/// </summary>

View File

@@ -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 <Address>.
/// </summary>
public string DisplayName => Address == Name || string.IsNullOrWhiteSpace(Name) ? Address.ToLowerInvariant() : $"{Name} <{Address.ToLowerInvariant()}>";
[ObservableProperty]
public partial bool ThumbnailUpdatedEvent { get; set; }
}

View File

@@ -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
{

View File

@@ -41,7 +41,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
IRecipient<AccountSynchronizationCompleted>,
IRecipient<NewMailSynchronizationRequested>,
IRecipient<AccountSynchronizerStateChanged>,
IRecipient<AccountCacheResetMessage>
IRecipient<AccountCacheResetMessage>,
IRecipient<ThumbnailAdded>
{
private bool isChangingFolder = false;
@@ -1140,4 +1141,6 @@ public partial class MailListPageViewModel : MailBaseViewModel,
});
}
}
public void Receive(ThumbnailAdded message) => MailCollection.UpdateThumbnails(message.Email);
}

View File

@@ -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<NewMailItemRenderingRequestedEvent>,
IRecipient<ThumbnailAdded>,
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<AccountContactViewModel> 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;
}
}
});
}
}

View File

@@ -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<MailMarkAsOption>(), PreferencesService.MarkAsPreference);
}
[RelayCommand]
private async Task ClearAvatarsCacheAsync()
{
await _thumbnailService.ClearCache();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -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); }
}
/// <summary>
/// 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<ImageBrush> GetContactImageBrushAsync()
private static async Task<ImageBrush> 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<BitmapImage> 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.

View File

@@ -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}" />
<Grid

View File

@@ -1,6 +1,7 @@
using System.Linq;
using System.Numerics;
using System.Windows.Input;
using CommunityToolkit.WinUI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Wino.Core.Domain;
@@ -33,6 +34,13 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
public static readonly DependencyProperty Prefer24HourTimeFormatProperty = DependencyProperty.Register(nameof(Prefer24HourTimeFormat), typeof(bool), typeof(MailItemDisplayInformationControl), new PropertyMetadata(false));
public static readonly DependencyProperty IsThreadExpanderVisibleProperty = DependencyProperty.Register(nameof(IsThreadExpanderVisible), typeof(bool), typeof(MailItemDisplayInformationControl), new PropertyMetadata(false));
public static readonly DependencyProperty IsThreadExpandedProperty = DependencyProperty.Register(nameof(IsThreadExpanded), typeof(bool), typeof(MailItemDisplayInformationControl), new PropertyMetadata(false));
public static readonly DependencyProperty IsThumbnailUpdatedProperty = DependencyProperty.Register(nameof(IsThumbnailUpdated), typeof(bool), typeof(MailItemDisplayInformationControl), new PropertyMetadata(false));
public bool IsThumbnailUpdated
{
get { return (bool)GetValue(IsThumbnailUpdatedProperty); }
set { SetValue(IsThumbnailUpdatedProperty, value); }
}
public bool IsThreadExpanded
{

View File

@@ -28,13 +28,27 @@
Foreground="White" />
</Grid>
<!-- Squircle for favicon -->
<Border
x:Name="FaviconSquircle"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="Transparent"
CornerRadius="6"
Visibility="Collapsed">
<Image x:Name="FaviconImage" Stretch="Fill" />
</Border>
<Image
x:Name="KnownHostImage"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Stretch="UniformToFill" />
Stretch="UniformToFill"
Visibility="Collapsed" />
</Grid>
</ControlTemplate>
</Setter.Value>

View File

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

View File

@@ -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}" />
<TextBlock Grid.Column="1" Text="{x:Bind Name}" />

File diff suppressed because one or more lines are too long

View File

@@ -33,13 +33,6 @@
<None Remove="Assets\NotificationIcons\profile-dark.png" />
<None Remove="Assets\NotificationIcons\profile-light.png" />
<None Remove="Assets\ReleaseNotes\1102.md" />
<None Remove="Assets\Thumbnails\airbnb.com.png" />
<None Remove="Assets\Thumbnails\apple.com.png" />
<None Remove="Assets\Thumbnails\google.com.png" />
<None Remove="Assets\Thumbnails\microsoft.com.png" />
<None Remove="Assets\Thumbnails\steampowered.com.png" />
<None Remove="Assets\Thumbnails\uber.com.png" />
<None Remove="Assets\Thumbnails\youtube.com.png" />
<None Remove="JS\editor.html" />
<None Remove="JS\editor.js" />
<None Remove="JS\global.css" />
@@ -59,13 +52,6 @@
<Content Include="Assets\NotificationIcons\profile-dark.png" />
<Content Include="Assets\NotificationIcons\profile-light.png" />
<Content Include="Assets\ReleaseNotes\1102.md" />
<Content Include="Assets\Thumbnails\airbnb.com.png" />
<Content Include="Assets\Thumbnails\apple.com.png" />
<Content Include="Assets\Thumbnails\google.com.png" />
<Content Include="Assets\Thumbnails\microsoft.com.png" />
<Content Include="Assets\Thumbnails\steampowered.com.png" />
<Content Include="Assets\Thumbnails\uber.com.png" />
<Content Include="Assets\Thumbnails\youtube.com.png" />
<Content Include="JS\editor.html" />
<Content Include="JS\editor.js" />
<Content Include="JS\global.css" />

View File

@@ -0,0 +1,4 @@
using Wino.Core.Domain.Interfaces;
namespace Wino.Messaging.UI;
public record ThumbnailAdded(string Email): IUIMessage;

View File

@@ -79,6 +79,7 @@ public partial class App : Application
services.AddTransient<INotificationBuilder, NotificationBuilder>();
services.AddTransient<IUnderlyingThemeService, UnderlyingThemeService>();
services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>();
services.AddSingleton<IThumbnailService, ThumbnailService>();
// Register server message handler factory.
var serverMessageHandlerFactory = new ServerMessageHandlerFactory();

View File

@@ -34,11 +34,12 @@
</Resource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="H.NotifyIcon.Wpf" />
<PackageReference Include="CommunityToolkit.WinUI.Notifications" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="System.Text.Encoding.CodePages" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="gravatar-dotnet" />
<PackageReference Include="H.NotifyIcon.Wpf" />
<PackageReference Include="CommunityToolkit.WinUI.Notifications" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="System.Text.Encoding.CodePages" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Authentication\Wino.Authentication.csproj" />

View File

@@ -57,7 +57,8 @@ public class DatabaseService : IDatabaseService
typeof(AccountCalendar),
typeof(CalendarEventAttendee),
typeof(CalendarItem),
typeof(Reminder)
typeof(Reminder),
typeof(Thumbnail)
);
}
}