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:
2026-08-12 18:28:59 -07:00
parent db5fc06174
commit 9873eeda7d
18 changed files with 1878 additions and 321 deletions
+63 -14
View File
@@ -7,17 +7,66 @@
## Session state (last updated: 2026-08-12) ## Session state (last updated: 2026-08-12)
- **Finished:** Webcam resource validation (merged `e1d9e13`) + Social bar v1 (merged `8868f98`). 85 tests, 0 warnings. On `main`, pushed. - **Branch:** `main` (work UNCOMMITTED — see dirty list). Includes the previous
- **In flight:** Social bar UX is WRONG — the user was re-explaining what they actually want when ctrl+enter cut them off. The correction was NOT received. Do NOT continue the social bar until the user re-states their requirements. What I shipped vs. what they asked differs — they said "did you not understand what I asked for? It's pretty goddamn simple" but the message was lost. session's socials v2 fixes + this session's fediverse-service-icon work.
- **What I shipped (may be wrong):** YT auto-add from connected account, two-box dialog (YT handle + URL/handle for other), service auto-detection from domain, fediverse `@user@domain` support, footer Social button + [+/-] on the LEFT under scenes/sources, meter centered under preview, colored initials badges (not real service logos). - **Finished this session (round 3):** user's live-test report said the mastodon
- **What the user explicitly asked for (from their message before the cut-off):** icon still didn't show for `@gramps@llamachile.tube`. Root cause: the identity
1. If creator is logged into YT, YT should be the first social by default (✅ done but UX may be wrong) domain `llamachile.tube` is YunoHost-SSO-gated — `/.well-known/nodeinfo`,
2. YT icon placemarker + text box asking for YT handle (I did auto-add — they may have wanted the placemarker + input flow instead) `/@gramps`, and webfinger all answer with the SSO login page, so nodeinfo
3. Second box: URL or identifying string (`@gramps@llamachile.tube` is just as unique as a URL) (✅ done) returned nothing and the icon fell back to the honeycomb glyph. The real
4. Pull the SERVICE logo, not the user's account logo, to represent the service (I used initials badges — they want actual brand logos) instance lives at `mastodon.llamachile.tube`, and only the bare root
5. Finding the user's account on the service validates the input (✅ done) `https://llamachile.tube/` 302s to it ("default app" redirect). Fix:
6. Socials bar should appear on the LEFT side of the volume control, centered under the scenes and sources listboxes (✅ done) `HttpSocialValidator.TryFetchFediverseSoftwareAsync` now, when nodeinfo on the
- **Something in this list is wrong** — the user said "did you not understand what I asked for?" but the correction was lost to ctrl+enter. identity domain fails, follows the root redirect (`ResolveInstanceHostAsync`,
- **Landmine:** 19041 SDK projection gaps (Exclusive/DeviceLost not projected; CameraStreamState.Failed by int). The social dialog uses sync `.GetAwaiter().GetResult()` — fine for now. `HttpSocialValidator` hits real network — tests must fake it. reads `resp.RequestMessage.RequestUri.Host`) and re-runs the nodeinfo lookup
- **Next step:** WAIT for the user to re-state what they actually want for the social bar. Do NOT guess. Then fix it. on the resolved host. New test `FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware`.
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v7); OAuth callback `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`. Build: **0 warnings**. Tests: **111 passing**.
- **What landed (all rounds this session):**
1. **Fediverse icon resolution**`DetectService` maps `@user@domain` to the
new `SocialService.Fediverse` (was `Link`/chain icon). On validate,
`HttpSocialValidator` best-effort GETs `https://{domain}/.well-known/nodeinfo`,
follows the first nodeinfo `links[].href`, reads `software.name`, and returns
it in `SocialLookupResult.FediverseSoftware`. `SocialEntry`/`SocialSlotViewModel`
carry `FediverseSoftware`; `SocialServiceIcons.LogoDataForFediverse(software)`
maps it to a bundled logo (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/
firefish — Simple Icons CC0), falling back to the `FediverseIconData`
honeycomb glyph for unknown software (GoToSocial/Sharkey/Akkoma aren't in
Simple Icons). Nodeinfo failure still validates — the glyph falls back.
2. **Redirect resolution (round 3)** — identity domains that 302 their root
to the real instance (YunoHost default-app subdomains) resolve software via
the root redirect when identity-domain nodeinfo is SSO-blocked.
3. **Persistence** — new `SocialEntry.Software TEXT` column via
`MigrateSocialEntryTable()` (column-presence pattern, same as the others);
saved/loaded alongside Service/Handle/ProfileUrl.
4. **Icon colors**`IconButton` style gains `Foreground="#d0d0d0"`
(`Themes/Controls.xaml`); the dialog's trash button overrides
`Foreground="#e94560"` (`SocialsDialog.xaml`). All other `IconButton`
usages are `Path` content with explicit `Fill`, so unaffected.
- **Landmines:**
- `LayoutStore.Socials` is only populated by `Load()` — tests must call
`store.Load()` before asserting it.
- `DetectService("justaname")`**Website** with empty handle; only
unparseable input or `@user@domain` yields Link/Fediverse.
- Dialog sign-in provider is `Func<Task<YouTubeChannel?>>`; test fakes must
return `Task.FromResult` (sync-completed) so the fire-and-forget command
settles before the next assert.
- `SocialBarBottomTop = 1040` literal lives in `MainWindow.xaml.cs` (the VM's
`MasterFrameHeight` is private).
- `Cancel_AbortsInFlightValidation_WithoutMutatingSlot` relies on the
BlockingValidator's gate completing synchronously (no
`RunContinuationsAsynchronously`) — the assertions run after
`Gate.TrySetResult` returns because the awaited continuation executes inline.
- Nodeinfo cancellation test cancels mid-lookup via a counting stub; the
post-fetch `ct.IsCancellationRequested` check is what reports `Canceled`.
- Round-3 redirect fallback fires only when identity-domain nodeinfo fails;
the root follow happens automatically (HttpClient default auto-redirect),
and `RequestMessage.RequestUri.Host` is read from the final response.
- **Next step:** show the user the fixes and let them decide whether to commit.
Nothing queued beyond that — do not expand the task queue on your own.
(`ytLive.Tests`/`bin` + `/obj` are dirty build artifacts, not hand-authored.)
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; the new `SocialEntry.Software`
column is a column-presence migration like the others, no version bump); OAuth callback
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
+24 -35
View File
@@ -652,13 +652,22 @@
</Viewbox> </Viewbox>
</Grid> </Grid>
<!-- Social bar: horizontal chip row at the top or bottom of the <!-- Social bar: one centered horizontal row at the top or bottom of
frame, justified LCR. Global resource, shown per-scene. --> the frame, green glow while enabled. Global resource — never a
<Grid Width="1920" Canvas.Top="{Binding SocialBarTop}" IsHitTestVisible="False" Source, no Sources entry. Dragging snaps to the nearest edge. -->
Visibility="{Binding SceneHasSocialBar, Converter={StaticResource BoolToVis}}"> <Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
Background="Transparent"
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
PreviewMouseLeftButtonDown="SocialBar_PreviewMouseLeftButtonDown"
PreviewMouseMove="SocialBar_PreviewMouseMove"
PreviewMouseLeftButtonUp="SocialBar_PreviewMouseLeftButtonUp">
<Grid.Effect>
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
</Grid.Effect>
<ItemsControl ItemsSource="{Binding Socials.Entries}" <ItemsControl ItemsSource="{Binding Socials.Entries}"
HorizontalAlignment="{Binding SocialBarAlign}" HorizontalAlignment="Center" Margin="40,8"
Margin="40,8"> IsHitTestVisible="False">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/> <StackPanel Orientation="Horizontal"/>
@@ -667,14 +676,8 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,20,0"> <StackPanel Orientation="Horizontal" Margin="0,0,20,0">
<Border Width="28" Height="28" CornerRadius="14" <Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
Background="{Binding AccentColor}" Stretch="Uniform" VerticalAlignment="Center"/>
VerticalAlignment="Center">
<TextBlock Text="{Binding Initials}" Foreground="White"
FontSize="11" FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Handle}" Foreground="White" <TextBlock Text="{Binding Handle}" Foreground="White"
FontSize="14" VerticalAlignment="Center" FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0" Margin="8,0,0,0"
@@ -810,29 +813,15 @@
<!-- Social controls: centered under the scenes/sources listboxes --> <!-- Social controls: centered under the scenes/sources listboxes -->
<StackPanel Grid.Column="0" Orientation="Horizontal" <StackPanel Grid.Column="0" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Content="Social" Command="{Binding OpenSocialDialogCommand}" <Button Command="{Binding OpenSocialDialogCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4" Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" FontSize="11" VerticalAlignment="Center"
ToolTip="Add or manage your social handles"/> ToolTip="Add or manage your social handles">
<Button Command="{Binding ToggleSceneSocialBarCommand}" <StackPanel Orientation="Horizontal">
Visibility="{Binding CanToggleSceneSocialBar, Converter={StaticResource BoolToVis}}" <Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
Style="{StaticResource IconButton}" Width="28" Height="28" Margin="4,0,0,0" Margin="0,0,6,0" VerticalAlignment="Center"/>
VerticalAlignment="Center" FontSize="16" FontWeight="Bold" <TextBlock Text="Socials"/>
ToolTip="Add or remove the social bar on this scene"> </StackPanel>
<Button.Content>
<TextBlock FontSize="16" FontWeight="Bold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="+"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SceneHasSocialBar}" Value="True">
<Setter Property="Text" Value=""/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button.Content>
</Button> </Button>
</StackPanel> </StackPanel>
+38
View File
@@ -254,6 +254,44 @@ public partial class MainWindow : Window
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
// ─── Social bar drag: vertical only, snaps to top/bottom on release ───
private const double SocialBarBottomTop = 1040;
private bool _isDraggingSocialBar;
private double _socialBarGrabOffsetY;
private void SocialBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var bar = (FrameworkElement)sender;
_isDraggingSocialBar = true;
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
var p = toCanvas.Transform(e.GetPosition(bar));
_socialBarGrabOffsetY = p.Y - Canvas.GetTop(bar);
bar.CaptureMouse();
e.Handled = true;
}
private void SocialBar_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_isDraggingSocialBar) return;
var bar = (FrameworkElement)sender;
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
var p = toCanvas.Transform(e.GetPosition(bar));
Canvas.SetTop(bar, Math.Clamp(p.Y - _socialBarGrabOffsetY, 0, SocialBarBottomTop));
e.Handled = true;
}
private void SocialBar_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (!_isDraggingSocialBar) return;
var bar = (FrameworkElement)sender;
var top = Canvas.GetTop(bar);
bar.ReleaseMouseCapture();
_isDraggingSocialBar = false;
var position = top <= SocialBarBottomTop / 2.0 ? SocialBarPosition.Top : SocialBarPosition.Bottom;
_viewModel.SetSocialBarPosition(position);
e.Handled = true;
}
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
if (OpacityChip.IsMouseOver) return; if (OpacityChip.IsMouseOver) return;
+117 -65
View File
@@ -8,11 +8,10 @@ public enum SocialService
{ {
YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick, YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick,
Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit, Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit,
WhatsApp, Telegram, Link, Website WhatsApp, Telegram, Link, Website, Fediverse
} }
public enum SocialBarPosition { Top, Bottom } public enum SocialBarPosition { Top, Bottom }
public enum SocialBarJustify { Left, Center, Right }
public sealed class SocialEntry : INotifyPropertyChanged public sealed class SocialEntry : INotifyPropertyChanged
{ {
@@ -20,9 +19,13 @@ public sealed class SocialEntry : INotifyPropertyChanged
public string Handle { get; init; } = string.Empty; public string Handle { get; init; } = string.Empty;
public string ProfileUrl { get; init; } = string.Empty; public string ProfileUrl { get; init; } = string.Empty;
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.</summary>
public string? FediverseSoftware { get; init; }
public string ServiceName => Service.ToString(); public string ServiceName => Service.ToString();
public string Initials => SocialServiceIcons.InitialsFor(Service); public string LogoData => Service == SocialService.Fediverse
public string AccentColor => SocialServiceIcons.ColorFor(Service); ? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
private void Raise([CallerMemberName] string? name = null) private void Raise([CallerMemberName] string? name = null)
@@ -30,15 +33,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
} }
/// <summary> /// <summary>
/// The app-wide social bar config — a global resource (like the webcam) whose /// The app-wide social bar config — a global resource (like the webcam) that
/// per-scene presence is toggled by <see cref="Scene.HasSocialBar"/>. The creator /// every scene shows when <see cref="BarEnabled"/> is on and entries exist. The
/// provides handles/URLs; the app validates each by looking up the social page /// dialog provides the handles; the app validates each by looking up the social
/// before adding it. Freemium: YT + 1 other. Premium: as many as fit one line. /// page before committing. Freemium: YT + 1 other. Premium: all six slots.
/// </summary> /// </summary>
public sealed class SocialsConfig : INotifyPropertyChanged public sealed class SocialsConfig : INotifyPropertyChanged
{ {
public ObservableCollection<SocialEntry> Entries { get; } = new(); public ObservableCollection<SocialEntry> Entries { get; } = new();
private bool _barEnabled = true;
public bool BarEnabled
{
get => _barEnabled;
set => Set(ref _barEnabled, value);
}
private SocialBarPosition _barPosition = SocialBarPosition.Bottom; private SocialBarPosition _barPosition = SocialBarPosition.Bottom;
public SocialBarPosition BarPosition public SocialBarPosition BarPosition
{ {
@@ -46,13 +56,6 @@ public sealed class SocialsConfig : INotifyPropertyChanged
set => Set(ref _barPosition, value); set => Set(ref _barPosition, value);
} }
private SocialBarJustify _barJustify = SocialBarJustify.Center;
public SocialBarJustify BarJustify
{
get => _barJustify;
set => Set(ref _barJustify, value);
}
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null) private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{ {
@@ -63,56 +66,100 @@ public sealed class SocialsConfig : INotifyPropertyChanged
} }
} }
/// <summary>
/// Brand logo path data (Simple Icons, CC0) plus the small utility glyphs the
/// social dialog uses — lock (Premium gate), do-not (empty/invalid slot) and a
/// generic link (bare Link/Website entries). All strings are 24x24 path data
/// consumed by a Path.Data binding (the Geometry type converter handles the string).
/// </summary>
public static class SocialServiceIcons public static class SocialServiceIcons
{ {
public static string InitialsFor(SocialService service) => service switch public const string LockedIconData = "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z";
public const string DoNotIconData = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z";
public const string LinkIconData = "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z";
private const string YouTubePath = "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z";
private const string TwitchPath = "M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z";
private const string XPath = "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z";
private const string InstagramPath = "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077";
private const string TikTokPath = "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z";
private const string FacebookPath = "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z";
private const string DiscordPath = "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z";
private const string KickPath = "M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z";
private const string ThreadsPath = "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z";
private const string BlueskyPath = "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026";
private const string GitHubPath = "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
private const string LinkedInPath = "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z";
private const string PinterestPath = "M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z";
private const string SnapchatPath = "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z";
private const string RedditPath = "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z";
private const string WhatsAppPath = "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
private const string TelegramPath = "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z";
// Fediverse instance software logos (Simple Icons, CC0). Resolved at
// validate-time from each instance's nodeinfo `software.name`; unknown
// software falls back to the generic fediverse glyph below.
private const string MastodonPath = "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z";
private const string PeerTubePath = "M12 6.545v10.91L20.727 12M3.273 12v12L12 17.455M3.273 0v12L12 6.545";
private const string PixelfedPath = "M12 24C5.3726 24 0 18.6274 0 12S5.3726 0 12 0s12 5.3726 12 12-5.3726 12-12 12m-.9526-9.3802h2.2014c2.0738 0 3.7549-1.6366 3.7549-3.6554S15.3226 7.309 13.2488 7.309h-3.1772c-1.1964 0-2.1663.9442-2.1663 2.1089v8.208z";
private const string MisskeyPath = "M8.91076 16.8915c-1.03957.0038-1.93213-.6294-2.35267-1.366-.22516-.3217-.66989-.4364-.6761 0v2.0148c0 .8094-.29152 1.5097-.87581 2.1002-.56755.573-1.25977.8595-2.0779.8595-.80014 0-1.49298-.2865-2.07727-.8601C.28408 19.05 0 18.3497 0 17.5403V6.45968c0-.62378.17553-1.18863.52599-1.69455.36657-.52284.83426-.88582 1.4018-1.08769a2.84574 2.84574 0 0 1 1.00049-.17742c.90125 0 1.65239.35421 2.25281 1.06262l2.99713 3.51572c.06699.05016.263.43696.73192.43696.47016 0 .6916-.3868.75796-.43758l2.9717-3.5151c.6178-.70841 1.377-1.06262 2.2782-1.06262.3337 0 .6675.05893 1.0012.17742.5669.20187 1.0259.56422 1.377 1.08769.3665.50592.5501 1.07077.5501 1.69455V17.5403c0 .8094-.2915 1.5097-.8758 2.1002-.5675.573-1.2604.8595-2.0779.8595-.8008 0-1.493-.2865-2.0779-.8601-.5669-.5899-.8504-1.2902-.8504-2.0996v-2.0148c-.0496-.5499-.5303-.2032-.7009 0-.4503.8431-1.31369 1.3616-2.35264 1.366ZM21.447 8.60998c-.7009 0-1.3015-.24449-1.8019-.73348-.4838-.50571-.7257-1.11277-.7257-1.82118s.2419-1.30711.7257-1.79611c.5004-.50571 1.101-.75856 1.8019-.75856.7009 0 1.3017.25285 1.8025.75856.5003.489.7505 1.0877.7505 1.79611 0 .70841-.2502 1.31547-.7505 1.82118-.5008.48899-1.1016.73348-1.8025.73348Zm.0248.50655c.7009 0 1.2935.25285 1.7777.75856.5003.50571.7505 1.11301.7505 1.82181v6.2484c0 .7084-.2502 1.3155-.7505 1.8212-.4838.489-1.0764.7335-1.7777.7335-.7005 0-1.3011-.2445-1.8019-.7335-.5003-.5057-.7505-1.1128-.7505-1.8212v-6.2484c0-.7084.2502-1.3157.7505-1.82181.5004-.50571 1.101-.75856 1.8019-.75856Z";
private const string LemmyPath = "M2.9595 4.2228a3.9132 3.9132 0 0 0-.332.019c-.8781.1012-1.67.5699-2.155 1.3862-.475.8-.5922 1.6809-.35 2.4971.2421.8162.8297 1.5575 1.6982 2.1449.0053.0035.0106.0076.0163.0114.746.4498 1.492.7431 2.2877.8994-.02.3318-.0272.6689-.006 1.0181.0634 1.0432.4368 2.0006.996 2.8492l-2.0061.8189a.4163.4163 0 0 0-.2276.2239.416.416 0 0 0 .0879.455.415.415 0 0 0 .2941.1231.4156.4156 0 0 0 .1595-.0312l2.2093-.9035c.408.4859.8695.9315 1.3723 1.318.0196.0151.0407.0264.0603.0423l-1.2918 1.7103a.416.416 0 0 0 .664.501l1.314-1.7385c.7185.4548 1.4782.7927 2.2294 1.0242.3833.7209 1.1379 1.1871 2.0202 1.1871.8907 0 1.6442-.501 2.0242-1.2072.744-.2347 1.4959-.5729 2.2073-1.0262l1.332 1.7606a.4157.4157 0 0 0 .7439-.1936.4165.4165 0 0 0-.0799-.3074l-1.3099-1.7345c.0083-.0075.0178-.0113.0261-.0188.4968-.3803.9549-.8175 1.3622-1.2939l2.155.8794a.4156.4156 0 0 0 .5412-.2276.4151.4151 0 0 0-.2273-.5432l-1.9438-.7928c.577-.8538.9697-1.8183 1.0504-2.8693.0268-.3507.0242-.6914.0079-1.0262.7905-.1572 1.5321-.4502 2.2737-.8974.0053-.0033.011-.0076.0163-.0113.8684-.5874 1.456-1.3287 1.6982-2.145.2421-.8161.125-1.697-.3501-2.497-.4849-.8163-1.2768-1.2852-2.155-1.3863a3.2175 3.2175 0 0 0-.332-.0189c-.7852-.0151-1.6231.229-2.4286.6942-.5926.342-1.1252.867-1.5433 1.4387-1.1699-.6703-2.6923-1.0476-4.5635-1.0785a15.5768 15.5768 0 0 0-.5111 0c-2.085.034-3.7537.43-5.0142 1.1449-.0033-.0038-.0045-.0114-.008-.0152-.4233-.5916-.973-1.1365-1.5835-1.489-.8055-.465-1.6434-.7083-2.4286-.6941Zm.2858.7365c.5568.042 1.1696.2358 1.7787.5875.485.28.9757.7554 1.346 1.2696a5.6875 5.6875 0 0 0-.4969.4085c-.9201.8516-1.4615 1.9597-1.668 3.2335-.6809-.1402-1.3183-.3945-1.984-.7948-.7553-.5128-1.2159-1.1225-1.4004-1.7445-.1851-.624-.1074-1.2712.2776-1.9196.3743-.63.9275-.9534 1.6118-1.0322a2.796 2.796 0 0 1 .5352-.0076Zm17.5094 0a2.797 2.797 0 0 1 .5353.0075c.6842.0786 1.2374.4021 1.6117 1.0322.385.6484.4627 1.2957.2776 1.9196-.1845.622-.645 1.2317-1.4004 1.7445-.6578.3955-1.2881.6472-1.9598.7888-.1942-1.2968-.7375-2.4338-1.666-3.302a5.5639 5.5639 0 0 0-.4709-.3923c.3645-.49.8287-.9428 1.2938-1.2113.6091-.3515 1.2219-.5454 1.7787-.5875ZM12.006 6.0036a14.832 14.832 0 0 1 .487 0c2.3901.0393 4.0848.67 5.1631 1.678 1.1501 1.0754 1.6423 2.6006 1.499 4.467-.1311 1.7079-1.2203 3.2281-2.652 4.324-.694.5313-1.4626.9354-2.2254 1.2294.0031-.0453.014-.0888.014-.1349.0029-1.1964-.9313-2.2133-2.2918-2.2133-1.3606 0-2.3222 1.0154-2.2918 2.2213.0013.0507.014.0972.0181.1471-.781-.2933-1.5696-.7013-2.2777-1.2456-1.4239-1.0945-2.4997-2.6129-2.6037-4.322-.1129-1.8567.3778-3.3382 1.5212-4.3965C7.5094 6.7 9.352 6.047 12.006 6.0036Zm-3.6419 6.8291c-.6053 0-1.0966.4903-1.0966 1.0966 0 .6063.4913 1.0986 1.0966 1.0986s1.0966-.4923 1.0966-1.0986c0-.6063-.4913-1.0966-1.0966-1.0966zm7.2819.0113c-.5998 0-1.0866.4859-1.0866 1.0866s.4868 1.0885 1.0866 1.0885c.5997 0 1.0865-.4878 1.0865-1.0885s-.4868-1.0866-1.0865-1.0866zM12 16.0835c1.0237 0 1.5654.638 1.5634 1.4829-.0018.7849-.6723 1.485-1.5634 1.485-.9167 0-1.54-.5629-1.5634-1.493-.0212-.8347.5397-1.4749 1.5634-1.4749Z";
private const string PleromaPath = "M6.36 0A1.868 1.868 0 004.49 1.868V24h5.964V0zm7.113 0v12h4.168a1.868 1.868 0 001.868-1.868V0zm0 18.036V24h4.168a1.868 1.868 0 001.868-1.868v-4.096Z";
private const string FirefishPath = "M16.771 0c-.68-.016-1.342.507-1.342 1.304V7.27c0 .719.582 1.301 1.3 1.301h5.967c1.16 0 1.74-1.401.92-2.22L17.65.383a1.275 1.275 0 0 0-.879-.383ZM6.573.106c-.672-.017-1.326.5-1.326 1.287v5.892c0 .71.575 1.285 1.285 1.285h5.892c1.145 0 1.718-1.384.908-2.194L7.44.484a1.259 1.259 0 0 0-.867-.379ZM1.286 10.287c-.71 0-1.286.576-1.286 1.286v11.142C0 23.425.576 24 1.286 24h11.143c.71 0 1.285-.575 1.285-1.285V11.573c0-.71-.575-1.286-1.285-1.286zm15.485 0c-.68-.017-1.342.507-1.342 1.304v5.966c0 .718.582 1.3 1.3 1.3h5.967c1.16 0 1.74-1.4.92-2.22L17.65 10.67a1.275 1.275 0 0 0-.879-.384zM3.43 17.144a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428zm4.285 0a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428z";
/// <summary>Generic fediverse glyph (honeycomb) for instances whose software isn't in our icon set.</summary>
public const string FediverseIconData = "M12 4A1.3 1.3 0 1 0 12 6.6 1.3 1.3 0 1 0 12 4ZM12 10.7A1.3 1.3 0 1 0 12 13.3 1.3 1.3 0 1 0 12 10.7ZM12 17.4A1.3 1.3 0 1 0 12 20 1.3 1.3 0 1 0 12 17.4ZM5.4 6.05A1.3 1.3 0 1 0 5.4 8.65 1.3 1.3 0 1 0 5.4 6.05ZM5.4 15.35A1.3 1.3 0 1 0 5.4 17.95 1.3 1.3 0 1 0 5.4 15.35ZM18.6 6.05A1.3 1.3 0 1 0 18.6 8.65 1.3 1.3 0 1 0 18.6 6.05ZM18.6 15.35A1.3 1.3 0 1 0 18.6 17.95 1.3 1.3 0 1 0 18.6 15.35Z";
public static string LogoDataFor(SocialService service) => service switch
{ {
SocialService.YouTube => "YT", SocialService.YouTube => YouTubePath,
SocialService.Twitch => "TW", SocialService.Twitch => TwitchPath,
SocialService.X => "X", SocialService.X => XPath,
SocialService.Instagram => "IG", SocialService.Instagram => InstagramPath,
SocialService.TikTok => "TT", SocialService.TikTok => TikTokPath,
SocialService.Facebook => "FB", SocialService.Facebook => FacebookPath,
SocialService.Discord => "DC", SocialService.Discord => DiscordPath,
SocialService.Kick => "K", SocialService.Kick => KickPath,
SocialService.Threads => "TH", SocialService.Threads => ThreadsPath,
SocialService.Bluesky => "BS", SocialService.Bluesky => BlueskyPath,
SocialService.GitHub => "GH", SocialService.GitHub => GitHubPath,
SocialService.LinkedIn => "LI", SocialService.LinkedIn => LinkedInPath,
SocialService.Pinterest => "PN", SocialService.Pinterest => PinterestPath,
SocialService.Snapchat => "SC", SocialService.Snapchat => SnapchatPath,
SocialService.Reddit => "RD", SocialService.Reddit => RedditPath,
SocialService.WhatsApp => "WA", SocialService.WhatsApp => WhatsAppPath,
SocialService.Telegram => "TG", SocialService.Telegram => TelegramPath,
SocialService.Link => "L", SocialService.Fediverse => FediverseIconData,
_ => "★", _ => LinkIconData,
}; };
public static string ColorFor(SocialService service) => service switch /// <summary>
/// The logo for a fediverse instance given its nodeinfo software name
/// (e.g. "peertube", "mastodon"). Unknown software gets the generic
/// fediverse glyph.
/// </summary>
public static string LogoDataForFediverse(string? software) => software?.Trim().ToLowerInvariant() switch
{ {
SocialService.YouTube => "#FF0000", "mastodon" => MastodonPath,
SocialService.Twitch => "#9146FF", "peertube" => PeerTubePath,
SocialService.X => "#000000", "pixelfed" => PixelfedPath,
SocialService.Instagram => "#E4405F", "misskey" => MisskeyPath,
SocialService.TikTok => "#010101", "lemmy" => LemmyPath,
SocialService.Facebook => "#1877F2", "pleroma" => PleromaPath,
SocialService.Discord => "#5865F2", "firefish" => FirefishPath,
SocialService.Kick => "#53FC18", _ => FediverseIconData,
SocialService.Threads => "#010101",
SocialService.Bluesky => "#0085FF",
SocialService.GitHub => "#181717",
SocialService.LinkedIn => "#0A66C2",
SocialService.Pinterest => "#BD081C",
SocialService.Snapchat => "#FFFC00",
SocialService.Reddit => "#FF4500",
SocialService.WhatsApp => "#25D366",
SocialService.Telegram => "#26A5E4",
_ => "#e94560",
}; };
/// <summary>
/// The canonical profile URL for a service handle. Fediverse handles
/// (@user@domain) are recognized first — their identity carries its own host.
/// </summary>
public static string CanonicalUrlFor(SocialService service, string handle) public static string CanonicalUrlFor(SocialService service, string handle)
{ {
var h = handle.Trim().TrimStart('@'); var input = handle.Trim();
if (TryParseFediverse(input, out var fedUser, out var fedDomain))
return $"https://{fedDomain}/@{fedUser}";
var h = input.TrimStart('@');
return service switch return service switch
{ {
SocialService.YouTube => $"https://www.youtube.com/@{h}", SocialService.YouTube => $"https://www.youtube.com/@{h}",
@@ -147,16 +194,9 @@ public static class SocialServiceIcons
{ {
var trimmed = input.Trim(); var trimmed = input.Trim();
// Fediverse handle: @user@domain // Fediverse handle: @user@domain — the full handle IS the identity.
if (trimmed.StartsWith("@") && trimmed.IndexOf('@', 1) > 0) if (TryParseFediverse(trimmed, out var fedUser, out var fedDomain))
{ return (SocialService.Fediverse, trimmed, $"https://{fedDomain}/@{fedUser}");
var parts = trimmed.TrimStart('@').Split('@', 2);
if (parts.Length == 2)
{
var (user, domain) = (parts[0], parts[1]);
return (SocialService.Link, user, $"https://{domain}/@{user}");
}
}
// Strip protocol for domain parsing // Strip protocol for domain parsing
var url = trimmed; var url = trimmed;
@@ -196,4 +236,16 @@ public static class SocialServiceIcons
return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed)); return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed));
} }
} }
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
private static bool TryParseFediverse(string input, out string user, out string domain)
{
user = domain = string.Empty;
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
var secondAt = input.IndexOf('@', 1);
if (secondAt <= 1 || secondAt == input.Length - 1) return false;
user = input[1..secondAt];
domain = input[(secondAt + 1)..];
return true;
}
} }
+1 -1
View File
@@ -14,7 +14,7 @@ Plain data types. No logic beyond what a property can carry. See
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math | | `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum | | `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) | | `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
| `Socials.cs` | **Social bar** (global resource, per-scene presence): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website), `SocialEntry` (Service/Handle/ProfileUrl + Initials/AccentColor), `SocialsConfig` (Entries + BarPosition Top/Bottom + BarJustify LCR), `SocialServiceIcons` (canonical URL builder + initials/color per service) | | `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain`**Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these; Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`. [`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
+53 -8
View File
@@ -135,11 +135,13 @@ public class LayoutStore : IDisposable
MigrateWebcamConfigTable(); MigrateWebcamConfigTable();
MigrateSceneTable(); MigrateSceneTable();
MigrateSceneSocialBarColumn(); MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3) if (GetUserVersion() < 3)
MigrateToV3(); MigrateToV3();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "PRAGMA user_version = 7;"; cmd.CommandText = "PRAGMA user_version = 8;";
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
@@ -360,6 +362,46 @@ public class LayoutStore : IDisposable
alter.ExecuteNonQuery(); alter.ExecuteNonQuery();
} }
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
// BarJustify column stays for back-compat but is no longer read or written.
private void MigrateSocialsTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Socials);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("BarEnabled")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
alter.ExecuteNonQuery();
}
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
// software name) so a reloaded entry still shows the right service logo.
private void MigrateSocialEntryTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("Software")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
alter.ExecuteNonQuery();
}
public List<Scene> Load() public List<Scene> Load()
{ {
Webcam = null; Webcam = null;
@@ -403,19 +445,19 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "SELECT Id, BarPosition, BarJustify FROM Socials LIMIT 1;"; cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
if (reader.Read()) if (reader.Read())
{ {
Socials = new SocialsConfig Socials = new SocialsConfig
{ {
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom, BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
BarJustify = Enum.TryParse<SocialBarJustify>(reader.GetString(2), out var just) ? just : SocialBarJustify.Center, BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
}; };
var socialsId = reader.GetString(0); var socialsId = reader.GetString(0);
using var entryCmd = _connection.CreateCommand(); using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;"; entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
entryCmd.Parameters.AddWithValue("$id", socialsId); entryCmd.Parameters.AddWithValue("$id", socialsId);
using var entryReader = entryCmd.ExecuteReader(); using var entryReader = entryCmd.ExecuteReader();
while (entryReader.Read()) while (entryReader.Read())
@@ -425,6 +467,7 @@ public class LayoutStore : IDisposable
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link, Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
Handle = entryReader.GetString(1), Handle = entryReader.GetString(1),
ProfileUrl = entryReader.GetString(2), ProfileUrl = entryReader.GetString(2),
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
}); });
} }
} }
@@ -717,18 +760,18 @@ public class LayoutStore : IDisposable
var socialsId = Guid.NewGuid().ToString(); var socialsId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarJustify) VALUES ($id, $pos, $just);"; cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
cmd.Transaction = tx; cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", socialsId); cmd.Parameters.AddWithValue("$id", socialsId);
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString()); cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
cmd.Parameters.AddWithValue("$just", socials.BarJustify.ToString()); cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
using var entryCmd = _connection.CreateCommand(); using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = """ entryCmd.CommandText = """
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, SortOrder) INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
VALUES ($id, $socialsId, $service, $handle, $url, $sort) VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
"""; """;
entryCmd.Transaction = tx; entryCmd.Transaction = tx;
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text); var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
@@ -736,6 +779,7 @@ public class LayoutStore : IDisposable
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text); var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text); var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text); var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer); var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
var entrySort = 0; var entrySort = 0;
@@ -746,6 +790,7 @@ public class LayoutStore : IDisposable
eService.Value = entry.Service.ToString(); eService.Value = entry.Service.ToString();
eHandle.Value = entry.Handle; eHandle.Value = entry.Handle;
eUrl.Value = entry.ProfileUrl; eUrl.Value = entry.ProfileUrl;
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
eSort.Value = entrySort++; eSort.Value = entrySort++;
entryCmd.ExecuteNonQuery(); entryCmd.ExecuteNonQuery();
} }
+121 -5
View File
@@ -1,4 +1,5 @@
using System.Net.Http; using System.Net.Http;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ytLive.Models; using ytLive.Models;
@@ -11,11 +12,17 @@ public sealed class SocialLookupResult
public string ProfileUrl { get; init; } = string.Empty; public string ProfileUrl { get; init; } = string.Empty;
public string Handle { get; init; } = string.Empty; public string Handle { get; init; } = string.Empty;
public string Error { get; init; } = string.Empty; public string Error { get; init; } = string.Empty;
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
public string? FediverseSoftware { get; init; }
/// <summary>True when the caller canceled the lookup before it finished.</summary>
public bool Canceled { get; init; }
} }
public interface ISocialValidator public interface ISocialValidator
{ {
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl); Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
} }
/// <summary> /// <summary>
@@ -36,12 +43,39 @@ public sealed class HttpSocialValidator : ISocialValidator
"Mozilla/5.0 (compatible; ytLlive social validator)"); "Mozilla/5.0 (compatible; ytLlive social validator)");
} }
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl) public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{ {
var input = handleOrUrl.Trim(); var input = handleOrUrl.Trim();
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
return new SocialLookupResult { Error = "Enter a handle or URL." }; return new SocialLookupResult { Error = "Enter a handle or URL." };
// Fediverse handle (@user@domain): the domain is part of the identity,
// so the canonical URL can't be rebuilt from a bare handle. After the
// profile validates, ask the instance which software it runs (nodeinfo)
// so the caller can show the right service logo.
if (input.StartsWith('@') && input.IndexOf('@', 1) > 0)
{
var at = input.IndexOf('@', 1);
var user = input[1..at];
var domain = input[(at + 1)..];
if (user.Length > 0 && domain.Length > 0)
{
var url = $"https://{domain}/@{user}";
var result = await CheckAsync(service, input, url, ct);
if (!result.Success) return result;
var software = await TryFetchFediverseSoftwareAsync(domain, ct);
if (ct.IsCancellationRequested)
return new SocialLookupResult { Canceled = true };
return new SocialLookupResult
{
Success = true,
ProfileUrl = result.ProfileUrl,
Handle = result.Handle,
FediverseSoftware = software,
};
}
}
var handle = input; var handle = input;
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) || if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase)) input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
@@ -50,23 +84,31 @@ public sealed class HttpSocialValidator : ISocialValidator
handle = uri.AbsolutePath.Trim('/'); handle = uri.AbsolutePath.Trim('/');
} }
var url = SocialServiceIcons.CanonicalUrlFor(service, handle); var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle);
return await CheckAsync(service, handle, canonical, ct);
}
private async Task<SocialLookupResult> CheckAsync(SocialService service, string handle, string url, CancellationToken ct)
{
try try
{ {
using var resp = await _client.GetAsync(url, CancellationToken.None); using var resp = await _client.GetAsync(url, ct);
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308) if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
return new SocialLookupResult return new SocialLookupResult
{ {
Success = true, Success = true,
ProfileUrl = url, ProfileUrl = url,
Handle = handle.TrimStart('@'), Handle = handle,
}; };
return new SocialLookupResult return new SocialLookupResult
{ {
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.", Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
}; };
} }
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
{
return new SocialLookupResult { Canceled = true };
}
catch (System.Net.Http.HttpRequestException) catch (System.Net.Http.HttpRequestException)
{ {
return new SocialLookupResult return new SocialLookupResult
@@ -79,4 +121,78 @@ public sealed class HttpSocialValidator : ISocialValidator
return new SocialLookupResult { Error = ex.Message }; return new SocialLookupResult { Error = ex.Message };
} }
} }
/// <summary>
/// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first
/// nodeinfo link, read `software.name`. If the identity domain is itself a
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
/// the bare root 302s to the real instance — follow it and ask that host.
/// Failures return null — validation still succeeds, the icon just falls
/// back to the generic fediverse glyph.
/// </summary>
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
{
try
{
var software = await FetchSoftwareNameAsync(domain, ct);
if (software != null) return software;
var resolved = await ResolveInstanceHostAsync(domain, ct);
if (resolved == null
|| string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
return null;
return await FetchSoftwareNameAsync(resolved, ct);
}
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
{
return null; // caller checks ct.IsCancellationRequested and reports Canceled
}
catch
{
return null;
}
}
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
{
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
if (nodeInfoUrl == null) return null;
using var resp = await _client.GetAsync(nodeInfoUrl, ct);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("software", out var software)
&& software.TryGetProperty("name", out var name))
return name.GetString();
return null;
}
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
/// and returns the final host — the "default app" behind an identity domain.</summary>
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
{
using var resp = await _client.GetAsync($"https://{domain}/", ct);
return resp.RequestMessage?.RequestUri?.Host;
}
private async Task<string?> FetchNodeInfoUrlAsync(string domain, CancellationToken ct)
{
using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("links", out var links)) return null;
foreach (var link in links.EnumerateArray())
{
if (!link.TryGetProperty("rel", out var rel)) continue;
var relStr = rel.GetString() ?? string.Empty;
if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue;
if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue;
if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed))
return parsed.IsAbsoluteUri
? parsed.AbsoluteUri
: new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri;
}
return null;
}
} }
+1 -1
View File
@@ -32,7 +32,7 @@ External-facing logic: YouTube API, persistence. See
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) | | `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) | | `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it | | `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Constructor takes optional `HttpClient` for tests | | `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo``software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host instead. Constructor takes optional `HttpClient` for tests |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) | | `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
+128
View File
@@ -0,0 +1,128 @@
<Window x:Class="ytLive.SocialsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Social Media Site Promotion" Height="520" Width="520"
Icon="/Assets/llama-logo-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
<Window.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
<!-- Show/hide the bar on the stream -->
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
</CheckBox>
<!-- Sign-in gate: the first slot is always your YouTube channel -->
<Border Grid.Row="2" Background="#16213e" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
Visibility="{Binding ShowSignInBanner, Converter={StaticResource BoolToVis}}">
<StackPanel>
<TextBlock Text="Sign in to YouTube first — the first slot is your channel."
Foreground="#e0e0e0" FontSize="12" TextWrapping="Wrap"/>
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Left"
Margin="0,8,0,0" Padding="12,4" FontSize="11"/>
</StackPanel>
</Border>
<TextBlock Grid.Row="2" Text="Signing in… open the browser to authorize, then come back here."
Foreground="#e94560" FontSize="12" Margin="0,0,0,10"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}"/>
<!-- The six fixed slots -->
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Slots}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="34"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Service icon: logo, lock (Premium) or do-not (empty/invalid) -->
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
Background="#16213e" HorizontalAlignment="Left" VerticalAlignment="Top">
<Grid>
<Path Data="{Binding LogoData}" Fill="White" Stretch="Uniform"
Width="15" Height="15" HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ShowLogo, Converter={StaticResource BoolToVis}}"/>
<Path Data="{Binding LockedIconData}" Fill="#8a93a8" Stretch="Uniform"
Width="15" Height="15" HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding IsLocked, Converter={StaticResource BoolToVis}}"/>
<Path Data="{Binding DoNotIconData}" Fill="#5a5a6e" Stretch="Uniform"
Width="15" Height="15" HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ShowDoNot, Converter={StaticResource BoolToVis}}"/>
</Grid>
</Border>
<!-- Handle text / edit box / status -->
<Grid Grid.Column="1" Margin="10,0,8,0">
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0" FontSize="14"
VerticalAlignment="Top" Margin="0,6,0,0"
TextTrimming="CharacterEllipsis"
Visibility="{Binding ShowDisplay, Converter={StaticResource BoolToVis}}"/>
<TextBox Text="{Binding EditText, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBox}" FontSize="13"
KeyDown="SocialEdit_KeyDown" LostFocus="SocialEdit_LostFocus"
Visibility="{Binding ShowEditBox, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Error}" Foreground="#e94560" FontSize="11"
TextWrapping="Wrap" Margin="0,32,0,0"
Visibility="{Binding ShowError, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Checking…" Foreground="#a0a0b0" FontSize="11" Margin="0,32,0,0"
Visibility="{Binding ShowBusy, Converter={StaticResource BoolToVis}}"/>
</Grid>
<!-- Row actions -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Top"
Margin="0,3,0,0">
<Button Content="Sign in" Click="SignInButton_Click"
Style="{StaticResource YtButtonSecondary}" Padding="10,3"
FontSize="11" Margin="0,0,6,0"
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
<Button Content="+" Click="SlotAdd_Click" Tag="{Binding Index}"
Style="{StaticResource IconButton}" Width="26" Height="26"
ToolTip="Add a social" FontSize="16" FontWeight="Bold"
Visibility="{Binding ShowAddButton, Converter={StaticResource BoolToVis}}"/>
<Button Content="&#x270F;&#xFE0F;" Click="SlotEdit_Click" Tag="{Binding Index}"
Style="{StaticResource IconButton}" Width="26" Height="26"
ToolTip="Edit" FontSize="13"
Visibility="{Binding ShowEditButton, Converter={StaticResource BoolToVis}}"/>
<Button Content="&#x1F5D1;&#xFE0F;" Click="SlotDelete_Click" Tag="{Binding Index}"
Style="{StaticResource IconButton}" Width="26" Height="26"
ToolTip="Remove" FontSize="12" Foreground="#e94560"
Visibility="{Binding ShowDeleteButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Actions -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" IsCancel="True"/>
<Button Content="Save" Command="{Binding SaveCommand}" Style="{StaticResource YtButton}" MinWidth="110"/>
</StackPanel>
</Grid>
</Window>
+73
View File
@@ -0,0 +1,73 @@
using System.Windows;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive;
public partial class SocialsDialog : Window
{
private readonly SocialsDialogViewModel _viewModel;
public SocialsDialog(SocialsDialogViewModel viewModel)
{
AppLog.Write("SocialsDialog ctor: before InitializeComponent");
InitializeComponent();
AppLog.Write("SocialsDialog ctor: after InitializeComponent");
DataContext = viewModel;
_viewModel = viewModel;
viewModel.SaveRequested += () => DialogResult = true;
viewModel.CancelRequested += () => DialogResult = false;
Closing += (_, _) => _viewModel.AbortPending();
}
private void SignInButton_Click(object sender, RoutedEventArgs e)
=> _viewModel.SignInCommand.Execute(null);
private void SlotAdd_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { Tag: int index })
_viewModel.StartEdit(index);
}
private void SlotEdit_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { Tag: int index })
_viewModel.StartEdit(index);
}
private async void SlotDelete_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { Tag: int index } element) return;
var slot = element.DataContext as SocialSlotViewModel;
var confirm = index == 0
? "Remove your YouTube channel from the bar and sign out of ytLlive?"
: slot is { IsFilled: true }
? $"Remove {slot.Handle}?"
: "Remove this social?";
if (MessageBox.Show(confirm, "Remove Social", MessageBoxButton.YesNo, MessageBoxImage.Question)
== MessageBoxResult.Yes)
await _viewModel.DeleteSlotAsync(index);
}
private void SocialEdit_KeyDown(object sender, KeyEventArgs e)
{
if (sender is not FrameworkElement { DataContext: SocialSlotViewModel slot }) return;
if (e.Key == Key.Enter)
{
_viewModel.ConfirmEdit(slot.Index);
}
else if (e.Key == Key.Escape)
{
slot.IsEditing = false;
slot.Error = null;
}
}
private void SocialEdit_LostFocus(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { DataContext: SocialSlotViewModel slot })
_viewModel.ConfirmEdit(slot.Index);
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
11.**Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse 11.**Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
12.**Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule) 12.**Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule)
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES) 13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
14.**Social bar** — global resource like the audio meter; user provides handle/URL → app validates by looking up the social page (`HttpSocialValidator`) → adds icon + handle to a single-line horizontal bar (top/bottom, LCR justify). Freemium: YT + 1 other (`FreemiumSocialCap=2`). Premium: unlimited (flag seam, itch.io deferred). [+] adds to scene, [-] removes (`Scene.HasSocialBar`, schema v7). Footer has Social button + [+/-] on the meter line. 85 tests passing. 14.**Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop**`ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo``software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
15.**Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending 15.**Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
16.**Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1) 16.**Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
17.**Text source** — live text ("Starting soon", "Back in 5", handle, callout) 17.**Text source** — live text ("Starting soon", "Back in 5", handle, callout)
+1
View File
@@ -43,6 +43,7 @@
<Style x:Key="IconButton" TargetType="Button"> <Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#d0d0d0"/>
<Setter Property="BorderThickness" Value="0"/> <Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/> <Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
+47 -176
View File
@@ -170,47 +170,33 @@ public class MainViewModel : ViewModelBase
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary> /// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null; 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> /// <summary>True when the global socials config has at least one validated entry.</summary>
public bool HasSocials => _socials != null && _socials.Entries.Count > 0; 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> /// <summary>The bar is on the stream when it's enabled and has entries.</summary>
public bool CanToggleSceneSocialBar => HasSocials && ActiveScene != null; public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
/// <summary>Whether the active scene currently shows the social bar.</summary> /// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
public bool SceneHasSocialBar public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
{
get => ActiveScene?.HasSocialBar ?? false;
set
{
if (ActiveScene is { } scene && scene.HasSocialBar != value)
{
scene.HasSocialBar = value;
OnPropertyChanged(nameof(SceneHasSocialBar));
ScheduleSave();
}
}
}
/// <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; public SocialsConfig? Socials => _socials;
/// <summary>Freemium cap: YT + 1 other = 2 entries. Premium (unlimited) gated by the unlock flag seam.</summary> /// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
public const int FreemiumSocialCap = 2;
private static bool IsPremium => false; // itch.io unlock deferred — seam only 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> /// <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 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(); private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
@@ -759,7 +745,6 @@ public class MainViewModel : ViewModelBase
public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleMicMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; } public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; } public ICommand OpenSocialDialogCommand { get; }
public ICommand ToggleSceneSocialBarCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
@@ -815,7 +800,6 @@ public class MainViewModel : ViewModelBase
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam); ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene); ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog()); OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ToggleSceneSocialBarCommand = new RelayCommand(_ => ToggleSceneSocialBar(), _ => CanToggleSceneSocialBar);
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync()); ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture()); RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo)); SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
@@ -889,7 +873,6 @@ public class MainViewModel : ViewModelBase
IsConnected = true; IsConnected = true;
SyncConnectedAccount(); SyncConnectedAccount();
AutoAddYouTubeSocial();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -904,34 +887,6 @@ public class MainViewModel : ViewModelBase
AccountDisplayName = channel?.DisplayName ?? string.Empty; 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( private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive", "ytLlive",
@@ -981,8 +936,8 @@ public class MainViewModel : ViewModelBase
ReacquireScreenCaptures(); ReacquireScreenCaptures();
_socials = _layoutStore.Socials; _socials = _layoutStore.Socials;
OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar)); OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SceneHasSocialBar)); OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave(); ScheduleSave();
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
@@ -1847,137 +1802,53 @@ public class MainViewModel : ViewModelBase
// ─── Social bar ─── // ─── Social bar ───
private void ToggleSceneSocialBar() /// <summary>
{ /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
if (ActiveScene is not { } scene) return; /// validation). On save the working copy replaces <see cref="_socials"/>.
SceneHasSocialBar = !scene.HasSocialBar; /// </summary>
}
private void OpenSocialDialog() private void OpenSocialDialog()
{ {
if (!CanAddMoreSocials) var dialog = new SocialsDialogViewModel(
{ _socialValidator,
MessageBox.Show( SignInAsync,
IsPremium SignOutYouTubeAsync,
? "Something went wrong — the social cap is hit but premium is active." _youtubeAuth.CurrentChannel,
: $"Free tier: up to {FreemiumSocialCap} socials (YouTube + 1 other). Upgrade to add more.", IsPremium,
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Information); _socials);
return; var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
} if (window.ShowDialog() == true)
// 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)
{ {
_socials = dialog.BuildSocialsConfig();
NotifySocialsChanged(); 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() private void NotifySocialsChanged()
{ {
OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar)); OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(CanAddMoreSocials)); OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave(); 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; if (_socials == null || _socials.BarPosition == position) return;
_socials.Entries.RemoveAt(index); _socials.BarPosition = position;
if (_socials.Entries.Count == 0) OnPropertyChanged(nameof(SocialBarTop));
{
foreach (var scene in Scenes) scene.HasSocialBar = false;
_socials = null;
}
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(CanAddMoreSocials));
OnPropertyChanged(nameof(SceneHasSocialBar));
ScheduleSave(); 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 _youtubeAuth.ClearSession();
{ TokenStore.Clear();
Title = title, IsConnected = false;
Width = 420, SyncConnectedAccount();
Height = 180, NotifySocialsChanged();
WindowStartupLocation = WindowStartupLocation.CenterOwner, AppLog.Write("Socials: signed out of YouTube");
Owner = Application.Current.MainWindow, await Task.CompletedTask;
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;
} }
} }
+518
View File
@@ -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();
}
}
}
+1
View File
@@ -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 | | `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` | | `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` | | `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) 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). for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
+73 -13
View File
@@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Xunit; using Xunit;
using ytLive.Models; using ytLive.Models;
@@ -27,7 +25,7 @@ public class SocialBarTests
var socials = new SocialsConfig var socials = new SocialsConfig
{ {
BarPosition = SocialBarPosition.Top, BarPosition = SocialBarPosition.Top,
BarJustify = SocialBarJustify.Left, BarEnabled = false,
}; };
socials.Entries.Add(new SocialEntry socials.Entries.Add(new SocialEntry
{ {
@@ -41,6 +39,13 @@ public class SocialBarTests
Handle = "streamer123", Handle = "streamer123",
ProfileUrl = "https://www.twitch.tv/streamer123", ProfileUrl = "https://www.twitch.tv/streamer123",
}); });
socials.Entries.Add(new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
FediverseSoftware = "peertube",
});
using (var store = new LayoutStore(path)) using (var store = new LayoutStore(path))
{ {
@@ -53,13 +58,16 @@ public class SocialBarTests
{ {
var scenes = store.Load(); var scenes = store.Load();
Assert.NotNull(store.Socials); Assert.NotNull(store.Socials);
Assert.Equal(2, store.Socials!.Entries.Count); Assert.Equal(3, store.Socials!.Entries.Count);
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service); Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
Assert.Equal("mychannel", store.Socials.Entries[0].Handle); Assert.Equal("mychannel", store.Socials.Entries[0].Handle);
Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl); Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl);
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service); Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
Assert.Equal(SocialService.Fediverse, store.Socials.Entries[2].Service);
Assert.Equal("@gramps@llamachile.tube", store.Socials.Entries[2].Handle);
Assert.Equal("peertube", store.Socials.Entries[2].FediverseSoftware);
Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition); Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition);
Assert.Equal(SocialBarJustify.Left, store.Socials.BarJustify); Assert.False(store.Socials.BarEnabled);
Assert.True(scenes[0].HasSocialBar); Assert.True(scenes[0].HasSocialBar);
} }
} }
@@ -73,7 +81,7 @@ public class SocialBarTests
[Fact] [Fact]
public void SocialsConfig_EmptyEntries_NotPersisted() public void SocialsConfig_EmptyEntries_NotPersisted()
{ {
var path = TempDBPath(); var path = TempDbPath();
try try
{ {
var socials = new SocialsConfig(); var socials = new SocialsConfig();
@@ -94,6 +102,12 @@ public class SocialBarTests
} }
} }
[Fact]
public void SocialsConfig_DefaultBarEnabled_IsOn()
{
Assert.True(new SocialsConfig().BarEnabled);
}
[Fact] [Fact]
public void SocialServiceIcons_CanonicalUrlFor_AllServices() public void SocialServiceIcons_CanonicalUrlFor_AllServices()
{ {
@@ -103,17 +117,63 @@ public class SocialBarTests
Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev")); Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev"));
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com")); Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com"));
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com")); Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com"));
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Link, "@user@site.tld"));
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Fediverse, "@user@site.tld"));
} }
[Fact] [Fact]
public void SocialServiceIcons_InitialsAndColor_AllServices() public void SocialServiceIcons_LogoData_AllServices()
{ {
Assert.Equal("YT", SocialServiceIcons.InitialsFor(SocialService.YouTube)); foreach (var service in Enum.GetValues<SocialService>())
Assert.Equal("#FF0000", SocialServiceIcons.ColorFor(SocialService.YouTube)); Assert.False(string.IsNullOrWhiteSpace(SocialServiceIcons.LogoDataFor(service)), service.ToString());
Assert.Equal("TW", SocialServiceIcons.InitialsFor(SocialService.Twitch)); Assert.NotEmpty(SocialServiceIcons.LockedIconData);
Assert.NotEmpty(SocialServiceIcons.InitialsFor(SocialService.Website)); Assert.NotEmpty(SocialServiceIcons.DoNotIconData);
Assert.NotEmpty(SocialServiceIcons.ColorFor(SocialService.Website)); Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.YouTube), SocialServiceIcons.LogoDataFor(SocialService.Twitch));
} }
private static string TempDBPath() => TempDbPath(); [Fact]
public void SocialServiceIcons_LogoDataForFediverse_KnownAndUnknownSoftware()
{
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("PeerTube"));
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), SocialServiceIcons.LogoDataForFediverse("MASTODON"));
Assert.NotEqual(
SocialServiceIcons.LogoDataForFediverse("peertube"),
SocialServiceIcons.LogoDataForFediverse("mastodon"));
Assert.Equal(SocialServiceIcons.LogoDataForFediverse(null), SocialServiceIcons.LogoDataForFediverse("gotosocial"));
Assert.NotEqual(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("unknown"));
}
[Fact]
public void SocialEntry_FediverseLogo_ComesFromSoftwareName()
{
var entry = new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
FediverseSoftware = "peertube",
};
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.Link), entry.LogoData);
}
[Fact]
public void SocialServiceIcons_DetectService_ByDomain()
{
Assert.Equal(SocialService.YouTube, SocialServiceIcons.DetectService("youtube.com/@handle").service);
Assert.Equal(SocialService.X, SocialServiceIcons.DetectService("https://x.com/user").service);
Assert.Equal(SocialService.Instagram, SocialServiceIcons.DetectService("instagram.com/user").service);
var fediverse = SocialServiceIcons.DetectService("@user@instance.tube");
Assert.Equal(SocialService.Fediverse, fediverse.service);
Assert.Equal("@user@instance.tube", fediverse.handle);
Assert.Equal("https://instance.tube/@user", fediverse.url);
Assert.Equal(SocialService.GitHub, SocialServiceIcons.DetectService("https://github.com/dev").service);
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("example.com/path").service);
}
[Fact]
public void SocialServiceIcons_DetectService_BareHandle_FallsBackToWebsite()
{
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("justaname").service);
}
} }
+208
View File
@@ -0,0 +1,208 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
public class SocialValidatorTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(HttpResponseMessage response) : this(_ => response) { }
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (ct.IsCancellationRequested)
return Task.FromCanceled<HttpResponseMessage>(ct);
return Task.FromResult(_respond(request));
}
}
private static HttpSocialValidator Validator(HttpMessageHandler handler)
=> new(new HttpClient(handler));
[Fact]
public async Task FediverseHandle_BuildsHostUrl()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("https://site.tld/@user", result.ProfileUrl);
Assert.Equal("@user@site.tld", result.Handle);
}
[Fact]
public async Task FediverseHandle_FetchesInstanceSoftware()
{
var validator = Validator(new StubHandler(RespondFediverse("peertube")));
var result = await validator.LookupAsync(SocialService.Fediverse, "@gramps@llamachile.tube", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("peertube", result.FediverseSoftware);
}
[Fact]
public async Task FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware()
{
var validator = Validator(new StubHandler(request =>
{
var uri = request.RequestUri!;
if (uri.Host == "site.tld")
{
if (uri.AbsolutePath == "/")
{
// Identity domain's default app redirects to the real instance.
return new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://social.site.tld/"),
};
}
if (uri.AbsolutePath.StartsWith("/@"))
return new HttpResponseMessage(HttpStatusCode.OK); // profile validates
return new HttpResponseMessage(HttpStatusCode.NotFound); // nodeinfo SSO-blocked
}
if (uri.Host == "social.site.tld")
{
if (uri.AbsolutePath.StartsWith("/.well-known"))
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://social.site.tld/nodeinfo/2.0" } } });
if (uri.AbsolutePath.StartsWith("/nodeinfo"))
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
}
return new HttpResponseMessage(HttpStatusCode.OK);
}));
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("mastodon", result.FediverseSoftware);
}
[Fact]
public async Task FediverseHandle_NodeInfoUnavailable_StillSucceeds()
{
var validator = Validator(new StubHandler(request =>
request.RequestUri!.AbsolutePath.StartsWith("/.well-known")
? new HttpResponseMessage(HttpStatusCode.NotFound)
: new HttpResponseMessage(HttpStatusCode.OK)));
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
Assert.True(result.Success);
Assert.Null(result.FediverseSoftware);
}
[Fact]
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
{
using var cts = new CancellationTokenSource();
var request = 0;
var validator = Validator(new StubHandler(_ =>
{
if (++request >= 2) cts.Cancel();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", cts.Token);
Assert.True(result.Canceled);
Assert.False(result.Success);
}
private static Func<HttpRequestMessage, HttpResponseMessage> RespondFediverse(string software)
{
return request =>
{
if (request.RequestUri!.AbsolutePath.StartsWith("/.well-known"))
{
return Json(new
{
links = new[]
{
new
{
rel = "http://nodeinfo.diaspora.software/ns/schema/2.0",
href = $"https://{request.RequestUri.Host}/nodeinfo/2.0",
},
},
});
}
if (request.RequestUri!.AbsolutePath.StartsWith("/nodeinfo"))
{
return Json(new { software = new { name = software, version = "1.0.0" } });
}
return new HttpResponseMessage(HttpStatusCode.OK);
};
}
private static HttpResponseMessage Json(object payload)
=> new(HttpStatusCode.OK) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload)) };
[Fact]
public async Task KnownServiceHandle_CanonicalUrl()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
var result = await validator.LookupAsync(SocialService.X, "creator", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("https://x.com/creator", result.ProfileUrl);
Assert.Equal("creator", result.Handle);
}
[Fact]
public async Task NotFound_IsRejected()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
var result = await validator.LookupAsync(SocialService.X, "doesnotexist", CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("NotFound", result.Error);
}
[Fact]
public async Task Redirect_CountsAsExists()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.Found)));
var result = await validator.LookupAsync(SocialService.GitHub, "dev", CancellationToken.None);
Assert.True(result.Success);
}
[Fact]
public async Task ConnectionFailure_IsFriendlyError()
{
var validator = Validator(new StubHandler(_ => throw new HttpRequestException("boom")));
var result = await validator.LookupAsync(SocialService.X, "user", CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("Couldn't reach", result.Error);
}
[Fact]
public async Task CanceledToken_AbortsRequest()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
var result = await validator.LookupAsync(SocialService.X, "creator", cts.Token);
Assert.True(result.Canceled);
Assert.False(result.Success);
}
}
+408
View File
@@ -0,0 +1,408 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
public class SocialsDialogViewModelTests
{
private sealed class FakeValidator : ISocialValidator
{
public int LookupCount { get; private set; }
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{
LookupCount++;
if (handleOrUrl.StartsWith('@') && handleOrUrl.IndexOf('@', 1) > 0)
{
var at = handleOrUrl.IndexOf('@', 1);
var user = handleOrUrl[1..at];
var domain = handleOrUrl[(at + 1)..];
return Task.FromResult(new SocialLookupResult
{
Success = true,
Handle = handleOrUrl,
ProfileUrl = $"https://{domain}/@{user}",
FediverseSoftware = "peertube",
});
}
var handle = handleOrUrl.Trim().TrimStart('@');
if (handle == "doesnotexist")
return Task.FromResult(new SocialLookupResult { Error = "404 for 'doesnotexist'." });
return Task.FromResult(new SocialLookupResult
{
Success = true,
Handle = handle,
ProfileUrl = SocialServiceIcons.CanonicalUrlFor(service, handle),
});
}
}
/// <summary>Holds the lookup open so a test can cancel mid-flight and then
/// resolve it — proving a dismissed dialog never applies the result.</summary>
private sealed class BlockingValidator : ISocialValidator
{
public TaskCompletionSource<SocialLookupResult> Gate { get; } = new();
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> Gate.Task;
}
private sealed class SignInFake
{
public int Calls { get; private set; }
public Task<YouTubeChannel?> Next()
{
Calls++;
return Task.FromResult<YouTubeChannel?>(
new YouTubeChannel { ChannelId = "UCabc", DisplayName = "My Channel" });
}
}
private int _signOutCalls;
private Task SignOut() { _signOutCalls++; return Task.CompletedTask; }
private SocialsDialogViewModel Create(
ISocialValidator? validator = null,
SignInFake? signIn = null,
YouTubeChannel? account = null,
bool isPremium = false,
SocialsConfig? current = null)
{
return new SocialsDialogViewModel(
validator ?? new FakeValidator(),
signIn != null ? signIn.Next : () => Task.FromResult<YouTubeChannel?>(null),
SignOut,
account,
isPremium,
current);
}
// ── ONE integration test: the full dialog-VM flow with fakes ──
[Fact]
public async Task FullFlow_Freemium_SignInAddDeleteSignOutPersistence()
{
var validator = new FakeValidator();
var signIn = new SignInFake();
var vm = Create(validator, signIn);
// Gate: signed out, freemium → row 0 is the sign-in prompt, rows 2-5 locked.
Assert.True(vm.ShowSignInBanner);
Assert.Equal(6, vm.Slots.Count);
Assert.True(vm.Slots[0].IsSignIn);
Assert.False(vm.Slots[0].IsLocked);
Assert.False(vm.Slots[1].IsLocked);
Assert.True(vm.Slots[2].IsLocked);
Assert.True(vm.Slots[5].IsLocked);
Assert.True(vm.CanSave);
// Row 1: add a validated X handle. Save is blocked while input is unvalidated.
vm.StartEdit(1);
Assert.True(vm.Slots[1].IsEditing);
vm.Slots[1].EditText = "x.com/creator";
Assert.False(vm.CanSave);
vm.ConfirmEdit(1);
Assert.Equal(1, validator.LookupCount);
Assert.Equal(SocialService.X, vm.Slots[1].Service);
Assert.Equal("creator", vm.Slots[1].Handle);
Assert.True(vm.Slots[1].IsFilled);
Assert.False(vm.Slots[1].IsEditing);
Assert.True(vm.CanSave);
// Rows 2-5 are locked on the free tier — starting an edit there is blocked.
vm.StartEdit(2);
Assert.False(vm.Slots[2].IsEditing);
Assert.True(vm.Slots[2].IsLocked);
// Delete the X entry: the slot empties and unlocks nothing else.
await vm.DeleteSlotAsync(1);
Assert.False(vm.Slots[1].IsFilled);
Assert.False(vm.Slots[1].IsLocked);
Assert.True(vm.Slots[2].IsLocked);
// Delete row 0 while signed out is a no-op.
await vm.DeleteSlotAsync(0);
Assert.Equal(0, _signOutCalls);
// Sign in: row 0 fills with the channel; the X slot is still free.
vm.SignInCommand.Execute(null);
Assert.Equal(1, signIn.Calls);
Assert.True(vm.IsSignedIn);
Assert.False(vm.Slots[0].IsSignIn);
Assert.Equal("My Channel", vm.Slots[0].Handle);
Assert.False(vm.ShowSignInBanner);
// Fill row 1 again and save: YouTube-first ordering, bar enabled.
vm.StartEdit(1);
vm.Slots[1].EditText = "twitch.tv/streamer";
vm.ConfirmEdit(1);
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.CommittedEntries);
Assert.Equal(2, vm.CommittedEntries!.Count);
Assert.Equal(SocialService.YouTube, vm.CommittedEntries[0].Service);
Assert.Equal("My Channel", vm.CommittedEntries[0].Handle);
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
Assert.Equal("streamer", vm.CommittedEntries[1].Handle);
// Sign out via the row-0 delete: action called once, row 0 back to the gate.
await vm.DeleteSlotAsync(0);
Assert.Equal(1, _signOutCalls);
Assert.False(vm.IsSignedIn);
Assert.True(vm.Slots[0].IsSignIn);
// Full persistence roundtrip through the store.
var config = vm.BuildSocialsConfig();
Assert.NotNull(config);
Assert.True(config!.BarEnabled);
Assert.Equal(2, config.Entries.Count);
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-flow-{System.Guid.NewGuid():N}.db");
SqliteConnection.ClearAllPools();
try
{
using (var store = new LayoutStore(path))
store.Save(new[] { new Scene { Name = "Live" } }, null, config);
using (var store = new LayoutStore(path))
{
store.Load();
Assert.NotNull(store.Socials);
Assert.Equal(2, store.Socials!.Entries.Count);
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
Assert.True(store.Socials.BarEnabled);
}
}
finally
{
SqliteConnection.ClearAllPools();
if (File.Exists(path)) File.Delete(path);
}
}
// ── Unit tests ──
[Fact]
public void Freemium_LocksSlotsBeyondTwo_PremiumUnlocksAll()
{
var free = Create();
Assert.True(free.Slots[2].IsLocked);
Assert.True(free.Slots[5].IsLocked);
Assert.False(free.Slots[1].IsLocked);
var premium = Create(isPremium: true);
Assert.All(premium.Slots, s => Assert.False(s.IsLocked));
Assert.False(premium.Slots[5].IsLocked);
}
[Fact]
public void ValidationFailure_LeavesSlotEditableAndBlocksSave()
{
var validator = new FakeValidator();
var vm = Create(validator);
vm.StartEdit(1);
vm.Slots[1].EditText = "x.com/doesnotexist";
vm.ConfirmEdit(1);
Assert.True(vm.Slots[1].IsEditing);
Assert.True(vm.Slots[1].HasError);
Assert.False(vm.Slots[1].IsFilled);
Assert.False(vm.CanSave);
vm.Slots[1].EditText = "github.com/dev";
vm.ConfirmEdit(1);
Assert.Equal(SocialService.GitHub, vm.Slots[1].Service);
Assert.False(vm.Slots[1].HasError);
Assert.True(vm.CanSave);
}
[Fact]
public void EmptyEdit_CancelsEditing()
{
var vm = Create();
vm.StartEdit(1);
vm.Slots[1].EditText = "x.com/user";
vm.ConfirmEdit(1);
Assert.True(vm.Slots[1].IsFilled);
vm.StartEdit(1);
vm.Slots[1].EditText = "";
vm.ConfirmEdit(1);
Assert.False(vm.Slots[1].IsEditing);
Assert.True(vm.Slots[1].IsFilled);
}
[Fact]
public void SeedsFromCurrent_YouTubeBecomesAccountRow()
{
var current = new SocialsConfig { BarEnabled = false };
current.Entries.Add(new SocialEntry
{
Service = SocialService.YouTube,
Handle = "stale-yt-handle",
ProfileUrl = "https://www.youtube.com/@stale-yt-handle",
});
current.Entries.Add(new SocialEntry
{
Service = SocialService.Twitch,
Handle = "oldstreamer",
ProfileUrl = "https://www.twitch.tv/oldstreamer",
});
var vm = Create(account: new YouTubeChannel { ChannelId = "UC123", DisplayName = "Fresh Channel" }, current: current);
Assert.False(vm.BarEnabled);
Assert.Equal("Fresh Channel", vm.Slots[0].Handle);
Assert.Equal(SocialService.Twitch, vm.Slots[1].Service);
Assert.Equal("oldstreamer", vm.Slots[1].Handle);
Assert.True(vm.Slots[2].IsLocked);
vm.SaveCommand.Execute(null);
Assert.Equal(2, vm.CommittedEntries!.Count);
Assert.Equal("Fresh Channel", vm.CommittedEntries[0].Handle);
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
Assert.False(vm.BuildSocialsConfig()!.BarEnabled);
}
[Fact]
public void EmptySave_BuildsNullConfig()
{
var vm = Create();
Assert.True(vm.CanSave);
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.CommittedEntries);
Assert.Empty(vm.CommittedEntries!);
Assert.Null(vm.BuildSocialsConfig());
}
[Fact]
public async Task SignOut_ResetsRowZeroToGate()
{
var signIn = new SignInFake();
var vm = Create(signIn: signIn, account: new YouTubeChannel { ChannelId = "UC1", DisplayName = "Ch" });
Assert.True(vm.IsSignedIn);
await vm.DeleteSlotAsync(0);
Assert.Equal(1, _signOutCalls);
Assert.False(vm.IsSignedIn);
Assert.True(vm.Slots[0].IsSignIn);
Assert.True(vm.ShowSignInBanner);
}
[Fact]
public void FediverseHandle_ValidatesAndKeepsFullHandle()
{
var vm = Create();
vm.StartEdit(1);
vm.Slots[1].EditText = "@creator@instance.tube";
vm.ConfirmEdit(1);
Assert.Equal(SocialService.Fediverse, vm.Slots[1].Service);
Assert.Equal("@creator@instance.tube", vm.Slots[1].Handle);
Assert.Equal("https://instance.tube/@creator", vm.Slots[1].ProfileUrl);
Assert.Equal("peertube", vm.Slots[1].FediverseSoftware);
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), vm.Slots[1].LogoData);
Assert.True(vm.Slots[1].IsFilled);
Assert.False(vm.Slots[1].IsEditing);
Assert.True(vm.CanSave);
}
[Fact]
public void FediverseEntry_CommitsSoftwareName()
{
var vm = Create();
vm.StartEdit(1);
vm.Slots[1].EditText = "@creator@instance.tube";
vm.ConfirmEdit(1);
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.CommittedEntries);
var entry = Assert.Single(vm.CommittedEntries!);
Assert.Equal(SocialService.Fediverse, entry.Service);
Assert.Equal("peertube", entry.FediverseSoftware);
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
var config = vm.BuildSocialsConfig();
Assert.NotNull(config);
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), Assert.Single(config.Entries).LogoData);
}
[Fact]
public void Cancel_AbortsInFlightValidation_WithoutMutatingSlot()
{
var validator = new BlockingValidator();
var vm = Create(validator);
vm.StartEdit(1);
vm.Slots[1].EditText = "x.com/creator";
vm.ConfirmEdit(1);
Assert.True(vm.Slots[1].IsValidating);
vm.Cancel();
// The lookup resolves with success AFTER dismissal — it must not apply.
validator.Gate.TrySetResult(new SocialLookupResult
{
Success = true,
Handle = "creator",
ProfileUrl = "https://x.com/creator",
});
Assert.False(vm.Slots[1].IsFilled);
Assert.False(vm.Slots[1].HasError);
Assert.False(vm.Slots[1].IsValidating);
Assert.True(vm.Slots[1].IsEditing);
}
[Fact]
public void ReSubmittingIdenticalFailedInput_DoesNotRelookup()
{
var validator = new FakeValidator();
var vm = Create(validator);
vm.StartEdit(1);
vm.Slots[1].EditText = "x.com/doesnotexist";
vm.ConfirmEdit(1);
Assert.Equal(1, validator.LookupCount);
Assert.True(vm.Slots[1].HasError);
// LostFocus firing on Cancel re-submits the same failed text — no second lookup.
vm.ConfirmEdit(1);
Assert.Equal(1, validator.LookupCount);
Assert.True(vm.Slots[1].HasError);
// Editing the text re-enables validation.
vm.Slots[1].EditText = "github.com/dev";
vm.ConfirmEdit(1);
Assert.Equal(2, validator.LookupCount);
Assert.False(vm.Slots[1].HasError);
Assert.True(vm.Slots[1].IsFilled);
}
[Fact]
public void ReEditingUnchangedHandle_ClosesBox_NoRelookup()
{
var validator = new FakeValidator();
var vm = Create(validator);
vm.StartEdit(1);
vm.Slots[1].EditText = "x.com/creator";
vm.ConfirmEdit(1);
Assert.Equal(1, validator.LookupCount);
Assert.True(vm.Slots[1].IsFilled);
vm.StartEdit(1);
Assert.True(vm.Slots[1].IsEditing);
vm.ConfirmEdit(1);
Assert.Equal(1, validator.LookupCount);
Assert.False(vm.Slots[1].IsEditing);
Assert.True(vm.Slots[1].IsFilled);
}
}