Files
Wino-Mail/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs
T

403 lines
12 KiB
C#
Raw Normal View History

2025-11-15 14:52:01 +01:00
using System;
2025-09-29 11:16:14 +02:00
using System.IO;
using System.Net.Mail;
2025-09-29 11:16:14 +02:00
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.WinUI;
using EmailValidation;
using Microsoft.Extensions.DependencyInjection;
2025-09-29 11:16:14 +02:00
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Wino.Core.Domain.Interfaces;
2025-09-29 11:16:14 +02:00
using Wino.Mail.WinUI;
namespace Wino.Controls;
/// <summary>
/// Contact avatar control built on top of PersonPicture.
/// Priority:
/// 1) AccountContact/Base64 picture
/// 2) Gravatar thumbnail (if enabled)
/// 3) Initials from display name fallback
/// </summary>
public sealed partial class ImagePreviewControl : PersonPicture
2025-09-29 11:16:14 +02:00
{
private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture);
2025-09-29 11:16:14 +02:00
private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40);
2025-09-29 11:16:14 +02:00
[GeneratedDependencyProperty]
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
2025-09-29 11:16:14 +02:00
[GeneratedDependencyProperty]
public partial string? FromName { get; set; }
[GeneratedDependencyProperty]
public partial string? FromAddress { get; set; }
[GeneratedDependencyProperty]
public partial string? SenderContactPicture { get; set; }
[GeneratedDependencyProperty(DefaultValue = false)]
public partial bool ThumbnailUpdatedEvent { get; set; }
private readonly IThumbnailService? _thumbnailService;
private readonly IPreferencesService? _preferencesService;
private CancellationTokenSource? _refreshCancellationTokenSource;
private CancellationTokenSource? _scheduledRefreshCancellationTokenSource;
private long _refreshVersion;
public ImagePreviewControl()
2025-09-29 11:16:14 +02:00
{
DefaultStyleKey = typeof(PersonPicture);
try
{
_thumbnailService = App.Current.Services.GetService<IThumbnailService>();
_preferencesService = App.Current.Services.GetService<IPreferencesService>();
}
catch
{
// Keep control functional in design-time/test contexts without service provider.
}
Loaded += OnLoaded;
Unloaded += OnUnloaded;
2025-09-29 11:16:14 +02:00
}
partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e)
2025-09-29 11:16:14 +02:00
{
RequestRefresh();
2025-09-29 11:16:14 +02:00
}
partial void OnFromNameChanged(string? newValue) => RequestRefresh();
partial void OnFromAddressChanged(string? newValue) => RequestRefresh();
partial void OnSenderContactPictureChanged(string? newValue) => RequestRefresh();
partial void OnThumbnailUpdatedEventChanged(bool newValue) => RequestRefresh();
private void OnLoaded(object sender, RoutedEventArgs e)
2025-09-29 11:16:14 +02:00
{
RequestRefresh();
2025-09-29 11:16:14 +02:00
}
private void OnUnloaded(object sender, RoutedEventArgs e)
2025-09-29 11:16:14 +02:00
{
CancelScheduledRefresh();
CancelActiveRefresh();
2025-09-29 11:16:14 +02:00
}
private void RequestRefresh()
{
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
{
QueueRefresh();
return;
}
2025-09-29 11:16:14 +02:00
DispatcherQueue.TryEnqueue(QueueRefresh);
}
2025-09-29 11:16:14 +02:00
private void QueueRefresh()
2025-09-29 11:16:14 +02:00
{
if (!IsLoaded)
return;
CancelScheduledRefresh();
var cts = new CancellationTokenSource();
_scheduledRefreshCancellationTokenSource = cts;
_ = DebounceAndRefreshAsync(cts.Token);
2025-09-29 11:16:14 +02:00
}
private async Task DebounceAndRefreshAsync(CancellationToken cancellationToken)
2025-09-29 11:16:14 +02:00
{
try
{
await Task.Delay(RefreshDebounceDuration, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
2025-09-29 11:16:14 +02:00
StartRefresh();
2025-09-29 11:16:14 +02:00
}
private void StartRefresh()
2025-09-29 11:16:14 +02:00
{
CancelActiveRefresh();
var cts = new CancellationTokenSource();
_refreshCancellationTokenSource = cts;
var refreshVersion = Interlocked.Increment(ref _refreshVersion);
_ = RefreshAsync(refreshVersion, cts.Token);
2025-09-29 11:16:14 +02:00
}
private void CancelScheduledRefresh()
2025-09-29 11:16:14 +02:00
{
var cts = _scheduledRefreshCancellationTokenSource;
_scheduledRefreshCancellationTokenSource = null;
2025-09-29 11:16:14 +02:00
if (cts != null && !cts.IsCancellationRequested)
2025-09-29 11:16:14 +02:00
{
cts.Cancel();
2025-09-29 11:16:14 +02:00
}
cts?.Dispose();
}
2025-09-29 11:16:14 +02:00
private void CancelActiveRefresh()
{
var cts = _refreshCancellationTokenSource;
_refreshCancellationTokenSource = null;
2025-09-29 11:16:14 +02:00
if (cts != null && !cts.IsCancellationRequested)
2025-09-29 11:16:14 +02:00
{
cts.Cancel();
2025-09-29 11:16:14 +02:00
}
cts?.Dispose();
}
private async Task RefreshAsync(long refreshVersion, CancellationToken cancellationToken)
{
try
2025-09-29 11:16:14 +02:00
{
var snapshot = await CaptureSnapshotAsync(refreshVersion, cancellationToken).ConfigureAwait(false);
if (snapshot == null)
return;
2025-09-29 11:16:14 +02:00
await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false);
2025-09-29 11:16:14 +02:00
// 1) Explicit contact picture.
if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture))
{
var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false);
if (localBitmap != null)
2025-09-29 11:16:14 +02:00
{
await ApplyProfilePictureAsync(localBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
return;
2025-09-29 11:16:14 +02:00
}
}
// 2) Gravatar lookup through thumbnail service (if enabled).
if (_preferencesService?.IsGravatarEnabled == true &&
_thumbnailService != null &&
!string.IsNullOrWhiteSpace(snapshot.Address) &&
EmailValidator.Validate(snapshot.Address))
2025-09-29 11:16:14 +02:00
{
var thumbnailBase64 = await _thumbnailService
.GetThumbnailAsync(snapshot.Address.Trim().ToLowerInvariant(), awaitLoad: true)
.ConfigureAwait(false);
2025-09-29 11:16:14 +02:00
if (!string.IsNullOrWhiteSpace(thumbnailBase64))
{
var thumbnailBitmap = await CreateBitmapFromBase64Async(thumbnailBase64, cancellationToken).ConfigureAwait(false);
if (thumbnailBitmap != null)
2025-09-29 11:16:14 +02:00
{
await ApplyProfilePictureAsync(thumbnailBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
return;
2025-09-29 11:16:14 +02:00
}
}
}
// 3) Initials fallback is already in place via DisplayName + ProfilePicture = null.
}
catch (OperationCanceledException)
{
// Expected during virtualization/recycling.
2025-09-29 11:16:14 +02:00
}
catch
2025-09-29 11:16:14 +02:00
{
// Keep fallback initials if decoding/network fails.
}
}
2025-09-29 11:16:14 +02:00
// DependencyProperty-backed values must be read on UI thread once, then used off-thread.
private async Task<RefreshSnapshot?> CaptureSnapshotAsync(long refreshVersion, CancellationToken cancellationToken)
{
return await ExecuteOnUiThreadAsync(() =>
{
if (!IsActiveRefresh(refreshVersion, cancellationToken))
return null;
2025-09-29 11:16:14 +02:00
var address = ResolveAddress();
var displayName = ResolveDisplayName(address);
var base64Picture = ResolveBase64Picture();
return new RefreshSnapshot(displayName, address, base64Picture);
}).ConfigureAwait(false);
}
private string ResolveAddress()
{
var contactAddress = MailItemInformation?.SenderContact?.Address;
if (!string.IsNullOrWhiteSpace(contactAddress))
return contactAddress.Trim();
if (!string.IsNullOrWhiteSpace(MailItemInformation?.FromAddress))
return MailItemInformation.FromAddress.Trim();
return FromAddress?.Trim() ?? string.Empty;
2025-09-29 11:16:14 +02:00
}
private string ResolveDisplayName(string resolvedAddress)
2025-09-29 11:16:14 +02:00
{
var contactName = MailItemInformation?.SenderContact?.Name;
if (!string.IsNullOrWhiteSpace(contactName))
return contactName.Trim();
2025-09-29 11:16:14 +02:00
if (!string.IsNullOrWhiteSpace(MailItemInformation?.FromName))
return MailItemInformation.FromName.Trim();
2025-09-29 11:16:14 +02:00
if (!string.IsNullOrWhiteSpace(FromName))
return FromName.Trim();
2025-09-29 11:16:14 +02:00
return resolvedAddress.Trim();
2025-09-29 11:16:14 +02:00
}
private string ResolveBase64Picture()
2025-09-29 11:16:14 +02:00
{
if (!string.IsNullOrWhiteSpace(MailItemInformation?.SenderContact?.Base64ContactPicture))
return MailItemInformation.SenderContact.Base64ContactPicture;
if (!string.IsNullOrWhiteSpace(MailItemInformation?.Base64ContactPicture))
return MailItemInformation.Base64ContactPicture;
return SenderContactPicture ?? string.Empty;
}
private async Task ApplyInitialVisualStateAsync(string displayName, long refreshVersion, CancellationToken cancellationToken)
{
await ExecuteOnUiThreadAsync(() =>
2025-09-29 11:16:14 +02:00
{
if (!IsActiveRefresh(refreshVersion, cancellationToken))
return;
DisplayName = displayName;
ProfilePicture = null;
}).ConfigureAwait(false);
}
private async Task ApplyProfilePictureAsync(BitmapImage bitmapImage, long refreshVersion, CancellationToken cancellationToken)
{
await ExecuteOnUiThreadAsync(() =>
{
if (!IsActiveRefresh(refreshVersion, cancellationToken))
return;
ProfilePicture = bitmapImage;
}).ConfigureAwait(false);
}
private bool IsActiveRefresh(long refreshVersion, CancellationToken cancellationToken)
=> !cancellationToken.IsCancellationRequested && refreshVersion == _refreshVersion;
private async Task ExecuteOnUiThreadAsync(Action action)
{
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
{
action();
return;
}
var completion = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
var enqueued = DispatcherQueue.TryEnqueue(() =>
{
try
{
action();
completion.TrySetResult(null);
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
});
if (!enqueued)
{
completion.TrySetException(new InvalidOperationException("Failed to dispatch UI update."));
2025-09-29 11:16:14 +02:00
}
await completion.Task.ConfigureAwait(false);
2025-09-29 11:16:14 +02:00
}
private async Task<T> ExecuteOnUiThreadAsync<T>(Func<T> func)
2025-09-29 11:16:14 +02:00
{
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
2025-09-29 11:16:14 +02:00
{
return func();
2025-09-29 11:16:14 +02:00
}
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
2025-09-29 11:16:14 +02:00
var enqueued = DispatcherQueue.TryEnqueue(() =>
{
try
{
completion.TrySetResult(func());
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
});
2025-09-29 11:16:14 +02:00
if (!enqueued)
{
completion.TrySetException(new InvalidOperationException("Failed to dispatch UI update."));
}
2025-09-29 11:16:14 +02:00
return await completion.Task.ConfigureAwait(false);
}
2025-09-29 11:16:14 +02:00
private async Task<BitmapImage?> CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
byte[] bytes;
try
2025-09-29 11:16:14 +02:00
{
bytes = await Task.Run(() => Convert.FromBase64String(base64), cancellationToken).ConfigureAwait(false);
2025-09-29 11:16:14 +02:00
}
catch
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
return await ExecuteOnUiThreadAsync(() =>
{
cancellationToken.ThrowIfCancellationRequested();
2025-09-29 11:16:14 +02:00
using var memoryStream = new MemoryStream(bytes);
var bitmapImage = new BitmapImage();
bitmapImage.SetSource(memoryStream.AsRandomAccessStream());
return bitmapImage;
}).ConfigureAwait(false);
}
private static bool IsValidEmail(string email)
{
try
{
_ = new MailAddress(email);
return true;
}
catch
{
return false;
}
2025-09-29 11:16:14 +02:00
}
}