From efbabc8771e25a703bdb64a73b846ef271f346ce Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:46:15 -0700 Subject: [PATCH] feat(feed): link preview cards from inline linkMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web feed renders rich preview cards; the app rendered the bare URL. The data was already inline on every feed message, so the feed needs no extra request — 42 of 80 sampled rows carry linkMetadata. - Models/LinkMetadataExtensions.cs — SuccessfulLinks() / IsRenderable(). Same predicate as LinkMetadataEnvelope.Primary (which the base branch already has), but returning all matches, because a message can carry more than one unfurled link: 1 of 42 sampled rows had two. A failed unfurl arrives as { url, platform, fetchStatus: "failed" } with NO metadata object at all, so rendering it would draw an empty card — 7 of the 42 rows were exactly that. - Services/InterlinedApiClient.LinkMetadata.cs — the ad-hoc unfurl and the per-message read. The two envelope shapes differ and it's easy to get wrong: GET /api/link-metadata wraps ONE entry as { "link": {...} }, while GET /api/messages/{id}/metadata (and the inline field) wrap an ARRAY as { "links": [...] }. A failed unfurl is also not an HTTP error — an unreachable host still answers 200 with fetchStatus "failed" — so GetLinkMetadataAsync returns null for "nothing to show" and callers don't have to inspect status. - ViewModels/LinkPreviewViewModel.cs — one card. The thumbnail BitmapImage is built on first binding evaluation (i.e. when the card is realized, not when the page is parsed) with DelayCreation to defer the decode to render, and DecodePixelWidth=320 since the card never shows it larger. CacheOption stays OnDemand deliberately: OnLoad forces the decode at EndInit and would defeat DelayCreation. DownloadFailed/DecodeFailed flip HasThumbnail false, which collapses the image and leaves a text-only card rather than a hole. - The card's footer shows the URL host, not the server's platform field: 22 of 42 sampled links reported platform "other", which tells the reader nothing. Honors the preference. GET /api/user returns showPreviews and it is FALSE on the test account, so the off path is the one that actually got exercised: no card on feed cards, none in the composer. MessageItemViewModel's new showLinkPreviews parameter defaults to false — off unless a caller opts in — so a surface that hasn't been taught about the preference can't render previews against the user's wishes. FeedViewModel passes the real value; ProfileViewModel (off-limits here) keeps the default and shows none. Compose-time preview unfurls the draft's first URL after a 600ms debounce, skips re-unfurling while the URL is unchanged, trims trailing sentence punctuation off the match, and cancels superseded requests rather than awaiting them. Same fire-and-forget discipline as the tag autocomplete — nothing blocks the dispatcher. Model surface verified by deserializing the captured payloads with the throwaway net10.0 console harness CLAUDE.md describes: 36 renderable links across 80 messages, 7 rows where every unfurl failed (Primary correctly null), 1 row with two renderable links, and zero unmapped keys on Message. Closes #33 Co-Authored-By: Claude Opus 5 --- .../Models/LinkMetadataExtensions.cs | 27 ++++ .../InterlinedApiClient.LinkMetadata.cs | 54 ++++++++ InterlinedList/ViewModels/FeedViewModel.cs | 104 +++++++++++++- .../ViewModels/LinkPreviewViewModel.cs | 131 ++++++++++++++++++ .../ViewModels/MessageItemViewModel.cs | 27 +++- InterlinedList/Views/FeedView.xaml | 101 ++++++++++++++ 6 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 InterlinedList/Models/LinkMetadataExtensions.cs create mode 100644 InterlinedList/Services/InterlinedApiClient.LinkMetadata.cs create mode 100644 InterlinedList/ViewModels/LinkPreviewViewModel.cs diff --git a/InterlinedList/Models/LinkMetadataExtensions.cs b/InterlinedList/Models/LinkMetadataExtensions.cs new file mode 100644 index 0000000..6eb102e --- /dev/null +++ b/InterlinedList/Models/LinkMetadataExtensions.cs @@ -0,0 +1,27 @@ +namespace InterlinedList.Models; + +/// +/// Helpers over the linkMetadata envelope that live outside +/// itself. +/// +public static class LinkMetadataExtensions +{ + /// + /// Every link worth drawing a card for: fetched successfully and + /// carrying metadata. A failed unfurl arrives as + /// { url, platform, fetchStatus: "failed" } with no metadata at + /// all, so rendering it would produce an empty card. + /// + /// + /// Same predicate as , which returns + /// the first match — used for the ad-hoc single-URL compose preview. This + /// returns all of them, because a message can carry more than one unfurled + /// link (1 of 42 in an 80-row live sample had two). + /// + public static IReadOnlyList SuccessfulLinks(this LinkMetadataEnvelope? envelope) => + envelope?.Links?.Where(l => l.IsRenderable()).ToList() ?? new List(); + + /// True when this one unfurl produced something to show. + public static bool IsRenderable(this LinkPreview? link) => + link is not null && link.FetchStatus is null or "success" && link.Metadata is not null; +} diff --git a/InterlinedList/Services/InterlinedApiClient.LinkMetadata.cs b/InterlinedList/Services/InterlinedApiClient.LinkMetadata.cs new file mode 100644 index 0000000..7fa9f20 --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.LinkMetadata.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// Link unfurling. The feed rarely needs any of this: linkMetadata arrives +/// inline on each message in GET /api/messages (42 of 80 rows in a +/// live sample), so preview cards render with no extra call. These are for the +/// compose-time preview and for re-reading a single message's stored metadata. +/// +/// +/// +/// The two shapes differ, which is easy to get wrong: the ad-hoc endpoint wraps +/// one entry as { "link": { … } }, while the per-message endpoint (and the +/// inline field) wrap an array as { "links": [ … ] }. Verified live +/// 2026-09-16. +/// +/// +/// A failed unfurl is not an HTTP error: an unreachable host still answers +/// 200 with { "link": { url, platform, fetchStatus: "failed" } } and +/// no metadata. Callers must check, which is what +/// LinkMetadataExtensions.IsRenderable is for. A missing url +/// parameter is a 400. +/// +/// +public sealed partial class InterlinedApiClient +{ + /// + /// Unfurl an arbitrary URL — GET /api/link-metadata?url=. Returns null + /// when the fetch failed or produced no metadata, so a caller can treat + /// "nothing to show" uniformly rather than inspecting fetchStatus. + /// + public async Task GetLinkMetadataAsync(string url, CancellationToken ct = default) + { + var json = await GetElementAsync($"api/link-metadata?url={Uri.EscapeDataString(url)}", ct); + if (!json.TryGetProperty("link", out var link) || link.ValueKind != JsonValueKind.Object) + return null; + + var preview = link.Deserialize(JsonOptions); + return preview.IsRenderable() ? preview : null; + } + + /// + /// A message's stored metadata — GET /api/messages/{id}/metadata. + /// Lightweight (no re-fetch server-side); only the renderable entries come + /// back. Rarely needed, since the same data is inline on the feed. + /// + public async Task> GetMessageLinkMetadataAsync(string messageId, CancellationToken ct = default) + { + var json = await GetElementAsync($"api/messages/{messageId}/metadata", ct); + return json.Deserialize(JsonOptions).SuccessfulLinks(); + } +} diff --git a/InterlinedList/ViewModels/FeedViewModel.cs b/InterlinedList/ViewModels/FeedViewModel.cs index 44405e9..74aaa49 100644 --- a/InterlinedList/ViewModels/FeedViewModel.cs +++ b/InterlinedList/ViewModels/FeedViewModel.cs @@ -117,6 +117,37 @@ public partial class FeedViewModel : ObservableObject // dispatcher. private CancellationTokenSource? _autocompleteCts; + // ── Link previews ─────────────────────────────────────────────────────────── + + /// + /// The viewer's showPreviews preference from GET /api/user. When + /// off, no preview card renders anywhere in the feed — not on cards, not in the + /// composer. It is false on the test account, so the off path is the one + /// that actually got looked at. + /// + public bool ShowPreviews => _session.CurrentUser?.ShowPreviews ?? false; + + /// + /// Ad-hoc unfurl of the first URL in the draft + /// (GET /api/link-metadata?url=), so the composer shows what the post + /// will look like. Null when there's no URL yet, the unfurl failed, or + /// previews are switched off. + /// + [ObservableProperty] + private LinkPreviewViewModel? composePreview; + + // Same debounce discipline as the tag autocomplete. + private CancellationTokenSource? _composePreviewCts; + private string? _composePreviewUrl; + + /// + /// First http(s) URL in a draft. Trailing punctuation is trimmed — people + /// write "see https://example.com/x." and the sentence period is not part of + /// the link. + /// + private static readonly System.Text.RegularExpressions.Regex UrlPattern = + new(@"https?://[^\s<>""]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + public FeedViewModel(SessionService session) { _session = session; @@ -130,9 +161,9 @@ public FeedViewModel(SessionService session) /// — a Push or Quote publishes a new /// post, and the write response isn't parsed, so the feed re-fetches. /// - private MessageItemViewModel Wrap(Models.Message message) + private MessageItemViewModel Wrap(Message message) { - var item = new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id); + var item = new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id, ShowPreviews); item.Posted += OnItemPosted; return item; } @@ -220,6 +251,69 @@ partial void OnTagInputChanged(string value) _ = SuggestTagsAsync(value); } + // ── Compose-time link preview ─────────────────────────────────────────────── + + private static string? FirstUrl(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + var match = UrlPattern.Match(text); + if (!match.Success) return null; + + var url = match.Value.TrimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}', '"', '\''); + return url.Length > "https://".Length ? url : null; + } + + /// + /// Debounced ad-hoc unfurl of the draft's first URL. Fire-and-forget, like the + /// tag autocomplete: it must not block the dispatcher, and a keystroke that + /// supersedes an in-flight request cancels it. + /// + private async Task UpdateComposePreviewAsync(string? text) + { + if (!ShowPreviews) + { + ComposePreview = null; + return; + } + + var url = FirstUrl(text); + if (url is null) + { + _composePreviewCts?.Cancel(); + _composePreviewUrl = null; + ComposePreview = null; + return; + } + + // Still the same link — don't re-unfurl on every character of prose typed + // after it. + if (string.Equals(url, _composePreviewUrl, StringComparison.OrdinalIgnoreCase)) return; + + _composePreviewCts?.Cancel(); + _composePreviewCts?.Dispose(); + var cts = new CancellationTokenSource(); + _composePreviewCts = cts; + _composePreviewUrl = url; + + try + { + await Task.Delay(600, cts.Token); + var preview = await _session.Api.GetLinkMetadataAsync(url, cts.Token); + if (cts.Token.IsCancellationRequested) return; + + // A failed unfurl comes back as null, not as an exception — no card. + ComposePreview = preview is null ? null : new LinkPreviewViewModel(preview); + } + catch (OperationCanceledException) + { + // Superseded. + } + catch (InterlinedApiException) + { + ComposePreview = null; + } + } + /// /// Debounced prefix autocomplete. Fire-and-forget on purpose: it must never /// block the dispatcher, and a superseded keystroke's request is cancelled @@ -506,5 +600,9 @@ await _session.Api.PostMessageAsync(new NewMessage partial void OnIsLoadingChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged(); partial void OnIsLoadingMoreChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged(); partial void OnIsPostingChanged(bool value) => PostCommand.NotifyCanExecuteChanged(); - partial void OnComposeTextChanged(string value) => PostCommand.NotifyCanExecuteChanged(); + partial void OnComposeTextChanged(string value) + { + PostCommand.NotifyCanExecuteChanged(); + _ = UpdateComposePreviewAsync(value); + } } diff --git a/InterlinedList/ViewModels/LinkPreviewViewModel.cs b/InterlinedList/ViewModels/LinkPreviewViewModel.cs new file mode 100644 index 0000000..0d3a380 --- /dev/null +++ b/InterlinedList/ViewModels/LinkPreviewViewModel.cs @@ -0,0 +1,131 @@ +using System.Windows.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; + +namespace InterlinedList.ViewModels; + +/// +/// One unfurled link, as a card. Built from the linkMetadata that arrives +/// inline on a feed message, or from the ad-hoc +/// GET /api/link-metadata?url= used for the compose-time preview. +/// +/// +/// Only construct this for a renderable link (see +/// ) — a failed unfurl carries +/// no metadata at all and would draw an empty card. +/// +public partial class LinkPreviewViewModel : ObservableObject +{ + private BitmapImage? _thumbnail; + private bool _thumbnailRequested; + + public string? Url { get; } + public string? Title { get; } + public string? Description { get; } + public bool HasDescription => !string.IsNullOrWhiteSpace(Description); + + /// + /// The host, as the card's footer line — youtu.be, jmap.io. The + /// server's own platform ("youtube", "other", "instagram") is too + /// coarse to show: 22 of 42 sampled links were just "other". + /// + public string? Host { get; } + + private readonly string? _thumbnailUrl; + + /// + /// True until a thumbnail is known to be unusable. Flips to false if the + /// download or decode fails, which collapses the image and leaves a + /// text-only card rather than a hole where the picture should be. + /// + [ObservableProperty] + private bool hasThumbnail; + + public LinkPreviewViewModel(LinkPreview link) + { + Url = link.Url; + Title = link.Metadata?.Title; + Description = link.Metadata?.Description; + _thumbnailUrl = link.Metadata?.Thumbnail; + hasThumbnail = !string.IsNullOrWhiteSpace(_thumbnailUrl); + + Host = Uri.TryCreate(link.Url, UriKind.Absolute, out var uri) + ? uri.Host + : link.Platform; + + // Nothing usable to label the card with? Fall back to the raw URL so the + // card is never blank. (No sampled success case lacked a title, but the + // shape allows it.) + if (string.IsNullOrWhiteSpace(Title)) + Title = link.Url; + } + + /// + /// The decoded thumbnail, created on first binding evaluation — i.e. when the + /// card is actually realized by the virtualizing list, not when the feed page + /// is parsed. DelayCreation defers the decode further, until render. + /// Remote URIs download off the UI thread; a failure flips + /// instead of leaving a gap. + /// + public BitmapImage? Thumbnail + { + get + { + if (_thumbnailRequested) return _thumbnail; + _thumbnailRequested = true; + + if (string.IsNullOrWhiteSpace(_thumbnailUrl) || + !Uri.TryCreate(_thumbnailUrl, UriKind.Absolute, out var uri)) + { + HasThumbnail = false; + return null; + } + + try + { + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + // OnDemand (the default) rather than OnLoad: OnLoad forces the + // decode at EndInit and would defeat DelayCreation. + bitmap.CreateOptions = BitmapCreateOptions.DelayCreation | BitmapCreateOptions.IgnoreColorProfile; + bitmap.DecodePixelWidth = 320; // the card never shows it larger + bitmap.UriSource = uri; + bitmap.EndInit(); + + bitmap.DownloadFailed += (_, _) => HasThumbnail = false; + bitmap.DecodeFailed += (_, _) => HasThumbnail = false; + + _thumbnail = bitmap; + } + catch (Exception) + { + // A malformed image or an unsupported scheme throws from EndInit; + // degrade to the text-only card rather than taking down the feed. + HasThumbnail = false; + } + + return _thumbnail; + } + } + + [RelayCommand] + private void Open() + { + if (string.IsNullOrWhiteSpace(Url)) return; + + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = Url, + UseShellExecute = true, + }); + } + catch (Exception) + { + // No handler registered for the scheme, or the shell refused. Not + // worth an error banner on a decorative card. + } + } +} diff --git a/InterlinedList/ViewModels/MessageItemViewModel.cs b/InterlinedList/ViewModels/MessageItemViewModel.cs index fc63242..2a8396c 100644 --- a/InterlinedList/ViewModels/MessageItemViewModel.cs +++ b/InterlinedList/ViewModels/MessageItemViewModel.cs @@ -19,6 +19,7 @@ public partial class MessageItemViewModel : ObservableObject private readonly InterlinedApiClient _api; private readonly string? _currentUserId; private readonly bool _publiclyVisible; + private readonly bool _showLinkPreviews; public string Id { get; } public string TimeFormatted { get; } @@ -43,6 +44,15 @@ public partial class MessageItemViewModel : ObservableObject public IReadOnlyList Tags { get; } public bool HasTags => Tags.Count > 0; + /// + /// Unfurled link cards, from the linkMetadata that arrives inline on + /// the feed — no extra request. Empty when the viewer has previews switched + /// off (GET /api/user → showPreviews) or when every unfurl for + /// this message failed. + /// + public IReadOnlyList LinkPreviews { get; } + public bool HasLinkPreviews => LinkPreviews.Count > 0; + public ObservableCollection Replies { get; } = new(); // ── Push / Quote ──────────────────────────────────────────────────────────── @@ -132,11 +142,19 @@ public partial class MessageItemViewModel : ObservableObject [ObservableProperty] private string? errorMessage; - public MessageItemViewModel(Message message, InterlinedApiClient api, string? currentUserId) + /// + /// The viewer's showPreviews preference. Defaults to false — + /// off unless a caller opts in — so a surface that hasn't been taught about + /// the preference can't accidentally render previews against the user's + /// wishes. passes the real value; other callers + /// (e.g. a profile's message list) currently keep the default. + /// + public MessageItemViewModel(Message message, InterlinedApiClient api, string? currentUserId, bool showLinkPreviews = false) { _api = api; _currentUserId = currentUserId; _publiclyVisible = message.PubliclyVisible; + _showLinkPreviews = showLinkPreviews; Id = message.Id; content = message.Content; @@ -149,6 +167,9 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu ImageUrls = message.ImageUrls ?? new List(); VideoUrls = message.VideoUrls ?? new List(); Tags = message.Tags ?? new List(); + LinkPreviews = showLinkPreviews + ? message.LinkMetadata.SuccessfulLinks().Select(l => new LinkPreviewViewModel(l)).ToList() + : new List(); digCount = message.DigCount; pushCount = message.PushCount; @@ -368,7 +389,7 @@ private async Task ToggleRepliesAsync() var replies = await _api.GetRepliesAsync(Id); Replies.Clear(); foreach (var reply in replies) - Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId)); + Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId, _showLinkPreviews)); AreRepliesVisible = true; } catch (InterlinedApiException ex) @@ -401,7 +422,7 @@ private async Task PostReplyAsync() var replies = await _api.GetRepliesAsync(Id); Replies.Clear(); foreach (var reply in replies) - Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId)); + Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId, _showLinkPreviews)); AreRepliesVisible = true; } catch (InterlinedApiException ex) diff --git a/InterlinedList/Views/FeedView.xaml b/InterlinedList/Views/FeedView.xaml index 9350bba..cf8c466 100644 --- a/InterlinedList/Views/FeedView.xaml +++ b/InterlinedList/Views/FeedView.xaml @@ -131,6 +131,78 @@ + + + + + + + + + + + @@ -645,6 +737,15 @@ + + +