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; /// /// A validated social entry in the dialog's working copy (non-YouTube slots). /// public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null); /// /// 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. /// 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); } /// The last text submitted for validation (used to skip re-validating /// identical input — e.g. a LostFocus firing as the user clicks Cancel). 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; } /// /// 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. /// public sealed class SocialsDialogViewModel : ViewModelBase { public const int MaxSlots = 6; private readonly ISocialValidator _validator; private readonly Func> _signInProvider; private readonly Func _signOutAction; private readonly bool _isPremium; private readonly List _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> signInProvider, Func 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 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; /// /// 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. /// 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; /// Entries to persist after a successful save (YouTube first when signed in). public IReadOnlyList? CommittedEntries { get; private set; } /// /// Dismissal is a hard stop: abort every in-flight validation and get out. /// The canceled continuations never touch slot state. /// public void Cancel() { _cts.Cancel(); CancelRequested?.Invoke(); } /// Aborts in-flight work without raising /// (used by the window's Closing handler so a Save close isn't clobbered). 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; } } /// Starts editing the slot's input box (add or edit). 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; } /// 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. 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); } /// Runs the lookup; on success snaps the row back to its logo + handle. /// A canceled lookup (dismissal) leaves the slot untouched. 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(); } /// The window confirms the delete, then calls this. Slot 0 signs out of YouTube. 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(); 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(); } /// Builds the config the main window commits on save, or null when empty. 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(); } } }