Skip to content

feat(messages): apply refreshed link metadata to posted row - #103

Merged
Adron merged 2 commits into
mainfrom
feat/apply-link-metadata-to-posted-row
Sep 17, 2026
Merged

Adron merged 2 commits into
mainfrom
feat/apply-link-metadata-to-posted-row

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

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}/metadata after publishing and discards the response, so a new
post'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 AppDataStore mutation, with no second
request
— 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 appears
without waiting for a feed refresh") cannot be met until both are fixed:

  1. refreshMessageMetadata decodes a shape the route doesn't send. It expects
    { "metadata": { "links": [ { url, title, description, image } ] } }. The route
    (app/api/messages/[id]/metadata/route.ts, backend 8064a1bd) returns { "links": [...] } at
    the top level, and each entry is a full LinkMetadataItem — nested metadata object, thumbnail
    rather than image, plus platform/fetchStatus/fetchedAt. serialize() only normalizes
    values, it never restructures. So against the live API the decode returns [] every time, and the
    existing unit test passes only because its fixture was written to the assumed shape. Fixing it is
    an APIClient.swift change, which this PR deliberately does not make.
  2. FeedView doesn't observe in-place row updates. It mirrors store.feedMessages into a local
    @State private var messages and merges only messages whose id it doesn't already have
    (FeedView.swift:385 — the sync is keyed on store.feedMessages.count). Replacing an existing
    row's linkMetadata changes content, not count, so the rendered feed keeps the old value until a
    full 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:) in Models/Message.swift —
    pure, no SwiftUI, no networking. image → metadata.thumbnail, title/description pass through
    trimmed. A preview that resolved to nothing (or to whitespace only) converts to an item with
    nil metadata, because LinkPreviewBlock draws a card only for a non-nil metadata and an
    all-nil content object would render an empty grey box. The item itself is kept — its url is what
    the rest of the app reads off a link. platform and fetchStatus stay nil rather than being
    guessed; the flat preview carries neither and nothing renders off them.
  • AppDataStore.applyLinkMetadata(_:toMessageId:) — replaces one already-inserted message's
    metadata by id and persists the feed cache. Unknown id: no-op. Empty links: no-op, because the
    route answers { links: [] } for a message it resolved nothing for and blanking a preview a feed
    fetch had already supplied would be a regression, not a refresh.
  • Message.linkMetadata becomes var — the only mutable field on the row — so the backfill doesn't
    rebuild all sixteen fields to change one.
  • ComposeView.refreshLinkMetadata(for:) applies the response through the store inside
    Task { @MainActor in … }; try? keeps every failure invisible on the publish path.
  • Tests: new LinkMetadataConversionTests (10), plus 6 applyLinkMetadata cases in the existing
    AppDataStoreTests. The new file is hand-slotted into project.pbxproj (no re-sort).

Testing

  • Full suite, simulator 3E2891BA-FB4E-4EDF-9BF9-1226134E1BC8, serialized, E2E skipped, private
    DerivedData: 1172 tests, 0 failures (1156 on the feat(messages): refresh link metadata after publishing #94 base + 16 new). No new warnings.
  • Conversion (10): full mapping; platform/fetchStatus/text/type left nil; order preserved
    across several; empty input; no image and no title → url kept, metadata nil; description-only;
    image-only; all-whitespace fields treated as absent; padded title trimmed; a dead preview alongside
    a live one converted independently.
  • Store mutation (6): targets the right message; leaves other rows and feed order untouched;
    replaces existing metadata; unknown id is a no-op; empty feed is a no-op; empty links leaves
    existing metadata intact.
  • Mutation-checked that the new tests bite: inverting the nil-metadata rule in the conversion and
    making 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

  • The live end-to-end path — posting a real message with a link and watching the card fill in — is
    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.
  • That SwiftUI redraws the row once FeedView is fixed to see the update: the store-level change and
    its @Published emission are tested, the rendering is not (and view rendering is out of scope for
    unit tests here).
  • The { links: [] } response claim in the empty-links no-op is read from the route source, not
    observed against production.

🤖 Generated with Claude Code

#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
@Adron
Adron merged commit 884d8a8 into main Sep 17, 2026
1 check passed
@Adron
Adron deleted the feat/apply-link-metadata-to-posted-row branch September 17, 2026 09:06
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
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.

W8: Fold the refreshed link metadata into the just-posted feed row

1 participant