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
27 changes: 27 additions & 0 deletions InterlinedList/Models/LinkMetadataExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace InterlinedList.Models;

/// <summary>
/// Helpers over the <c>linkMetadata</c> envelope that live outside
/// <see cref="Message"/> itself.
/// </summary>
public static class LinkMetadataExtensions
{
/// <summary>
/// Every link worth drawing a card for: fetched successfully <b>and</b>
/// carrying metadata. A failed unfurl arrives as
/// <c>{ url, platform, fetchStatus: "failed" }</c> with no <c>metadata</c> at
/// all, so rendering it would produce an empty card.
/// </summary>
/// <remarks>
/// Same predicate as <see cref="LinkMetadataEnvelope.Primary"/>, 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).
/// </remarks>
public static IReadOnlyList<LinkPreview> SuccessfulLinks(this LinkMetadataEnvelope? envelope) =>
envelope?.Links?.Where(l => l.IsRenderable()).ToList() ?? new List<LinkPreview>();

/// <summary>True when this one unfurl produced something to show.</summary>
public static bool IsRenderable(this LinkPreview? link) =>
link is not null && link.FetchStatus is null or "success" && link.Metadata is not null;
}
54 changes: 54 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.LinkMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Text.Json;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// Link unfurling. The feed rarely needs any of this: <c>linkMetadata</c> arrives
/// <b>inline</b> on each message in <c>GET /api/messages</c> (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.
/// </summary>
/// <remarks>
/// <para>
/// The two shapes differ, which is easy to get wrong: the ad-hoc endpoint wraps
/// one entry as <c>{ "link": { … } }</c>, while the per-message endpoint (and the
/// inline field) wrap an array as <c>{ "links": [ … ] }</c>. Verified live
/// 2026-09-16.
/// </para>
/// <para>
/// A failed unfurl is <b>not</b> an HTTP error: an unreachable host still answers
/// <c>200</c> with <c>{ "link": { url, platform, fetchStatus: "failed" } }</c> and
/// no <c>metadata</c>. Callers must check, which is what
/// <c>LinkMetadataExtensions.IsRenderable</c> is for. A missing <c>url</c>
/// parameter <i>is</i> a <c>400</c>.
/// </para>
/// </remarks>
public sealed partial class InterlinedApiClient
{
/// <summary>
/// Unfurl an arbitrary URL — <c>GET /api/link-metadata?url=</c>. Returns null
/// when the fetch failed or produced no metadata, so a caller can treat
/// "nothing to show" uniformly rather than inspecting <c>fetchStatus</c>.
/// </summary>
public async Task<LinkPreview?> 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<LinkPreview>(JsonOptions);
return preview.IsRenderable() ? preview : null;
}

/// <summary>
/// A message's stored metadata — <c>GET /api/messages/{id}/metadata</c>.
/// Lightweight (no re-fetch server-side); only the renderable entries come
/// back. Rarely needed, since the same data is inline on the feed.
/// </summary>
public async Task<IReadOnlyList<LinkPreview>> GetMessageLinkMetadataAsync(string messageId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/messages/{messageId}/metadata", ct);
return json.Deserialize<LinkMetadataEnvelope>(JsonOptions).SuccessfulLinks();
}
}
104 changes: 101 additions & 3 deletions InterlinedList/ViewModels/FeedViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ public partial class FeedViewModel : ObservableObject
// dispatcher.
private CancellationTokenSource? _autocompleteCts;

// ── Link previews ───────────────────────────────────────────────────────────

/// <summary>
/// The viewer's <c>showPreviews</c> preference from <c>GET /api/user</c>. When
/// off, no preview card renders anywhere in the feed — not on cards, not in the
/// composer. It is <c>false</c> on the test account, so the off path is the one
/// that actually got looked at.
/// </summary>
public bool ShowPreviews => _session.CurrentUser?.ShowPreviews ?? false;

/// <summary>
/// Ad-hoc unfurl of the first URL in the draft
/// (<c>GET /api/link-metadata?url=</c>), 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.
/// </summary>
[ObservableProperty]
private LinkPreviewViewModel? composePreview;

// Same debounce discipline as the tag autocomplete.
private CancellationTokenSource? _composePreviewCts;
private string? _composePreviewUrl;

/// <summary>
/// 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.
/// </summary>
private static readonly System.Text.RegularExpressions.Regex UrlPattern =
new(@"https?://[^\s<>""]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase);

public FeedViewModel(SessionService session)
{
_session = session;
Expand All @@ -130,9 +161,9 @@ public FeedViewModel(SessionService session)
/// <see cref="MessageItemViewModel.Posted"/> — a Push or Quote publishes a new
/// post, and the write response isn't parsed, so the feed re-fetches.
/// </summary>
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;
}
Expand Down Expand Up @@ -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;
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Debounced prefix autocomplete. Fire-and-forget on purpose: it must never
/// block the dispatcher, and a superseded keystroke's request is cancelled
Expand Down Expand Up @@ -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);
}
}
131 changes: 131 additions & 0 deletions InterlinedList/ViewModels/LinkPreviewViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Models;

namespace InterlinedList.ViewModels;

/// <summary>
/// One unfurled link, as a card. Built from the <c>linkMetadata</c> that arrives
/// inline on a feed message, or from the ad-hoc
/// <c>GET /api/link-metadata?url=</c> used for the compose-time preview.
/// </summary>
/// <remarks>
/// Only construct this for a renderable link (see
/// <see cref="LinkMetadataExtensions.IsRenderable"/>) — a failed unfurl carries
/// no metadata at all and would draw an empty card.
/// </remarks>
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);

/// <summary>
/// The host, as the card's footer line — <c>youtu.be</c>, <c>jmap.io</c>. The
/// server's own <c>platform</c> ("youtube", "other", "instagram") is too
/// coarse to show: 22 of 42 sampled links were just "other".
/// </summary>
public string? Host { get; }

private readonly string? _thumbnailUrl;

/// <summary>
/// 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.
/// </summary>
[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;
}

/// <summary>
/// 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. <c>DelayCreation</c> defers the decode further, until render.
/// Remote URIs download off the UI thread; a failure flips
/// <see cref="HasThumbnail"/> instead of leaving a gap.
/// </summary>
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.
}
}
}
Loading
Loading