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 36b1bbc..bd13b99 100644
--- a/InterlinedList/Views/FeedView.xaml
+++ b/InterlinedList/Views/FeedView.xaml
@@ -131,6 +131,78 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -649,6 +741,15 @@
+
+
+