Files
ytLlive/ViewModels/SocialsDialogViewModel.cs

519 lines
17 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
/// <summary>
/// A validated social entry in the dialog's working copy (non-YouTube slots).
/// </summary>
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
/// <summary>
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
/// the dialog's working list, so deleting an entry makes the ones above advance up.
/// </summary>
public sealed class SocialSlotViewModel : ViewModelBase
{
public int Index { get; }
public bool IsYoutube => Index == 0;
private SocialService _service = SocialService.Link;
public SocialService Service
{
get => _service;
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
}
public string LogoData => Service == SocialService.Fediverse
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public string LockedIconData => SocialServiceIcons.LockedIconData;
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
private bool _isLocked;
public bool IsLocked
{
get => _isLocked;
set
{
if (SetProperty(ref _isLocked, value))
{
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isFilled;
public bool IsFilled
{
get => _isFilled;
set
{
if (SetProperty(ref _isFilled, value))
{
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isSignIn;
public bool IsSignIn
{
get => _isSignIn;
set
{
if (SetProperty(ref _isSignIn, value))
{
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowLogo));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set
{
if (SetProperty(ref _isEditing, value))
{
OnPropertyChanged(nameof(ShowEditBox));
OnPropertyChanged(nameof(ShowDisplay));
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private bool _isValidating;
public bool IsValidating
{
get => _isValidating;
set
{
if (SetProperty(ref _isValidating, value))
{
OnPropertyChanged(nameof(ShowBusy));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _handle = string.Empty;
public string Handle
{
get => _handle;
set
{
if (SetProperty(ref _handle, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private string? _fediverseSoftware;
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (SetProperty(ref _fediverseSoftware, value))
OnPropertyChanged(nameof(LogoData));
}
}
public string ProfileUrl { get; set; } = string.Empty;
private string? _error;
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(ShowError));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _editText = string.Empty;
public string EditText
{
get => _editText;
set => SetProperty(ref _editText, value);
}
/// <summary>The last text submitted for validation (used to skip re-validating
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
public string? LastValidatedInput { get; set; }
public string DisplayText =>
IsYoutube
? (IsSignIn ? "Sign in to YouTube" : Handle)
: IsLocked ? "Unlock with Premium"
: IsFilled ? Handle
: "No social yet";
public bool HasError => !string.IsNullOrEmpty(Error);
public bool ShowError => HasError;
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
public bool ShowBusy => IsValidating;
public bool ShowEditBox => IsEditing;
public bool ShowDisplay => !IsEditing && !IsValidating;
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
public SocialSlotViewModel(int index) => Index = index;
}
/// <summary>
/// View model for the Social Media Site Promotion dialog. Owns the working copy
/// of the social bar config: the six fixed slots, the sign-in gate, per-row
/// validation, the freemium lock, and the YouTube sign-out. Deliberately WPF-free
/// (no MessageBox, no Window) so the whole flow is unit-testable with fakes.
/// The window surfaces the confirmations (delete, sign-in gate) as dialogs and
/// calls back into the VM.
/// </summary>
public sealed class SocialsDialogViewModel : ViewModelBase
{
public const int MaxSlots = 6;
private readonly ISocialValidator _validator;
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
private readonly Func<Task> _signOutAction;
private readonly bool _isPremium;
private readonly List<DialogEntry> _working = new();
private readonly CancellationTokenSource _cts = new();
private YouTubeChannel? _account;
private bool _isSignedIn;
private bool _isBusy;
private bool _barEnabled = true;
public SocialsDialogViewModel(
ISocialValidator validator,
Func<Task<YouTubeChannel?>> signInProvider,
Func<Task> signOutAction,
YouTubeChannel? account,
bool isPremium,
SocialsConfig? current = null)
{
_validator = validator;
_signInProvider = signInProvider;
_signOutAction = signOutAction;
_isPremium = isPremium;
_barEnabled = current?.BarEnabled ?? true;
_account = account;
_isSignedIn = account != null;
foreach (var entry in current?.Entries ?? [])
if (entry.Service != SocialService.YouTube)
_working.Add(new DialogEntry(entry.Service, entry.Handle, entry.ProfileUrl));
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => CanSignIn);
SaveCommand = new RelayCommand(_ => Save(), _ => CanSave);
CancelCommand = new RelayCommand(_ => Cancel());
RebuildSlots();
}
public ObservableCollection<SocialSlotViewModel> Slots { get; } = new();
public bool BarEnabled
{
get => _barEnabled;
set => SetProperty(ref _barEnabled, value);
}
public bool IsSignedIn
{
get => _isSignedIn;
private set
{
if (SetProperty(ref _isSignedIn, value))
OnPropertyChanged(nameof(ShowSignInBanner));
}
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
{
OnPropertyChanged(nameof(ShowSignInBanner));
CommandManager.InvalidateRequerySuggested();
}
}
}
public bool ShowSignInBanner => !IsSignedIn && !IsBusy;
public bool CanSignIn => !IsBusy;
/// <summary>
/// Save is blocked while any slot has un-validated input, is mid-validation,
/// or failed validation — an empty slot or a confirmed entry never blocks.
/// </summary>
public bool CanSave =>
!IsBusy &&
Slots.All(s => !s.IsEditing || string.IsNullOrWhiteSpace(s.EditText)) &&
Slots.All(s => !s.IsValidating && !s.HasError);
public ICommand SignInCommand { get; }
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
public event Action? SaveRequested;
public event Action? CancelRequested;
/// <summary>Entries to persist after a successful save (YouTube first when signed in).</summary>
public IReadOnlyList<SocialEntry>? CommittedEntries { get; private set; }
/// <summary>
/// Dismissal is a hard stop: abort every in-flight validation and get out.
/// The canceled continuations never touch slot state.
/// </summary>
public void Cancel()
{
_cts.Cancel();
CancelRequested?.Invoke();
}
/// <summary>Aborts in-flight work without raising <see cref="CancelRequested"/>
/// (used by the window's Closing handler so a Save close isn't clobbered).</summary>
public void AbortPending() => _cts.Cancel();
private async Task SignInAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var channel = await _signInProvider();
if (channel != null)
{
_account = channel;
IsSignedIn = true;
RebuildSlots();
}
}
finally
{
IsBusy = false;
}
}
/// <summary>Starts editing the slot's input box (add or edit).</summary>
public void StartEdit(int slotIndex)
{
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
var slot = Slots[slotIndex];
if (slot.IsLocked || slot.IsYoutube || slot.IsValidating) return;
slot.EditText = slot.IsFilled ? slot.Handle : string.Empty;
slot.Error = null;
slot.IsEditing = true;
}
/// <summary>Commits the slot's input — empty cancels, otherwise validates.
/// Re-submitting text that was already handled is a no-op, so a focus shift
/// (clicking Cancel, tabbing away) never re-fires a lookup.</summary>
public void ConfirmEdit(int slotIndex)
{
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
var slot = Slots[slotIndex];
if (!slot.IsEditing || slot.IsValidating) return;
if (string.IsNullOrWhiteSpace(slot.EditText))
{
slot.IsEditing = false;
return;
}
var text = slot.EditText.Trim();
if (slot.IsFilled && text == slot.Handle)
{
slot.IsEditing = false;
return;
}
if (slot.LastValidatedInput == text)
{
if (slot.IsFilled) slot.IsEditing = false;
return;
}
slot.LastValidatedInput = text;
_ = ValidateAsync(slot);
}
/// <summary>Runs the lookup; on success snaps the row back to its logo + handle.
/// A canceled lookup (dismissal) leaves the slot untouched.</summary>
private async Task ValidateAsync(SocialSlotViewModel slot)
{
var ct = _cts.Token;
slot.IsValidating = true;
slot.Error = null;
try
{
var text = slot.EditText.Trim();
var (service, handle, _) = SocialServiceIcons.DetectService(text);
var result = await _validator.LookupAsync(service, handle, ct);
if (ct.IsCancellationRequested || result.Canceled) return;
if (result.Success)
ApplyValidated(slot, new DialogEntry(service, result.Handle, result.ProfileUrl, result.FediverseSoftware));
else
slot.Error = result.Error;
}
finally
{
slot.IsValidating = false;
}
}
private void ApplyValidated(SocialSlotViewModel slot, DialogEntry entry)
{
var index = slot.Index - 1;
if (slot.IsFilled && index >= 0 && index < _working.Count)
_working[index] = entry;
else
_working.Add(entry);
slot.Service = entry.Service;
slot.Handle = entry.Handle;
slot.ProfileUrl = entry.ProfileUrl;
slot.FediverseSoftware = entry.FediverseSoftware;
slot.IsFilled = true;
slot.IsEditing = false;
RebuildSlots();
}
/// <summary>The window confirms the delete, then calls this. Slot 0 signs out of YouTube.</summary>
public async Task DeleteSlotAsync(int slotIndex)
{
if (slotIndex == 0)
{
if (!IsSignedIn) return;
await _signOutAction();
_account = null;
IsSignedIn = false;
RebuildSlots();
return;
}
var entryIndex = slotIndex - 1;
if (entryIndex < 0 || entryIndex >= _working.Count) return;
_working.RemoveAt(entryIndex);
RebuildSlots();
}
private void Save()
{
if (!CanSave) return;
var entries = new List<SocialEntry>();
if (IsSignedIn && _account != null)
{
var handle = _account.DisplayName.Trim().TrimStart('@');
entries.Add(new SocialEntry
{
Service = SocialService.YouTube,
Handle = handle,
ProfileUrl = string.IsNullOrWhiteSpace(_account.ChannelId)
? SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle)
: $"https://www.youtube.com/channel/{_account.ChannelId}",
});
}
foreach (var entry in _working)
entries.Add(new SocialEntry
{
Service = entry.Service,
Handle = entry.Handle,
ProfileUrl = entry.ProfileUrl,
FediverseSoftware = entry.FediverseSoftware,
});
CommittedEntries = entries;
SaveRequested?.Invoke();
}
/// <summary>Builds the config the main window commits on save, or null when empty.</summary>
public SocialsConfig? BuildSocialsConfig()
{
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
if (entries.Count == 0) return null;
var config = new SocialsConfig { BarEnabled = BarEnabled };
foreach (var entry in entries)
config.Entries.Add(entry);
return config;
}
private void RebuildSlots()
{
foreach (var slot in Slots)
slot.PropertyChanged -= OnSlotPropertyChanged;
Slots.Clear();
for (var i = 0; i < MaxSlots; i++)
{
var slot = new SocialSlotViewModel(i);
if (i == 0)
{
slot.Service = SocialService.YouTube;
slot.IsSignIn = !IsSignedIn;
slot.IsFilled = IsSignedIn;
if (IsSignedIn && _account != null)
slot.Handle = _account.DisplayName;
}
else
{
var entryIndex = i - 1;
if (entryIndex < _working.Count)
{
var entry = _working[entryIndex];
slot.Service = entry.Service;
slot.IsFilled = true;
slot.Handle = entry.Handle;
slot.ProfileUrl = entry.ProfileUrl;
slot.FediverseSoftware = entry.FediverseSoftware;
}
else if (!_isPremium && i >= 2)
{
slot.IsLocked = true;
}
}
slot.PropertyChanged += OnSlotPropertyChanged;
Slots.Add(slot);
}
}
private void OnSlotPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SocialSlotViewModel.IsEditing)
or nameof(SocialSlotViewModel.IsValidating)
or nameof(SocialSlotViewModel.HasError)
or nameof(SocialSlotViewModel.EditText))
{
OnPropertyChanged(nameof(CanSave));
CommandManager.InvalidateRequerySuggested();
}
}
}