Contacts, thread animation and image preview control improvements.
This commit is contained in:
@@ -1,241 +1,402 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net.Mail;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fernandezja.ColorHashSharp;
|
||||
using CommunityToolkit.WinUI;
|
||||
using EmailValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Microsoft.UI.Xaml.Shapes;
|
||||
using Windows.UI;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Mail.WinUI;
|
||||
|
||||
namespace Wino.Controls;
|
||||
|
||||
public partial class ImagePreviewControl : Control
|
||||
/// <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
|
||||
{
|
||||
private const string PART_EllipseInitialsGrid = "EllipseInitialsGrid";
|
||||
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";
|
||||
private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture);
|
||||
|
||||
#region Dependency Properties
|
||||
private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40);
|
||||
|
||||
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)));
|
||||
[GeneratedDependencyProperty]
|
||||
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
|
||||
|
||||
public bool ThumbnailUpdatedEvent
|
||||
{
|
||||
get { return (bool)GetValue(ThumbnailUpdatedEventProperty); }
|
||||
set { SetValue(ThumbnailUpdatedEventProperty, value); }
|
||||
}
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? FromName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets base64 string of the sender contact picture.
|
||||
/// </summary>
|
||||
public string SenderContactPicture
|
||||
{
|
||||
get { return (string)GetValue(SenderContactPictureProperty); }
|
||||
set { SetValue(SenderContactPictureProperty, value); }
|
||||
}
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? FromAddress { get; set; }
|
||||
|
||||
public string FromName
|
||||
{
|
||||
get { return (string)GetValue(FromNameProperty); }
|
||||
set { SetValue(FromNameProperty, value); }
|
||||
}
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? SenderContactPicture { get; set; }
|
||||
|
||||
public string FromAddress
|
||||
{
|
||||
get { return (string)GetValue(FromAddressProperty); }
|
||||
set { SetValue(FromAddressProperty, value); }
|
||||
}
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
public partial bool ThumbnailUpdatedEvent { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
private Ellipse Ellipse = null!;
|
||||
private Grid InitialsGrid = null!;
|
||||
private TextBlock InitialsTextblock = null!;
|
||||
private Image KnownHostImage = null!;
|
||||
private Border FaviconSquircle = null!;
|
||||
private Image FaviconImage = null!;
|
||||
private CancellationTokenSource contactPictureLoadingCancellationTokenSource = null!;
|
||||
private readonly IThumbnailService? _thumbnailService;
|
||||
private readonly IPreferencesService? _preferencesService;
|
||||
private CancellationTokenSource? _refreshCancellationTokenSource;
|
||||
private CancellationTokenSource? _scheduledRefreshCancellationTokenSource;
|
||||
private long _refreshVersion;
|
||||
|
||||
public ImagePreviewControl()
|
||||
{
|
||||
DefaultStyleKey = nameof(ImagePreviewControl);
|
||||
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;
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
|
||||
InitialsGrid = (GetTemplateChild(PART_EllipseInitialsGrid) as Grid)!;
|
||||
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();
|
||||
RequestRefresh();
|
||||
}
|
||||
|
||||
private static void OnInformationChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
|
||||
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)
|
||||
{
|
||||
if (obj is ImagePreviewControl control)
|
||||
control.UpdateInformation();
|
||||
RequestRefresh();
|
||||
}
|
||||
|
||||
private async void UpdateInformation()
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((KnownHostImage == null && FaviconSquircle == null) || InitialsGrid == null || InitialsTextblock == null || (string.IsNullOrEmpty(FromName) && string.IsNullOrEmpty(FromAddress)))
|
||||
CancelScheduledRefresh();
|
||||
CancelActiveRefresh();
|
||||
}
|
||||
|
||||
private void RequestRefresh()
|
||||
{
|
||||
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
|
||||
{
|
||||
QueueRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(QueueRefresh);
|
||||
}
|
||||
|
||||
private void QueueRefresh()
|
||||
{
|
||||
if (!IsLoaded)
|
||||
return;
|
||||
|
||||
// Cancel active image loading if exists.
|
||||
if (!contactPictureLoadingCancellationTokenSource?.IsCancellationRequested ?? false)
|
||||
{
|
||||
contactPictureLoadingCancellationTokenSource!.Cancel();
|
||||
}
|
||||
CancelScheduledRefresh();
|
||||
|
||||
string contactPicture = SenderContactPicture;
|
||||
var cts = new CancellationTokenSource();
|
||||
_scheduledRefreshCancellationTokenSource = cts;
|
||||
|
||||
var isAvatarThumbnail = false;
|
||||
|
||||
if (string.IsNullOrEmpty(contactPicture) && !string.IsNullOrEmpty(FromAddress))
|
||||
{
|
||||
contactPicture = await App.Current.ThumbnailService.GetThumbnailAsync(FromAddress);
|
||||
isAvatarThumbnail = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(contactPicture))
|
||||
{
|
||||
if (isAvatarThumbnail && FaviconSquircle != null && FaviconImage != null)
|
||||
{
|
||||
// Show favicon in squircle
|
||||
FaviconSquircle.Visibility = Visibility.Visible;
|
||||
if (InitialsGrid != null)
|
||||
InitialsGrid.Visibility = Visibility.Collapsed;
|
||||
if (KnownHostImage != null)
|
||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
||||
|
||||
var bitmapImage = await GetBitmapImageAsync(contactPicture);
|
||||
|
||||
if (bitmapImage != null)
|
||||
{
|
||||
FaviconImage.Source = bitmapImage;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Show normal avatar (tondo)
|
||||
if (FaviconSquircle != null)
|
||||
FaviconSquircle.Visibility = Visibility.Collapsed;
|
||||
if (KnownHostImage != null)
|
||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
||||
if (InitialsGrid != null)
|
||||
InitialsGrid.Visibility = Visibility.Visible;
|
||||
contactPictureLoadingCancellationTokenSource = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
var brush = await GetContactImageBrushAsync(contactPicture);
|
||||
|
||||
if (brush != null)
|
||||
{
|
||||
if (!contactPictureLoadingCancellationTokenSource?.Token.IsCancellationRequested ?? false)
|
||||
{
|
||||
if (Ellipse != null)
|
||||
Ellipse.Fill = brush;
|
||||
if (InitialsTextblock != null)
|
||||
InitialsTextblock.Text = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Debugger.Break();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (FaviconSquircle != null)
|
||||
FaviconSquircle.Visibility = Visibility.Collapsed;
|
||||
if (KnownHostImage != null)
|
||||
KnownHostImage.Visibility = Visibility.Collapsed;
|
||||
if (InitialsGrid != null)
|
||||
InitialsGrid.Visibility = Visibility.Visible;
|
||||
|
||||
var colorHash = new ColorHash();
|
||||
var rgb = colorHash.Rgb(FromAddress);
|
||||
|
||||
if (Ellipse != null)
|
||||
Ellipse.Fill = new SolidColorBrush(Color.FromArgb(rgb.A, rgb.R, rgb.G, rgb.B));
|
||||
if (InitialsTextblock != null)
|
||||
InitialsTextblock.Text = ExtractInitialsFromName(FromName);
|
||||
}
|
||||
_ = DebounceAndRefreshAsync(cts.Token);
|
||||
}
|
||||
|
||||
private static async Task<ImageBrush?> GetContactImageBrushAsync(string base64)
|
||||
{
|
||||
// Load the image from base64 string.
|
||||
|
||||
var bitmapImage = await GetBitmapImageAsync(base64);
|
||||
|
||||
if (bitmapImage == null) return null;
|
||||
|
||||
return new ImageBrush() { ImageSource = bitmapImage };
|
||||
}
|
||||
|
||||
private static async Task<BitmapImage?> GetBitmapImageAsync(string base64)
|
||||
private async Task DebounceAndRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
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;
|
||||
await Task.Delay(RefreshDebounceDuration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
return null;
|
||||
StartRefresh();
|
||||
}
|
||||
|
||||
public string ExtractInitialsFromName(string name)
|
||||
private void StartRefresh()
|
||||
{
|
||||
// Change from name to from address in case of name doesn't exists.
|
||||
if (string.IsNullOrEmpty(name))
|
||||
CancelActiveRefresh();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_refreshCancellationTokenSource = cts;
|
||||
var refreshVersion = Interlocked.Increment(ref _refreshVersion);
|
||||
_ = RefreshAsync(refreshVersion, cts.Token);
|
||||
}
|
||||
|
||||
private void CancelScheduledRefresh()
|
||||
{
|
||||
var cts = _scheduledRefreshCancellationTokenSource;
|
||||
_scheduledRefreshCancellationTokenSource = null;
|
||||
|
||||
if (cts != null && !cts.IsCancellationRequested)
|
||||
{
|
||||
name = FromAddress;
|
||||
cts.Cancel();
|
||||
}
|
||||
|
||||
// first remove all: punctuation, separator chars, control chars, and numbers (unicode style regexes)
|
||||
string initials = Regex.Replace(name, @"[\p{P}\p{S}\p{C}\p{N}]+", "");
|
||||
cts?.Dispose();
|
||||
}
|
||||
|
||||
// Replacing all possible whitespace/separator characters (unicode style), with a single, regular ascii space.
|
||||
initials = Regex.Replace(initials, @"\p{Z}+", " ");
|
||||
private void CancelActiveRefresh()
|
||||
{
|
||||
var cts = _refreshCancellationTokenSource;
|
||||
_refreshCancellationTokenSource = null;
|
||||
|
||||
// Remove all Sr, Jr, I, II, III, IV, V, VI, VII, VIII, IX at the end of names
|
||||
initials = Regex.Replace(initials.Trim(), @"\s+(?:[JS]R|I{1,3}|I[VX]|VI{0,3})$", "", RegexOptions.IgnoreCase);
|
||||
|
||||
// Extract up to 2 initials from the remaining cleaned name.
|
||||
initials = Regex.Replace(initials, @"^(\p{L})[^\s]*(?:\s+(?:\p{L}+\s+(?=\p{L}))?(?:(\p{L})\p{L}*)?)?$", "$1$2").Trim();
|
||||
|
||||
if (initials.Length > 2)
|
||||
if (cts != null && !cts.IsCancellationRequested)
|
||||
{
|
||||
// Worst case scenario, everything failed, just grab the first two letters of what we have left.
|
||||
initials = initials.Substring(0, 2);
|
||||
cts.Cancel();
|
||||
}
|
||||
|
||||
return initials.ToUpperInvariant();
|
||||
cts?.Dispose();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync(long refreshVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await CaptureSnapshotAsync(refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot == null)
|
||||
return;
|
||||
|
||||
await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 1) Explicit contact picture.
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture))
|
||||
{
|
||||
var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false);
|
||||
if (localBitmap != null)
|
||||
{
|
||||
await ApplyProfilePictureAsync(localBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Gravatar lookup through thumbnail service (if enabled).
|
||||
if (_preferencesService?.IsGravatarEnabled == true &&
|
||||
_thumbnailService != null &&
|
||||
!string.IsNullOrWhiteSpace(snapshot.Address) &&
|
||||
EmailValidator.Validate(snapshot.Address))
|
||||
{
|
||||
var thumbnailBase64 = await _thumbnailService
|
||||
.GetThumbnailAsync(snapshot.Address.Trim().ToLowerInvariant(), awaitLoad: true)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(thumbnailBase64))
|
||||
{
|
||||
var thumbnailBitmap = await CreateBitmapFromBase64Async(thumbnailBase64, cancellationToken).ConfigureAwait(false);
|
||||
if (thumbnailBitmap != null)
|
||||
{
|
||||
await ApplyProfilePictureAsync(thumbnailBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Initials fallback is already in place via DisplayName + ProfilePicture = null.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during virtualization/recycling.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback initials if decoding/network fails.
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private string ResolveDisplayName(string resolvedAddress)
|
||||
{
|
||||
var contactName = MailItemInformation?.SenderContact?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(contactName))
|
||||
return contactName.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MailItemInformation?.FromName))
|
||||
return MailItemInformation.FromName.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(FromName))
|
||||
return FromName.Trim();
|
||||
|
||||
return resolvedAddress.Trim();
|
||||
}
|
||||
|
||||
private string ResolveBase64Picture()
|
||||
{
|
||||
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(() =>
|
||||
{
|
||||
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."));
|
||||
}
|
||||
|
||||
await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteOnUiThreadAsync<T>(Func<T> func)
|
||||
{
|
||||
if (DispatcherQueue == null || DispatcherQueue.HasThreadAccess)
|
||||
{
|
||||
return func();
|
||||
}
|
||||
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var enqueued = DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
completion.TrySetResult(func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (!enqueued)
|
||||
{
|
||||
completion.TrySetException(new InvalidOperationException("Failed to dispatch UI update."));
|
||||
}
|
||||
|
||||
return await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<BitmapImage?> CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return null;
|
||||
|
||||
byte[] bytes;
|
||||
|
||||
try
|
||||
{
|
||||
bytes = await Task.Run(() => Convert.FromBase64String(base64), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return await ExecuteOnUiThreadAsync(() =>
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
VerticalAlignment="Top"
|
||||
Canvas.ZIndex="0"
|
||||
Fill="{ThemeResource SystemAccentColor}"
|
||||
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(IsRead), Mode=OneWay}" />
|
||||
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(MailItemInformation.IsRead), Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -73,10 +73,7 @@
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
FromAddress="{x:Bind FromAddress, Mode=OneWay}"
|
||||
FromName="{x:Bind FromName, Mode=OneWay}"
|
||||
SenderContactPicture="{x:Bind Base64ContactPicture, Mode=OneWay}"
|
||||
ThumbnailUpdatedEvent="{x:Bind IsThumbnailUpdated, Mode=OneWay}"
|
||||
MailItemInformation="{x:Bind MailItemInformation, Mode=OneWay}"
|
||||
Visibility="{x:Bind IsAvatarVisible, Mode=OneWay}" />
|
||||
|
||||
<Grid
|
||||
@@ -104,7 +101,7 @@
|
||||
<TextBlock
|
||||
x:Name="DraftTitle"
|
||||
Margin="0,0,4,0"
|
||||
x:Load="{x:Bind IsDraft, Mode=OneWay}"
|
||||
x:Load="{x:Bind MailItemInformation.IsDraft, Mode=OneWay}"
|
||||
Foreground="{StaticResource DeleteBrush}">
|
||||
|
||||
<Run Text="[" /><Run Text="{x:Bind domain:Translator.Draft}" /><Run Text="]" /> <Run Text=" " />
|
||||
@@ -114,17 +111,17 @@
|
||||
<TextBlock
|
||||
x:Name="SenderTextFromName"
|
||||
Grid.Column="1"
|
||||
Text="{x:Bind FromName, Mode=OneWay}"
|
||||
Text="{x:Bind MailItemInformation.FromName, Mode=OneWay}"
|
||||
TextTrimming="WordEllipsis"
|
||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(FromName), Mode=OneWay}" />
|
||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(MailItemInformation.FromName), Mode=OneWay}" />
|
||||
|
||||
<!-- Sender -->
|
||||
<TextBlock
|
||||
x:Name="SenderTextFromAddress"
|
||||
Grid.Column="1"
|
||||
Text="{x:Bind FromAddress}"
|
||||
Text="{x:Bind MailItemInformation.FromAddress}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityReversedConverter(FromName), Mode=OneWay}" />
|
||||
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityReversedConverter(MailItemInformation.FromName), Mode=OneWay}" />
|
||||
|
||||
<!-- Hover button -->
|
||||
<StackPanel
|
||||
@@ -194,7 +191,7 @@
|
||||
x:Name="TitleText"
|
||||
Grid.Column="1"
|
||||
MaxLines="1"
|
||||
Text="{x:Bind Subject, Mode=OneWay}"
|
||||
Text="{x:Bind MailItemInformation.Subject, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<TextBlock
|
||||
@@ -204,7 +201,7 @@
|
||||
VerticalAlignment="Center"
|
||||
FontSize="11"
|
||||
Opacity="0.7"
|
||||
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(IsDraft, CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
||||
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(MailItemInformation.IsDraft, MailItemInformation.CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Message -->
|
||||
@@ -220,10 +217,10 @@
|
||||
<Grid x:Name="PreviewTextContainer">
|
||||
<TextBlock
|
||||
x:Name="PreviewTextblock"
|
||||
x:Load="{x:Bind helpers:XamlHelpers.ShouldDisplayPreview(PreviewText), Mode=OneWay}"
|
||||
x:Load="{x:Bind helpers:XamlHelpers.ShouldDisplayPreview(MailItemInformation.PreviewText), Mode=OneWay}"
|
||||
MaxLines="1"
|
||||
Opacity="0.7"
|
||||
Text="{x:Bind PreviewText, Mode=OneWay}"
|
||||
Text="{x:Bind MailItemInformation.PreviewText, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
@@ -237,12 +234,12 @@
|
||||
|
||||
<ContentPresenter
|
||||
x:Name="HasAttachmentContent"
|
||||
x:Load="{x:Bind HasAttachments, Mode=OneWay}"
|
||||
x:Load="{x:Bind MailItemInformation.HasAttachments, Mode=OneWay}"
|
||||
ContentTemplate="{StaticResource AttachmentSymbolControlTemplate}" />
|
||||
|
||||
<ContentPresenter
|
||||
x:Name="IsFlaggedContent"
|
||||
x:Load="{x:Bind IsFlagged, Mode=OneWay}"
|
||||
x:Load="{x:Bind MailItemInformation.IsFlagged, Mode=OneWay}"
|
||||
ContentTemplate="{StaticResource FlaggedSymbolControlTemplate}" />
|
||||
|
||||
<ProgressRing
|
||||
@@ -250,7 +247,7 @@
|
||||
Height="3"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
IsActive="{x:Bind IsBusy, Mode=OneWay}" />
|
||||
IsActive="{x:Bind MailItemInformation.IsBusy, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -263,7 +260,7 @@
|
||||
<VisualStateGroup x:Name="ReadStates">
|
||||
<VisualState x:Name="Unread">
|
||||
<VisualState.StateTriggers>
|
||||
<StateTrigger IsActive="{x:Bind IsRead, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}" />
|
||||
<StateTrigger IsActive="{x:Bind MailItemInformation.IsRead, Converter={StaticResource ReverseBooleanConverter}, Mode=OneWay}" />
|
||||
</VisualState.StateTriggers>
|
||||
|
||||
<VisualState.Setters>
|
||||
@@ -335,7 +332,7 @@
|
||||
<Setter Target="ExpandCollapseChevron.(controls:AnimatedIcon.State)" Value="NormalOn" />
|
||||
</VisualState.Setters>
|
||||
<VisualState.StateTriggers>
|
||||
<StateTrigger IsActive="{x:Bind IsThreadExpanded, Mode=OneWay}" />
|
||||
<StateTrigger IsActive="{x:Bind MailItemInformation.IsThreadExpanded, Mode=OneWay}" />
|
||||
</VisualState.StateTriggers>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
@@ -51,9 +51,6 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
||||
[GeneratedDependencyProperty(DefaultValue = true)]
|
||||
public partial bool IsHoverActionsEnabled { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
public event EventHandler<MailOperationPreperationRequest>? HoverActionExecuted;
|
||||
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
@@ -62,49 +59,12 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
||||
[GeneratedDependencyProperty]
|
||||
public partial IMailListItem? ActionItem { get; set; }
|
||||
|
||||
#region Display Properties
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? Subject { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? FromName { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? FromAddress { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? PreviewText { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial bool IsRead { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial bool IsDraft { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial bool HasAttachments { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial bool IsFlagged { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial DateTime CreationDate { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? Base64ContactPicture { get; set; }
|
||||
public partial IMailItemDisplayInformation? MailItemInformation { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
public partial bool IsThreadExpanderVisible { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
public partial bool IsThreadExpanded { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||
public partial bool IsThumbnailUpdated { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
public MailItemDisplayInformationControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -138,16 +98,14 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
||||
_compositor = this.Visual().Compositor;
|
||||
}
|
||||
|
||||
partial void OnIsBusyChanged(bool newValue)
|
||||
partial void OnMailItemInformationPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (newValue)
|
||||
if (ActionItem == null && MailItemInformation is IMailListItem mailListItem)
|
||||
{
|
||||
StartBusyAnimation();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopBusyAnimation();
|
||||
ActionItem = mailListItem;
|
||||
}
|
||||
|
||||
UpdateBusyAnimationState();
|
||||
}
|
||||
|
||||
private void StartBusyAnimation()
|
||||
@@ -184,9 +142,15 @@ public sealed partial class MailItemDisplayInformationControl : UserControl
|
||||
_opacityAnimation = null;
|
||||
}
|
||||
|
||||
partial void OnIsFlaggedChanged(bool newValue)
|
||||
private void UpdateBusyAnimationState()
|
||||
{
|
||||
if (MailItemInformation?.IsBusy == true)
|
||||
{
|
||||
StartBusyAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
StopBusyAnimation();
|
||||
}
|
||||
|
||||
private void ControlPointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:domain="using:Wino.Core.Domain"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="Edit Contact"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
DefaultButton="Primary"
|
||||
IsPrimaryButtonEnabled="True"
|
||||
PrimaryButtonClick="SaveClicked"
|
||||
PrimaryButtonText="Save"
|
||||
PrimaryButtonText="{x:Bind domain:Translator.Buttons_Save, Mode=OneTime}"
|
||||
SecondaryButtonClick="CancelClicked"
|
||||
SecondaryButtonText="Cancel"
|
||||
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel, Mode=OneTime}"
|
||||
Style="{StaticResource WinoDialogStyle}"
|
||||
mc:Ignorable="d">
|
||||
|
||||
@@ -24,15 +23,15 @@
|
||||
<!-- Contact Name -->
|
||||
<TextBox
|
||||
x:Name="ContactNameTextBox"
|
||||
Header="Name"
|
||||
PlaceholderText="Contact name"
|
||||
Header="{x:Bind domain:Translator.ContactEditDialog_NameHeader, Mode=OneTime}"
|
||||
PlaceholderText="{x:Bind domain:Translator.ContactEditDialog_NamePlaceholder, Mode=OneTime}"
|
||||
TextChanged="ValidateInput" />
|
||||
|
||||
<!-- Email Address -->
|
||||
<TextBox
|
||||
x:Name="EmailAddressTextBox"
|
||||
Header="Email Address"
|
||||
PlaceholderText="contact@example.com"
|
||||
Header="{x:Bind domain:Translator.ContactEditDialog_EmailHeader, Mode=OneTime}"
|
||||
PlaceholderText="{x:Bind domain:Translator.ContactEditDialog_EmailPlaceholder, Mode=OneTime}"
|
||||
TextChanged="ValidateInput" />
|
||||
|
||||
<!-- Contact Photo -->
|
||||
@@ -40,7 +39,7 @@
|
||||
<TextBlock
|
||||
Margin="0,0,0,8"
|
||||
FontWeight="SemiBold"
|
||||
Text="Photo" />
|
||||
Text="{x:Bind domain:Translator.ContactEditDialog_PhotoSection, Mode=OneTime}" />
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -61,11 +60,12 @@
|
||||
<Button
|
||||
x:Name="ChoosePhotoButton"
|
||||
Click="ChoosePhotoClicked"
|
||||
Content="Choose Photo" />
|
||||
Content="{x:Bind domain:Translator.ContactEditDialog_ChoosePhoto, Mode=OneTime}" />
|
||||
<Button
|
||||
x:Name="RemovePhotoButton"
|
||||
Click="RemovePhotoClicked"
|
||||
Content="Remove Photo" />
|
||||
Content="{x:Bind domain:Translator.ContactEditDialog_RemovePhoto, Mode=OneTime}"
|
||||
Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -79,7 +79,7 @@
|
||||
Visibility="Collapsed">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
||||
Text="This is a root contact and cannot be deleted."
|
||||
Text="{x:Bind domain:Translator.ContactEditDialog_RootContactInfo, Mode=OneTime}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
Visibility="Collapsed">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
||||
Text="This contact has been manually modified."
|
||||
Text="{x:Bind domain:Translator.ContactEditDialog_OverriddenContactInfo, Mode=OneTime}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
@@ -10,16 +13,20 @@ public sealed partial class ContactEditDialog : ContentDialog
|
||||
{
|
||||
private AccountContact _contact;
|
||||
private IDialogServiceBase? _dialogService;
|
||||
private bool _isEditMode;
|
||||
|
||||
public AccountContact Contact => _contact;
|
||||
|
||||
public ContactEditDialog(AccountContact? contact = null, IDialogServiceBase? dialogService = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
|
||||
_contact = contact ?? new AccountContact();
|
||||
_dialogService = dialogService;
|
||||
|
||||
_isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address);
|
||||
|
||||
Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle;
|
||||
|
||||
LoadContactData();
|
||||
ValidateInput();
|
||||
}
|
||||
@@ -31,67 +38,109 @@ public sealed partial class ContactEditDialog : ContentDialog
|
||||
ContactNameTextBox.Text = _contact.Name ?? string.Empty;
|
||||
EmailAddressTextBox.Text = _contact.Address ?? string.Empty;
|
||||
|
||||
// Show info badges
|
||||
// Disable email editing for existing contacts (Address is PK).
|
||||
EmailAddressTextBox.IsEnabled = !_isEditMode;
|
||||
|
||||
if (_contact.IsRootContact)
|
||||
{
|
||||
RootContactInfoBorder.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
if (_contact.IsOverridden)
|
||||
{
|
||||
OverriddenContactInfoBorder.Visibility = Visibility.Visible;
|
||||
|
||||
// Load existing photo.
|
||||
if (!string.IsNullOrEmpty(_contact.Base64ContactPicture))
|
||||
{
|
||||
LoadContactPhoto(_contact.Base64ContactPicture);
|
||||
RemovePhotoButton.Visibility = Visibility.Visible;
|
||||
}
|
||||
else
|
||||
{
|
||||
ContactPhotoPersonPicture.DisplayName = _contact.Name ?? string.Empty;
|
||||
RemovePhotoButton.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ChoosePhotoClicked(object sender, RoutedEventArgs e)
|
||||
private async void ChoosePhotoClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// TODO: Implement photo picker
|
||||
if (_dialogService == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var files = await _dialogService.PickFilesAsync(".png", ".jpg", ".jpeg");
|
||||
|
||||
if (files?.Count > 0)
|
||||
{
|
||||
var file = files[0];
|
||||
var base64 = Convert.ToBase64String(file.Data);
|
||||
_contact.Base64ContactPicture = base64;
|
||||
LoadContactPhoto(base64);
|
||||
RemovePhotoButton.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Failed to pick photo, ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePhotoClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_contact.Base64ContactPicture = null;
|
||||
ContactPhotoPersonPicture.ProfilePicture = null;
|
||||
ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text;
|
||||
RemovePhotoButton.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void LoadContactPhoto(string base64String)
|
||||
{
|
||||
try
|
||||
{
|
||||
var imageBytes = Convert.FromBase64String(base64String);
|
||||
using var stream = new MemoryStream(imageBytes);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.SetSource(stream.AsRandomAccessStream());
|
||||
ContactPhotoPersonPicture.ProfilePicture = bitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Failed to load image, ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateInput(object? sender = null, TextChangedEventArgs? e = null)
|
||||
{
|
||||
var hasName = !string.IsNullOrWhiteSpace(ContactNameTextBox.Text);
|
||||
var hasEmail = !string.IsNullOrWhiteSpace(EmailAddressTextBox.Text);
|
||||
var isValidEmail = hasEmail && IsValidEmail(EmailAddressTextBox.Text);
|
||||
|
||||
IsPrimaryButtonEnabled = hasName && isValidEmail;
|
||||
}
|
||||
var isValidEmail = hasEmail && EmailValidation.EmailValidator.Validate(EmailAddressTextBox.Text);
|
||||
|
||||
private bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
if (_isEditMode)
|
||||
{
|
||||
var addr = new System.Net.Mail.MailAddress(email);
|
||||
return addr.Address == email;
|
||||
// In edit mode, only name is required (email is locked).
|
||||
IsPrimaryButtonEnabled = hasName;
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
return false;
|
||||
// In create mode, both name and valid email are required.
|
||||
IsPrimaryButtonEnabled = hasName && isValidEmail;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
// Update contact data
|
||||
_contact.Name = ContactNameTextBox.Text?.Trim();
|
||||
_contact.Address = EmailAddressTextBox.Text?.Trim();
|
||||
|
||||
// Mark as overridden if this was a user edit
|
||||
if (!string.IsNullOrEmpty(_contact.Address))
|
||||
{
|
||||
if (!_isEditMode)
|
||||
_contact.Address = EmailAddressTextBox.Text?.Trim();
|
||||
|
||||
// Mark as overridden if this was a user edit of an existing contact.
|
||||
if (_isEditMode)
|
||||
_contact.IsOverridden = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
// Nothing to do, dialog will close
|
||||
// Nothing to do, dialog will close.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,54 +5,8 @@
|
||||
|
||||
<Style TargetType="controls:ImagePreviewControl">
|
||||
<Style.Setters>
|
||||
<Setter Property="Width" Value="34" />
|
||||
<Setter Property="Height" Value="34" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="controls:ImagePreviewControl">
|
||||
<Grid>
|
||||
<!-- Ellipse Initials -->
|
||||
<Grid x:Name="EllipseInitialsGrid">
|
||||
<Ellipse
|
||||
x:Name="Ellipse"
|
||||
Grid.RowSpan="2"
|
||||
Width="{TemplateBinding Width}"
|
||||
Height="{TemplateBinding Height}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock
|
||||
x:Name="InitialsTextBlock"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
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"
|
||||
Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="Width" Value="44" />
|
||||
<Setter Property="Height" Value="44" />
|
||||
</Style.Setters>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -55,20 +55,9 @@
|
||||
<controls:MailItemDisplayInformationControl
|
||||
x:DefaultBindMode="OneWay"
|
||||
ActionItem="{x:Bind}"
|
||||
Base64ContactPicture="{x:Bind MailCopy.SenderContact.Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
|
||||
ContextRequested="MailItemContextRequested"
|
||||
CreationDate="{x:Bind CreationDate}"
|
||||
FromAddress="{x:Bind FromAddress}"
|
||||
FromName="{x:Bind FromName}"
|
||||
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
|
||||
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
||||
IsBusy="{x:Bind IsBusy, Mode=OneWay}"
|
||||
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
|
||||
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
|
||||
IsRead="{x:Bind IsRead, Mode=OneWay}"
|
||||
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
|
||||
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
|
||||
Subject="{x:Bind Subject, Mode=OneWay}" />
|
||||
MailItemInformation="{x:Bind}" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="ThreadMailItemTemplate" x:DataType="viewModelData:ThreadMailItemViewModel">
|
||||
@@ -80,22 +69,10 @@
|
||||
<controls:MailItemDisplayInformationControl
|
||||
x:DefaultBindMode="OneWay"
|
||||
ActionItem="{x:Bind}"
|
||||
Base64ContactPicture="{x:Bind Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
|
||||
ContextRequested="MailItemContextRequested"
|
||||
CreationDate="{x:Bind CreationDate}"
|
||||
FromAddress="{x:Bind FromAddress, Mode=OneWay}"
|
||||
FromName="{x:Bind FromName, Mode=OneWay}"
|
||||
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
|
||||
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
||||
IsBusy="{x:Bind IsBusy, Mode=OneWay}"
|
||||
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
|
||||
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
|
||||
IsRead="{x:Bind IsRead, Mode=OneWay}"
|
||||
IsThreadExpanded="{x:Bind IsThreadExpanded, Mode=OneWay}"
|
||||
IsThreadExpanderVisible="True"
|
||||
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
|
||||
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
|
||||
Subject="{x:Bind Subject, Mode=OneWay}" />
|
||||
MailItemInformation="{x:Bind}" />
|
||||
</controls:WinoExpander.Header>
|
||||
<controls:WinoExpander.Content>
|
||||
<listview:WinoListView
|
||||
|
||||
@@ -633,7 +633,8 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
// * Clicking a single (non-thread) item OR a child item: collapse & unselect all others then toggle that item's selection.
|
||||
// If it was selected, result is nothing selected.
|
||||
|
||||
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed();
|
||||
// Treat toolbar multi-select mode the same as holding CTRL for click selection behavior.
|
||||
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed() || ViewModel.IsMultiSelectionModeEnabled;
|
||||
|
||||
// Helper local to collapse all other threads (we always collapse ALL then possibly re-expand the active thread per rules)
|
||||
async Task CollapseAllThreadsExceptAsync(ThreadMailItemViewModel? except)
|
||||
@@ -648,6 +649,36 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
}
|
||||
}
|
||||
|
||||
ThreadMailItemViewModel? FindParentThread(MailItemViewModel mail)
|
||||
{
|
||||
foreach (var group in ViewModel.MailCollection.MailItems)
|
||||
{
|
||||
foreach (var item in group)
|
||||
{
|
||||
if (item is ThreadMailItemViewModel thread && thread.ThreadEmails.Contains(mail))
|
||||
{
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static void SyncThreadSelectionFromChildren(ThreadMailItemViewModel? thread)
|
||||
{
|
||||
if (thread == null) return;
|
||||
|
||||
bool hasSelectedChildren = thread.ThreadEmails.Any(child => child.IsSelected);
|
||||
thread.IsSelected = hasSelectedChildren;
|
||||
|
||||
// Keep thread open while it has selected children.
|
||||
if (hasSelectedChildren)
|
||||
{
|
||||
thread.IsThreadExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCtrlPressed)
|
||||
{
|
||||
switch (clickedItem)
|
||||
@@ -678,6 +709,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
{
|
||||
// Toggle just this item; no collapse/unselect of others in multi-select mode.
|
||||
mail.IsSelected = !mail.IsSelected;
|
||||
SyncThreadSelectionFromChildren(FindParentThread(mail));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -730,20 +762,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
|
||||
// Determine if this mail belongs to an already selected & expanded thread.
|
||||
// If so, we only want to switch the selection inside that thread without collapsing or unselecting the thread header.
|
||||
ThreadMailItemViewModel? parentThread = null;
|
||||
|
||||
foreach (var group in ViewModel.MailCollection.MailItems)
|
||||
{
|
||||
foreach (var item in group)
|
||||
{
|
||||
if (item is ThreadMailItemViewModel thread && thread.ThreadEmails.Contains(clickedMail))
|
||||
{
|
||||
parentThread = thread;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parentThread != null) break;
|
||||
}
|
||||
ThreadMailItemViewModel? parentThread = FindParentThread(clickedMail);
|
||||
|
||||
bool isInSelectedExpandedThread = parentThread != null && parentThread.IsSelected && parentThread.IsThreadExpanded;
|
||||
|
||||
@@ -758,20 +777,31 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
}
|
||||
}
|
||||
|
||||
if (wasSelected && parentThread != null)
|
||||
{
|
||||
// Clicking the already selected child should leave the thread header selected (canonical state: thread + first child previously).
|
||||
// Decide whether to keep a child selected; spec wants toggle off allowed, so leave no child selected.
|
||||
// Ensure parent thread stays selected & expanded.
|
||||
parentThread.IsSelected = true;
|
||||
parentThread.IsThreadExpanded = true;
|
||||
}
|
||||
SyncThreadSelectionFromChildren(parentThread);
|
||||
return; // Done.
|
||||
}
|
||||
|
||||
// Normal single-item (non-thread or entering a thread via child) behavior.
|
||||
await ViewModel.MailCollection.UnselectAllAsync();
|
||||
await ViewModel.MailCollection.CollapseAllThreadsAsync();
|
||||
|
||||
// If parent thread is already expanded, keep it as-is to avoid collapse/expand animation.
|
||||
if (parentThread != null && parentThread.IsThreadExpanded)
|
||||
{
|
||||
foreach (var group in ViewModel.MailCollection.MailItems)
|
||||
{
|
||||
foreach (var item in group)
|
||||
{
|
||||
if (item is ThreadMailItemViewModel thread && !ReferenceEquals(thread, parentThread))
|
||||
{
|
||||
thread.IsThreadExpanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.MailCollection.CollapseAllThreadsAsync();
|
||||
}
|
||||
|
||||
if (parentThread != null && selectExpandThread)
|
||||
{
|
||||
@@ -784,6 +814,8 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
||||
{
|
||||
clickedMail.IsSelected = true; // Toggle on
|
||||
}
|
||||
|
||||
SyncThreadSelectionFromChildren(parentThread);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,99 +3,100 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:abstract="using:Wino.Views.Abstract"
|
||||
xmlns:controls="using:Wino.Controls"
|
||||
xmlns:controls1="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:domain="using:Wino.Core.Domain"
|
||||
xmlns:entities="using:Wino.Core.Domain.Entities.Shared"
|
||||
xmlns:helpers="using:Wino.Helpers"
|
||||
xmlns:listview="using:Wino.Mail.WinUI.Controls.ListView"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
|
||||
xmlns:toolkitExt="using:CommunityToolkit.WinUI"
|
||||
x:Name="root"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Page.Resources>
|
||||
<DataTemplate x:Key="ContactTemplate" x:DataType="entities:AccountContact">
|
||||
<Grid Margin="0,4" Padding="16,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid Margin="0,0,0,8" Padding="0,4">
|
||||
<Border
|
||||
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8" />
|
||||
|
||||
<!-- Contact Picture -->
|
||||
<PersonPicture
|
||||
Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,0,16,0"
|
||||
DisplayName="{x:Bind Name}"
|
||||
ProfilePicture="{x:Bind helpers:XamlHelpers.Base64ToBitmapImage(Base64ContactPicture)}" />
|
||||
<Grid Padding="12" ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PersonPicture
|
||||
Grid.Column="0"
|
||||
Width="40"
|
||||
Height="40"
|
||||
DisplayName="{x:Bind Name, Mode=OneTime, TargetNullValue=''}"
|
||||
ProfilePicture="{x:Bind helpers:XamlHelpers.Base64ToBitmapImage(Base64ContactPicture), Mode=OneWay}" />
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind Name, Mode=OneTime, TargetNullValue=''}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Address, Mode=OneTime}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border
|
||||
Padding="4,2"
|
||||
Background="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
CornerRadius="2"
|
||||
Visibility="{x:Bind IsRootContact}">
|
||||
<TextBlock
|
||||
FontSize="10"
|
||||
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
|
||||
Text="{x:Bind domain:Translator.ContactStatus_Account, Mode=OneTime}" />
|
||||
</Border>
|
||||
<Border
|
||||
Padding="4,2"
|
||||
CornerRadius="2"
|
||||
Visibility="{x:Bind IsOverridden}">
|
||||
<TextBlock FontSize="10" Text="{x:Bind domain:Translator.ContactStatus_Modified, Mode=OneTime}" />
|
||||
x:Name="ModifiedBorder"
|
||||
Padding="6,2"
|
||||
HorizontalAlignment="Left"
|
||||
x:Load="{x:Bind IsOverridden, Mode=OneTime}"
|
||||
Background="{ThemeResource SubtleFillColorSecondaryBrush}"
|
||||
CornerRadius="4">
|
||||
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.ContactStatus_Modified, Mode=OneTime}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button
|
||||
Click="EditContact_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Edit, Mode=OneTime}">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
</Button>
|
||||
<Button
|
||||
Click="PickContactPhoto_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_ChangePhoto, Mode=OneTime}">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
</Button>
|
||||
<Button
|
||||
Click="DeleteContact_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
IsEnabled="{x:Bind helpers:XamlHelpers.ReverseBoolConverter(IsRootContact)}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="4">
|
||||
<Button
|
||||
Click="EditContact_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Edit, Mode=OneTime}">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</Button>
|
||||
<Button
|
||||
Click="PickContactPhoto_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_ChangePhoto, Mode=OneTime}">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</Button>
|
||||
<Button
|
||||
Click="DeleteContact_Click"
|
||||
CommandParameter="{x:Bind}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</Page.Resources>
|
||||
|
||||
<Grid MaxWidth="700">
|
||||
<Grid
|
||||
MaxWidth="980"
|
||||
Padding="24,20,24,16"
|
||||
RowSpacing="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
@@ -103,14 +104,13 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<Grid Grid.Row="0" Padding="24,16">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
@@ -118,122 +118,175 @@
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind domain:Translator.ContactsPage_Subtitle, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Add, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Command="{x:Bind ViewModel.ToggleSelectionCommand}" Style="{StaticResource DefaultButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
<TextBlock Text="{x:Bind helpers:XamlHelpers.BoolToSelectionModeText(ViewModel.IsSelectionMode), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
Command="{x:Bind ViewModel.AddContactCommand}"
|
||||
Style="{StaticResource AccentButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Add, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Search and Selection Bar -->
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Padding="24,0,24,16"
|
||||
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}">
|
||||
<Grid Grid.Row="1" ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}"
|
||||
TextWrapping="Wrap">
|
||||
<Run Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}" />
|
||||
<Run Text="{x:Bind domain:Translator.ContactSelection_Selected, Mode=OneTime}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<AutoSuggestBox
|
||||
PlaceholderText="{x:Bind domain:Translator.ContactsPage_SearchPlaceholder, Mode=OneTime}"
|
||||
QueryIcon="Find"
|
||||
Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay}" />
|
||||
|
||||
<StackPanel
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button Command="{x:Bind ViewModel.SelectAllContactsCommand}" Style="{StaticResource SubtleButtonStyle}">
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_SelectAll, Mode=OneTime}" />
|
||||
</Button>
|
||||
<Button Command="{x:Bind ViewModel.ClearSelectionCommand}" Style="{StaticResource SubtleButtonStyle}">
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_Clear, Mode=OneTime}" />
|
||||
</Button>
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DeleteSelectedContactsCommand}"
|
||||
IsEnabled="{x:Bind helpers:XamlHelpers.CountToBooleanConverter(ViewModel.SelectedContactsCount), Mode=OneWay}"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactAction_Delete, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
Command="{x:Bind ViewModel.ReloadContactsCommand}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Refresh, Mode=OneTime}">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Command="{x:Bind ViewModel.ToggleSelectionCommand}"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock Text="{x:Bind helpers:XamlHelpers.BoolToSelectionModeText(ViewModel.IsSelectionMode), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Search Box -->
|
||||
<AutoSuggestBox
|
||||
Grid.Row="1"
|
||||
Margin="24,0,24,16"
|
||||
PlaceholderText="{x:Bind domain:Translator.ContactsPage_SearchPlaceholder, Mode=OneTime}"
|
||||
QueryIcon="Find"
|
||||
Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay}"
|
||||
Visibility="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}" />
|
||||
<Grid
|
||||
x:Name="SelectionModeGrid"
|
||||
Grid.Row="2"
|
||||
x:Load="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}"
|
||||
ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Content -->
|
||||
<Grid Grid.Row="3" Padding="24,0">
|
||||
<!-- Loading Indicator -->
|
||||
<ProgressRing
|
||||
Width="48"
|
||||
Height="48"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
|
||||
Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />
|
||||
<TextBlock VerticalAlignment="Center">
|
||||
<Run Text="{x:Bind ViewModel.SelectedContactsCount, Mode=OneWay}" />
|
||||
<Run Text=" " />
|
||||
<Run Text="{x:Bind domain:Translator.ContactSelection_Selected, Mode=OneTime}" />
|
||||
</TextBlock>
|
||||
|
||||
<!-- Contacts List -->
|
||||
<ListView
|
||||
ItemTemplate="{StaticResource ContactTemplate}"
|
||||
ItemsSource="{x:Bind ViewModel.Contacts, Mode=OneWay}"
|
||||
SelectionMode="{x:Bind helpers:XamlHelpers.BoolToSelectionMode(ViewModel.IsSelectionMode), Mode=OneWay}">
|
||||
<ListView.ItemContainerTransitions>
|
||||
<TransitionCollection>
|
||||
<AddDeleteThemeTransition />
|
||||
<ContentThemeTransition />
|
||||
<ReorderThemeTransition />
|
||||
<EntranceThemeTransition IsStaggeringEnabled="True" />
|
||||
</TransitionCollection>
|
||||
</ListView.ItemContainerTransitions>
|
||||
</ListView>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Click="SelectAllContacts_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_SelectAll, Mode=OneTime}" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Click="ClearSelection_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactSelection_Clear, Mode=OneTime}" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Command="{x:Bind ViewModel.DeleteSelectedContactsCommand}"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_DeleteSelectedContacts, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<Border
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="10">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid
|
||||
Grid.Row="0"
|
||||
Padding="12,10"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}">
|
||||
<Run Text="{x:Bind ViewModel.TotalContactsCount, Mode=OneWay}" />
|
||||
<Run Text=" " />
|
||||
<Run Text="{x:Bind domain:Translator.ContactsPage_ContactsCountSuffix, Mode=OneTime}" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
<listview:WinoListView
|
||||
x:Name="ContactsListView"
|
||||
Grid.Row="1"
|
||||
Margin="6"
|
||||
toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal"
|
||||
IsMultiSelectCheckBoxEnabled="{x:Bind ViewModel.IsSelectionMode, Mode=OneWay}"
|
||||
ItemTemplate="{StaticResource ContactTemplate}"
|
||||
ItemsSource="{x:Bind ViewModel.Contacts, Mode=OneWay}"
|
||||
LoadMoreCommand="{x:Bind ViewModel.LoadMoreContactsCommand}"
|
||||
SelectionChanged="ContactsListView_SelectionChanged"
|
||||
SelectionMode="{x:Bind helpers:XamlHelpers.BoolToSelectionMode(ViewModel.IsSelectionMode), Mode=OneWay}">
|
||||
<listview:WinoListView.ItemContainerTransitions>
|
||||
<TransitionCollection>
|
||||
<AddDeleteThemeTransition />
|
||||
<ContentThemeTransition />
|
||||
<EntranceThemeTransition IsStaggeringEnabled="True" />
|
||||
</TransitionCollection>
|
||||
</listview:WinoListView.ItemContainerTransitions>
|
||||
</listview:WinoListView>
|
||||
|
||||
<ProgressRing
|
||||
Grid.Row="2"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Margin="0,8,0,10"
|
||||
HorizontalAlignment="Center"
|
||||
IsActive="{x:Bind ViewModel.IsLoadingMore, Mode=OneWay}"
|
||||
Visibility="{x:Bind ViewModel.IsLoadingMore, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid
|
||||
x:Name="LoadingGrid"
|
||||
x:Load="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
||||
<ProgressRing
|
||||
Width="48"
|
||||
Height="48"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsActive="True" />
|
||||
</Grid>
|
||||
|
||||
<!-- Empty State -->
|
||||
<StackPanel
|
||||
x:Name="IsEmptyPanel"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="16"
|
||||
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(ViewModel.SelectedContactsCount), Mode=OneWay}">
|
||||
x:Load="{x:Bind ViewModel.IsEmpty, Mode=OneWay}"
|
||||
Spacing="10">
|
||||
<FontIcon
|
||||
FontSize="48"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
FontSize="40"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind domain:Translator.ContactsPage_EmptyState, Mode=OneTime}"
|
||||
TextAlignment="Center" />
|
||||
Text="{x:Bind domain:Translator.ContactsPage_NoContacts, Mode=OneTime}" />
|
||||
<Button Command="{x:Bind ViewModel.AddContactCommand}" Style="{StaticResource AccentButtonStyle}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_AddFirstContact, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{x:Bind domain:Translator.ContactsPage_AddFirstContact, Mode=OneTime}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Mail.ViewModels;
|
||||
using Wino.Views.Abstract;
|
||||
|
||||
namespace Wino.Views.Settings;
|
||||
@@ -9,6 +13,9 @@ public sealed partial class ContactsPage : ContactsPageAbstract
|
||||
public ContactsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ViewModel.PropertyChanged += ViewModelPropertyChanged;
|
||||
Unloaded += ContactsPageUnloaded;
|
||||
}
|
||||
|
||||
private void EditContact_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
@@ -34,4 +41,73 @@ public sealed partial class ContactsPage : ContactsPageAbstract
|
||||
ViewModel.DeleteContactCommand.Execute(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ContactsListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView)
|
||||
return;
|
||||
|
||||
if (!ViewModel.IsSelectionMode)
|
||||
{
|
||||
ClearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var removedItem in e.RemovedItems.OfType<AccountContact>())
|
||||
{
|
||||
var selectedContact = ViewModel.SelectedContacts.FirstOrDefault(c =>
|
||||
string.Equals(c.Address, removedItem.Address, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (selectedContact != null)
|
||||
{
|
||||
ViewModel.SelectedContacts.Remove(selectedContact);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var addedItem in e.AddedItems.OfType<AccountContact>())
|
||||
{
|
||||
var alreadySelected = ViewModel.SelectedContacts.Any(c =>
|
||||
string.Equals(c.Address, addedItem.Address, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!alreadySelected)
|
||||
{
|
||||
ViewModel.SelectedContacts.Add(addedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAllContacts_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
if (!ViewModel.IsSelectionMode)
|
||||
return;
|
||||
|
||||
ContactsListView.SelectAll();
|
||||
}
|
||||
|
||||
private void ClearSelection_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
private void ViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ContactsPageViewModel.IsSelectionMode) && !ViewModel.IsSelectionMode)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
private void ContactsPageUnloaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
ViewModel.PropertyChanged -= ViewModelPropertyChanged;
|
||||
Unloaded -= ContactsPageUnloaded;
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
ContactsListView.SelectionChanged -= ContactsListView_SelectionChanged;
|
||||
ContactsListView.SelectedItems.Clear();
|
||||
ContactsListView.SelectionChanged += ContactsListView_SelectionChanged;
|
||||
ViewModel.SelectedContacts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,28 +26,25 @@
|
||||
<DataTemplate x:Key="CompactDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||
<controls1:MailItemDisplayInformationControl
|
||||
DisplayMode="Compact"
|
||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
||||
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||
ShowPreviewText="False"
|
||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
||||
/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="MediumDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||
<controls1:MailItemDisplayInformationControl
|
||||
DisplayMode="Medium"
|
||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
||||
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||
ShowPreviewText="True"
|
||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
||||
/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="SpaciousDisplayModePreviewTemplate" x:DataType="enums:MailListDisplayMode">
|
||||
<controls1:MailItemDisplayInformationControl
|
||||
DisplayMode="Spacious"
|
||||
FromAddress="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromAddress}"
|
||||
FromName="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.FromName}"
|
||||
MailItemInformation="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailItemInformation}"
|
||||
ShowPreviewText="True"
|
||||
Subject="{Binding ElementName=root, Path=ViewModel.DemoPreviewMailCopy.Subject}" />
|
||||
/>
|
||||
</DataTemplate>
|
||||
|
||||
<mailSelectors:MailItemDisplayModePreviewTemplateSelector
|
||||
|
||||
Reference in New Issue
Block a user