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 @@ -65,6 +65,7 @@
5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */; };
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 */; };
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 @@ -271,6 +272,7 @@
0221B233F89B966D044C37FA /* ServerLimits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimits.swift; sourceTree = "<group>"; };
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>"; };
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 @@ -837,6 +839,7 @@
T1E5T1E5T1E5P030 /* ModerationModelTests.swift */,
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */,
F78B096C366108083D174366 /* MarkdownBlockTests.swift */,
C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */,
05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */,
EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */,
A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */,
Expand Down Expand Up @@ -1181,6 +1184,7 @@
2101251E75F418E9EB28DA8F /* ImageUploadProcessorTests.swift in Sources */,
09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */,
98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */,
C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */,
6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */,
362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */,
874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */,
Expand Down
46 changes: 34 additions & 12 deletions InterlinedList/Views/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,18 +299,7 @@ struct ComposeView: View {

/// The first `http(s)` URL in the draft, used to drive a live preview card.
/// Recomputed on each content change; cheap enough for per-keystroke use.
private var firstDetectedURL: String? {
guard !content.isEmpty,
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
else { return nil }
let range = NSRange(content.startIndex..<content.endIndex, in: content)
guard let match = detector.firstMatch(in: content, options: [], range: range),
let url = match.url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return nil }
return url.absoluteString
}
private var firstDetectedURL: String? { firstDetectedHTTPURL(in: content) }

private func loadLinkPreview(_ url: String) async {
do {
Expand Down Expand Up @@ -796,6 +785,9 @@ struct ComposeView: View {
store.insertFeedMessage(result.message)
}
showSuccess = true
if firstDetectedHTTPURL(in: text) != nil {
refreshLinkMetadata(for: result.message.id)
}
if isReply || isRepost {
dismiss()
}
Expand All @@ -813,4 +805,34 @@ struct ComposeView: View {
errorMessage = "Connection failed. Please try again."
}
}

/// Asks the server to fetch OpenGraph metadata for a just-published message's
/// links, matching what the web composer does, so the preview populates without
/// 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.
///
/// 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) }
}
}

/// The first `http(s)` URL in `text`, or `nil` when it contains none. The composer's
/// live preview card and the post-publish metadata refresh both key off this, and
/// they have to agree on whether the message carries a link at all.
func firstDetectedHTTPURL(in text: String) -> String? {
guard !text.isEmpty,
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = detector.firstMatch(in: text, options: [], range: range),
let url = match.url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return nil }
return url.absoluteString
}
88 changes: 88 additions & 0 deletions InterlinedListTests/ModelTests/ComposeLinkDetectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import XCTest
@testable import InterlinedList

/// `firstDetectedHTTPURL(in:)` is the composer's single answer to "does this draft
/// carry a link?" — it gates both the live preview card and the post-publish
/// metadata refresh, so a link-free message must produce no request at all.
final class ComposeLinkDetectionTests: XCTestCase {

// MARK: - Detection

func test_firstDetectedHTTPURL_emptyContent_returnsNil() {
XCTAssertNil(firstDetectedHTTPURL(in: ""))
}

func test_firstDetectedHTTPURL_plainProse_returnsNil() {
XCTAssertNil(firstDetectedHTTPURL(in: "no links here, just words and a period."))
}

func test_firstDetectedHTTPURL_httpsURL_returnsAbsoluteString() {
XCTAssertEqual(
firstDetectedHTTPURL(in: "look at https://interlinedlist.com/about please"),
"https://interlinedlist.com/about"
)
}

func test_firstDetectedHTTPURL_httpURL_returnsAbsoluteString() {
XCTAssertEqual(firstDetectedHTTPURL(in: "http://example.com"), "http://example.com")
}

func test_firstDetectedHTTPURL_multipleURLs_returnsTheFirst() {
let text = "https://one.example.com and then https://two.example.com"
XCTAssertEqual(firstDetectedHTTPURL(in: text), "https://one.example.com")
}

func test_firstDetectedHTTPURL_mailtoLink_returnsNil() {
XCTAssertNil(firstDetectedHTTPURL(in: "mail me at someone@example.com"))
}

// MARK: - Publish guard

/// The guard as `postMessage` applies it: no detected URL ⇒ no metadata request.
private func publishAndRefreshIfLinked(
_ content: String,
using client: APIClient,
messageId: String = "m1"
) async {
guard firstDetectedHTTPURL(in: content) != nil else { return }
_ = try? await client.refreshMessageMetadata(messageId: messageId)
}

func test_publishGuard_contentWithoutLink_issuesNoRequest() async {
let session = MockURLSession()
let sut = APIClient(session: session)
sut.setBearerToken("tok")
session.stub(json: #"{"metadata":{"links":[]}}"#)

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

XCTAssertNil(session.lastRequest)
XCTAssertTrue(session.requestHistory.isEmpty)
}

func test_publishGuard_contentWithLink_postsToMetadataRoute() async {
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}]}}"#)

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

XCTAssertEqual(session.requestHistory.count, 1)
XCTAssertEqual(session.lastRequest?.httpMethod, "POST")
XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/m42/metadata") == true)
}

/// A failed refresh is swallowed on the publish path: the caller ignores the
/// throw, so a 401 or a 500 can never surface an error after a successful post.
func test_publishGuard_failureIsSwallowed() async {
let session = MockURLSession()
let sut = APIClient(session: session)
sut.setBearerToken("tok")
session.stub(json: #"{"error":"nope"}"#, statusCode: 500)

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

XCTAssertEqual(session.requestHistory.count, 1)
}
}
Loading