From 886dd8c6b3070d271bb43acd0426616198bfa741 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:14:55 -0700 Subject: [PATCH] feat(messages): apply refreshed link metadata to posted row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- InterlinedList.xcodeproj/project.pbxproj | 4 + InterlinedList/Models/Message.swift | 39 ++++++- InterlinedList/Services/AppDataStore.swift | 14 +++ InterlinedList/Views/ComposeView.swift | 10 +- .../LinkMetadataConversionTests.swift | 102 ++++++++++++++++++ .../ServiceTests/AppDataStoreTests.swift | 72 ++++++++++++- 6 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index b912dd9..4547383 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 5F39C031ED69D47574ACF17F /* APIClientGitHubMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94499C7F9007F4B2CE90CA91 /* APIClientGitHubMetadataTests.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */; }; + C0FFEE9602ABCDEF00000096 /* LinkMetadataConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; @@ -252,6 +253,7 @@ 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = ""; }; 03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = ""; }; C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeLinkDetectionTests.swift; sourceTree = ""; }; + C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkMetadataConversionTests.swift; sourceTree = ""; }; 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = ""; }; 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = ""; }; 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = ""; }; @@ -783,6 +785,7 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, F78B096C366108083D174366 /* MarkdownBlockTests.swift */, C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */, + C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */, 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */, EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */, A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */, @@ -1109,6 +1112,7 @@ 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */, 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */, C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */, + C0FFEE9602ABCDEF00000096 /* LinkMetadataConversionTests.swift in Sources */, 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */, 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */, 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */, diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index 3ea8663..8af9889 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -42,6 +42,41 @@ struct MessageLinkPreview: Codable, Identifiable { var id: String { url } } +extension LinkMetadataItem { + /// Rebuilds the feed row's nested preview shape from the flat previews the + /// metadata refresh returns, so a just-published message can render its card + /// without waiting for the next feed fetch. + /// + /// `metadata` stays nil when a preview resolved to nothing renderable: the feed + /// card is drawn only for a non-nil `metadata` (`LinkPreviewBlock`), so an + /// all-nil content object would draw an empty box. The item itself is kept, + /// because 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. + init(preview: MessageLinkPreview) { + let title = Self.trimmedNonEmpty(preview.title) + let description = Self.trimmedNonEmpty(preview.description) + let thumbnail = Self.trimmedNonEmpty(preview.image) + let content: LinkMetadataItemContent? = + title == nil && description == nil && thumbnail == nil + ? nil + : LinkMetadataItemContent(thumbnail: thumbnail, title: title, + description: description, text: nil, type: nil) + self.init(url: preview.url, platform: nil, metadata: content, fetchStatus: nil) + } + + static func from(previews: [MessageLinkPreview]) -> [LinkMetadataItem] { + previews.map(LinkMetadataItem.init(preview:)) + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } +} + /// One destination a message was actually cross-posted to, echoed back on the /// Message after it publishes (server field: `crossPostUrls`). The shape differs /// per platform — Mastodon carries `statusId`/`instanceUrl`, Bluesky carries @@ -76,7 +111,9 @@ struct Message: Codable, Identifiable { let user: MessageUser? let imageUrls: [String]? let videoUrls: [String]? - let linkMetadata: LinkMetadata? + /// The only mutable field on the row: the metadata refresh that follows a + /// publish backfills it in place on the already-inserted feed message. + var linkMetadata: LinkMetadata? let parentId: String? let scheduledAt: String? let tags: [String]? diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index fc0bdf2..4179310 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -483,6 +483,20 @@ final class AppDataStore: ObservableObject { saveFeedCache() } + /// Replaces the link previews on one already-inserted feed message, so the row + /// a publish just added can show its card as soon as the metadata refresh lands. + /// + /// A message that isn't in the feed is a no-op, and so is an empty `links`: the + /// metadata 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. + func applyLinkMetadata(_ links: [LinkMetadataItem], toMessageId id: String) { + guard !links.isEmpty, + let index = feedMessages.firstIndex(where: { $0.id == id }) else { return } + feedMessages[index].linkMetadata = LinkMetadata(links: links) + saveFeedCache() + } + func removeList(id: String) { userLists.removeAll { $0.id == id }; saveListsCache() } /// Idempotent upsert by id — safe to call after `createDocumentOffline` diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 0276d82..a8bb354 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -801,13 +801,19 @@ struct ComposeView: View { /// waiting on something else to backfill it. Fire-and-forget by design: the /// preview is a secondary datum, so a failure must never surface on the publish /// path or delay the success alert — not even a 401, which isn't escalated here - /// because the post that just succeeded proves the session is live. + /// because the post that just succeeded proves the session is live. The response + /// is folded into the row this publish inserted; an empty response or a message + /// no longer in the feed leaves it exactly as it was. /// /// Not called from the edit path: there is no route that accepts a content edit /// today (`PATCH /api/messages/{id}` reschedules), so an edited message's links /// can't change server-side anyway. See issue #76. private func refreshLinkMetadata(for messageId: String) { - Task { _ = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId) } + Task { @MainActor in + guard let previews = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId) + else { return } + store.applyLinkMetadata(LinkMetadataItem.from(previews: previews), toMessageId: messageId) + } } } diff --git a/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift b/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift new file mode 100644 index 0000000..144fb85 --- /dev/null +++ b/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import InterlinedList + +/// `LinkMetadataItem.from(previews:)` bridges the two link-preview shapes: the flat +/// previews the metadata refresh returns and the nested shape the feed row renders. +/// The feed only draws a card for a non-nil `metadata`, so a preview that resolved +/// to nothing must convert to an item with no `metadata` — not an empty one. +final class LinkMetadataConversionTests: XCTestCase { + + private func preview(url: String = "https://example.com", + title: String? = nil, + description: String? = nil, + image: String? = nil) -> MessageLinkPreview { + MessageLinkPreview(url: url, title: title, description: description, image: image) + } + + // MARK: - Full previews + + func test_from_fullPreview_mapsEveryField() { + let items = LinkMetadataItem.from(previews: [ + preview(url: "https://example.com/a", title: "T", description: "D", + image: "https://cdn.example.com/hero.png") + ]) + + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.url, "https://example.com/a") + XCTAssertEqual(items.first?.metadata?.title, "T") + XCTAssertEqual(items.first?.metadata?.description, "D") + XCTAssertEqual(items.first?.metadata?.thumbnail, "https://cdn.example.com/hero.png") + } + + /// The flat preview carries neither, so neither is invented. + func test_from_fullPreview_leavesPlatformAndFetchStatusNil() { + let items = LinkMetadataItem.from(previews: [preview(title: "T")]) + XCTAssertNil(items.first?.platform) + XCTAssertNil(items.first?.fetchStatus) + XCTAssertNil(items.first?.metadata?.text) + XCTAssertNil(items.first?.metadata?.type) + } + + func test_from_multiplePreviews_preservesOrder() { + let items = LinkMetadataItem.from(previews: [ + preview(url: "https://one.example", title: "One"), + preview(url: "https://two.example", title: "Two"), + preview(url: "https://three.example", title: "Three") + ]) + XCTAssertEqual(items.map(\.url), + ["https://one.example", "https://two.example", "https://three.example"]) + XCTAssertEqual(items.map { $0.metadata?.title }, ["One", "Two", "Three"]) + } + + func test_from_emptyPreviews_returnsEmpty() { + XCTAssertTrue(LinkMetadataItem.from(previews: []).isEmpty) + } + + // MARK: - Partial previews + + func test_from_previewWithNoImageAndNoTitle_keepsUrlWithNilMetadata() { + let items = LinkMetadataItem.from(previews: [preview(url: "https://bare.example")]) + + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.url, "https://bare.example") + XCTAssertNil(items.first?.metadata) + } + + func test_from_previewWithOnlyDescription_keepsMetadata() { + let items = LinkMetadataItem.from(previews: [preview(description: "just a blurb")]) + XCTAssertEqual(items.first?.metadata?.description, "just a blurb") + XCTAssertNil(items.first?.metadata?.title) + XCTAssertNil(items.first?.metadata?.thumbnail) + } + + func test_from_previewWithOnlyImage_keepsMetadata() { + let items = LinkMetadataItem.from(previews: [preview(image: "https://cdn.example/x.png")]) + XCTAssertEqual(items.first?.metadata?.thumbnail, "https://cdn.example/x.png") + XCTAssertNil(items.first?.metadata?.title) + } + + /// The feed row already treats an empty title as nothing to draw; an all-blank + /// preview must not sneak past the nil-metadata rule on whitespace alone. + func test_from_previewWithBlankFields_treatsThemAsAbsent() { + let items = LinkMetadataItem.from(previews: [ + preview(title: "", description: " ", image: "\n") + ]) + XCTAssertNil(items.first?.metadata) + } + + func test_from_previewWithPaddedTitle_trimsIt() { + let items = LinkMetadataItem.from(previews: [preview(title: " Padded ")]) + XCTAssertEqual(items.first?.metadata?.title, "Padded") + } + + /// One dead preview must not suppress a live one alongside it. + func test_from_mixedPreviews_convertsEachIndependently() { + let items = LinkMetadataItem.from(previews: [ + preview(url: "https://dead.example"), + preview(url: "https://live.example", title: "Live") + ]) + XCTAssertNil(items.first?.metadata) + XCTAssertEqual(items.last?.metadata?.title, "Live") + } +} diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index 820f800..bd24488 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -282,13 +282,81 @@ final class AppDataStoreTests: XCTestCase { createdAt: "2026-07-01T00:00:00Z", updatedAt: updatedAt) } + // MARK: - applyLinkMetadata + + private func link(_ url: String, title: String) -> LinkMetadataItem { + LinkMetadataItem(url: url, platform: nil, + metadata: LinkMetadataItemContent(thumbnail: nil, title: title, + description: nil, text: nil, type: nil), + fetchStatus: nil) + } + + func test_applyLinkMetadata_setsMetadataOnTargetMessage() { + sut.insertFeedMessage(makeMessage(id: "m1")) + + sut.applyLinkMetadata([link("https://example.com", title: "Example")], toMessageId: "m1") + + let updated = sut.feedMessages.first { $0.id == "m1" } + XCTAssertEqual(updated?.linkMetadata?.links.count, 1) + XCTAssertEqual(updated?.linkMetadata?.links.first?.url, "https://example.com") + XCTAssertEqual(updated?.linkMetadata?.links.first?.metadata?.title, "Example") + } + + func test_applyLinkMetadata_leavesOtherMessagesUntouched() { + sut.insertFeedMessage(makeMessage(id: "older")) + sut.insertFeedMessage(makeMessage(id: "target")) + sut.insertFeedMessage(makeMessage(id: "newer")) + + sut.applyLinkMetadata([link("https://example.com", title: "Example")], toMessageId: "target") + + XCTAssertNil(sut.feedMessages.first { $0.id == "older" }?.linkMetadata) + XCTAssertNil(sut.feedMessages.first { $0.id == "newer" }?.linkMetadata) + XCTAssertEqual(sut.feedMessages.map(\.id), ["newer", "target", "older"]) + } + + func test_applyLinkMetadata_replacesExistingMetadata() { + let stale = LinkMetadata(links: [link("https://stale.example", title: "Stale")]) + sut.insertFeedMessage(makeMessage(id: "m1", linkMetadata: stale)) + + sut.applyLinkMetadata([link("https://fresh.example", title: "Fresh")], toMessageId: "m1") + + let links = sut.feedMessages.first { $0.id == "m1" }?.linkMetadata?.links + XCTAssertEqual(links?.count, 1) + XCTAssertEqual(links?.first?.url, "https://fresh.example") + } + + func test_applyLinkMetadata_unknownMessageId_isNoOp() { + sut.insertFeedMessage(makeMessage(id: "m1")) + + sut.applyLinkMetadata([link("https://example.com", title: "Example")], toMessageId: "gone") + + XCTAssertEqual(sut.feedMessages.count, 1) + XCTAssertNil(sut.feedMessages.first?.linkMetadata) + } + + func test_applyLinkMetadata_emptyFeed_isNoOp() { + sut.applyLinkMetadata([link("https://example.com", title: "Example")], toMessageId: "m1") + XCTAssertTrue(sut.feedMessages.isEmpty) + } + + /// `{ links: [] }` is what the route answers when it resolved nothing, so it + /// must not blank a preview a feed fetch already supplied. + func test_applyLinkMetadata_emptyLinks_leavesExistingMetadata() { + let existing = LinkMetadata(links: [link("https://example.com", title: "Example")]) + sut.insertFeedMessage(makeMessage(id: "m1", linkMetadata: existing)) + + sut.applyLinkMetadata([], toMessageId: "m1") + + XCTAssertEqual(sut.feedMessages.first?.linkMetadata?.links.first?.metadata?.title, "Example") + } + // MARK: - Helpers - private func makeMessage(id: String) -> Message { + private func makeMessage(id: String, linkMetadata: LinkMetadata? = nil) -> Message { Message(id: id, content: "test", publiclyVisible: true, userId: "u1", createdAt: "2026-01-01T00:00:00Z", updatedAt: nil, user: nil, imageUrls: nil, videoUrls: nil, - linkMetadata: nil, parentId: nil, scheduledAt: nil, + linkMetadata: linkMetadata, parentId: nil, scheduledAt: nil, tags: nil, digCount: 0, dugByMe: false, crossPostUrls: nil) }