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 @@ + + +