Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Blog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Net.Http;

namespace InterlinedList.Services;

/// <summary>
/// Blog newsletter subscription.
/// </summary>
/// <remarks>
/// <para>
/// <b>There is no public blog-read endpoint.</b> Verified live 2026-09-16:
/// <c>/api/blog</c>, <c>/api/blog/posts</c> and <c>/api/blog/list</c> all
/// return <c>404</c>, and the only blog routes in the spec besides these
/// subscription ones are <c>/api/admin/blog*</c> — admin-only and
/// cookie-authed. So reading the blog is a browser handoff (#121); there is
/// nothing to fetch, and scraping the website's HTML is not a substitute for
/// an API.
/// </para>
/// <para>
/// <b>Unsubscribe is token-based, not email-based.</b> Both
/// <c>GET</c> and <c>POST /api/blog/unsubscribe</c> take a <c>?token=</c>
/// query parameter and nothing else — the token comes from the email footer
/// (the <c>POST</c> form is the RFC-8058 one-click target). A client that only
/// knows the user's address therefore <b>cannot</b> unsubscribe them, so that
/// is a handoff too rather than a button that can't work.
/// </para>
/// </remarks>
public sealed partial class InterlinedApiClient
{
/// <summary>
/// Subscribe an address to the blog newsletter. Double opt-in — a
/// confirmation email follows, and the subscription isn't live until its
/// link is clicked.
/// </summary>
/// <remarks>
/// Request shape is <c>{email}</c>. The server validates it:
/// an invalid address returns
/// <c>400 {"error":"A valid email is required","code":"bad_request"}</c>
/// (verified live with <c>not-an-email</c> — no message was sent to anyone).
/// The success envelope is <b>not</b> verified: confirming it would mean
/// mailing a real address, so nothing is parsed from it.
/// </remarks>
public Task SubscribeToBlogAsync(string email, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/blog/subscribe", new { email }, ct);

/// <summary>
/// Complete a double opt-in confirmation from an emailed token.
/// </summary>
/// <remarks>
/// Included for completeness; in practice the confirmation link is clicked
/// in the browser from the email, which is where it lands anyway.
/// </remarks>
public Task ConfirmBlogSubscriptionAsync(string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Get, $"api/blog/subscribe/confirm?token={Uri.EscapeDataString(token)}", null, ct);

/// <summary>
/// Unsubscribe using a token from an email footer.
/// </summary>
/// <remarks>
/// Only usable if the caller actually has the token — see the type-level
/// remarks. Do not surface this as "unsubscribe me"; the app has no way to
/// obtain the token.
/// </remarks>
public Task UnsubscribeFromBlogAsync(string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/blog/unsubscribe?token={Uri.EscapeDataString(token)}", new { }, ct);

/// <summary>The blog's web address, for the browser handoff.</summary>
public static string BlogUrl => $"{ApiConfig.BaseUrl}blog";
}
94 changes: 94 additions & 0 deletions InterlinedList/ViewModels/BlogPanelViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Services;

namespace InterlinedList.ViewModels;

/// <summary>
/// Blog: read it in the browser, and subscribe to the newsletter.
/// </summary>
/// <remarks>
/// Reading is a handoff because there is no public blog-read endpoint (#121) —
/// <c>/api/blog</c>, <c>/api/blog/posts</c> and <c>/api/blog/list</c> all 404,
/// and authoring lives under admin-only cookie-authed routes. Unsubscribe is
/// also a handoff, because the endpoint needs a token that only the email
/// footer carries.
/// </remarks>
public partial class BlogPanelViewModel : ObservableObject
{
private readonly SessionService _session;

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
private string email = "";

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
private bool isBusy;

[ObservableProperty]
private string? statusMessage;

[ObservableProperty]
private string? errorMessage;

public BlogPanelViewModel(SessionService session)
{
_session = session;
// Pre-fill with the account address — the overwhelmingly likely choice.
email = session.CurrentUser?.Email ?? "";
}

private bool CanSubscribe() => !IsBusy && LooksLikeEmail(Email);

// Cheap client-side shape check only. The server is authoritative and
// returns 400 "A valid email is required"; this just avoids an obviously
// pointless round trip.
private static bool LooksLikeEmail(string value)
{
var trimmed = value.Trim();
var at = trimmed.IndexOf('@');
return at > 0
&& at < trimmed.Length - 1
&& trimmed.IndexOf('.', at) > at + 1
&& !trimmed.Contains(' ');
}

[RelayCommand(CanExecute = nameof(CanSubscribe))]
private async Task SubscribeAsync()
{
IsBusy = true;
StatusMessage = null;
ErrorMessage = null;
try
{
await _session.Api.SubscribeToBlogAsync(Email.Trim());
// Double opt-in: say so, or the user will assume they're done.
StatusMessage = $"Check {Email.Trim()} for a confirmation link — "
+ "the subscription isn't active until you click it.";
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsBusy = false;
}
}

/// <summary>Open the blog in the OS browser — there is no read API to render.</summary>
[RelayCommand]
private void OpenBlog() => OpenInBrowser(InterlinedApiClient.BlogUrl);

private static void OpenInBrowser(string url)
{
// UseShellExecute is required for .NET Core+ to hand a URL to the OS
// default browser — same pattern as the OAuth handoff.
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true,
});
}
}
101 changes: 101 additions & 0 deletions InterlinedList/Views/BlogPanel.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<UserControl x:Class="InterlinedList.Views.BlogPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:InterlinedList.Views">

<UserControl.Resources>
<local:NullOrEmptyToVisibilityConverter x:Key="NullOrEmptyToVis"/>
</UserControl.Resources>

<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="4"
Padding="16">
<StackPanel>
<TextBlock Text="BLOG"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}"
Margin="0,0,0,8"/>

<!-- Reading is a browser handoff: there is no public blog-read
endpoint (/api/blog, /api/blog/posts and /api/blog/list all
404; authoring is admin-only and cookie-authed). Same pattern
as the OAuth and "Manage account on the web" handoffs. -->
<TextBlock Text="Posts are published on the web — the API has no public blog feed to read from."
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"
Margin="0,0,0,8"/>

<Button Content="Open the blog in your browser"
Command="{Binding OpenBlogCommand}"
Background="Transparent"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
Foreground="{DynamicResource LinkBrush}"
FontSize="11" Padding="10,5"
HorizontalAlignment="Left" Cursor="Hand"
Margin="0,0,0,14"/>

<TextBlock Text="NEWSLETTER"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}"
Margin="0,0,0,6"/>

<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource SurfaceBrush}"
Foreground="{DynamicResource TextBodyBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
Padding="8,6"
FontSize="13"
VerticalContentAlignment="Center"
Margin="0,0,8,0"/>
<Button Grid.Column="1"
Content="Subscribe"
Command="{Binding SubscribeCommand}"
Background="{DynamicResource PrimaryBrush}"
Foreground="{DynamicResource OnMastheadBrush}"
BorderThickness="0"
FontSize="11" Padding="12,6" Cursor="Hand"/>
</Grid>

<TextBlock Text="Double opt-in: you'll get a confirmation email, and the subscription only starts once you click its link."
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"/>

<!-- Unsubscribe is deliberately NOT a button. Both
GET and POST /api/blog/unsubscribe take only a ?token= that
the email footer carries, so an app knowing just the address
cannot unsubscribe anyone. Saying that beats a control that
can't work. -->
<TextBlock Text="To unsubscribe, use the link in any newsletter email — it carries a token this app has no way to obtain."
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>

<TextBlock Text="{Binding StatusMessage}"
FontSize="11"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"
Margin="0,8,0,0"
Visibility="{Binding StatusMessage, Converter={StaticResource NullOrEmptyToVis}}"/>

<TextBlock Text="{Binding ErrorMessage}"
FontSize="11"
Foreground="#FFE81123"
TextWrapping="Wrap"
Margin="0,6,0,0"
Visibility="{Binding ErrorMessage, Converter={StaticResource NullOrEmptyToVis}}"/>
</StackPanel>
</Border>
</UserControl>
21 changes: 21 additions & 0 deletions InterlinedList/Views/BlogPanel.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Windows.Controls;
using InterlinedList.Services;
using InterlinedList.ViewModels;

namespace InterlinedList.Views;

/// <summary>
/// Blog reading (browser handoff) and newsletter subscription.
/// </summary>
/// <remarks>
/// Self-contained so it can be hosted with a one-line add — <c>SettingsView</c>
/// is being reworked across several open PRs.
/// </remarks>
public partial class BlogPanel : UserControl
{
public BlogPanel()
{
InitializeComponent();
DataContext = new BlogPanelViewModel(AppServices.Session);
}
}
Loading