From 7ff378c8689786f677b230654c82fae084dec001 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Sat, 25 Jul 2026 13:48:11 -0400 Subject: [PATCH] updates for atprosphere --- Package.resolved | 26 +-- Package.swift | 1 - Sources/bskyKit/Models/Timeline.swift | 87 +++++--- Sources/bskyKit/RichText/FacetURL.swift | 51 +++++ Tests/bskyKitTests/AttributedTextTests.swift | 204 +++++++++++++++++++ 5 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 Sources/bskyKit/RichText/FacetURL.swift create mode 100644 Tests/bskyKitTests/AttributedTextTests.swift diff --git a/Package.resolved b/Package.resolved index 6c0bdf5..55dd5da 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1cc87e98c4114f0c0c0a6fea792a84d353c9e8d6b11e58b00673b46e6086d02d", + "originHash" : "588bff50c2acc1e7fc8a48e4cd7e69605871ec12cb138ce96710e0b88cebb635", "pins" : [ { "identity" : "coreatprotocol", @@ -7,7 +7,7 @@ "location" : "https://tangled.org/@sparrowtek.com/CoreATProtocol", "state" : { "branch" : "main", - "revision" : "a48471113f64de5b7c3f37be472c7832601013c1" + "revision" : "584811dfa73fa31202ec6d55e0cffd73c05a0d10" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "94a8b96d0ab5aae91324b68f100e3670cd908b51", - "version" : "5.4.1" + "revision" : "7abc497b0f907191933c87166e9b6a41b9d028d6", + "version" : "5.6.0" } }, { @@ -34,7 +34,7 @@ "location" : "https://github.com/ChimeHQ/OAuthenticator.git", "state" : { "branch" : "main", - "revision" : "0962bcc02e8e5c0fc49771c0d0eff3d33433863e" + "revision" : "581a32044c7c4200b9f688e403310b52a7fc41b8" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", - "version" : "1.19.0" + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", - "version" : "4.5.0" + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" } } ], diff --git a/Package.swift b/Package.swift index a91b37c..5f97eaa 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,6 @@ let package = Package( ), ], dependencies: [ -// .package(path: "../CoreATProtocol"), .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), ], targets: [ diff --git a/Sources/bskyKit/Models/Timeline.swift b/Sources/bskyKit/Models/Timeline.swift index bf53f33..cd5a493 100644 --- a/Sources/bskyKit/Models/Timeline.swift +++ b/Sources/bskyKit/Models/Timeline.swift @@ -166,10 +166,17 @@ public struct EmbedRecordValue: Codable, Sendable { public let langs: [String]? public let reply: ReplyDetail? public let createdAt: Date? + public let facets: [Facet]? enum CodingKeys: String, CodingKey { case type = "$type" - case text, langs, reply, createdAt + case text, langs, reply, createdAt, facets + } + + /// Rich-text rendering of the embedded post body, with mention/tag/link + /// facets converted into tappable `link` attributes (see ``FacetURL``). + public var attributedText: AttributedString { + Record.makeAttributedString(text: text ?? "", facets: facets ?? []) } } @@ -279,7 +286,6 @@ public struct Record: Codable, Sendable { public let createdAt: Date? public let embed: Embed? public let facets: [Facet]? - public let markdownText: String enum CodingKeys: String, CodingKey { case type = "$type" @@ -295,7 +301,6 @@ public struct Record: Codable, Sendable { createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) embed = try container.decodeIfPresent(Embed.self, forKey: .embed) facets = try container.decodeIfPresent([Facet].self, forKey: .facets) - markdownText = Record.convertToMarkdown(original: text ?? "", facets: facets) } public func encode(to encoder: Encoder) throws { @@ -309,34 +314,68 @@ public struct Record: Codable, Sendable { try container.encodeIfPresent(facets, forKey: .facets) } - private static func convertToMarkdown(original: String, facets: [Facet]?) -> String { - guard let facets, !facets.isEmpty else { return original } + /// The post body as an `AttributedString` with each AT Protocol facet + /// (mention, tag, link) carrying a `link` attribute pointing at a URL the + /// caller can route. Mentions and tags use the `bskykit://` scheme via + /// ``FacetURL``; link facets keep their original URI. + public var attributedText: AttributedString { + Record.makeAttributedString(text: text ?? "", facets: facets ?? []) + } - var result = original - let sortedFacets = facets.sorted { $0.index.byteEnd > $1.index.byteEnd } - let utf8 = Array(original.utf8) + static func makeAttributedString(text: String, facets: [Facet]) -> AttributedString { + var attributed = AttributedString(text) + guard !facets.isEmpty else { return attributed } - for facet in sortedFacets { + let utf8Count = text.utf8.count + for facet in facets { let start = facet.index.byteStart let end = facet.index.byteEnd + guard start >= 0, end > start, end <= utf8Count else { continue } + guard let url = url(for: facet) else { continue } + guard let range = attributedRange(byteStart: start, byteEnd: end, in: text, attributed: attributed) else { continue } + attributed[range].link = url + } + return attributed + } - guard start >= 0, end <= utf8.count, start < end else { continue } - guard let uri = facet.features.first(where: { $0.uri != nil })?.uri else { continue } - - let startBytes = utf8.prefix(start) - let rangeBytes = utf8[start.. URL? { + for feature in facet.features { + switch feature.type { + case .mention: + if let did = feature.did, let url = FacetURL.profile(did: did).url { + return url + } + case .tag: + if let tag = feature.tag, let url = FacetURL.tag(tag).url { + return url + } + case .link: + if let uri = feature.uri, let url = URL(string: uri) { + return url + } + case .unknown: + continue + } } + return nil + } - return result + private static func attributedRange( + byteStart: Int, + byteEnd: Int, + in text: String, + attributed: AttributedString + ) -> Range? { + let utf8 = text.utf8 + guard let startUTF8 = utf8.index(utf8.startIndex, offsetBy: byteStart, limitedBy: utf8.endIndex), + let endUTF8 = utf8.index(utf8.startIndex, offsetBy: byteEnd, limitedBy: utf8.endIndex), + let startStr = startUTF8.samePosition(in: text), + let endStr = endUTF8.samePosition(in: text), + let startIdx = AttributedString.Index(startStr, within: attributed), + let endIdx = AttributedString.Index(endStr, within: attributed) else { + return nil + } + return startIdx.. Facet { + Facet( + index: FacetIndex(byteEnd: byteEnd, byteStart: byteStart), + features: [FacetFeature(uri: nil, did: did, tag: nil, type: .mention)] + ) + } + + private func tag(_ name: String, byteStart: Int, byteEnd: Int) -> Facet { + Facet( + index: FacetIndex(byteEnd: byteEnd, byteStart: byteStart), + features: [FacetFeature(uri: nil, did: nil, tag: name, type: .tag)] + ) + } + + private func link(_ uri: String, byteStart: Int, byteEnd: Int) -> Facet { + Facet( + index: FacetIndex(byteEnd: byteEnd, byteStart: byteStart), + features: [FacetFeature(uri: uri, did: nil, tag: nil, type: .link)] + ) + } + + private func plainString(of attributed: AttributedString) -> String { + String(attributed.characters) + } + + private func linkRanges(in attributed: AttributedString) -> [(URL, String)] { + var results: [(URL, String)] = [] + for run in attributed.runs { + if let url = run.link { + let substring = String(attributed[run.range].characters) + results.append((url, substring)) + } + } + return results + } + + // MARK: - Mention + + @Test("Mention becomes a tappable bskykit://profile link") + func mentionLink() { + let text = "Hi @alice" + let facets = [mention("did:plc:alice", byteStart: 3, byteEnd: 9)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + #expect(plainString(of: attr) == text) + let links = linkRanges(in: attr) + #expect(links.count == 1) + #expect(links[0].1 == "@alice") + #expect(links[0].0.scheme == "bskykit") + #expect(FacetURL(url: links[0].0) == .profile(did: "did:plc:alice")) + } + + // MARK: - Tag + + @Test("Tag becomes a tappable bskykit://tag link") + func tagLink() { + let text = "Love #swift" + let facets = [tag("swift", byteStart: 5, byteEnd: 11)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + let links = linkRanges(in: attr) + #expect(links.count == 1) + #expect(links[0].1 == "#swift") + #expect(FacetURL(url: links[0].0) == .tag("swift")) + } + + // MARK: - Link + + @Test("URL facet preserves original URI") + func urlLink() { + let text = "See https://example.com today" + let facets = [link("https://example.com", byteStart: 4, byteEnd: 23)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + let links = linkRanges(in: attr) + #expect(links.count == 1) + #expect(links[0].1 == "https://example.com") + #expect(links[0].0.absoluteString == "https://example.com") + } + + // MARK: - UTF-8 byte indexing (regression for prior bug) + + @Test("Multi-byte text before a facet still slices on the correct range") + func multiByteTextBeforeFacet() { + // "👋" is 4 UTF-8 bytes. Mention starts after "👋 Hi @" + // byteStart for "@alice" = 4 (👋) + 1 (space) + 1 (H) + 1 (i) + 1 (space) = 8 + let text = "👋 Hi @alice!" + let byteStart = 8 + let byteEnd = byteStart + "@alice".utf8.count + let facets = [mention("did:plc:alice", byteStart: byteStart, byteEnd: byteEnd)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + let links = linkRanges(in: attr) + #expect(links.count == 1) + #expect(links[0].1 == "@alice") + } + + @Test("Multi-byte text inside a facet's range") + func multiByteWithinFacet() { + // Tag containing CJK characters; verify byte counting is correct. + let text = "Hello #日本語!" + let prefixBytes = "Hello ".utf8.count // 6 + let tagText = "#日本語" + let tagBytes = tagText.utf8.count + let facets = [tag("日本語", byteStart: prefixBytes, byteEnd: prefixBytes + tagBytes)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + let links = linkRanges(in: attr) + #expect(links.count == 1) + #expect(links[0].1 == "#日本語") + #expect(FacetURL(url: links[0].0) == .tag("日本語")) + } + + // MARK: - Multiple facets + + @Test("Multiple facets all linked") + func multipleFacets() { + let text = "Hey @alice check #swift at https://example.com" + let mentionEnd = "Hey @alice".utf8.count + let tagStart = "Hey @alice check ".utf8.count + let tagEnd = "Hey @alice check #swift".utf8.count + let linkStart = "Hey @alice check #swift at ".utf8.count + let linkEnd = text.utf8.count + + let facets = [ + mention("did:plc:alice", byteStart: 4, byteEnd: mentionEnd), + tag("swift", byteStart: tagStart, byteEnd: tagEnd), + link("https://example.com", byteStart: linkStart, byteEnd: linkEnd) + ] + let attr = Record.makeAttributedString(text: text, facets: facets) + + let links = linkRanges(in: attr) + #expect(links.count == 3) + } + + // MARK: - Edge cases + + @Test("Out-of-range facet is skipped") + func outOfRangeFacet() { + let text = "Short" + let facets = [mention("did:plc:x", byteStart: 0, byteEnd: 999)] + let attr = Record.makeAttributedString(text: text, facets: facets) + + #expect(linkRanges(in: attr).isEmpty) + #expect(plainString(of: attr) == text) + } + + @Test("Empty text with no facets") + func emptyText() { + let attr = Record.makeAttributedString(text: "", facets: []) + #expect(plainString(of: attr) == "") + #expect(linkRanges(in: attr).isEmpty) + } + + @Test("Mention without DID produces no link") + func mentionMissingDID() { + let text = "Hi @alice" + let facets = [ + Facet( + index: FacetIndex(byteEnd: 9, byteStart: 3), + features: [FacetFeature(uri: nil, did: nil, tag: nil, type: .mention)] + ) + ] + let attr = Record.makeAttributedString(text: text, facets: facets) + #expect(linkRanges(in: attr).isEmpty) + } + + // MARK: - FacetURL round-trip + + @Test("FacetURL profile round-trip") + func facetURLProfileRoundTrip() { + let original = FacetURL.profile(did: "did:plc:abc123") + guard let url = original.url else { + Issue.record("Expected URL for profile facet") + return + } + #expect(FacetURL(url: url) == original) + } + + @Test("FacetURL tag round-trip with Unicode") + func facetURLTagRoundTrip() { + let original = FacetURL.tag("日本語") + guard let url = original.url else { + Issue.record("Expected URL for tag facet") + return + } + #expect(FacetURL(url: url) == original) + } + + @Test("Plain https URL is not a FacetURL") + func nonFacetURLIgnored() { + let url = URL(string: "https://example.com/foo")! + #expect(FacetURL(url: url) == nil) + } +} -- 2.51.2