519 lines
17 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|