Social bar v2: fediverse icons resolved via nodeinfo (with redirect-following), persisted software column, icon colors — 112 tests passing, 0 warnings
This commit is contained in:
+47
-176
@@ -170,47 +170,33 @@ public class MainViewModel : ViewModelBase
|
||||
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||
public bool CanChangeWebcam => _webcam != null;
|
||||
|
||||
// ─── Social bar (global resource, per-scene presence) ───
|
||||
// ─── Social bar (global resource, shown on every scene) ───
|
||||
|
||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
||||
private static SolidColorBrush CreateBrush(string hex)
|
||||
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
||||
|
||||
/// <summary>True when the global socials config has at least one validated entry.</summary>
|
||||
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
|
||||
|
||||
/// <summary>The footer [+]/[-] can toggle the bar on the active scene only when socials exist.</summary>
|
||||
public bool CanToggleSceneSocialBar => HasSocials && ActiveScene != null;
|
||||
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
|
||||
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
|
||||
|
||||
/// <summary>Whether the active scene currently shows the social bar.</summary>
|
||||
public bool SceneHasSocialBar
|
||||
{
|
||||
get => ActiveScene?.HasSocialBar ?? false;
|
||||
set
|
||||
{
|
||||
if (ActiveScene is { } scene && scene.HasSocialBar != value)
|
||||
{
|
||||
scene.HasSocialBar = value;
|
||||
OnPropertyChanged(nameof(SceneHasSocialBar));
|
||||
ScheduleSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
|
||||
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
|
||||
|
||||
/// <summary>The global socials config (entries + bar position/justify), or null.</summary>
|
||||
/// <summary>Green glow on the bar while it's on the stream.</summary>
|
||||
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
|
||||
|
||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||
public SocialsConfig? Socials => _socials;
|
||||
|
||||
/// <summary>Freemium cap: YT + 1 other = 2 entries. Premium (unlimited) gated by the unlock flag seam.</summary>
|
||||
public const int FreemiumSocialCap = 2;
|
||||
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
|
||||
private static bool IsPremium => false; // itch.io unlock deferred — seam only
|
||||
|
||||
public int SocialCap => IsPremium ? int.MaxValue : FreemiumSocialCap;
|
||||
public bool CanAddMoreSocials => _socials == null || _socials.Entries.Count < SocialCap;
|
||||
|
||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||
public HorizontalAlignment SocialBarAlign => _socials?.BarJustify switch
|
||||
{
|
||||
SocialBarJustify.Left => HorizontalAlignment.Left,
|
||||
SocialBarJustify.Right => HorizontalAlignment.Right,
|
||||
_ => HorizontalAlignment.Center,
|
||||
};
|
||||
|
||||
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
||||
|
||||
@@ -759,7 +745,6 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand ToggleMicMuteCommand { get; }
|
||||
public ICommand OpenMicPickerCommand { get; }
|
||||
public ICommand OpenSocialDialogCommand { get; }
|
||||
public ICommand ToggleSceneSocialBarCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
@@ -815,7 +800,6 @@ public class MainViewModel : ViewModelBase
|
||||
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
||||
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
||||
ToggleSceneSocialBarCommand = new RelayCommand(_ => ToggleSceneSocialBar(), _ => CanToggleSceneSocialBar);
|
||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||
@@ -889,7 +873,6 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
IsConnected = true;
|
||||
SyncConnectedAccount();
|
||||
AutoAddYouTubeSocial();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -904,34 +887,6 @@ public class MainViewModel : ViewModelBase
|
||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the creator is connected to YouTube and hasn't defined any socials yet,
|
||||
/// auto-add YouTube as the first entry — the connected channel is the default
|
||||
/// social, no extra input needed.
|
||||
/// </summary>
|
||||
private void AutoAddYouTubeSocial()
|
||||
{
|
||||
if (_socials != null && _socials.Entries.Count > 0) return;
|
||||
var channel = _youtubeAuth.CurrentChannel;
|
||||
if (channel == null || string.IsNullOrWhiteSpace(channel.DisplayName)) return;
|
||||
|
||||
_socials ??= new SocialsConfig();
|
||||
var handle = channel.DisplayName.Trim().TrimStart('@');
|
||||
_socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = handle,
|
||||
ProfileUrl = !string.IsNullOrWhiteSpace(channel.ChannelId)
|
||||
? $"https://www.youtube.com/channel/{channel.ChannelId}"
|
||||
: SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle),
|
||||
});
|
||||
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||
OnPropertyChanged(nameof(CanAddMoreSocials));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private static string DefaultLayoutPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
@@ -981,8 +936,8 @@ public class MainViewModel : ViewModelBase
|
||||
ReacquireScreenCaptures();
|
||||
_socials = _layoutStore.Socials;
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||
OnPropertyChanged(nameof(SceneHasSocialBar));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
ScheduleSave();
|
||||
AppLog.Write("LoadLayout end");
|
||||
}
|
||||
@@ -1847,137 +1802,53 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
// ─── Social bar ───
|
||||
|
||||
private void ToggleSceneSocialBar()
|
||||
{
|
||||
if (ActiveScene is not { } scene) return;
|
||||
SceneHasSocialBar = !scene.HasSocialBar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
||||
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
||||
/// </summary>
|
||||
private void OpenSocialDialog()
|
||||
{
|
||||
if (!CanAddMoreSocials)
|
||||
{
|
||||
MessageBox.Show(
|
||||
IsPremium
|
||||
? "Something went wrong — the social cap is hit but premium is active."
|
||||
: $"Free tier: up to {FreemiumSocialCap} socials (YouTube + 1 other). Upgrade to add more.",
|
||||
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: YouTube handle — pre-filled from the connected account if available.
|
||||
var ytHandle = IsConnected && _youtubeAuth.CurrentChannel is { } ch
|
||||
? ch.DisplayName
|
||||
: ShowInputDialog("Your YouTube handle (e.g. @yourchannel):", "Add Social — YouTube", "@yourchannel");
|
||||
if (string.IsNullOrWhiteSpace(ytHandle)) return;
|
||||
ytHandle = ytHandle.Trim().TrimStart('@');
|
||||
|
||||
// Validate the YT handle (or skip validation if it came from the connected account).
|
||||
if (!IsConnected || _socials?.Entries.Any(e => e.Service == SocialService.YouTube) != true)
|
||||
{
|
||||
var ytResult = _socialValidator.LookupAsync(SocialService.YouTube, ytHandle).GetAwaiter().GetResult();
|
||||
if (!ytResult.Success)
|
||||
{
|
||||
var proceed = MessageBox.Show(
|
||||
$"{ytResult.Error}\n\nAdd anyway?", "ytLlive",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (proceed != MessageBoxResult.Yes) return;
|
||||
}
|
||||
_socials ??= new SocialsConfig();
|
||||
_socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = ytHandle,
|
||||
ProfileUrl = SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, ytHandle),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Another social — URL or handle string, auto-detect the service.
|
||||
if (!CanAddMoreSocials)
|
||||
var dialog = new SocialsDialogViewModel(
|
||||
_socialValidator,
|
||||
SignInAsync,
|
||||
SignOutYouTubeAsync,
|
||||
_youtubeAuth.CurrentChannel,
|
||||
IsPremium,
|
||||
_socials);
|
||||
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
|
||||
if (window.ShowDialog() == true)
|
||||
{
|
||||
_socials = dialog.BuildSocialsConfig();
|
||||
NotifySocialsChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var input = ShowInputDialog(
|
||||
"Enter a URL or handle for another social:\n" +
|
||||
"e.g. https://x.com/yourname or @you@instance.tube",
|
||||
"Add Social — Step 2 of 2", "");
|
||||
if (string.IsNullOrWhiteSpace(input)) { NotifySocialsChanged(); return; }
|
||||
|
||||
var (service, handle, url) = SocialServiceIcons.DetectService(input);
|
||||
var result = _socialValidator.LookupAsync(service, handle).GetAwaiter().GetResult();
|
||||
if (!result.Success)
|
||||
{
|
||||
var proceed = MessageBox.Show(
|
||||
$"{result.Error}\n\nAdd anyway?", "ytLlive",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (proceed != MessageBoxResult.Yes) { NotifySocialsChanged(); return; }
|
||||
}
|
||||
|
||||
_socials ??= new SocialsConfig();
|
||||
_socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = service,
|
||||
Handle = handle,
|
||||
ProfileUrl = result.Success ? result.ProfileUrl : url,
|
||||
});
|
||||
|
||||
NotifySocialsChanged();
|
||||
}
|
||||
|
||||
private void NotifySocialsChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||
OnPropertyChanged(nameof(CanAddMoreSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
public void RemoveSocial(int index)
|
||||
/// <summary>Drag release snaps the bar to the closest edge; called by the preview drag.</summary>
|
||||
public void SetSocialBarPosition(SocialBarPosition position)
|
||||
{
|
||||
if (_socials == null || index < 0 || index >= _socials.Entries.Count) return;
|
||||
_socials.Entries.RemoveAt(index);
|
||||
if (_socials.Entries.Count == 0)
|
||||
{
|
||||
foreach (var scene in Scenes) scene.HasSocialBar = false;
|
||||
_socials = null;
|
||||
}
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||
OnPropertyChanged(nameof(CanAddMoreSocials));
|
||||
OnPropertyChanged(nameof(SceneHasSocialBar));
|
||||
if (_socials == null || _socials.BarPosition == position) return;
|
||||
_socials.BarPosition = position;
|
||||
OnPropertyChanged(nameof(SocialBarTop));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private static string ShowInputDialog(string prompt, string title, string defaultValue)
|
||||
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
||||
private async Task SignOutYouTubeAsync()
|
||||
{
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 420,
|
||||
Height = 180,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Owner = Application.Current.MainWindow,
|
||||
Background = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#1a1a2e")!,
|
||||
};
|
||||
var stack = new StackPanel { Margin = new Thickness(16) };
|
||||
var label = new TextBlock { Text = prompt, Foreground = System.Windows.Media.Brushes.White, Margin = new Thickness(0, 0, 0, 8), FontSize = 14 };
|
||||
var box = new TextBox { Text = defaultValue, FontSize = 14, Padding = new Thickness(8, 6, 8, 6) };
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
|
||||
var ok = new Button { Content = "OK", Padding = new Thickness(20, 6, 20, 6), Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
|
||||
var cancel = new Button { Content = "Cancel", Padding = new Thickness(20, 6, 20, 6), IsCancel = true };
|
||||
buttons.Children.Add(ok);
|
||||
buttons.Children.Add(cancel);
|
||||
stack.Children.Add(label);
|
||||
stack.Children.Add(box);
|
||||
stack.Children.Add(buttons);
|
||||
window.Content = stack;
|
||||
|
||||
ok.Click += (_, _) => { window.DialogResult = true; window.Close(); };
|
||||
box.SelectAll();
|
||||
box.Focus();
|
||||
|
||||
return window.ShowDialog() == true ? box.Text : string.Empty;
|
||||
_youtubeAuth.ClearSession();
|
||||
TokenStore.Clear();
|
||||
IsConnected = false;
|
||||
SyncConnectedAccount();
|
||||
NotifySocialsChanged();
|
||||
AppLog.Write("Socials: signed out of YouTube");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
||||
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
||||
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
|
||||
|
||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||||
|
||||
Reference in New Issue
Block a user