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: 4 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
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 @@ -273,6 +274,7 @@
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 @@ -840,6 +842,7 @@
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 @@ -1185,6 +1188,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 */,
Expand Down
39 changes: 38 additions & 1 deletion InterlinedList/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]?
Expand Down
14 changes: 14 additions & 0 deletions InterlinedList/Services/AppDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
10 changes: 8 additions & 2 deletions InterlinedList/Views/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,13 +811,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)
}
}
}

Expand Down
102 changes: 102 additions & 0 deletions InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
72 changes: 70 additions & 2 deletions InterlinedListTests/ServiceTests/AppDataStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading