Messages: link preview cards (inline linkMetadata + ad-hoc unfurl) - #158
Merged
Merged
Conversation
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>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked PR — base is
issue-32-tags(#155), notmainLast 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, butreturning all matches: a message can carry more than one unfurled link (1 of
42 sampled rows had two).
Primarystays the single-preview accessor and iswhat the ad-hoc compose path uses.
Services/InterlinedApiClient.LinkMetadata.cs— the ad-hoc unfurl and theper-message read.
ViewModels/LinkPreviewViewModel.cs— one card.Three shape gotchas worth not re-learning
GET /api/link-metadata?url=wraps one entry as{"link": {…}};GET /api/messages/{id}/metadataand the inline field wrap anarray as
{"links": […]}. Both are handled explicitly.200with{"link":{url, platform, fetchStatus:"failed"}}and nometadataobject at all. 7 of the 42linkMetadatarows in the live samplewere exactly that.
GetLinkMetadataAsyncreturnsnullfor "nothing toshow" so callers never inspect
fetchStatusthemselves. (A missingurlparameter is a
400.)platformis too coarse to display. 22 of 42 links reportedplatform: "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/userreturnsshowPreviews, and it isfalseon the testaccount — so the "off" branch is the one real data exercises: no card on feed
cards, none in the composer.
MessageItemViewModel's newshowLinkPreviewsparameter defaults tofalse— off unless a caller opts in — so a surface that hasn't been taughtabout the preference can't render previews against the user's wishes.
FeedViewModelpasses the real value.ProfileViewModelis off-limits in thisPR 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
BitmapImageis built on first binding evaluation — when the virtualizinglist realizes the card, not when the feed page is parsed — with
BitmapCreateOptions.DelayCreationto defer the decode to render time andDecodePixelWidth = 320because the card never shows it larger.CacheOptionstays
OnDemanddeliberately:OnLoadforces the decode atEndInitandwould defeat
DelayCreation.DownloadFailed/DecodeFailedflipHasThumbnailtofalse, which collapsesthe thumbnail
Borderand leaves a text-only card rather than a hole — thatis the acceptance criterion, wired to the actual failure events rather than
assumed. A malformed URI or unsupported scheme throwing out of
EndInitiscaught to the same end.
Clicking anywhere on the card opens the target with
UseShellExecute(OSbrowser). 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.mdprescribes — a throwawaynet10.0console harness that compilesModels/*.csand deserializes the captured payloads, because eyeballing doesnot catch a silently-unmapped property:
All read-only.
GET /api/link-metadataandGET /api/messages/{id}/metadatawere 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
pushedMessageblock). 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 Debugand-c Release.Closes #33
🤖 Generated with Claude Code