feat(messages): apply refreshed link metadata to posted row - #103
Merged
Merged
Conversation
#94 fires POST /api/messages/{id}/metadata after publishing and throws the response away, so a new post's link card only appeared on the next feed fetch. The response now feeds the row the publish just inserted, still fire-and-forget and with no second request. - LinkMetadataItem.init(preview:) / .from(previews:) converts the flat preview shape into the nested shape the feed row renders. A preview that resolved to nothing (no title, no description, no image, or only whitespace) converts to an item with nil `metadata`, because the feed card is drawn only for a non-nil `metadata` and an empty content object would draw a blank box. The item is kept so its `url` survives. `platform` and `fetchStatus` stay nil rather than being guessed — the flat shape carries neither. - AppDataStore.applyLinkMetadata(_:toMessageId:) replaces the metadata of one already-inserted feed message and persists the feed cache. An unknown id is a no-op, and so is an empty links array: the route answers `{ links: [] }` when it resolved nothing, and blanking a preview a feed fetch had already supplied would be a regression. - Message.linkMetadata becomes `var` — the only mutable field on the row — so the backfill doesn't have to rebuild all sixteen fields. - ComposeView's existing refreshLinkMetadata(for:) applies the response through the store on the main actor. A throw, an empty response, or a message no longer in the feed leaves everything exactly as it was, silently. Two downstream blockers found while reading the wire, both outside this issue's scope and neither introduced here: 1. APIClient.refreshMessageMetadata decodes `{ metadata: { links: [...] } }` with flat `title`/`image` members, but the route returns `{ links: [...] }` holding full LinkMetadataItem objects (nested `metadata`, `thumbnail`, not `image`) — app/api/messages/[id]/metadata/route.ts. The decode therefore yields [] against the live API. 2. FeedView mirrors store.feedMessages into a local @State copy and merges only messages with ids it doesn't already have (FeedView.swift:385), so an in-place update to an existing row isn't rendered until a full reload. Both must be fixed for the preview to appear without a feed refresh; the conversion and the store mutation are the pieces that will consume them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
This was referenced Sep 16, 2026
Closed
Adron
changed the base branch from
feat/refresh-link-metadata-after-publish
to
main
September 17, 2026 08:59
…ata-to-posted-row
Adron
added a commit
that referenced
this pull request
Sep 17, 2026
Two conflicts, both from #103 landing on main and touching the same places: - AppDataStore.swift: this branch adds updateFeedMessage(_:), #103 added applyLinkMetadata(_:toMessageId:), and the two bodies met at the shared trailing saveFeedCache(). Spliced into two complete methods rather than a blind union, which would have fused them into one broken function. - AppDataStoreTests.swift: both sides added a makeMessage helper — this branch with a `content:` parameter, #103 with `linkMetadata:`. Merged into a single helper carrying both, so every call site on both sides still compiles. Worth noting the two features compose the way #105 intended: feedRevision's didSet now fires for applyLinkMetadata too, so the link-preview backfill is exactly the in-place update this branch makes the feed observe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
Adron
added a commit
that referenced
this pull request
Sep 17, 2026
Same two conflicts #107 hit, from #103's applyLinkMetadata landing on main: - AppDataStore.swift: removeFeedMessage and applyLinkMetadata met at the shared trailing saveFeedCache(). Spliced into two complete methods, both bodies verified intact afterwards rather than assumed from the brace structure. - AppDataStoreTests.swift: the makeMessage helper now carries content, parentId and linkMetadata, so call sites from all three branches compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
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.
Summary
Closes #96. Stacked on #94 (
feat/refresh-link-metadata-after-publish), which must merge first —this branch is based on it, and its diff is included below the stack base.
#94 fires
POST /api/messages/{id}/metadataafter publishing and discards the response, so a newpost's link card only appeared on the next feed fetch. The response is now folded into the row that
publish just inserted, through a pure conversion plus one
AppDataStoremutation, with no secondrequest — it reuses the response #94 already receives.
The refresh stays fire-and-forget: a throw, an empty response, or a message that has since left the
feed leaves everything exactly as it was, silently and without a crash. The publish request, the
live-preview-while-composing path, and the metadata call added in #94 are untouched, as is the edit
path (#76).
Two blockers found downstream — read before merging
Neither is introduced here and both are outside this issue's stated scope (
ComposeView.swift,AppDataStore.swift,Message.swift), but the user-visible acceptance criterion ("preview appearswithout waiting for a feed refresh") cannot be met until both are fixed:
refreshMessageMetadatadecodes a shape the route doesn't send. It expects{ "metadata": { "links": [ { url, title, description, image } ] } }. The route(
app/api/messages/[id]/metadata/route.ts, backend8064a1bd) returns{ "links": [...] }atthe top level, and each entry is a full
LinkMetadataItem— nestedmetadataobject,thumbnailrather than
image, plusplatform/fetchStatus/fetchedAt.serialize()only normalizesvalues, it never restructures. So against the live API the decode returns
[]every time, and theexisting unit test passes only because its fixture was written to the assumed shape. Fixing it is
an
APIClient.swiftchange, which this PR deliberately does not make.FeedViewdoesn't observe in-place row updates. It mirrorsstore.feedMessagesinto a local@State private var messagesand merges only messages whose id it doesn't already have(
FeedView.swift:385— the sync is keyed onstore.feedMessages.count). Replacing an existingrow's
linkMetadatachanges content, not count, so the rendered feed keeps the old value until afull reload. The store and its cache are correct; only the view's private copy is stale.
The conversion and the store mutation in this PR are the pieces that will consume both fixes, so
they are worth landing either way — but nothing observable changes until the two above are resolved.
What's included
LinkMetadataItem.init(preview:)/LinkMetadataItem.from(previews:)inModels/Message.swift—pure, no SwiftUI, no networking.
image → metadata.thumbnail,title/descriptionpass throughtrimmed. A preview that resolved to nothing (or to whitespace only) converts to an item with
nil
metadata, becauseLinkPreviewBlockdraws a card only for a non-nilmetadataand anall-nil content object would render an empty grey box. The item itself is kept — its
urlis whatthe rest of the app reads off a link.
platformandfetchStatusstay nil rather than beingguessed; the flat preview carries neither and nothing renders off them.
AppDataStore.applyLinkMetadata(_:toMessageId:)— replaces one already-inserted message'smetadata by id and persists the feed cache. Unknown id: no-op. Empty
links: no-op, because theroute answers
{ links: [] }for a message it resolved nothing for and blanking a preview a feedfetch had already supplied would be a regression, not a refresh.
Message.linkMetadatabecomesvar— the only mutable field on the row — so the backfill doesn'trebuild all sixteen fields to change one.
ComposeView.refreshLinkMetadata(for:)applies the response through the store insideTask { @MainActor in … };try?keeps every failure invisible on the publish path.LinkMetadataConversionTests(10), plus 6applyLinkMetadatacases in the existingAppDataStoreTests. The new file is hand-slotted intoproject.pbxproj(no re-sort).Testing
3E2891BA-FB4E-4EDF-9BF9-1226134E1BC8, serialized, E2E skipped, privateDerivedData: 1172 tests, 0 failures (1156 on the feat(messages): refresh link metadata after publishing #94 base + 16 new). No new warnings.
platform/fetchStatus/text/typeleft nil; order preservedacross several; empty input; no image and no title → url kept,
metadatanil; description-only;image-only; all-whitespace fields treated as absent; padded title trimmed; a dead preview alongside
a live one converted independently.
replaces existing metadata; unknown id is a no-op; empty feed is a no-op; empty
linksleavesexisting metadata intact.
metadatarule in the conversion andmaking the store write to index 0 regardless of id produced 5 failures across exactly the
intended tests (3 conversion, 2 store); both edits were reverted and the full suite re-run green.
Not verified
blocked by the two items in the Summary and needs a real account plus a real OpenGraph fetch. No
simulator run can confirm it today.
FeedViewis fixed to see the update: the store-level change andits
@Publishedemission are tested, the rendering is not (and view rendering is out of scope forunit tests here).
{ links: [] }response claim in the empty-links no-op is read from the route source, notobserved against production.
🤖 Generated with Claude Code