Skip to content

Messages: link preview cards (inline linkMetadata + ad-hoc unfurl) - #158

Merged
Adron merged 2 commits into
mainfrom
issue-33-link-previews
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-33-link-previews

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stacked PR — base is issue-32-tags (#155), not main

Last of three stacked PRs (#31 → #32 → #33), all touching the same three feed
files. Merge order: #131 → #152 → #155 → this.

What this does

The web feed renders rich preview cards; the app rendered the bare URL. The data
is already inline on every feed message (42 of 80 sampled rows carry
linkMetadata), so the feed itself needs no extra request.

  • Models/LinkMetadataExtensions.cs — SuccessfulLinks() / IsRenderable().
    Same predicate as the base branch's LinkMetadataEnvelope.Primary, but
    returning all matches: a message can carry more than one unfurled link (1 of
    42 sampled rows had two). Primary stays the single-preview accessor and is
    what the ad-hoc compose path uses.
  • Services/InterlinedApiClient.LinkMetadata.cs — the ad-hoc unfurl and the
    per-message read.
  • ViewModels/LinkPreviewViewModel.cs — one card.

Three shape gotchas worth not re-learning

  1. The envelopes differ. GET /api/link-metadata?url= wraps one entry as
    {"link": {…}}; GET /api/messages/{id}/metadata and the inline field wrap an
    array as {"links": […]}. Both are handled explicitly.
  2. A failed unfurl is not an HTTP error. An unreachable host still answers
    200 with {"link":{url, platform, fetchStatus:"failed"}} and no
    metadata object at all
    . 7 of the 42 linkMetadata rows in the live sample
    were exactly that. GetLinkMetadataAsync returns null for "nothing to
    show" so callers never inspect fetchStatus themselves. (A missing url
    parameter is a 400.)
  3. platform is too coarse to display. 22 of 42 links reported
    platform: "other". The card's footer line shows the URL's host
    (youtu.be, jmap.io) instead.

The preference is honored, and that's the path that got tested

GET /api/user returns showPreviews, and it is false on the test
account
— so the "off" branch is the one real data exercises: 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 is off-limits in this
PR and keeps the default, so a profile's message list shows no previews; that's
a deliberate no-op, not an oversight.

Lazy thumbnails, and failure that degrades

The BitmapImage is built on first binding evaluation — when the virtualizing
list realizes the card, not when the feed page is parsed — with
BitmapCreateOptions.DelayCreation to defer the decode to render time and
DecodePixelWidth = 320 because the card never shows it larger. CacheOption
stays OnDemand deliberately: OnLoad forces the decode at EndInit and
would defeat DelayCreation.

DownloadFailed / DecodeFailed flip HasThumbnail to false, which collapses
the thumbnail Border and leaves a text-only card rather than a hole — that
is the acceptance criterion, wired to the actual failure events rather than
assumed. A malformed URI or unsupported scheme throwing out of EndInit is
caught to the same end.

Clicking anywhere on the card opens the target with UseShellExecute (OS
browser). No modal dialogs, nothing blocking the dispatcher — the compose-time
unfurl is debounced 600 ms, skips re-unfurling an unchanged URL, trims trailing
sentence punctuation off the match, and cancels superseded requests rather than
awaiting them.

Live verification (read-only)

Beyond the endpoint probes, the model surface was verified the way
CLAUDE.md prescribes — a throwaway net10.0 console harness that compiles
Models/*.cs and deserializes the captured payloads, because eyeballing does
not catch a silently-unmapped property:

messages: 80  total=363
linkMetadata rows: 42
renderable links (SuccessfulLinks): 36
rows whose Primary is null but had linkMetadata: 7   <- all-failed rows
rows with >1 renderable: 1                            <- why SuccessfulLinks exists
renderable links with a thumbnail: 36
adhoc unfurl: renderable=True
adhoc failed unfurl: status=failed metadata=null renderable=False
per-message metadata: renderable=1
UNMAPPED message keys: (none)

All read-only. GET /api/link-metadata and GET /api/messages/{id}/metadata
were exercised freely; POST /api/messages/{id}/metadata (fetch and persist)
is not implemented — it mutates stored server state on a shared account, and
nothing in the feed needs it when the data is already inline.

Not done here

  • Previews inside the embedded quoted original (Messages: Push (repost) and Quote #152's pushedMessage
    block). 3 of the 5 sampled pushed originals had their own linkMetadata;
    nesting a card inside a card was left out as clutter, easy to add later.
  • POST /api/messages/{id}/metadata, per above.

Builds clean in both -c Debug and -c Release.

Closes #33

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@Adron
Adron changed the base branch from issue-32-tags to main September 24, 2026 06:17
@Adron
Adron merged commit bfe59ce into main Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Messages: link preview cards (linkMetadata / /api/link-metadata)

1 participant