Files
LlamaCasty/ViewModels/SocialsDialogViewModel.cs
gramps d3271a0405 refactor: split SocialsDialogViewModel under 500 lines — 500-limit Phase 3, Commit D
SocialsDialogViewModel.cs 515 -> 334. Extracted the DialogEntry record + the
row-level SocialSlotViewModel (service/logo/lock/edit/validate state, computed
Show* flags) into a new SocialSlotViewModel.cs partial. The dialog VM keeps the
sign-in gate, validation orchestration, and commit. DialogEntry stays public in
the same namespace.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 08:59:38 -07:00

335 lines
11 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>
/// 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 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,
SocialsConfig? current = null)
{
_validator = validator;
_signInProvider = signInProvider;
_signOutAction = signOutAction;
_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 (i >= 2)
{
// Slot is empty — ready for user to add
}
}
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();
}
}
}