Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.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 */; };
Expand Down Expand Up @@ -274,7 +273,6 @@
03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = "<group>"; };
03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = "<group>"; };
C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeLinkDetectionTests.swift; sourceTree = "<group>"; };
C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkMetadataConversionTests.swift; sourceTree = "<group>"; };
05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = "<group>"; };
06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = "<group>"; };
08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -842,7 +840,6 @@
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */,
F78B096C366108083D174366 /* MarkdownBlockTests.swift */,
C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */,
C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */,
05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */,
EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */,
A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */,
Expand Down Expand Up @@ -1188,7 +1185,6 @@
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 */,
Expand Down
45 changes: 0 additions & 45 deletions InterlinedList/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,51 +32,6 @@ struct LinkMetadata: Codable {
let links: [LinkMetadataItem]
}

/// A single resolved link preview from POST /api/messages/:id/metadata.
struct MessageLinkPreview: Codable, Identifiable {
let url: String
let title: String?
let description: String?
let image: String?

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
Expand Down
19 changes: 9 additions & 10 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -420,20 +420,19 @@ final class APIClient {

/// Fetch/refresh OpenGraph link-preview metadata for a message's links.
///
/// No caller yet, deliberately. The web fires this after publishing and after an
/// edit (`MessageInput.tsx:511`) so a new post's link previews populate without a
/// reload. Wiring it adds a request to the publish path, so it is tracked
/// separately rather than slipped into a hygiene sweep.
/// `app/api/messages/[id]/metadata/route.ts` answers `{ "links": [...] }` at the
/// top level from both exits (no links detected → `[]`; otherwise the fetched
/// items), and each entry is the same `LinkMetadataItem` the feed row carries —
/// both come out of `fetchMultipleLinkMetadata`, as `/api/link-metadata` does.
/// `links` is decoded as required on purpose: the previous wrapper mismatch
/// survived because a missing key degraded to an empty array in silence.
@discardableResult
func refreshMessageMetadata(messageId: String) async throws -> [MessageLinkPreview] {
struct Response: Decodable {
struct Meta: Decodable { let links: [MessageLinkPreview]? }
let metadata: Meta?
}
func refreshMessageMetadata(messageId: String) async throws -> [LinkMetadataItem] {
struct Response: Decodable { let links: [LinkMetadataItem] }
let encoded = messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? messageId
struct Empty: Encodable {}
let response: Response = try await postCamel("/api/messages/\(encoded)/metadata", body: Empty())
return response.metadata?.links ?? []
return response.links
}

func editMessage(id: String, content: String, publiclyVisible: Bool?) async throws -> Message {
Expand Down
4 changes: 2 additions & 2 deletions InterlinedList/Views/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -820,9 +820,9 @@ struct ComposeView: View {
/// can't change server-side anyway. See issue #76.
private func refreshLinkMetadata(for messageId: String) {
Task { @MainActor in
guard let previews = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId)
guard let links = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId)
else { return }
store.applyLinkMetadata(LinkMetadataItem.from(previews: previews), toMessageId: messageId)
store.applyLinkMetadata(links, toMessageId: messageId)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,4 @@ final class APIClientGapPhasesTests: XCTestCase {
XCTAssertEqual(result.crossPostResults.first?.platform, "bluesky")
XCTAssertEqual(result.crossPostResults.first?.success, true)
}

func test_refreshMessageMetadata_decodesLinks() async throws {
session.stub(json: #"{"message":"ok","metadata":{"links":[{"url":"https://x.com","title":"X","description":"d","image":"i"}]}}"#)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(session.lastRequest?.httpMethod, "POST")
XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/m1/metadata") == true)
XCTAssertEqual(links.first?.title, "X")
}
}
124 changes: 124 additions & 0 deletions InterlinedListTests/APIClientTests/APIClientMessagesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,4 +247,128 @@ final class APIClientMessagesTests: XCTestCase {
XCTAssertTrue(msg.lowercased().contains("own"))
}
}

// MARK: refreshMessageMetadata()

/// Every fixture below is transcribed from the route itself —
/// `app/api/messages/[id]/metadata/route.ts` (`serialize({ links: … })` at :86
/// and :105) — and from the entry shape its items are built with,
/// `lib/messages/metadata-fetcher.ts` (`fetchMultipleLinkMetadata`, which also
/// feeds `GET /api/link-metadata`). Do not re-derive them from the Swift type:
/// the bug this covers (#104) shipped green because the old fixture was written
/// to the decoder's assumption instead of to the wire.
private let metadataSuccessJSON = #"""
{"links":[{
"url":"https://example.com/post",
"platform":"other",
"metadata":{
"thumbnail":"https://example.com/og.png",
"title":"Example Post",
"description":"A description",
"type":"link",
"ogType":"article"
},
"fetchStatus":"success",
"fetchedAt":"2026-09-16T12:00:00.000Z"
}]}
"""#

func test_refreshMessageMetadata_sendsPostToMetadataPath() async throws {
session.stub(json: metadataSuccessJSON)
_ = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(session.lastRequest?.httpMethod, "POST")
XCTAssertEqual(session.lastRequest?.url?.path, "/api/messages/m1/metadata")
XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok")
}

func test_refreshMessageMetadata_routeShape_decodesNonEmptyLinks() async throws {
session.stub(json: metadataSuccessJSON)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(links.count, 1)
XCTAssertEqual(links.first?.url, "https://example.com/post")
XCTAssertEqual(links.first?.platform, "other")
XCTAssertEqual(links.first?.fetchStatus, "success")
XCTAssertEqual(links.first?.metadata?.title, "Example Post")
XCTAssertEqual(links.first?.metadata?.description, "A description")
XCTAssertEqual(links.first?.metadata?.thumbnail, "https://example.com/og.png")
XCTAssertEqual(links.first?.metadata?.type, "link")
}

/// The pre-#104 shape: a `metadata` wrapper the route has never sent. It must
/// now fail loudly rather than decode to an empty array, which is how the
/// mismatch stayed invisible.
func test_refreshMessageMetadata_legacyMetadataWrapper_throwsDecodingError() async throws {
session.stub(json: #"{"message":"ok","metadata":{"links":[{"url":"https://x.com","title":"X","description":"d","image":"i"}]}}"#)
do {
_ = try await sut.refreshMessageMetadata(messageId: "m1")
XCTFail("Expected a decoding failure for the wrapped shape")
} catch is DecodingError {
// expected
}
}

/// Route :84-89 — content with no detectable link short-circuits to `{links:[]}`.
func test_refreshMessageMetadata_noDetectedLinks_returnsEmpty() async throws {
session.stub(json: #"{"links":[]}"#)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertTrue(links.isEmpty)
}

/// `fetchMultipleLinkMetadata` emits `{url, platform, fetchStatus:"failed"}` with
/// no `metadata` and no `fetchedAt` when a fetch throws or resolves to nothing —
/// and the route persists exactly that on the message, so the item is kept.
func test_refreshMessageMetadata_failedEntry_keepsUrlWithNilMetadata() async throws {
session.stub(json: #"{"links":[{"url":"https://dead.example","platform":"other","fetchStatus":"failed"}]}"#)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(links.count, 1)
XCTAssertEqual(links.first?.url, "https://dead.example")
XCTAssertEqual(links.first?.fetchStatus, "failed")
XCTAssertNil(links.first?.metadata)
}

/// Instagram entries carry `caption`/`derivedList` for the composer's
/// paste-to-expand assist; the link card models neither, and neither may break
/// the decode of the fields it does model.
func test_refreshMessageMetadata_instagramExtras_decodeWithoutError() async throws {
session.stub(json: #"""
{"links":[{
"url":"https://www.instagram.com/p/abc123/",
"platform":"instagram",
"metadata":{"thumbnail":"https://cdn.example/ig.jpg","title":"On Instagram","description":"caption text","type":"image"},
"caption":"caption text - one - two",
"derivedList":{"markdown":"- one","ordered":false,"itemCount":2},
"fetchStatus":"success",
"fetchedAt":"2026-09-16T12:00:00.000Z"
}]}
"""#)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(links.first?.platform, "instagram")
XCTAssertEqual(links.first?.metadata?.type, "image")
XCTAssertEqual(links.first?.metadata?.thumbnail, "https://cdn.example/ig.jpg")
}

func test_refreshMessageMetadata_multipleLinks_preservesRouteOrder() async throws {
session.stub(json: #"""
{"links":[
{"url":"https://one.example","platform":"other","metadata":{"title":"One","type":"link"},"fetchStatus":"success"},
{"url":"https://two.example","platform":"other","metadata":{"title":"Two","type":"link"},"fetchStatus":"success"}
]}
"""#)
let links = try await sut.refreshMessageMetadata(messageId: "m1")
XCTAssertEqual(links.map(\.url), ["https://one.example", "https://two.example"])
XCTAssertEqual(links.map { $0.metadata?.title }, ["One", "Two"])
}

/// Route :65-67 — the POST is owner-only, so an unauthenticated refresh 401s.
/// It stays a bare `.status(401)`: the publish path swallows it rather than
/// treating it as a logout (CLAUDE.md).
func test_refreshMessageMetadata_401_throwsStatus401() async throws {
session.stub(data: Data(), statusCode: 401)
do {
_ = try await sut.refreshMessageMetadata(messageId: "m1")
XCTFail("Expected throw")
} catch APIError.status(let code) {
XCTAssertEqual(code, 401)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ final class ComposeLinkDetectionTests: XCTestCase {
let session = MockURLSession()
let sut = APIClient(session: session)
sut.setBearerToken("tok")
session.stub(json: #"{"metadata":{"links":[]}}"#)
session.stub(json: #"{"links":[]}"#)

await publishAndRefreshIfLinked("just a thought, no links at all", using: sut)

Expand All @@ -64,7 +64,7 @@ final class ComposeLinkDetectionTests: XCTestCase {
let session = MockURLSession()
let sut = APIClient(session: session)
sut.setBearerToken("tok")
session.stub(json: #"{"metadata":{"links":[{"url":"https://example.com","title":"E","description":null,"image":null}]}}"#)
session.stub(json: #"{"links":[{"url":"https://example.com","platform":"other","metadata":{"title":"E","type":"link"},"fetchStatus":"success"}]}"#)

await publishAndRefreshIfLinked("read this https://example.com", using: sut, messageId: "m42")

Expand Down
Loading
Loading